Skip to content

Commit bdb0d5f

Browse files
committed
First attempt at playwright. We need visual e2e tests.
Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 2cb4d68 commit bdb0d5f

8 files changed

Lines changed: 554 additions & 1 deletion

File tree

.github/workflows/playwright.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: E2E Tests
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
workflow_dispatch:
9+
10+
jobs:
11+
playwright:
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Use Node.js 24
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: 24.x
22+
cache: npm
23+
24+
- name: Install dependencies
25+
run: npm ci
26+
27+
- name: Install Playwright browser
28+
run: npx playwright install --with-deps chromium
29+
30+
- name: Run Playwright tests
31+
run: npm run test:e2e
32+
33+
- name: Upload Playwright report
34+
if: always()
35+
uses: actions/upload-artifact@v4
36+
with:
37+
name: playwright-report
38+
path: playwright-report/
39+
if-no-files-found: ignore
40+
41+
- name: Upload Playwright test results
42+
if: always()
43+
uses: actions/upload-artifact@v4
44+
with:
45+
name: playwright-test-results
46+
path: test-results/
47+
if-no-files-found: ignore

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ dist
55
.env.local
66
.htaccess
77
src/generated
8+
playwright-report
9+
test-results
10+
.playwright

build/serve-dist.mjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createReadStream, existsSync } from "node:fs";
2+
import { stat } from "node:fs/promises";
3+
import { createServer } from "node:http";
4+
import { extname, join, normalize, resolve } from "node:path";
5+
6+
const port = Number.parseInt(process.env.PORT ?? "4173", 10);
7+
const host = process.env.HOST ?? "127.0.0.1";
8+
const distRoot = resolve(process.cwd(), "dist");
9+
10+
const mimeTypes = {
11+
".css": "text/css; charset=utf-8",
12+
".gif": "image/gif",
13+
".html": "text/html; charset=utf-8",
14+
".ico": "image/x-icon",
15+
".jpeg": "image/jpeg",
16+
".jpg": "image/jpeg",
17+
".js": "text/javascript; charset=utf-8",
18+
".json": "application/json; charset=utf-8",
19+
".mp3": "audio/mpeg",
20+
".png": "image/png",
21+
".svg": "image/svg+xml",
22+
".ttf": "font/ttf",
23+
".txt": "text/plain; charset=utf-8",
24+
".wav": "audio/wav",
25+
".webp": "image/webp",
26+
".woff": "font/woff",
27+
".woff2": "font/woff2",
28+
};
29+
30+
const sendFile = async (filePath, response) => {
31+
const fileInfo = await stat(filePath);
32+
const extension = extname(filePath).toLowerCase();
33+
34+
response.writeHead(200, {
35+
"Content-Length": fileInfo.size,
36+
"Content-Type": mimeTypes[extension] ?? "application/octet-stream",
37+
"Cache-Control": "no-store",
38+
});
39+
40+
createReadStream(filePath).pipe(response);
41+
};
42+
43+
const server = createServer(async (request, response) => {
44+
const method = request.method ?? "GET";
45+
if (!["GET", "HEAD"].includes(method)) {
46+
response.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
47+
response.end("Method Not Allowed");
48+
49+
return;
50+
}
51+
52+
const requestUrl = new URL(request.url ?? "/", `http://${host}:${port}`);
53+
const requestPath = decodeURIComponent(requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname);
54+
const candidatePath = normalize(join(distRoot, requestPath));
55+
const safePath = candidatePath.startsWith(distRoot) ? candidatePath : join(distRoot, "index.html");
56+
const filePath = existsSync(safePath) ? safePath : join(distRoot, "index.html");
57+
58+
try {
59+
if (method === "HEAD") {
60+
const fileInfo = await stat(filePath);
61+
const extension = extname(filePath).toLowerCase();
62+
response.writeHead(200, {
63+
"Content-Length": fileInfo.size,
64+
"Content-Type": mimeTypes[extension] ?? "application/octet-stream",
65+
"Cache-Control": "no-store",
66+
});
67+
response.end();
68+
69+
return;
70+
}
71+
72+
await sendFile(filePath, response);
73+
} catch {
74+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
75+
response.end("Not Found");
76+
}
77+
});
78+
79+
server.listen(port, host, () => {
80+
console.log(`Serving dist at http://${host}:${port}`);
81+
});

package-lock.json

Lines changed: 65 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
"scripts": {
1313
"dev": "vite --host",
1414
"build": "npm run generate-themes && vite build",
15+
"serve:e2e": "node build/serve-dist.mjs",
1516
"test": "vitest --no-watch",
17+
"test:e2e": "playwright test",
18+
"test:e2e:headed": "playwright test --headed",
19+
"test:e2e:ui": "playwright test --ui",
20+
"playwright:install": "playwright install chromium",
1621
"lint": "eslint 'src/**/*.{ts,tsx}'",
1722
"watch": "tsc --noEmit --watch",
1823
"generate-themes": "tsx build/generate-daisyui-themes.ts"
@@ -30,6 +35,7 @@
3035
},
3136
"devDependencies": {
3237
"@eslint/js": "9.39.4",
38+
"@playwright/test": "^1.59.1",
3339
"@preact/preset-vite": "2.10.3",
3440
"@stylistic/eslint-plugin": "5.10.0",
3541
"@stylistic/eslint-plugin-ts": "4.4.1",

playwright.config.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./tests/e2e",
5+
timeout: 30_000,
6+
expect: {
7+
timeout: 10_000,
8+
},
9+
fullyParallel: true,
10+
retries: process.env.CI ? 1 : 0,
11+
reporter: [["list"], ["html", { open: "never" }]],
12+
use: {
13+
baseURL: "http://127.0.0.1:4173",
14+
trace: "on-first-retry",
15+
screenshot: "only-on-failure",
16+
video: "retain-on-failure",
17+
},
18+
webServer: {
19+
command: "npm run build && npm run serve:e2e",
20+
url: "http://127.0.0.1:4173",
21+
reuseExistingServer: true,
22+
timeout: 120_000,
23+
},
24+
projects: [
25+
{
26+
name: "chromium",
27+
use: { ...devices["Desktop Chrome"] },
28+
},
29+
],
30+
});

tests/e2e/playback-display.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const routeApi = async (page: Page): Promise<void> => {
4+
await page.route("**/api.php**", async (route) => {
5+
const url = new URL(route.request().url());
6+
const action = url.searchParams.get("action");
7+
8+
if (action === "listSoundLib") {
9+
await route.fulfill({
10+
status: 200,
11+
contentType: "application/json",
12+
body: JSON.stringify([]),
13+
});
14+
15+
return;
16+
}
17+
18+
if (action === "listScoreFolderContent") {
19+
await route.fulfill({
20+
status: 200,
21+
contentType: "application/json",
22+
body: JSON.stringify({ folders: [], scores: [] }),
23+
});
24+
25+
return;
26+
}
27+
28+
await route.fulfill({
29+
status: 200,
30+
contentType: "application/json",
31+
body: JSON.stringify({ success: true, id: 1 }),
32+
});
33+
});
34+
};
35+
36+
test.beforeEach(async ({ page }) => {
37+
await routeApi(page);
38+
});
39+
40+
test("renders arrangement UI and note grid", async ({ page }) => {
41+
await page.goto("/");
42+
43+
await expect(page.locator("#appRoot")).toBeVisible();
44+
await expect(page.locator("#trackViewerHost")).toBeVisible();
45+
await expect(page.locator(".bar-track-row .note-viewer").first()).toBeVisible();
46+
});
47+
48+
test("playback button starts and stops playback", async ({ page }) => {
49+
await page.goto("/");
50+
51+
const playbackToggle = page.locator("#playbackButton");
52+
await expect(playbackToggle).toBeVisible();
53+
54+
await playbackToggle.check({ force: true });
55+
await expect(playbackToggle).toBeChecked();
56+
57+
await playbackToggle.uncheck({ force: true });
58+
await expect(playbackToggle).not.toBeChecked();
59+
});
60+
61+
test("play beam moves while playback is running", async ({ page }) => {
62+
await page.goto("/");
63+
64+
const playbackToggle = page.locator("#playbackButton");
65+
const playBeam = page.locator("#playBeam");
66+
67+
await expect(playbackToggle).toBeVisible();
68+
await expect(playBeam).toBeVisible();
69+
70+
const initialLeft = await playBeam.evaluate((element) => {
71+
const left = (element as HTMLElement).style.left;
72+
return Number.parseFloat(left || "0");
73+
});
74+
75+
await playbackToggle.check({ force: true });
76+
await expect(playbackToggle).toBeChecked();
77+
78+
await expect.poll(async () => {
79+
const left = await playBeam.evaluate((element) => {
80+
return Number.parseFloat((element as HTMLElement).style.left || "0");
81+
});
82+
83+
return left !== initialLeft;
84+
}).toBeTruthy();
85+
86+
await playbackToggle.uncheck({ force: true });
87+
await expect(playbackToggle).not.toBeChecked();
88+
});

0 commit comments

Comments
 (0)