Files
immich-folio/e2e/admin-smoke.spec.ts
Ralksta e99d3f2f1b release: v0.14.0
Fourteen commits since v0.13.0. See CHANGELOG.md for the full list.

Two additions: `npm run doctor`, which runs the config doctor from a terminal — including inside the shipped image — and exits with the worst finding, so it can gate a deployment script; and a Playwright spec that loads all fourteen admin routes against a production build.

One change, invisible on screen: the admin panel got a form-field layer (#533).

Five fixes: grid.gap on both axes (#513), the dashboard status badge (#539), the journal editor's unsaved-changes guard (#538), essay paragraph markup (#537), and aria-pressed on the panel's switches and pickers.
2026-08-30 15:11:25 +02:00

111 lines
4.7 KiB
TypeScript

import { test, expect } from '@playwright/test';
/**
* Smoke test for every admin route.
*
* This exists because of #542: a module-evaluation TDZ error in SettingsEditor
* took the entire admin panel down in production while all four gates stayed
* green. tsc could not see it (the read went through a hoisted function),
* `next build` could not (the module is only evaluated in the browser), the
* unit tests could not (nothing imports it), and an HTTP smoke check could not
* (the server renders the shell; the throw happens after hydration).
*
* A real browser is the only thing that catches that class of bug, so this
* spec loads each route and asserts three things: no error boundary, the
* route's own content actually rendered, and nothing threw during hydration.
*/
/** Matches the fallback playwright.config.ts hands the dev/start server. */
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'e2e-admin-password';
/**
* Each route plus a selector that only exists once that route's own component
* has rendered. Asserting the admin chrome would not be enough — a page that
* throws is replaced wholesale by app/error.tsx, but a route that renders an
* empty shell would still pass.
*/
const ROUTES: { path: string; content: string; name: string }[] = [
{ path: '/admin', content: '.page-builder', name: 'dashboard' },
{ path: '/admin/pages', content: '.page-builder', name: 'pages' },
{ path: '/admin/journal', content: '.journal-studio', name: 'journal' },
// AnalyticsView falls back to .admin-panel when the analytics API is
// unreachable, which is the normal case without a live Immich behind it.
{ path: '/admin/analytics', content: '.analytics-view, .admin-panel', name: 'analytics' },
{ path: '/admin/help', content: '.settings-panel', name: 'help' },
{ path: '/admin/settings', content: '.settings-editor', name: 'settings' },
...['general', 'theme', 'grid', 'footer', 'legal', 'seo', 'security', 'about'].map((id) => ({
path: `/admin/settings/${id}`,
content: '.settings-panel',
name: `settings/${id}`,
})),
];
/**
* Console noise that says nothing about the page's own health. Without a live
* Immich the status probe and the analytics fetch both fail, and their failures
* are reported as console errors. Uncaught exceptions are never filtered —
* those are the signal this test was written for.
*/
const IGNORED_CONSOLE = [
/Failed to load resource/i,
/net::ERR_/i,
/ECONNREFUSED/i,
/the server responded with a status of 5\d\d/i,
];
function isRealConsoleError(text: string): boolean {
return !IGNORED_CONSOLE.some((pattern) => pattern.test(text));
}
test('every admin route renders without throwing', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && isRealConsoleError(message.text())) {
consoleErrors.push(message.text());
}
});
await page.goto('/admin');
// A reused local dev server may have been started without ADMIN_PASSWORD.
// Skipping beats failing on a configuration difference — CI starts its own
// server with the password set, so it never takes this branch.
const disabled = await page
.locator('.admin-disabled')
.isVisible()
.catch(() => false);
test.skip(disabled, 'Admin panel disabled — start the server with ADMIN_PASSWORD set.');
await page.fill('input[type="password"]', ADMIN_PASSWORD);
await page.click('button[type="submit"]');
await expect(page.locator('.page-builder')).toBeVisible({ timeout: 15_000 });
// One test walking every route rather than one test each: the routes share a
// login, and expect.soft keeps a broken route from hiding the ones after it.
// When the panel goes down it usually goes down everywhere, and the useful
// report is the full list — a run that stops at the first failure was what
// made #542 look like a Settings problem instead of a panel-wide one.
for (const route of ROUTES) {
await test.step(route.name, async () => {
pageErrors.length = 0;
consoleErrors.length = 0;
await page.goto(route.path);
// app/error.tsx replaces the whole page when a route throws. Assert it
// first: its absence is what "the panel still works" actually means.
await expect
.soft(page.locator('.empty-state__title'), `error boundary on ${route.path}`)
.toHaveCount(0);
await expect
.soft(page.locator(route.content).first(), `no content on ${route.path}`)
.toBeVisible({ timeout: 15_000 });
expect.soft(pageErrors, `uncaught exception on ${route.path}`).toEqual([]);
expect.soft(consoleErrors, `console errors on ${route.path}`).toEqual([]);
});
}
});