more fixes
This commit is contained in:
39
tests/.auth/admin.json
Normal file
39
tests/.auth/admin.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "XSRF-TOKEN",
|
||||
"value": "eyJpdiI6IlIyTWVLc1doRWZmL0xhWmpIbFp5akE9PSIsInZhbHVlIjoiY2Q0ZzA1blBXRFpxR2pDdlZ5S2E5YmVYUWdyOHJsc3RYR0ZYZUllYjFZeHRZNmFMUXFuamd3NlZwVnNtaW10Qjhsd0JaRDVRYUg2cGFUTmE4ZjZIMlN0bmdSYzB3NnRveHcwMHI1QVdMSUJaQ3ZZNTZHeUxxUXBkbEFqYTkyZ0QiLCJtYWMiOiIyODg0YzY5NmVhNTVlMWMyMjA0YmMxMjcxYWJkZmJlZWU2ZGI1Mzg5NmY4Y2IzMGJmZWE5NGViN2I5NzRmYTY5IiwidGFnIjoiIn0%3D",
|
||||
"domain": "skinbase26.test",
|
||||
"path": "/",
|
||||
"expires": 1772951912.063718,
|
||||
"httpOnly": false,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "skinbasenova-session",
|
||||
"value": "eyJpdiI6IklCV1VlRmlWL1owYkJiWTRpZkY4MkE9PSIsInZhbHVlIjoiRXJ2VTNjMm56Z3NNNWdwSE5RUHlSV2JhMHpoY21aeHJEb3JSUnVBRGIvVUpLZmVEbjBSM2ZQeFM0eDJEZDRYNHB0YVlpZUVqNVBYMFNXbXArYmFybVdrTnpBN3pJS0ZBcm9kSGlmQWs5dThVMXdyRUhOUjBRUFloWDJKWGl0L0UiLCJtYWMiOiIyYmZkMThmYjljNzIwMDgyOGE1N2U0NDgyMWVmNDg4YTU4MWFhNTk0MDA0NmM4MDA5YzU4ZGFlN2M5MGJmNjk3IiwidGFnIjoiIn0%3D",
|
||||
"domain": "skinbase26.test",
|
||||
"path": "/",
|
||||
"expires": 1772951912.063846,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "http://skinbase26.test",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "phpdebugbar-height",
|
||||
"value": "301"
|
||||
},
|
||||
{
|
||||
"name": "phpdebugbar-visible",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
133
tests/Feature/ArtworkDownloadTest.php
Normal file
133
tests/Feature/ArtworkDownloadTest.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads');
|
||||
config(['uploads.storage_root' => $root]);
|
||||
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
|
||||
File::makeDirectory($root, 0755, true);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$root = storage_path('framework/testing/artwork-downloads');
|
||||
if (File::exists($root)) {
|
||||
File::deleteDirectory($root);
|
||||
}
|
||||
});
|
||||
|
||||
function makeOriginalFile(string $hash, string $ext, string $content = 'test-image-content'): string
|
||||
{
|
||||
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
|
||||
$firstDir = substr($hash, 0, 2);
|
||||
$secondDir = substr($hash, 2, 2);
|
||||
$dir = $root . DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR . $firstDir . DIRECTORY_SEPARATOR . $secondDir;
|
||||
|
||||
File::makeDirectory($dir, 0755, true, true);
|
||||
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
||||
File::put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
it('downloads an existing artwork file', function () {
|
||||
$hash = 'a9f3e6c1b8';
|
||||
$ext = 'png';
|
||||
makeOriginalFile($hash, $ext);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'file_name' => 'Sky Sunset',
|
||||
'hash' => $hash,
|
||||
'file_ext' => $ext,
|
||||
]);
|
||||
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload('Sky Sunset.png');
|
||||
});
|
||||
|
||||
it('forces the download filename using file_name and extension', function () {
|
||||
$hash = 'b7c4d1e2f3';
|
||||
$ext = 'jpg';
|
||||
makeOriginalFile($hash, $ext);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'file_name' => 'My Original Name',
|
||||
'hash' => $hash,
|
||||
'file_ext' => $ext,
|
||||
]);
|
||||
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload('My Original Name.jpg');
|
||||
});
|
||||
|
||||
it('returns 404 for a missing artwork', function () {
|
||||
$this->get('/download/artwork/999999')->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the original file is missing', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => 'c1d2e3f4a5',
|
||||
'file_ext' => 'webp',
|
||||
]);
|
||||
|
||||
$this->get("/download/artwork/{$artwork->id}")->assertNotFound();
|
||||
});
|
||||
|
||||
it('logs download metadata with user and request context', function () {
|
||||
$hash = 'd4e5f6a7b8';
|
||||
$ext = 'gif';
|
||||
makeOriginalFile($hash, $ext);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => $ext,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withHeaders([
|
||||
'User-Agent' => 'SkinbaseTestAgent/1.0',
|
||||
'Referer' => 'https://example.test/art/' . $artwork->id,
|
||||
])
|
||||
->get("/download/artwork/{$artwork->id}")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('artwork_downloads', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'SkinbaseTestAgent/1.0',
|
||||
'referer' => 'https://example.test/art/' . $artwork->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs guest download with null user_id', function () {
|
||||
$hash = 'e1f2a3b4c5';
|
||||
$ext = 'png';
|
||||
makeOriginalFile($hash, $ext);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'hash' => $hash,
|
||||
'file_ext' => $ext,
|
||||
]);
|
||||
|
||||
$this->get("/download/artwork/{$artwork->id}")->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('artwork_downloads', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
76
tests/Feature/Stories/AdminStoryModerationWorkflowTest.php
Normal file
76
tests/Feature/Stories/AdminStoryModerationWorkflowTest.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function createPendingReviewStory(User $creator): Story
|
||||
{
|
||||
return Story::query()->create([
|
||||
'creator_id' => $creator->id,
|
||||
'title' => 'Pending Story ' . Str::random(6),
|
||||
'slug' => 'pending-story-' . Str::lower(Str::random(8)),
|
||||
'content' => '<p>Pending review content</p>',
|
||||
'story_type' => 'creator_story',
|
||||
'status' => 'pending_review',
|
||||
'submitted_for_review_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('non moderator cannot access admin stories review queue', function () {
|
||||
$user = User::factory()->create(['role' => 'user']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.stories.review'))
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
it('admin can approve a pending story and notify creator', function () {
|
||||
Notification::fake();
|
||||
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = createPendingReviewStory($creator);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.stories.approve', ['story' => $story->id]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$story->refresh();
|
||||
|
||||
expect($story->status)->toBe('published');
|
||||
expect($story->reviewed_by_id)->toBe($admin->id);
|
||||
expect($story->reviewed_at)->not->toBeNull();
|
||||
expect($story->published_at)->not->toBeNull();
|
||||
|
||||
Notification::assertSentTo($creator, StoryStatusNotification::class);
|
||||
});
|
||||
|
||||
it('moderator can reject a pending story with reason and notify creator', function () {
|
||||
Notification::fake();
|
||||
|
||||
$moderator = User::factory()->create(['role' => 'moderator']);
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = createPendingReviewStory($creator);
|
||||
|
||||
$response = $this->actingAs($moderator)
|
||||
->post(route('admin.stories.reject', ['story' => $story->id]), [
|
||||
'reason' => 'Please remove promotional external links and resubmit.',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$story->refresh();
|
||||
|
||||
expect($story->status)->toBe('rejected');
|
||||
expect($story->reviewed_by_id)->toBe($moderator->id);
|
||||
expect($story->reviewed_at)->not->toBeNull();
|
||||
expect($story->rejected_reason)->toContain('promotional external links');
|
||||
|
||||
Notification::assertSentTo($creator, StoryStatusNotification::class);
|
||||
});
|
||||
96
tests/Feature/Stories/CreatorStoryWorkflowTest.php
Normal file
96
tests/Feature/Stories/CreatorStoryWorkflowTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function storyPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'title' => 'Story ' . Str::random(8),
|
||||
'cover_image' => 'https://example.test/cover.jpg',
|
||||
'excerpt' => 'A compact excerpt for testing.',
|
||||
'story_type' => 'creator_story',
|
||||
'content' => '<p>Hello Story World</p>',
|
||||
'status' => 'draft',
|
||||
'submit_action' => 'save_draft',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('creator can create a draft story from editor form', function () {
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($creator)
|
||||
->post(route('creator.stories.store'), storyPayload());
|
||||
|
||||
$story = Story::query()->where('creator_id', $creator->id)->latest('id')->first();
|
||||
|
||||
expect($story)->not->toBeNull();
|
||||
expect($story->status)->toBe('draft');
|
||||
expect($story->slug)->not->toBe('');
|
||||
|
||||
$response->assertRedirect(route('creator.stories.edit', ['story' => $story->id]));
|
||||
});
|
||||
|
||||
it('creator autosave updates draft fields and creates tags from csv', function () {
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = Story::query()->create([
|
||||
'creator_id' => $creator->id,
|
||||
'title' => 'Autosave Draft',
|
||||
'slug' => 'autosave-draft-' . Str::lower(Str::random(6)),
|
||||
'content' => '<p>Original</p>',
|
||||
'story_type' => 'creator_story',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($creator)->postJson(
|
||||
route('creator.stories.autosave', ['story' => $story->id]),
|
||||
[
|
||||
'title' => 'Autosaved Title',
|
||||
'content' => '<p>Autosaved content with enough words for reading time.</p>',
|
||||
'tags_csv' => 'alpha,beta',
|
||||
]
|
||||
);
|
||||
|
||||
$response->assertOk()->assertJson(['ok' => true]);
|
||||
|
||||
$story->refresh();
|
||||
|
||||
expect($story->title)->toBe('Autosaved Title');
|
||||
expect($story->status)->toBe('draft');
|
||||
|
||||
$this->assertDatabaseHas('story_tags', ['slug' => 'alpha']);
|
||||
$this->assertDatabaseHas('story_tags', ['slug' => 'beta']);
|
||||
|
||||
$tagIds = DB::table('story_tags')->whereIn('slug', ['alpha', 'beta'])->pluck('id')->all();
|
||||
foreach ($tagIds as $tagId) {
|
||||
$this->assertDatabaseHas('relation_story_tags', [
|
||||
'story_id' => $story->id,
|
||||
'tag_id' => $tagId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('creator can submit draft for review', function () {
|
||||
$creator = User::factory()->create();
|
||||
|
||||
$story = Story::query()->create([
|
||||
'creator_id' => $creator->id,
|
||||
'title' => 'Review Draft',
|
||||
'slug' => 'review-draft-' . Str::lower(Str::random(6)),
|
||||
'content' => '<p>Review content</p>',
|
||||
'story_type' => 'creator_story',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($creator)
|
||||
->post(route('creator.stories.submit-review', ['story' => $story->id]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$story->refresh();
|
||||
expect($story->status)->toBe('pending_review');
|
||||
expect($story->submitted_for_review_at)->not->toBeNull();
|
||||
});
|
||||
6
tests/artifacts/.last-run.json
Normal file
6
tests/artifacts/.last-run.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"598fdabf36083b33787e-d0e56fbd27a2103ba5b0"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e4]:
|
||||
- generic [ref=e5]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e10]: Internal Server Error
|
||||
- button "Copy as Markdown" [ref=e11] [cursor=pointer]:
|
||||
- img [ref=e12]
|
||||
- generic [ref=e15]: Copy as Markdown
|
||||
- generic [ref=e18]:
|
||||
- generic [ref=e19]:
|
||||
- heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20]
|
||||
- generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528
|
||||
- paragraph [ref=e23]: Route [artwork.show] not defined.
|
||||
- generic [ref=e24]:
|
||||
- generic [ref=e25]:
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]: LARAVEL
|
||||
- generic [ref=e28]: 12.53.0
|
||||
- generic [ref=e29]:
|
||||
- generic [ref=e30]: PHP
|
||||
- generic [ref=e31]: 8.4.12
|
||||
- generic [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- text: UNHANDLED
|
||||
- generic [ref=e36]: CODE 0
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e39]:
|
||||
- img [ref=e40]
|
||||
- text: "500"
|
||||
- generic [ref=e43]:
|
||||
- img [ref=e44]
|
||||
- text: GET
|
||||
- generic [ref=e47]: http://skinbase26.test/explore
|
||||
- button [ref=e48] [cursor=pointer]:
|
||||
- img [ref=e49]
|
||||
- generic [ref=e53]:
|
||||
- generic [ref=e54]:
|
||||
- generic [ref=e55]:
|
||||
- img [ref=e57]
|
||||
- heading "Exception trace" [level=3] [ref=e60]
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e63] [cursor=pointer]:
|
||||
- img [ref=e64]
|
||||
- generic [ref=e68]: 2 vendor frames
|
||||
- button [ref=e69]:
|
||||
- img [ref=e70]
|
||||
- generic [ref=e74]:
|
||||
- generic [ref=e75] [cursor=pointer]:
|
||||
- generic [ref=e78]:
|
||||
- code [ref=e82]:
|
||||
- generic [ref=e83]: route(string, string)
|
||||
- generic [ref=e85]: resources\views\web\explore\index.blade.php:18
|
||||
- button [ref=e87]:
|
||||
- img [ref=e88]
|
||||
- code [ref=e96]:
|
||||
- generic [ref=e97]: 13 <span class="text-xs font-semibold uppercase tracking-widest text-amber-400">✦ Featured Today</span>
|
||||
- generic [ref=e98]: 14 <span class="flex-1 border-t border-white/10"></span>
|
||||
- generic [ref=e99]: 15 </div>
|
||||
- generic [ref=e100]: 16 <div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
|
||||
- generic [ref=e101]: 17 @foreach($spotlight as $item)
|
||||
- generic [ref=e102]: "18 <a href=\"{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}\""
|
||||
- generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
|
||||
- generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40
|
||||
- generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
|
||||
- generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">"
|
||||
- generic [ref=e107]: "23"
|
||||
- generic [ref=e108]: "24 {{-- Thumbnail --}}"
|
||||
- generic [ref=e109]: 25 <div class="aspect-[4/3] overflow-hidden bg-neutral-900">
|
||||
- generic [ref=e110]: 26 <img
|
||||
- generic [ref=e111]: "27 src=\"{{ $item->thumb_url ?? '' }}\""
|
||||
- generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif"
|
||||
- generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\""
|
||||
- generic [ref=e114]: "30"
|
||||
- generic [ref=e116] [cursor=pointer]:
|
||||
- img [ref=e117]
|
||||
- generic [ref=e121]: 15 vendor frames
|
||||
- button [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- generic [ref=e128] [cursor=pointer]:
|
||||
- generic [ref=e131]:
|
||||
- code [ref=e135]:
|
||||
- generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))"
|
||||
- generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27
|
||||
- button [ref=e140]:
|
||||
- img [ref=e141]
|
||||
- generic [ref=e146] [cursor=pointer]:
|
||||
- img [ref=e147]
|
||||
- generic [ref=e151]: 45 vendor frames
|
||||
- button [ref=e152]:
|
||||
- img [ref=e153]
|
||||
- generic [ref=e158] [cursor=pointer]:
|
||||
- generic [ref=e161]:
|
||||
- code [ref=e165]:
|
||||
- generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request))
|
||||
- generic [ref=e168]: public\index.php:20
|
||||
- button [ref=e170]:
|
||||
- img [ref=e171]
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]:
|
||||
- generic [ref=e177]:
|
||||
- img [ref=e179]
|
||||
- heading "Queries" [level=3] [ref=e181]
|
||||
- generic [ref=e183]: 1-10 of 90
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]:
|
||||
- generic [ref=e186]:
|
||||
- generic [ref=e187]:
|
||||
- img [ref=e188]
|
||||
- generic [ref=e190]: mysql
|
||||
- code [ref=e194]:
|
||||
- generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
|
||||
- generic [ref=e196]: 13.96ms
|
||||
- generic [ref=e197]:
|
||||
- generic [ref=e198]:
|
||||
- generic [ref=e199]:
|
||||
- img [ref=e200]
|
||||
- generic [ref=e202]: mysql
|
||||
- code [ref=e206]:
|
||||
- generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
|
||||
- generic [ref=e208]: 1.1ms
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]:
|
||||
- generic [ref=e211]:
|
||||
- img [ref=e212]
|
||||
- generic [ref=e214]: mysql
|
||||
- code [ref=e218]:
|
||||
- generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')"
|
||||
- generic [ref=e220]: 0.53ms
|
||||
- generic [ref=e221]:
|
||||
- generic [ref=e222]:
|
||||
- generic [ref=e223]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e226]: mysql
|
||||
- code [ref=e230]:
|
||||
- generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1"
|
||||
- generic [ref=e232]: 0.69ms
|
||||
- generic [ref=e233]:
|
||||
- generic [ref=e234]:
|
||||
- generic [ref=e235]:
|
||||
- img [ref=e236]
|
||||
- generic [ref=e238]: mysql
|
||||
- code [ref=e242]:
|
||||
- generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')"
|
||||
- generic [ref=e244]: 0.51ms
|
||||
- generic [ref=e245]:
|
||||
- generic [ref=e246]:
|
||||
- generic [ref=e247]:
|
||||
- img [ref=e248]
|
||||
- generic [ref=e250]: mysql
|
||||
- code [ref=e254]:
|
||||
- generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)"
|
||||
- generic [ref=e256]: 3.31ms
|
||||
- generic [ref=e257]:
|
||||
- generic [ref=e258]:
|
||||
- generic [ref=e259]:
|
||||
- img [ref=e260]
|
||||
- generic [ref=e262]: mysql
|
||||
- code [ref=e266]:
|
||||
- generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')"
|
||||
- generic [ref=e268]: 2.8ms
|
||||
- generic [ref=e269]:
|
||||
- generic [ref=e270]:
|
||||
- generic [ref=e271]:
|
||||
- img [ref=e272]
|
||||
- generic [ref=e274]: mysql
|
||||
- code [ref=e278]:
|
||||
- generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1"
|
||||
- generic [ref=e280]: 1.28ms
|
||||
- generic [ref=e281]:
|
||||
- generic [ref=e282]:
|
||||
- generic [ref=e283]:
|
||||
- img [ref=e284]
|
||||
- generic [ref=e286]: mysql
|
||||
- code [ref=e290]:
|
||||
- generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')"
|
||||
- generic [ref=e292]: 0.58ms
|
||||
- generic [ref=e293]:
|
||||
- generic [ref=e294]:
|
||||
- generic [ref=e295]:
|
||||
- img [ref=e296]
|
||||
- generic [ref=e298]: mysql
|
||||
- code [ref=e302]:
|
||||
- generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)"
|
||||
- generic [ref=e304]: 3.13ms
|
||||
- generic [ref=e305]:
|
||||
- button [disabled] [ref=e306]:
|
||||
- img [ref=e307]
|
||||
- button [disabled] [ref=e310]:
|
||||
- img [ref=e311]
|
||||
- button "1" [ref=e314] [cursor=pointer]
|
||||
- button "2" [ref=e316] [cursor=pointer]
|
||||
- button "3" [ref=e318] [cursor=pointer]
|
||||
- button "4" [ref=e320] [cursor=pointer]
|
||||
- button "5" [ref=e322] [cursor=pointer]
|
||||
- generic [ref=e324]: ...
|
||||
- button "9" [ref=e326] [cursor=pointer]
|
||||
- button [ref=e327] [cursor=pointer]:
|
||||
- img [ref=e328]
|
||||
- button [ref=e330] [cursor=pointer]:
|
||||
- img [ref=e331]
|
||||
- generic [ref=e335]:
|
||||
- generic [ref=e336]:
|
||||
- heading "Headers" [level=2] [ref=e337]
|
||||
- generic [ref=e338]:
|
||||
- generic [ref=e339]:
|
||||
- generic [ref=e340]: cookie
|
||||
- generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D
|
||||
- generic [ref=e343]:
|
||||
- generic [ref=e344]: accept-encoding
|
||||
- generic [ref=e346]: gzip, deflate
|
||||
- generic [ref=e347]:
|
||||
- generic [ref=e348]: accept
|
||||
- generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||
- generic [ref=e351]:
|
||||
- generic [ref=e352]: accept-language
|
||||
- generic [ref=e354]: en-US
|
||||
- generic [ref=e355]:
|
||||
- generic [ref=e356]: user-agent
|
||||
- generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36
|
||||
- generic [ref=e359]:
|
||||
- generic [ref=e360]: upgrade-insecure-requests
|
||||
- generic [ref=e362]: "1"
|
||||
- generic [ref=e363]:
|
||||
- generic [ref=e364]: connection
|
||||
- generic [ref=e366]: close
|
||||
- generic [ref=e367]:
|
||||
- generic [ref=e368]: host
|
||||
- generic [ref=e370]: skinbase26.test
|
||||
- generic [ref=e371]:
|
||||
- heading "Body" [level=2] [ref=e372]
|
||||
- generic [ref=e373]: // No request body
|
||||
- generic [ref=e374]:
|
||||
- heading "Routing" [level=2] [ref=e375]
|
||||
- generic [ref=e376]:
|
||||
- generic [ref=e377]:
|
||||
- generic [ref=e378]: controller
|
||||
- generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index
|
||||
- generic [ref=e381]:
|
||||
- generic [ref=e382]: route name
|
||||
- generic [ref=e384]: explore.index
|
||||
- generic [ref=e385]:
|
||||
- generic [ref=e386]: middleware
|
||||
- generic [ref=e388]: web
|
||||
- generic [ref=e389]:
|
||||
- heading "Routing parameters" [level=2] [ref=e390]
|
||||
- generic [ref=e391]: // No routing parameters
|
||||
- generic [ref=e394]:
|
||||
- img [ref=e396]
|
||||
- img [ref=e3434]
|
||||
- generic [ref=e6474]:
|
||||
- generic [ref=e6476]:
|
||||
- generic [ref=e6477] [cursor=pointer]:
|
||||
- generic: Request
|
||||
- generic [ref=e6478]: "500"
|
||||
- generic [ref=e6479] [cursor=pointer]:
|
||||
- generic: Exceptions
|
||||
- generic [ref=e6480]: "2"
|
||||
- generic [ref=e6481] [cursor=pointer]:
|
||||
- generic: Messages
|
||||
- generic [ref=e6482]: "5"
|
||||
- generic [ref=e6483] [cursor=pointer]:
|
||||
- generic: Timeline
|
||||
- generic [ref=e6484] [cursor=pointer]:
|
||||
- generic: Views
|
||||
- generic [ref=e6485]: "513"
|
||||
- generic [ref=e6486] [cursor=pointer]:
|
||||
- generic: Queries
|
||||
- generic [ref=e6487]: "91"
|
||||
- generic [ref=e6488] [cursor=pointer]:
|
||||
- generic: Models
|
||||
- generic [ref=e6489]: "156"
|
||||
- generic [ref=e6490] [cursor=pointer]:
|
||||
- generic: Cache
|
||||
- generic [ref=e6491]: "8"
|
||||
- generic [ref=e6492]:
|
||||
- generic [ref=e6499] [cursor=pointer]:
|
||||
- generic [ref=e6500]: "2"
|
||||
- generic [ref=e6501]: GET /explore
|
||||
- generic [ref=e6502] [cursor=pointer]:
|
||||
- generic: 4.91s
|
||||
- generic [ref=e6504] [cursor=pointer]:
|
||||
- generic: 51MB
|
||||
- generic [ref=e6506] [cursor=pointer]:
|
||||
- generic: 12.x
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
28
tests/cpad/auth.setup.ts
Normal file
28
tests/cpad/auth.setup.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* cPad Authentication Setup
|
||||
*
|
||||
* This file is matched by the `cpad-setup` Playwright project (see playwright.config.ts).
|
||||
* It runs ONCE before all cpad tests, logs in as admin, and saves the browser
|
||||
* storage state to tests/.auth/admin.json so subsequent tests skip the login step.
|
||||
*
|
||||
* Run only this setup step:
|
||||
* npx playwright test --project=cpad-setup
|
||||
*/
|
||||
|
||||
import { test as setup } from '@playwright/test';
|
||||
import { loginAsAdmin, AUTH_FILE } from '../helpers/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
setup('authenticate as cPad admin', async ({ page }) => {
|
||||
// Ensure the .auth directory exists
|
||||
const authDir = path.dirname(AUTH_FILE);
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
}
|
||||
|
||||
await loginAsAdmin(page);
|
||||
|
||||
// Persist cookies + localStorage for the cpad project
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
});
|
||||
149
tests/cpad/auth.spec.ts
Normal file
149
tests/cpad/auth.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Authentication tests for the cPad Control Panel.
|
||||
*
|
||||
* Coverage:
|
||||
* • Login page renders correctly
|
||||
* • Login with valid credentials → redirected to /cp/dashboard (or /cp/)
|
||||
* • Login with invalid credentials → error message shown, no redirect
|
||||
* • Logout → session is destroyed, login page is shown
|
||||
*
|
||||
* These tests do NOT use the pre-saved storageState; they exercise the actual
|
||||
* login/logout flow from scratch.
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/auth.spec.ts --project=chromium
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD,
|
||||
CP_PATH,
|
||||
DASHBOARD_PATH,
|
||||
attachErrorListeners,
|
||||
} from '../helpers/auth';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Login page
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Login Page', () => {
|
||||
test('login page loads and shows email + password fields', async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(CP_PATH + '/login');
|
||||
|
||||
// Basic page health
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
const title = await page.title();
|
||||
expect(title.length, 'Page title should not be empty').toBeGreaterThan(0);
|
||||
|
||||
// Form fields
|
||||
const emailField = page.locator('input[name="email"], input[type="email"]').first();
|
||||
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
|
||||
await expect(emailField).toBeVisible();
|
||||
await expect(passwordField).toBeVisible();
|
||||
await expect(submitButton).toBeVisible();
|
||||
|
||||
// No JS crashes on page load
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Successful login
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Successful Login', () => {
|
||||
test('admin can log in and is redirected to dashboard', async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(CP_PATH + '/login');
|
||||
|
||||
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
|
||||
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"], input[type="submit"]').first().click();
|
||||
|
||||
// After successful login the URL should be within /cp and not be /login
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// No server errors
|
||||
const body = await page.locator('body').textContent() ?? '';
|
||||
expect(/Whoops|Server Error|500/.test(body), 'Server error page after login').toBe(false);
|
||||
|
||||
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Failed login
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Failed Login', () => {
|
||||
test('wrong password shows error and stays on login page', async ({ page }) => {
|
||||
const { networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(CP_PATH + '/login');
|
||||
|
||||
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
|
||||
await page.locator('input[name="password"], input[type="password"]').first().fill('WrongPassword999!');
|
||||
await page.locator('button[type="submit"], input[type="submit"]').first().click();
|
||||
|
||||
// Should stay on the login page
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(page.url()).toContain('/login');
|
||||
|
||||
// No 5xx errors from a bad credentials attempt
|
||||
expect(networkErrors.length, `HTTP 5xx errors: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test('empty credentials show validation errors', async ({ page }) => {
|
||||
await page.goto(CP_PATH + '/login');
|
||||
|
||||
// Submit without filling in anything
|
||||
await page.locator('button[type="submit"], input[type="submit"]').first().click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Still on login page
|
||||
expect(page.url()).toContain(CP_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Logout
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Logout', () => {
|
||||
test('logout destroys session and shows login page', async ({ page }) => {
|
||||
// Log in first
|
||||
await page.goto(CP_PATH + '/login');
|
||||
await page.locator('input[name="email"], input[type="email"]').first().fill(ADMIN_EMAIL);
|
||||
await page.locator('input[name="password"], input[type="password"]').first().fill(ADMIN_PASSWORD);
|
||||
await page.locator('button[type="submit"], input[type="submit"]').first().click();
|
||||
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
// Perform logout via the /cp/logout route
|
||||
await page.goto(CP_PATH + '/logout');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should land on the login page again
|
||||
expect(page.url()).toContain('/login');
|
||||
|
||||
// Attempting to access dashboard now should redirect back to login
|
||||
await page.goto(DASHBOARD_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(page.url()).toContain('/login');
|
||||
});
|
||||
});
|
||||
122
tests/cpad/dashboard.spec.ts
Normal file
122
tests/cpad/dashboard.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Dashboard tests for the cPad Control Panel.
|
||||
*
|
||||
* Uses the pre-saved authenticated session (tests/.auth/admin.json) so no
|
||||
* explicit login is required in each test.
|
||||
*
|
||||
* Coverage:
|
||||
* • Dashboard page loads (HTTP 200, not a Laravel error page)
|
||||
* • Page title is not empty
|
||||
* • No JavaScript console errors
|
||||
* • No HTTP 5xx responses
|
||||
* • Key dashboard elements are visible (sidebar, main content area)
|
||||
* • Dashboard setup page loads
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/dashboard.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { DASHBOARD_PATH, CP_PATH, attachErrorListeners } from '../helpers/auth';
|
||||
|
||||
test.describe('cPad Dashboard', () => {
|
||||
test('dashboard page loads without errors', async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(DASHBOARD_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Must not be redirected to login
|
||||
expect(page.url(), 'Should not redirect to login').not.toContain('/login');
|
||||
|
||||
// Title check
|
||||
const title = await page.title();
|
||||
expect(title.length, 'Page must have a non-empty title').toBeGreaterThan(0);
|
||||
|
||||
// Body must be visible
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// No server error page
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
expect(/Whoops|Server Error|5[0-9]{2}/.test(bodyText), 'Server error detected in body').toBe(false);
|
||||
|
||||
// No JS exceptions
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test('dashboard root redirect works', async ({ page }) => {
|
||||
const { networkErrors } = attachErrorListeners(page);
|
||||
|
||||
// /cp redirects to /cp/dashboard
|
||||
await page.goto(CP_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url(), 'Should not be on login page').not.toContain('/login');
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test('dashboard has visible sidebar or navigation', async ({ page }) => {
|
||||
await page.goto(DASHBOARD_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check for a nav/sidebar element — any of these common AdminLTE/Bootstrap selectors
|
||||
const navSelectors = [
|
||||
'nav',
|
||||
'.sidebar',
|
||||
'#sidebar',
|
||||
'.main-sidebar',
|
||||
'#main-sidebar',
|
||||
'[data-testid="sidebar"]',
|
||||
'.navbar',
|
||||
];
|
||||
|
||||
let foundNav = false;
|
||||
for (const sel of navSelectors) {
|
||||
const count = await page.locator(sel).count();
|
||||
if (count > 0) {
|
||||
foundNav = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundNav, 'Dashboard should contain a sidebar or navigation element').toBe(true);
|
||||
});
|
||||
|
||||
test('dashboard main content area is visible', async ({ page }) => {
|
||||
await page.goto(DASHBOARD_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Any of these indicate a main content wrapper is rendered
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'#content',
|
||||
'.content-wrapper',
|
||||
'.main-content',
|
||||
'[data-testid="main-content"]',
|
||||
'.wrapper',
|
||||
];
|
||||
|
||||
let foundContent = false;
|
||||
for (const sel of contentSelectors) {
|
||||
const count = await page.locator(sel).count();
|
||||
if (count > 0) {
|
||||
foundContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundContent, 'Dashboard main content area should be present').toBe(true);
|
||||
});
|
||||
|
||||
test('dashboard setup page loads', async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(CP_PATH + '/dashboard/setup');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).not.toContain('/login');
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `Network errors: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
});
|
||||
123
tests/cpad/modules/configuration.spec.ts
Normal file
123
tests/cpad/modules/configuration.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Configuration module tests for cPad.
|
||||
*
|
||||
* Routes:
|
||||
* /cp/configuration — main configuration overview (alias)
|
||||
* /cp/config — canonical config route
|
||||
*
|
||||
* Coverage:
|
||||
* • Both config URLs load without errors
|
||||
* • No console/server errors
|
||||
* • Configuration form is present and contains input elements
|
||||
* • Smart form filler can fill the form without crashing
|
||||
* • Tab-based navigation within config (if applicable) works
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/modules/configuration.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
|
||||
import { fillForm, hasForm } from '../../helpers/formFiller';
|
||||
|
||||
const CONFIG_URLS = [
|
||||
`${CP_PATH}/configuration`,
|
||||
`${CP_PATH}/config`,
|
||||
] as const;
|
||||
|
||||
test.describe('cPad Configuration Module', () => {
|
||||
for (const configUrl of CONFIG_URLS) {
|
||||
test(`config page (${configUrl}) loads without errors`, async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(configUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).not.toContain('/login');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
|
||||
expect(hasError, `Server error at ${configUrl}`).toBe(false);
|
||||
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('configuration page (/cp/config) contains a form or settings inputs', async ({ page }) => {
|
||||
await page.goto(`${CP_PATH}/config`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const pageHasForm = await hasForm(page);
|
||||
expect(pageHasForm, '/cp/config should contain a form or input fields').toBe(true);
|
||||
});
|
||||
|
||||
test('smart form filler can fill configuration form without errors', async ({ page }) => {
|
||||
const { networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(`${CP_PATH}/config`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill the form — should not throw
|
||||
await fillForm(page);
|
||||
|
||||
// No 5xx errors triggered by the fill operations
|
||||
expect(networkErrors.length, `HTTP 5xx during form fill: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test('configuration tab navigation works (if tabs present)', async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(`${CP_PATH}/config`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Look for tab buttons (Bootstrap / AdminLTE tabs)
|
||||
const tabSelectors = [
|
||||
'[role="tab"]',
|
||||
'.nav-tabs .nav-link',
|
||||
'.nav-pills .nav-link',
|
||||
'a[data-toggle="tab"]',
|
||||
'a[data-bs-toggle="tab"]',
|
||||
];
|
||||
|
||||
for (const sel of tabSelectors) {
|
||||
const tabs = page.locator(sel);
|
||||
const count = await tabs.count();
|
||||
|
||||
if (count > 1) {
|
||||
// Click each tab and verify no server errors
|
||||
for (let i = 0; i < Math.min(count, 8); i++) {
|
||||
const tab = tabs.nth(i);
|
||||
if (await tab.isVisible()) {
|
||||
await tab.click();
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => null);
|
||||
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
const hasError = /Whoops|Server Error|SQLSTATE/.test(bodyText);
|
||||
expect(hasError, `Error after clicking tab ${i} at ${sel}`).toBe(false);
|
||||
}
|
||||
}
|
||||
break; // found tabs, done
|
||||
}
|
||||
}
|
||||
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
});
|
||||
94
tests/cpad/modules/languages.spec.ts
Normal file
94
tests/cpad/modules/languages.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Languages module tests for cPad.
|
||||
*
|
||||
* Routes under /cp/language/{type} — type can be: app, system
|
||||
*
|
||||
* Coverage:
|
||||
* • Language list pages load without errors
|
||||
* • No console/server errors
|
||||
* • Add-language page loads
|
||||
* • (Optional) CRUD flow when list data is available
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/modules/languages.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
|
||||
import { hasListData, tryCrudCreate } from '../../helpers/crudHelper';
|
||||
|
||||
const LANGUAGE_TYPES = ['app', 'system'] as const;
|
||||
|
||||
test.describe('cPad Languages Module', () => {
|
||||
for (const type of LANGUAGE_TYPES) {
|
||||
const basePath = `${CP_PATH}/language/${type}`;
|
||||
|
||||
test(`language list (${type}) loads without errors`, async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(basePath);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).not.toContain('/login');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
|
||||
expect(hasError, `Server error on ${basePath}`).toBe(false);
|
||||
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test(`language add page (${type}) loads`, async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(`${basePath}/add`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(page.url()).not.toContain('/login');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
expect(consoleErrors.length, `Console errors: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
|
||||
test(`language list (${type}) shows table or empty state`, async ({ page }) => {
|
||||
await page.goto(basePath);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Either a data table OR a "no data" message must be present
|
||||
const tableCount = await page.locator('table').count();
|
||||
const emptyMsgCount = await page.locator(
|
||||
':has-text("No languages"), :has-text("No records"), :has-text("Empty")',
|
||||
).count();
|
||||
|
||||
expect(tableCount + emptyMsgCount, 'Should show a table or an empty-state message').toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('language list (app) — CRUD: add language form submission', async ({ page }) => {
|
||||
const { networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(`${CP_PATH}/language/app/add`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Skipped: not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try submitting the add form
|
||||
const didCreate = await tryCrudCreate(page);
|
||||
if (!didCreate) {
|
||||
// The add page itself is the form; submit directly
|
||||
const submit = page.locator('button[type=submit], input[type=submit]').first();
|
||||
if (await submit.count() > 0 && await submit.isVisible()) {
|
||||
await submit.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
|
||||
expect(networkErrors.length, `HTTP 5xx after form submit: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
});
|
||||
});
|
||||
112
tests/cpad/modules/translations.spec.ts
Normal file
112
tests/cpad/modules/translations.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Translations module tests for cPad.
|
||||
*
|
||||
* Routes under /cp/translation/{file}
|
||||
* The most common translation file is "app".
|
||||
*
|
||||
* Coverage:
|
||||
* • Main translation index page loads
|
||||
* • Translation list page for "app" file loads
|
||||
* • Add translation entry page loads
|
||||
* • Translation grid page loads
|
||||
* • No console/server errors on any page
|
||||
* • (Optional) inline edit via CRUD helper
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/modules/translations.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CP_PATH, attachErrorListeners } from '../../helpers/auth';
|
||||
|
||||
/** Translation file slugs to probe — add more as needed */
|
||||
const TRANSLATION_FILES = ['app'] as const;
|
||||
|
||||
/** Reusable page-health assertion */
|
||||
async function assertPageHealthy(page: import('@playwright/test').Page, path: string) {
|
||||
const consoleErrors: string[] = [];
|
||||
const networkErrors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(err.message));
|
||||
page.on('response', (res) => {
|
||||
if (res.status() >= 500) networkErrors.push(`HTTP ${res.status()}: ${res.url()}`);
|
||||
});
|
||||
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 });
|
||||
|
||||
expect(page.url(), `${path} must not redirect to login`).not.toContain('/login');
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
const hasError = /Whoops|Server Error|SQLSTATE|Call to undefined/.test(bodyText);
|
||||
expect(hasError, `Server error at ${path}`).toBe(false);
|
||||
|
||||
expect(consoleErrors.length, `Console errors at ${path}: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
expect(networkErrors.length, `HTTP 5xx at ${path}: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
}
|
||||
|
||||
test.describe('cPad Translations Module', () => {
|
||||
for (const file of TRANSLATION_FILES) {
|
||||
const base = `${CP_PATH}/translation/${file}`;
|
||||
|
||||
test(`translation main page (${file}) loads`, async ({ page }) => {
|
||||
await assertPageHealthy(page, base);
|
||||
});
|
||||
|
||||
test(`translation list page (${file}) loads`, async ({ page }) => {
|
||||
await assertPageHealthy(page, `${base}/list`);
|
||||
});
|
||||
|
||||
test(`translation add page (${file}) loads`, async ({ page }) => {
|
||||
await assertPageHealthy(page, `${base}/add`);
|
||||
});
|
||||
|
||||
test(`translation grid page (${file}) loads`, async ({ page }) => {
|
||||
await assertPageHealthy(page, `${base}/grid`);
|
||||
});
|
||||
|
||||
test(`translation list (${file}) renders content`, async ({ page }) => {
|
||||
await page.goto(`${base}/list`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// A table, a grid element, or an empty-state message should be present
|
||||
const tableCount = await page.locator('table, .grid, [class*="grid"]').count();
|
||||
const emptyMsgCount = await page.locator(
|
||||
':has-text("No translations"), :has-text("No records"), :has-text("Empty")',
|
||||
).count();
|
||||
const inputCount = await page.locator('input[type=text], textarea').count();
|
||||
|
||||
expect(
|
||||
tableCount + emptyMsgCount + inputCount,
|
||||
'Translation list should contain table, grid, empty state, or editable fields',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test(`translation add page (${file}) contains a form`, async ({ page }) => {
|
||||
await page.goto(`${base}/add`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
test.skip(true, 'Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const formCount = await page.locator('form').count();
|
||||
const inputCount = await page.locator('input:not([type=hidden]), textarea').count();
|
||||
|
||||
expect(
|
||||
formCount + inputCount,
|
||||
'Add translation page should contain a form or input fields',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
95
tests/cpad/navigation-discovery.spec.ts
Normal file
95
tests/cpad/navigation-discovery.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Navigation Discovery — scans the cPad sidebar and collects all /cp links.
|
||||
*
|
||||
* This test acts as both:
|
||||
* 1. A standalone spec that validates the nav scan returns ≥ 1 link.
|
||||
* 2. A data producer: it writes the discovered URLs to
|
||||
* tests/.discovered/cpad-links.json so that navigation.spec.ts can
|
||||
* consume them dynamically.
|
||||
*
|
||||
* Ignored links:
|
||||
* • /cp/logout
|
||||
* • javascript:void / # anchors
|
||||
* • External URLs (not starting with /cp)
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/navigation-discovery.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { DASHBOARD_PATH, CP_PATH } from '../helpers/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DISCOVERED_DIR = path.join('tests', '.discovered');
|
||||
const DISCOVERED_FILE = path.join(DISCOVERED_DIR, 'cpad-links.json');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if the href should be included in navigation tests */
|
||||
function isNavigableLink(href: string | null): boolean {
|
||||
if (!href) return false;
|
||||
if (!href.startsWith(CP_PATH)) return false;
|
||||
if (href.includes('/logout')) return false;
|
||||
if (href.startsWith('javascript:')) return false;
|
||||
if (href === CP_PATH || href === CP_PATH + '/') return false; // exclude root (same as dashboard)
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove query strings and anchors for clean deduplication */
|
||||
function normalise(href: string): string {
|
||||
try {
|
||||
const u = new URL(href, 'http://placeholder');
|
||||
return u.pathname;
|
||||
} catch {
|
||||
return href.split('?')[0].split('#')[0];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Discovery test
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Navigation Discovery', () => {
|
||||
test('scan sidebar and collect all /cp navigation links', async ({ page }) => {
|
||||
await page.goto(DASHBOARD_PATH);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Ensure we're not on the login page
|
||||
expect(page.url()).not.toContain('/login');
|
||||
|
||||
// ── Widen the scan to cover lazy-loaded submenu items ──────────────────
|
||||
// Hover over sidebar nav items to expand hidden submenus
|
||||
const menuItems = page.locator('.nav-item, .sidebar-item, .menu-item, li.nav-item');
|
||||
const menuCount = await menuItems.count();
|
||||
for (let i = 0; i < Math.min(menuCount, 30); i++) {
|
||||
await menuItems.nth(i).hover().catch(() => null);
|
||||
}
|
||||
|
||||
// ── Collect all anchor hrefs ───────────────────────────────────────────
|
||||
const rawHrefs: string[] = await page.$$eval('a[href]', (anchors) =>
|
||||
anchors.map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? ''),
|
||||
);
|
||||
|
||||
const discovered = [...new Set(
|
||||
rawHrefs
|
||||
.map((h) => normalise(h))
|
||||
.filter(isNavigableLink),
|
||||
)].sort();
|
||||
|
||||
console.log(`[discovery] Found ${discovered.length} navigable /cp links`);
|
||||
discovered.forEach((l) => console.log(' •', l));
|
||||
|
||||
// Must find at least a few links — if not, something is wrong with auth
|
||||
expect(discovered.length, 'Expected to find /cp navigation links').toBeGreaterThanOrEqual(1);
|
||||
|
||||
// ── Persist for navigation.spec.ts ────────────────────────────────────
|
||||
if (!fs.existsSync(DISCOVERED_DIR)) {
|
||||
fs.mkdirSync(DISCOVERED_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(DISCOVERED_FILE, JSON.stringify(discovered, null, 2), 'utf8');
|
||||
console.log(`[discovery] Links saved to ${DISCOVERED_FILE}`);
|
||||
});
|
||||
});
|
||||
107
tests/cpad/navigation.spec.ts
Normal file
107
tests/cpad/navigation.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Navigation health test for the cPad Control Panel.
|
||||
*
|
||||
* Two operating modes:
|
||||
*
|
||||
* 1. Dynamic — reads tests/.discovered/cpad-links.json (written by
|
||||
* navigation-discovery.spec.ts) and visits every link.
|
||||
* Run the discovery spec first to populate this file.
|
||||
*
|
||||
* 2. Fallback — when the discovery file is absent, falls back to a static
|
||||
* list of known /cp routes so the suite never fails silently.
|
||||
*
|
||||
* For every visited page the test asserts:
|
||||
* • No redirect to /cp/login (i.e. session is still valid)
|
||||
* • HTTP status not 5xx (detected via response interception)
|
||||
* • No uncaught JavaScript exceptions
|
||||
* • No browser console errors
|
||||
* • Page body is visible and non-empty
|
||||
* • Page does not contain Laravel error text
|
||||
*
|
||||
* Run:
|
||||
* npx playwright test tests/cpad/navigation.spec.ts --project=cpad
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CP_PATH, attachErrorListeners } from '../helpers/auth';
|
||||
import { hasForm } from '../helpers/formFiller';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Link source
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DISCOVERED_FILE = path.join('tests', '.discovered', 'cpad-links.json');
|
||||
|
||||
/** Well-known /cp pages used when the discovery file is missing */
|
||||
const STATIC_FALLBACK_LINKS: string[] = [
|
||||
'/cp/dashboard',
|
||||
'/cp/configuration',
|
||||
'/cp/config',
|
||||
'/cp/language/app',
|
||||
'/cp/language/system',
|
||||
'/cp/translation/app',
|
||||
'/cp/security/access',
|
||||
'/cp/security/roles',
|
||||
'/cp/security/permissions',
|
||||
'/cp/security/login',
|
||||
'/cp/plugins',
|
||||
'/cp/user/profile',
|
||||
'/cp/messages',
|
||||
'/cp/api/keys',
|
||||
'/cp/friendly-url',
|
||||
];
|
||||
|
||||
function loadLinks(): string[] {
|
||||
if (fs.existsSync(DISCOVERED_FILE)) {
|
||||
try {
|
||||
const links: string[] = JSON.parse(fs.readFileSync(DISCOVERED_FILE, 'utf8'));
|
||||
if (links.length > 0) return links;
|
||||
} catch { /* fall through to static list */ }
|
||||
}
|
||||
console.warn('[navigation] discovery file not found — using static fallback list');
|
||||
return STATIC_FALLBACK_LINKS;
|
||||
}
|
||||
|
||||
const CP_LINKS = loadLinks();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main navigation suite
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('cPad Navigation Health', () => {
|
||||
for (const linkPath of CP_LINKS) {
|
||||
test(`page loads: ${linkPath}`, async ({ page }) => {
|
||||
const { consoleErrors, networkErrors } = attachErrorListeners(page);
|
||||
|
||||
await page.goto(linkPath);
|
||||
await page.waitForLoadState('networkidle', { timeout: 20_000 });
|
||||
|
||||
// ── Auth check ──────────────────────────────────────────────────────
|
||||
expect(page.url(), `${linkPath} should not redirect to login`).not.toContain('/login');
|
||||
|
||||
// ── HTTP errors ─────────────────────────────────────────────────────
|
||||
expect(networkErrors.length, `HTTP 5xx on ${linkPath}: ${networkErrors.join(' | ')}`).toBe(0);
|
||||
|
||||
// ── Body visibility ─────────────────────────────────────────────────
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
const bodyText = await page.locator('body').textContent() ?? '';
|
||||
expect(bodyText.trim().length, `Body should not be empty on ${linkPath}`).toBeGreaterThan(0);
|
||||
|
||||
// ── Laravel / server error page ──────────────────────────────────────
|
||||
const hasServerError = /Whoops[,!]|Server Error|Call to undefined function|SQLSTATE/.test(bodyText);
|
||||
expect(hasServerError, `Server/Laravel error page at ${linkPath}: ${bodyText.slice(0, 200)}`).toBe(false);
|
||||
|
||||
// ── JS exceptions ────────────────────────────────────────────────────
|
||||
expect(consoleErrors.length, `JS console errors on ${linkPath}: ${consoleErrors.join(' | ')}`).toBe(0);
|
||||
|
||||
// ── Form detection (informational — logged, not asserted) ─────────────
|
||||
const pageHasForm = await hasForm(page);
|
||||
if (pageHasForm) {
|
||||
console.log(` [form] form detected on ${linkPath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
93
tests/helpers/auth.ts
Normal file
93
tests/helpers/auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Authentication helper for cPad Control Panel tests.
|
||||
*
|
||||
* Usage in tests that do NOT use the pre-saved storageState:
|
||||
* import { loginAsAdmin } from '../helpers/auth';
|
||||
* await loginAsAdmin(page);
|
||||
*
|
||||
* The cpad-setup project (auth.setup.ts) calls loginAsAdmin once and persists
|
||||
* the session to tests/.auth/admin.json so all other cpad tests reuse it.
|
||||
*/
|
||||
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
export const ADMIN_EMAIL = 'gregor@klevze.si';
|
||||
export const ADMIN_PASSWORD = 'Gre15#10gor!1976$';
|
||||
export const CP_PATH = '/cp';
|
||||
export const DASHBOARD_PATH = '/cp/dashboard';
|
||||
export const AUTH_FILE = path.join('tests', '.auth', 'admin.json');
|
||||
|
||||
/**
|
||||
* Perform a full cPad login and wait for the dashboard to load.
|
||||
* Verifies the redirect ends up on a /cp/dashboard URL.
|
||||
*/
|
||||
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||
// Attach console-error listener so callers can detect JS exceptions
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
console.warn(`[CONSOLE ERROR] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(CP_PATH);
|
||||
|
||||
// Wait for the login form to be ready
|
||||
const emailField = page.locator('input[name="email"], input[type="email"]').first();
|
||||
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
|
||||
await emailField.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await emailField.fill(ADMIN_EMAIL);
|
||||
await passwordField.fill(ADMIN_PASSWORD);
|
||||
await submitButton.click();
|
||||
|
||||
// After login, the panel may show a 2FA screen or land on dashboard
|
||||
// Wait up to 20 s for a URL that contains /cp (but isn't the login page)
|
||||
await page.waitForURL((url) => url.pathname.startsWith(CP_PATH) && !url.pathname.endsWith('/login'), {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the current page is the cPad dashboard.
|
||||
* Call this *after* loginAsAdmin to assert a successful login.
|
||||
*/
|
||||
export async function assertDashboard(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(new RegExp(`${CP_PATH}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach console-error and network-failure listeners to the page.
|
||||
* Returns arrays that accumulate errors so the calling test can assert them.
|
||||
*/
|
||||
export function attachErrorListeners(page: Page): {
|
||||
consoleErrors: string[];
|
||||
networkErrors: string[];
|
||||
} {
|
||||
const consoleErrors: string[] = [];
|
||||
const networkErrors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (err) => {
|
||||
consoleErrors.push(`[pageerror] ${err.message}`);
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const status = response.status();
|
||||
if (status >= 500) {
|
||||
networkErrors.push(`HTTP ${status}: ${response.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
networkErrors.push(`[requestfailed] ${request.url()} — ${request.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
return { consoleErrors, networkErrors };
|
||||
}
|
||||
147
tests/helpers/crudHelper.ts
Normal file
147
tests/helpers/crudHelper.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* CRUD Helper for cPad module tests.
|
||||
*
|
||||
* Provides reusable functions that detect and execute common CRUD operations
|
||||
* (Create, Edit, Delete) through the cPad UI without hard-coding selectors
|
||||
* for every individual module.
|
||||
*
|
||||
* Strategy:
|
||||
* • "Create" — look for button text: Create / Add / New
|
||||
* • "Edit" — look for button/link text: Edit, or table action icons
|
||||
* • "Delete" — look for button/link text: Delete / Remove (with confirmation handling)
|
||||
*
|
||||
* Each function returns false if the appropriate trigger could not be found,
|
||||
* allowing callers to skip gracefully when a module lacks a given operation.
|
||||
*/
|
||||
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { fillForm } from './formFiller';
|
||||
|
||||
// Selectors used to locate CRUD triggers (order = priority)
|
||||
const CREATE_SELECTORS = [
|
||||
'a:has-text("Create")',
|
||||
'a:has-text("Add")',
|
||||
'a:has-text("New")',
|
||||
'button:has-text("Create")',
|
||||
'button:has-text("Add")',
|
||||
'button:has-text("New")',
|
||||
'[data-testid="btn-create"]',
|
||||
'[data-testid="btn-add"]',
|
||||
];
|
||||
|
||||
const EDIT_SELECTORS = [
|
||||
'a:has-text("Edit")',
|
||||
'button:has-text("Edit")',
|
||||
'td a[href*="/edit/"]',
|
||||
'[data-testid="btn-edit"]',
|
||||
];
|
||||
|
||||
const DELETE_SELECTORS = [
|
||||
'a:has-text("Delete")',
|
||||
'button:has-text("Delete")',
|
||||
'a:has-text("Remove")',
|
||||
'button:has-text("Remove")',
|
||||
'[data-testid="btn-delete"]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Try to find and click a Create/Add/New button, fill the resulting form,
|
||||
* submit it, and verify the page does not show a 500 error.
|
||||
*
|
||||
* @returns true if a create button was found and the flow completed without crashing.
|
||||
*/
|
||||
export async function tryCrudCreate(page: Page): Promise<boolean> {
|
||||
for (const selector of CREATE_SELECTORS) {
|
||||
const btn = page.locator(selector).first();
|
||||
if (await btn.count() > 0 && await btn.isVisible()) {
|
||||
await btn.click();
|
||||
|
||||
// Wait for either a form or a new page section to appear
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
// Fill any visible form
|
||||
await fillForm(page);
|
||||
|
||||
// Look for a submit button
|
||||
const submit = page.locator('button[type=submit], input[type=submit]').first();
|
||||
if (await submit.count() > 0 && await submit.isVisible()) {
|
||||
await submit.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
// Fail if a Laravel/server error page appeared
|
||||
const body = await page.locator('body').textContent() ?? '';
|
||||
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
|
||||
expect(hasServerError, `Server error after create on ${page.url()}`).toBe(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find and click the first Edit button/link on the page, fill the form,
|
||||
* and submit.
|
||||
*
|
||||
* @returns true if an edit trigger was found.
|
||||
*/
|
||||
export async function tryCrudEdit(page: Page): Promise<boolean> {
|
||||
for (const selector of EDIT_SELECTORS) {
|
||||
const btn = page.locator(selector).first();
|
||||
if (await btn.count() > 0 && await btn.isVisible()) {
|
||||
await btn.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
await fillForm(page);
|
||||
|
||||
const submit = page.locator('button[type=submit], input[type=submit]').first();
|
||||
if (await submit.count() > 0 && await submit.isVisible()) {
|
||||
await submit.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
const body = await page.locator('body').textContent() ?? '';
|
||||
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
|
||||
expect(hasServerError, `Server error after edit on ${page.url()}`).toBe(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to click the first delete trigger, handle a confirmation dialog if one
|
||||
* appears, and assert no server error is displayed.
|
||||
*
|
||||
* @returns true if a delete trigger was found.
|
||||
*/
|
||||
export async function tryCrudDelete(page: Page): Promise<boolean> {
|
||||
for (const selector of DELETE_SELECTORS) {
|
||||
const btn = page.locator(selector).first();
|
||||
if (await btn.count() > 0 && await btn.isVisible()) {
|
||||
// Some delete links pop a browser confirm() dialog
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await btn.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null);
|
||||
|
||||
const body = await page.locator('body').textContent() ?? '';
|
||||
const hasServerError = /Whoops|Server Error|500|SQLSTATE|Call to undefined/i.test(body);
|
||||
expect(hasServerError, `Server error after delete on ${page.url()}`).toBe(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check whether the page contains any list/table data (i.e. the module
|
||||
* has records to operate on).
|
||||
*/
|
||||
export async function hasListData(page: Page): Promise<boolean> {
|
||||
const tableRows = await page.locator('table tbody tr').count();
|
||||
return tableRows > 0;
|
||||
}
|
||||
127
tests/helpers/formFiller.ts
Normal file
127
tests/helpers/formFiller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Smart Form Filler helper for cPad tests.
|
||||
*
|
||||
* Detects all interactive form controls on a page (or within a locator scope)
|
||||
* and fills them with sensible test values so form-validation tests do not
|
||||
* need to enumerate every field manually.
|
||||
*
|
||||
* Supported field types:
|
||||
* text, email, number, url, tel, search, password, date, time, textarea,
|
||||
* checkbox, radio, select, file (skipped)
|
||||
*
|
||||
* Hidden fields and readonly fields are ignored.
|
||||
*/
|
||||
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/** Seed text used for text-like inputs */
|
||||
const RANDOM_TEXT = `Test_${Date.now()}`;
|
||||
const RANDOM_EMAIL = 'playwright-test@test.com';
|
||||
const RANDOM_NUM = '42';
|
||||
const RANDOM_URL = 'https://skinbase.test';
|
||||
const RANDOM_DATE = '2025-01-01';
|
||||
const RANDOM_TIME = '09:00';
|
||||
const RANDOM_TEXTAREA = `Automated test content generated at ${new Date().toISOString()}.`;
|
||||
|
||||
/**
|
||||
* Fill all detectable form controls inside `scope` (defaults to the whole page).
|
||||
*
|
||||
* @param page Playwright Page object (used for evaluate calls)
|
||||
* @param scope Optional Locator to restrict filling to a specific container
|
||||
*/
|
||||
export async function fillForm(page: Page, scope?: Locator): Promise<void> {
|
||||
const root: Page | Locator = scope ?? page;
|
||||
|
||||
// ── text / email / url / tel / search / password / date / time ──────────
|
||||
const textInputs = root.locator(
|
||||
'input:not([type=hidden]):not([type=submit]):not([type=button])' +
|
||||
':not([type=reset]):not([type=file]):not([type=checkbox]):not([type=radio])' +
|
||||
':not([readonly]):not([disabled])',
|
||||
);
|
||||
|
||||
const inputCount = await textInputs.count();
|
||||
for (let i = 0; i < inputCount; i++) {
|
||||
const input = textInputs.nth(i);
|
||||
const type = (await input.getAttribute('type'))?.toLowerCase() ?? 'text';
|
||||
const name = (await input.getAttribute('name')) ?? '';
|
||||
|
||||
// Skip honeypot / internal fields whose names suggest they must stay empty
|
||||
if (/honeypot|_token|csrf/i.test(name)) continue;
|
||||
|
||||
try {
|
||||
await input.scrollIntoViewIfNeeded();
|
||||
|
||||
switch (type) {
|
||||
case 'email':
|
||||
await input.fill(RANDOM_EMAIL);
|
||||
break;
|
||||
case 'number':
|
||||
case 'range':
|
||||
await input.fill(RANDOM_NUM);
|
||||
break;
|
||||
case 'url':
|
||||
await input.fill(RANDOM_URL);
|
||||
break;
|
||||
case 'date':
|
||||
await input.fill(RANDOM_DATE);
|
||||
break;
|
||||
case 'time':
|
||||
await input.fill(RANDOM_TIME);
|
||||
break;
|
||||
case 'password':
|
||||
await input.fill(`Pw@${Date.now()}`);
|
||||
break;
|
||||
default:
|
||||
await input.fill(RANDOM_TEXT);
|
||||
}
|
||||
} catch {
|
||||
// Field might have become detached or invisible — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
// ── textarea ─────────────────────────────────────────────────────────────
|
||||
const textareas = root.locator('textarea:not([readonly]):not([disabled])');
|
||||
const taCount = await textareas.count();
|
||||
for (let i = 0; i < taCount; i++) {
|
||||
try {
|
||||
await textareas.nth(i).scrollIntoViewIfNeeded();
|
||||
await textareas.nth(i).fill(RANDOM_TEXTAREA);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// ── select ────────────────────────────────────────────────────────────────
|
||||
const selects = root.locator('select:not([disabled])');
|
||||
const selCount = await selects.count();
|
||||
for (let i = 0; i < selCount; i++) {
|
||||
try {
|
||||
// Pick the first non-empty option
|
||||
const firstOption = await selects.nth(i).locator('option:not([value=""])').first().getAttribute('value');
|
||||
if (firstOption !== null) {
|
||||
await selects.nth(i).selectOption(firstOption);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// ── checkboxes (toggle unchecked → checked) ───────────────────────────────
|
||||
const checkboxes = root.locator('input[type=checkbox]:not([disabled])');
|
||||
const cbCount = await checkboxes.count();
|
||||
for (let i = 0; i < cbCount; i++) {
|
||||
try {
|
||||
const isChecked = await checkboxes.nth(i).isChecked();
|
||||
if (!isChecked) {
|
||||
await checkboxes.nth(i).check();
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the given scope contains a visible, submittable form.
|
||||
* Returns true if any <form>, submit button, or text input is found.
|
||||
*/
|
||||
export async function hasForm(scope: Page | Locator): Promise<boolean> {
|
||||
const formCount = await scope.locator('form').count();
|
||||
const submitCount = await scope.locator('button[type=submit], input[type=submit]').count();
|
||||
const inputCount = await scope.locator('input:not([type=hidden])').count();
|
||||
return formCount > 0 || submitCount > 0 || inputCount > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user