test(webui): add responsive parity gate

This commit is contained in:
joshpatra committed 2026-07-27 01:54:32 -04:00
1 parent 7b43fd8d99
commit 8b6cb525ed
6 files changed
+224 -1

No files matched your search

+4 -1
View File
@@ -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
+2
View File
@@ -118,3 +118,5 @@ sampleMissingPlaylists/
/webui/node_modules/
/webui/.svelte-kit/
/webui/build/
/webui/playwright-report/
/webui/test-results/
+64
View File
@@ -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",
+2
View File
@@ -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",
+18
View File
@@ -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,
},
});
+134
View File
@@ -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<string, unknown> = {
"/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();
});
});
}