Frontend Contract Test
tests/Feature/Panel/FrontendContractTest.php melakukan assertion terhadap file, bukan terhadap perilaku runtime. Ini memang tidak biasa, dan justru itulah tujuannya. Kegagalan yang ditangkap di sini semuanya dapat terjadi secara diam-diam di aplikasi dan tidak terlihat oleh test PHP yang hanya menguji server: page panel tanpa layout dapat dirender di dalam shell milik host dan tetap menjawab 200; composable yang membaca usePage().props.panel secara langsung dapat lolos type-check di repository ini tetapi gagal di project nyata. Tidak ada cara lain yang dapat menjangkau kedua kegagalan tersebut dari PHP suite, sehingga file sumber itu sendiri menjadi objek yang diuji.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
it('declares a layout on every published panel page', function (): void {
$without = [];
foreach (File::allFiles(base_path('resources/js/pages/panel')) as $file) {
if (! str_contains(File::get($file->getPathname()), 'defineOptions({ layout:')) {
$without[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
// A page that names no layout takes whatever the application's resolver
// gives a page it has no case for, which on a starter kit is the signed-in
// application shell. The panel then renders with the host's sidebar and
// its own navigation nowhere, at HTTP 200, with nothing logged.
expect($without)->toBe([]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Kumpulkan seluruh file yang melanggar lalu pastikan daftar tersebut kosong, alih-alih melakukan assertion di dalam loop. Jika test gagal, hasilnya akan menampilkan semua file bermasalah sekaligus dan tidak berhenti pada file pertama.
Mengapa ini berupa test PHP
Package tidak mengirim JavaScript test runner. package.json memiliki lint, format:check, typecheck, dan build, dan keempatnya menangkap hal-hal yang dapat diperiksa oleh type system serta toolchain. Yang tidak dapat mereka tangkap adalah sebuah convention: file yang dapat dikompilasi, lolos type-check, dan dapat dirender, tetapi tetap salah karena sesuatu yang seharusnya ada justru tidak ditulis. Kasus seperti ini adalah assertion string terhadap source file, dan PHP adalah tempat suite pengujian package memang sudah berjalan.
Selama test, base_path() menunjuk ke root package — TestCase::applicationBasePath() mengembalikan realpath(__DIR__.'/..') — sehingga base_path('resources/js/panel') benar-benar membaca frontend yang dipublish, bukan skeleton kosong milik Testbench.
Tujuh assertion utama
| Test | Membaca | Gagal ketika |
|---|---|---|
| setiap page mendeklarasikan layout | resources/js/pages/panel/** | sebuah page tidak memiliki defineOptions({ layout: |
| auth page tetap berada di luar panel shell | resources/js/pages/panel/**/auth/** | auth page mendeklarasikan PanelLayout alih-alih PanelBlankLayout |
| shared prop dibaca melalui satu accessor | resources/js/panel/** | sebuah file membaca usePage() dan props.<shared key> secara langsung |
| seluruh host module dideklarasikan | resources/js/panel/**, resources/js/pages/** | import @/… tidak dikirim package dan juga tidak ada di FrontendRequirements::HOST_MODULES |
| entry yang menimpa layout terdeteksi | resources/js/app.ts | FrontendRequirements::layoutOverrides() gagal menemukan assignment tanpa fallback |
| entry yang melakukan defer diterima | resources/js/app.ts | ??=, ` |
| tabel grid konsisten | resources/js/panel/lib/grid.ts | clamp PHP dan class renderer tidak selaras |
Layout
Ada dua test. Test pertama sudah ditunjukkan di atas. Test kedua memastikan auth page bawaan panel tetap berada di luar shell panel:
it('keeps the panel auth pages out of the panel shell', function (): void {
foreach (panelPageFiles() as $path) {
if (! str_contains($path, '/auth/')) {
continue;
}
// They draw their own frame with `PanelAuthLayout` — a guest has no
// navigation, no notifications and no user menu.
expect(File::get($path))
->toContain('defineOptions({ layout: PanelBlankLayout })')
->not->toContain('defineOptions({ layout: PanelLayout })');
}
});2
3
4
5
6
7
8
9
10
11
12
13
Accessor untuk shared prop
SharePanelData menambahkan tujuh key ke page. Membaca salah satunya melalui usePage() di luar accessor khusus berarti bergantung pada Inertia module augmentation agar sampai ke aplikasi, ikut dibaca tsconfig aplikasi, dan berhasil merge dengan deklarasi dari starter kit. Jika salah satu tahap tersebut gagal, prop menjadi {} dan build aplikasi rusak di file yang bahkan tidak ditulis oleh developer aplikasi.
it('reads the panel shared props through one accessor', function (): void {
$shared = ['panel', 'navigation', 'panels', 'broadcasting', 'search', 'notifications', 'tenancy'];
$offenders = [];
foreach (File::allFiles(base_path('resources/js/panel')) as $file) {
$path = $file->getPathname();
if (str_ends_with($path, 'types/shared.ts')) {
continue;
}
$contents = File::get($path);
foreach ($shared as $prop) {
if (str_contains($contents, 'props.'.$prop) && str_contains($contents, 'usePage()')) {
$offenders[] = str_replace(base_path().'/', '', $path);
break;
}
}
}
expect($offenders)->toBe([]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Daftar pengecualian hanya berisi satu file dan disebut secara eksplisit. Prop lain tetap boleh dibaca langsung jika memang aman: usePanelPage membaca props.page dari usePage() lalu meneruskannya ke fungsi narrowing, sesuai aturan repository "validate, do not assert". Pendekatan tersebut aman bahkan ketika module augmentation gagal dan nilai awalnya {}.
Host module
Component yang dipublish mengimpor sejumlah module @/… yang tidak dikirim package — Wayfinder route module, component starter kit, dan shared type. Semua dependency host tersebut didaftarkan di FrontendRequirements::HOST_MODULES. panel:install melaporkan module yang hilang, dan tentu hanya dapat melaporkan dependency yang diketahuinya. Jika sebuah import digunakan tetapi tidak tercatat di HOST_MODULES, installer dapat mengatakan semuanya baik-baik saja sementara build aplikasi host kemudian gagal.
Test menurunkan daftar tersebut langsung dari source, bukan menyalin ulang daftar secara manual:
it('lists every host module the published components import', function (): void {
$imported = [];
$shipped = array_merge(
File::allFiles(base_path('resources/js/panel')),
File::allFiles(base_path('resources/js/pages')),
);
foreach ($shipped as $file) {
preg_match_all('/from \'@\/([^\']+)\'/', File::get($file->getPathname()), $matches);
foreach ($matches[1] as $specifier) {
$imported[$specifier] = true;
}
}
$missing = [];
foreach (array_keys($imported) as $specifier) {
// Anything the package itself publishes is not a host module. Tried as
// written and with each extension, because a specifier may or may not
// carry one.
foreach (['', '.vue', '.ts', '/index.ts', '.d.ts'] as $extension) {
if (File::exists(base_path('resources/js/'.$specifier.$extension))) {
continue 2;
}
}
$missing[] = $specifier;
}
sort($missing);
$declared = array_map(
static fn (string $module): string => ltrim($module, '@/'),
FrontendRequirements::missingHostModules(),
);
expect(array_diff($missing, $declared))->toBe([]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
FrontendRequirements::missingHostModules(): array mengembalikan daftar deklarasi dalam bentuk specifier @/…, lalu memeriksanya terhadap resources/js milik aplikasi menggunakan extension .ts, .vue, .d.ts, /index.ts, /index.vue, dan /index.d.ts. Extension kosong '' sengaja tidak digunakan di sana: File::exists() mengembalikan true untuk directory, sehingga memperbolehkan extension kosong membuat entry berbentuk directory selalu dianggap valid meskipun file sebenarnya tidak ada.
Public method lain pada class yang sama yang dapat digunakan dalam test aplikasi:
| Method | Signature | Mengembalikan |
|---|---|---|
npmPackages | static npmPackages(): array | list<string> berupa pasangan name@range |
missingNpmPackages | static missingNpmPackages(): array | package yang belum di-install aplikasi |
missingHostModules | static missingHostModules(): array | specifier @/… yang tidak memiliki target |
hasVite | static hasVite(): bool | apakah vite.config.ts/.js tersedia |
missingInertia | static missingInertia(): array | dependency yang hilang, dalam bentuk teks |
layoutOverrides | static layoutOverrides(): array | list<array{file: string, line: int, code: string}> |
Entry aplikasi
Ini adalah satu bagian dari seam integrasi yang tidak dapat diperbaiki dari dalam package. Entry yang menulis page.default.layout = AppLayout mengganti panel shell dengan shell milik aplikasi setelah page sudah mendeklarasikan layout-nya sendiri — tidak ada error, HTTP tetap 200, tetapi navigation tiba-tiba berasal dari shell yang salah.
Test menulis entry file sementara, memeriksa report, lalu mengembalikan kondisi file seperti semula:
it('spots an entry that overwrites the layout a page declared', function (): void {
$entry = resource_path('js/app.ts');
$existing = File::exists($entry) ? File::get($entry) : null;
File::ensureDirectoryExists(dirname($entry));
File::put($entry, <<<'TS'
createInertiaApp({
resolve: (name) => {
const page = resolvePageComponent(name);
page.default.layout = AppLayout;
return page;
},
});
TS);
try {
$overrides = FrontendRequirements::layoutOverrides();
expect($overrides)->toHaveCount(1)
->and($overrides[0]['file'])->toBe('resources/js/app.ts')
->and($overrides[0]['line'])->toBe(4)
->and($overrides[0]['code'])->toBe('page.default.layout = AppLayout;');
} finally {
$existing === null ? File::delete($entry) : File::put($entry, $existing);
}
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
finally tidak boleh dihilangkan. Test yang menulis ke resources/js lalu gagal sebelum membersihkan file akan meninggalkan repository dalam keadaan berubah untuk semua test berikutnya.
Ada juga test negatif yang memastikan pemeriksaan tersebut tidak menjadi gangguan. Semua bentuk assignment yang memberi kesempatan layout page menang terlebih dahulu adalah benar dan harus lulus:
foreach ([
'page.default.layout ??= AppLayout;',
'page.default.layout ||= AppLayout;',
'page.default.layout = page.default.layout || AppLayout;',
] as $line) {
File::put($entry, $line);
expect(FrontendRequirements::layoutOverrides())->toBe([]);
}2
3
4
5
6
7
8
9
layoutOverrides() memindai js/app.ts, js/app.js, js/ssr.ts, dan js/ssr.js, dalam urutan tersebut.
Grid yang dua bagiannya berada di sisi berbeda dari wire
PHP membatasi jumlah column hingga PandaPanel\Support\ColumnCount::MAX; renderer menggunakan fallback satu column untuk nilai yang tidak memiliki literal class. Jika dua sisi tersebut tidak konsisten, nilai column dapat lolos clamp tetapi jatuh ke fallback renderer — menghasilkan form satu column secara diam-diam. Test berikut dibuat untuk mencegah kondisi tersebut.
Test membaca TypeScript secara langsung, bukan menyalin ulang nilainya:
/**
* @return array{grid: array<int, string>, effective: array<int, array{md: int, lg: int}>}
*/
function gridTables(): array
{
$source = File::get(base_path('resources/js/panel/lib/grid.ts'));
preg_match('/const GRID_CLASSES[^{]*\{(.*?)\n\};/s', $source, $gridBlock);
preg_match('/const EFFECTIVE_COLUMNS[^{]*\{(.*?)\n\};/s', $source, $effectiveBlock);
preg_match_all("/(\d+): '([^']+)'/", $gridBlock[1] ?? '', $grid);
preg_match_all('/(\d+): \{ md: (\d+), lg: (\d+) \}/', $effectiveBlock[1] ?? '', $effective);
return [
'grid' => array_combine($grid[1], $grid[2]),
'effective' => array_combine($effective[1], array_map(
static fn (string $md, string $lg): array => ['md' => (int) $md, 'lg' => (int) $lg],
$effective[2],
$effective[3],
)),
];
}
it('clamps columns to the counts the renderer has classes for', function (): void {
$counts = array_keys(gridTables()['grid']);
expect(max($counts))->toBe(ColumnCount::MAX)
->and($counts)->toBe(range(1, ColumnCount::MAX));
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Test grid kedua membaca breakpoint yang benar-benar dideklarasikan setiap class row dan membandingkannya dengan span table. Span yang mengklaim lebih banyak column daripada grid pada breakpoint tersebut akan membuat implicit track dan menyebabkan row overflow secara horizontal:
it('never lets a span outgrow the columns at its breakpoint', function (): void {
$tables = gridTables();
foreach ($tables['grid'] as $columns => $classes) {
preg_match('/(?:^|\s)md:grid-cols-(\d+)/', $classes, $md);
preg_match('/(?:^|\s)lg:grid-cols-(\d+)/', $classes, $lg);
$actualMd = (int) ($md[1] ?? 1);
$actualLg = (int) ($lg[1] ?? $actualMd);
expect($tables['effective'][$columns])->toBe(['md' => $actualMd, 'lg' => $actualLg]);
}
});2
3
4
5
6
7
8
9
10
11
12
13
Membaca breakpoint dari class yang sebenarnya, bukan menulis ulang angka yang diharapkan, adalah hal yang membuat ini menjadi test. Versi yang hard-code ['md' => 2, 'lg' => 4] akan tetap lulus apa pun isi grid.ts.
Registry yang dihasilkan
Tiga assertion file tambahan berada di negative suite dan masih termasuk keluarga masalah yang sama. Build-time registry dibuat secara generated, dan jika registry tertinggal maka sebuah component dapat merender kosong — sulit dibedakan dari component yang memang sengaja tidak menampilkan apa pun. Karena itu setiap registry harus memiliki development-time warning sendiri, dan test memastikan warning tersebut tetap ada:
it('tells a developer when a widget component is not in the registry', function (): void {
expect(File::get(base_path('resources/js/panel/widgets/registry.ts')))
->toContain('is not in the build-time registry')
->toContain('resources/js/pages/Panels/{Panel}/Widgets/')
// Development only. A console message on a live panel helps nobody.
->toContain('import.meta.env.DEV')
// Once per name, so one typo is one warning rather than one per row.
->toContain('missing.has(name)');
});2
3
4
5
6
7
8
9
Hal yang sama berlaku untuk resources/js/panel/forms/registry.ts dan resources/js/panel/icons/registry.ts; registry icon juga diuji agar menyebut command php artisan panel:icons.
Menulis contract test untuk aplikasi
Polanya dapat digunakan kembali. Jika project Anda memiliki convention yang tidak dapat ditegakkan compiler — misalnya setiap custom column component harus mengekspor prop state, atau semua panel page di directory tertentu harus mendeklarasikan layout — test-nya cukup berupa glob file, str_contains, lalu assertion bahwa daftar pelanggar kosong:
use Illuminate\Support\Facades\File;
it('gives every custom panel component a display name', function (): void {
$offenders = [];
foreach (File::allFiles(resource_path('js/pages/Panels')) as $file) {
if (! str_contains(File::get($file->getPathname()), 'defineOptions(')) {
$offenders[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
expect($offenders)->toBe([]);
});2
3
4
5
6
7
8
9
10
11
12
13
Jaga jumlah test seperti ini tetap sedikit dan gunakan hanya untuk kegagalan yang benar-benar silent. Assertion terhadap source file adalah instrumen kasar. Jika ia hanya mengkodekan preferensi gaya alih-alih failure mode nyata, test akan terus diubah setiap kali seseorang menulis code valid dengan cara berbeda.
Hal yang perlu diperhatikan
base_path()adalah root package di suite ini. Di aplikasi nilainya adalah root aplikasi, sehingga test yang sama dapat dipindahkan tanpa perubahan. Test yang menargetkan path divendor/tidak memiliki sifat tersebut.- Pulihkan apa pun yang Anda tulis. Test entry file menulis ke
resources/jslalu mengembalikan kondisi sebelumnya di dalamfinally, termasuk menghapus file yang awalnya memang tidak ada. - String assertion bersifat exact.
defineOptions({ layout:cocok dengan format source repository saat ini. Prettier diwajibkan melaluinpm run format:check, dan itulah yang membuat assertion literal ini aman. Tanpa formatter, test seperti ini mudah menghasilkan false failure. - Kumpulkan dulu, baru assert.
expect()di dalam loop berhenti pada pelanggar pertama dan menyembunyikan sisanya. - Test ini tidak menjalankan frontend. Ia membuktikan source menyatakan kontrak yang benar.
npm run typecheckdannpm run buildmembuktikan code dapat dikompilasi, dan CI menjalankan keduanya pada tiga versi Node — lihat Matrix CI.