diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cca2e5fb..43817059 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,9 @@ jobs: - name: Restore Svelte WebUI working-directory: webui - run: npm ci + run: | + npm ci + npx playwright install --with-deps chromium - name: Verify C# formatting and analyzers run: dotnet format allstarr.sln --no-restore --verify-no-changes --verbosity minimal @@ -102,6 +104,7 @@ jobs: npm run check npm test npm run build + npm run test:e2e - name: Test run: dotnet test --configuration Release --no-build --verbosity normal diff --git a/.gitignore b/.gitignore index e0b638af..4d6cd865 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,5 @@ sampleMissingPlaylists/ /webui/node_modules/ /webui/.svelte-kit/ /webui/build/ +/webui/playwright-report/ +/webui/test-results/ diff --git a/webui/package-lock.json b/webui/package-lock.json index e2320179..456257d1 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -9,6 +9,7 @@ "bits-ui": "^2.18.1" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.1", "@tailwindcss/vite": "^4.3.3", @@ -167,6 +168,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1646,6 +1663,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "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.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", diff --git a/webui/package.json b/webui/package.json index ab24c563..fc9e2740 100644 --- a/webui/package.json +++ b/webui/package.json @@ -9,12 +9,14 @@ "dev": "vite --host 0.0.0.0", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run", + "test:e2e": "playwright test", "build": "vite build" }, "dependencies": { "bits-ui": "^2.18.1" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.70.1", "@tailwindcss/vite": "^4.3.3", diff --git a/webui/playwright.config.ts b/webui/playwright.config.ts new file mode 100644 index 00000000..cb4df991 --- /dev/null +++ b/webui/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.e2e.ts", + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "line", + use: { + baseURL: "http://127.0.0.1:4173/next/", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173/next/", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/webui/tests/parity.e2e.ts b/webui/tests/parity.e2e.ts new file mode 100644 index 00000000..395e29fc --- /dev/null +++ b/webui/tests/parity.e2e.ts @@ -0,0 +1,134 @@ +import { expect, test, type Page } from "@playwright/test"; + +const viewports = [ + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1280, height: 800 }, + { width: 1440, height: 900 }, +]; + +const schema = { + activeBackend: "Jellyfin", + providers: [ + { id: "jellyfin", name: "Jellyfin", categories: ["streaming"] }, + { id: "lumen-audio", name: "Lumen Audio", categories: ["metadata", "streaming"] }, + ], + configSections: [{ + id: "general", label: "General", fields: [ + { key: "Theme", label: "Theme", type: "select", valuePath: "general.theme", options: ["Dark"] }, + { key: "PublicUrl", label: "Public URL", type: "text", valuePath: "deployment.url", ownership: "deployment", readOnly: true }, + ], + }], + priorityGroups: [{ + id: "streaming", label: "Playback", envKey: "StreamingOrder", providers: ["lumen-audio"], + pinnedProvider: { id: "jellyfin", name: "Jellyfin", icon: "server", reason: "Local media server" }, + }], +}; + +const responses: Record = { + "/api/admin/auth/me": { + authenticated: true, backend: "Jellyfin", + user: { id: "user", name: "Tester", isAdministrator: true }, + }, + "/api/admin/ui/schema": schema, + "/api/admin/status": { version: "test", backendType: "Jellyfin" }, + "/api/admin/playlists": { playlists: [], inventory: { managed: 0, unmanaged: 0 } }, + "/api/admin/jobs?limit=100": { jobs: [] }, + "/api/admin/ui/activity?limit=8": { items: [], hasMore: false }, + "/api/admin/ui/provider-summaries": { providers: [] }, + "/api/admin/playlist-links": { playlistLinks: [] }, + "/api/admin/provider-accounts": { managementMode: "ApplicationManaged", accounts: [] }, + "/api/admin/providers/status": [], + "/api/admin/provider-diagnostics/deep-stream/latest": { measurements: [] }, + "/api/admin/config": { + general: { theme: "Dark" }, deployment: { url: "https://music.example.test" }, + providers: { streamingOrder: "lumen-audio" }, + }, + "/api/admin/storage": { storage: { provider: "PostgreSQL", readiness: "Ready" }, backups: [] }, + "/api/admin/cache": { + database: { entryCount: 0, payloadBytes: 0, hitRatio: 0 }, + hot: { entryCount: 0, payloadBytes: 0, hitRatio: 0 }, + media: { entryCount: 0, payloadBytes: 0, hitRatio: 0 }, + activity: { coalescedRequests: 0, staleServes: 0, upstreamBytesAvoided: 0 }, + extensionStorage: { activeExtensions: 0, entryCount: 0, payloadBytes: 0, maximumBytes: 0 }, + capturedAt: "2026-01-01", + }, + "/api/admin/cache/maintenance/preview": { + metadata: { expiredEntries: 0, overQuotaEntries: 0, reclaimableBytes: 0 }, + media: { expiredEntries: 0, overQuotaEntries: 0, reclaimableBytes: 0 }, + unreferencedArtworkPayloads: 0, unreferencedArtworkBytes: 0, + }, + "/api/admin/extensions/packages": [{ + id: "package", extensionId: "lumen-audio", displayName: "Lumen Audio", version: "1.0.0", + lifecycle: "active", state: "active", active: true, installed: true, + permissionReviewRequired: false, capabilities: ["metadata", "streaming"], revision: 1, + }], + "/api/admin/extensions/registries": [], + "/api/admin/extensions/store": { items: [], errors: [] }, + "/api/admin/extensions/logs?limit=100": [], +}; + +async function mockApi(page: Page) { + await page.route("**/fonts/**", (route) => route.fulfill({ status: 204 })); + await page.route("**/api/admin/**", async (route) => { + const url = new URL(route.request().url()); + let body = responses[`${url.pathname}${url.search}`] ?? responses[url.pathname]; + if (url.pathname === "/api/admin/ui/activity") body = { items: [], hasMore: false }; + if (url.pathname === "/api/admin/downloads") + body = { storage: url.searchParams.get("storage"), files: [], totalSize: 0, totalSizeFormatted: "0 B", count: 0 }; + if (url.pathname === "/api/admin/track-matches") + body = { + matches: [], stats: { total: 0, matched: 0, accepted: 0, unresolved: 0, review: 0, rejected: 0, attention: 0 }, + pagination: { page: 1, pageSize: 50, total: 0, totalPages: 0 }, + }; + await route.fulfill({ + status: body === undefined ? 404 : 200, + contentType: "application/json", + body: JSON.stringify(body ?? { error: `Missing fixture: ${url.pathname}` }), + }); + }); +} + +const routes = [ + ["#/", "Home"], + ["#/library/playlists", "Library"], + ["#/library/mappings", "Library"], + ["#/library/cached", "Library"], + ["#/library/kept", "Library"], + ["#/activity", "Activity"], + ["#/sources", "Sources"], + ["#/settings/general", "Settings"], +] as const; + +for (const viewport of viewports) { + test.describe(`${viewport.width}x${viewport.height}`, () => { + test.use({ viewport }); + + for (const [route, heading] of routes) { + test(`${route} has no document overflow`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await mockApi(page); + await page.goto(route); + await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible(); + await expect.poll(() => page.evaluate(() => + document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + expect(errors).toEqual([]); + }); + } + + test("Settings dialogs remain usable", async ({ page }) => { + await mockApi(page); + await page.goto("#/settings/extensions"); + await page.getByRole("button", { name: "Install extension" }).click(); + await expect(page.getByRole("dialog", { name: "Install extension" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verify package" })).toBeInViewport(); + await page.getByRole("button", { name: "Close installer" }).click(); + await page.goto("#/settings/maintenance"); + await page.getByRole("button", { name: "Purge cache" }).click(); + const dialog = page.getByRole("alertdialog", { name: "Purge the application cache?" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Purge cache" })).toBeInViewport(); + }); + }); +}