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.
111 lines
4.7 KiB
TypeScript
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([]);
|
|
});
|
|
}
|
|
});
|