diff --git a/.gitignore b/.gitignore index 6c00c9a..37b4405 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,13 @@ config.local.toml /dashboard/build /dashboard/.env +# Dashboard — Playwright E2E +/dashboard/tests/e2e/.auth +/dashboard/tests/e2e/.results +/dashboard/playwright-report +/dashboard/test-results +/dashboard/.playwright + # Caddy /caddy/data /caddy/config diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore index 9536046..5234ccb 100644 --- a/dashboard/.prettierignore +++ b/dashboard/.prettierignore @@ -2,3 +2,9 @@ build node_modules package-lock.json + +# Playwright generated artifacts +playwright-report +test-results +tests/e2e/.auth +tests/e2e/.results diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 7f738a4..8c56c79 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.60.0", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.17.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", @@ -885,6 +886,22 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3010,6 +3027,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 4b0b515..3845826 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,49 +1,53 @@ { - "name": "picloud-dashboard", - "version": "0.6.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "test": "vitest run" - }, - "devDependencies": { - "@eslint/js": "^9.18.0", - "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.17.0", - "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@types/node": "^22.10.5", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-svelte": "^3.0.0", - "globals": "^15.14.0", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "svelte": "^5.19.0", - "svelte-check": "^4.1.4", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0", - "vite": "^6.0.7", - "vitest": "^3.0.5" - }, - "overrides": { - "cookie": "^0.7.2" - }, - "dependencies": { - "@codemirror/autocomplete": "^6.20.2", - "@codemirror/commands": "^6.10.3", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/language": "^6.12.3", - "@codemirror/search": "^6.7.0", - "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.43.0", - "@lezer/highlight": "^1.2.3", - "codemirror": "^6.0.2" - } + "name": "picloud-dashboard", + "version": "0.6.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:install": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.17.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@types/node": "^22.10.5", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vite": "^6.0.7", + "vitest": "^3.0.5" + }, + "overrides": { + "cookie": "^0.7.2" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", + "@lezer/highlight": "^1.2.3", + "codemirror": "^6.0.2" + } } diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 0000000..33c9049 --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173); +// baseURL is the origin only — the SvelteKit dashboard is mounted at +// `/admin` (svelte.config.js paths.base), so tests use full paths like +// `/admin/login` rather than relying on baseURL path resolution. +const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './tests/e2e/.results', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI ? [['html'], ['github']] : 'html', + globalSetup: './tests/e2e/global-setup.ts', + expect: { timeout: 5_000 }, + use: { + baseURL: DASHBOARD_BASE, + actionTimeout: 10_000, + navigationTimeout: 15_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json') + } + } + ], + webServer: { + command: 'npm run dev', + url: `http://localhost:${DASHBOARD_PORT}/admin/`, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + timeout: 60_000 + } +}); diff --git a/dashboard/tests/e2e/README.md b/dashboard/tests/e2e/README.md new file mode 100644 index 0000000..d54c204 --- /dev/null +++ b/dashboard/tests/e2e/README.md @@ -0,0 +1,75 @@ +# Dashboard E2E tests + +Browser-driven tests for the PiCloud dashboard, powered by [Playwright]. + +## Prerequisites + +The tests drive a real dashboard against a real backend. Bring up both +before running: + +```sh +# 1. Postgres +docker compose up -d postgres + +# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy) +PICLOUD_BIND=127.0.0.1:18080 \ +PICLOUD_ADMIN_USERNAME=admin \ +PICLOUD_ADMIN_PASSWORD=admin \ +DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \ +cargo run -p picloud + +# 3. Browser binaries (one-time, ~200 MB) +cd dashboard && npm run test:e2e:install +``` + +The Vite dev server is started automatically by Playwright's `webServer` +config — you do not need to run `npm run dev` yourself. + +## Running + +```sh +cd dashboard +npm run test:e2e # headless, full suite +npm run test:e2e:ui # interactive UI runner +npx playwright test smoke # run a single spec +npx playwright show-report +``` + +## Env vars + +| Var | Default | Notes | +| ------------------------ | ------------------------ | ----------------------------------------------------------------- | +| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). | +| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. | +| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. | +| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. | +| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. | +| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. | + +## How isolation works + +Tests share one backend + one Postgres. To avoid cross-test interference: + +- A shared bootstrap admin session is captured once in + `tests/e2e/.auth/admin.json` (gitignored) and reused by every test via + `storageState`. +- Each test creates resources with a unique slug / username produced by + `fixtures/ids.ts` (`e2e--w-`). +- Each test registers cleanup via `fixtures/cleanup.ts` and tears down + in `afterEach`. Cleanup is best-effort: a missing resource doesn't + fail the suite, so a test can pre-delete and still register the entry. + +## Layout + +``` +tests/e2e/ + global-setup.ts # health probe + admin login + storageState seed + smoke.spec.ts # A.5 smoke + fixtures/ + auth.ts # UI login/logout helpers (for login-flow specs) + api.ts # bearer-token-backed APIRequestContext + ids.ts # unique slug/username generators (test-fixture) + cleanup.ts # afterEach resource teardown +``` + +[Playwright]: https://playwright.dev diff --git a/dashboard/tests/e2e/fixtures/api.ts b/dashboard/tests/e2e/fixtures/api.ts new file mode 100644 index 0000000..7256d36 --- /dev/null +++ b/dashboard/tests/e2e/fixtures/api.ts @@ -0,0 +1,47 @@ +import { request, type APIRequestContext } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080'; +const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json'); + +interface StoredState { + origins: Array<{ + origin: string; + localStorage: Array<{ name: string; value: string }>; + }>; +} + +let cachedToken: string | null = null; + +async function readAdminToken(): Promise { + if (cachedToken) return cachedToken; + const raw = await fs.readFile(STATE_PATH, 'utf8'); + const state = JSON.parse(raw) as StoredState; + for (const origin of state.origins) { + const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token'); + if (entry) { + cachedToken = entry.value; + return entry.value; + } + } + throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`); +} + +// Thin wrapper around Playwright's request context that injects the +// admin bearer token from the shared storageState. Use this for +// setup/teardown shortcuts when the *test itself* is about something +// else (e.g., a script-editor test that just needs an app to exist). +export async function adminApi(): Promise { + const token = await readAdminToken(); + return request.newContext({ + baseURL: API_BASE, + extraHTTPHeaders: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + } + }); +} diff --git a/dashboard/tests/e2e/fixtures/auth.ts b/dashboard/tests/e2e/fixtures/auth.ts new file mode 100644 index 0000000..d955c9d --- /dev/null +++ b/dashboard/tests/e2e/fixtures/auth.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin'; + +// Drive the login form like a real user. globalSetup already saves a +// storageState for the shared admin, so most tests don't need this — +// it's reserved for specs that explicitly cover the login UI. +export async function loginAsAdmin(page: Page): Promise { + await page.goto('/admin/login'); + await page.getByLabel('Username').fill(ADMIN_USERNAME); + await page.getByLabel('Password').fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /sign in/i }).click(); + await expect(page).toHaveURL(/\/admin\/apps$/); +} + +export async function logout(page: Page): Promise { + await page.getByRole('button', { name: /logout/i }).click(); + await expect(page).toHaveURL(/\/admin\/login$/); +} diff --git a/dashboard/tests/e2e/fixtures/cleanup.ts b/dashboard/tests/e2e/fixtures/cleanup.ts new file mode 100644 index 0000000..98bc1f7 --- /dev/null +++ b/dashboard/tests/e2e/fixtures/cleanup.ts @@ -0,0 +1,48 @@ +import type { APIRequestContext } from '@playwright/test'; +import { adminApi } from './api'; + +// Resources to delete after a test, in LIFO order. Tests register +// their creations and the registry tears everything down in +// `cleanupRegistered` — typically called from `test.afterEach`. + +type Cleanup = (api: APIRequestContext) => Promise; + +export class CleanupRegistry { + private items: Cleanup[] = []; + + app(slugOrId: string): void { + this.items.push(async (api) => { + await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`); + }); + } + + adminUser(userId: string): void { + this.items.push(async (api) => { + await api.delete(`/api/v1/admin/admins/${userId}`); + }); + } + + apiKey(keyId: string): void { + this.items.push(async (api) => { + await api.delete(`/api/v1/admin/api-keys/${keyId}`); + }); + } + + async run(): Promise { + if (this.items.length === 0) return; + const api = await adminApi(); + try { + for (const item of this.items.reverse()) { + try { + await item(api); + } catch { + // Best-effort cleanup — a missing resource (already + // deleted by the test) shouldn't fail the suite. + } + } + } finally { + await api.dispose(); + this.items = []; + } + } +} diff --git a/dashboard/tests/e2e/fixtures/ids.ts b/dashboard/tests/e2e/fixtures/ids.ts new file mode 100644 index 0000000..3bf4e08 --- /dev/null +++ b/dashboard/tests/e2e/fixtures/ids.ts @@ -0,0 +1,42 @@ +/* eslint-disable no-empty-pattern -- Playwright fixtures require an + object-pattern first arg; these fixtures don't depend on any other + fixture so the pattern is intentionally empty. */ +import { test as base } from '@playwright/test'; +import { randomBytes } from 'node:crypto'; + +// Tests share a single backend/Postgres. To avoid collisions we tag +// every resource the test creates with a short random suffix plus the +// Playwright worker index. This way two workers running the same spec +// in parallel never fight over the same slug or username. + +export function shortId(): string { + return randomBytes(3).toString('hex'); +} + +export function uniqueSlug(prefix: string, workerIndex: number): string { + const cleaned = prefix + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return `e2e-${cleaned}-w${workerIndex}-${shortId()}`; +} + +export function uniqueUsername(prefix: string, workerIndex: number): string { + // Username regex is [a-z0-9._-]{2,32}. Mirror the slug format. + const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, ''); + return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32); +} + +export const test = base.extend<{ + uniqueSlug: (prefix: string) => string; + uniqueUsername: (prefix: string) => string; +}>({ + uniqueSlug: async ({}, use, testInfo) => { + await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex)); + }, + uniqueUsername: async ({}, use, testInfo) => { + await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex)); + } +}); + +export { expect } from '@playwright/test'; diff --git a/dashboard/tests/e2e/global-setup.ts b/dashboard/tests/e2e/global-setup.ts new file mode 100644 index 0000000..c77fcdc --- /dev/null +++ b/dashboard/tests/e2e/global-setup.ts @@ -0,0 +1,94 @@ +import { chromium, request } from '@playwright/test'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080'; +const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173); +const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`; +const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin'; + +const AUTH_DIR = path.join(__dirname, '.auth'); +const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json'); + +export default async function globalSetup(): Promise { + await assertBackendUp(); + await fs.mkdir(AUTH_DIR, { recursive: true }); + const token = await loginAsAdmin(); + await persistAdminStorageState(token); +} + +async function assertBackendUp(): Promise { + const probe = await request.newContext(); + try { + const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 }); + if (!res.ok()) { + throw new Error( + `backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?` + ); + } + } catch (err) { + throw new Error( + `Could not reach backend at ${API_BASE}/healthz. ` + + `Bring it up before running E2E tests:\n\n` + + ` docker compose up -d postgres\n` + + ` PICLOUD_BIND=127.0.0.1:18080 \\\n` + + ` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` + + ` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` + + ` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` + + ` cargo run -p picloud\n\n` + + `Underlying error: ${(err as Error).message}` + ); + } finally { + await probe.dispose(); + } +} + +async function loginAsAdmin(): Promise { + const ctx = await request.newContext(); + try { + const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, { + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + headers: { 'content-type': 'application/json' } + }); + if (!res.ok()) { + const body = await res.text(); + throw new Error( + `Admin login failed (${res.status()}): ${body}. ` + + `Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.` + ); + } + const payload = (await res.json()) as { token?: string }; + if (!payload.token) { + throw new Error('Admin login response missing token field'); + } + return payload.token; + } finally { + await ctx.dispose(); + } +} + +// The dashboard reads its session from localStorage under the key +// `picloud.admin.token` (see src/lib/auth.ts). We can't write to +// localStorage without a browser context, so launch a throwaway one, +// seed the value, then save storageState for every test to reuse. +async function persistAdminStorageState(token: string): Promise { + const browser = await chromium.launch(); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(`${DASHBOARD_ORIGIN}/admin/login`); + await page.evaluate( + ([key, value]) => { + localStorage.setItem(key, value); + }, + ['picloud.admin.token', token] + ); + await context.storageState({ path: ADMIN_STATE_PATH }); + } finally { + await browser.close(); + } +} diff --git a/dashboard/tests/e2e/smoke.spec.ts b/dashboard/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..7d73aa8 --- /dev/null +++ b/dashboard/tests/e2e/smoke.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { loginAsAdmin } from './fixtures/auth'; + +// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work. + +test.describe('smoke', () => { + test.describe('unauthenticated', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('root redirects to login and shows the form', async ({ page }) => { + await page.goto('/admin/'); + await expect(page).toHaveURL(/\/admin\/login$/); + await expect(page.getByLabel('Username')).toBeVisible(); + await expect(page.getByLabel('Password')).toBeVisible(); + await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); + }); + + test('valid credentials land on the apps page', async ({ page }) => { + await loginAsAdmin(page); + await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible(); + }); + }); + + test('admin storageState already lands on apps', async ({ page }) => { + await page.goto('/admin/'); + await expect(page).toHaveURL(/\/admin\/apps$/); + }); +});