authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 12:18:54-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 10:20:34-08:00
log5470801c4aecca55ea426be9b110bc879266be32
tree6c9124d8a4908cb4a94bdc59af2a2bf96e7d4125
parent2ad2d3e6f55d90da9107c9254423bbcaa9be4898

Add Playwright timeline height parity tests to CI

- add browser E2E tests validating timeline height estimator against rendered DOM - configure Playwright test runner and scripts in `apps/web` - run browser tests in CI with Playwright browser caching and install step

6 files changed, 193 insertions(+), 3 deletions(-)

.github/workflows/ci.yml+17-1
......@@ -8,7 +8,7 @@ on:
88
99jobs:
1010 quality:
11 name: Lint, Typecheck, Test, Build
11 name: Lint, Typecheck, Test, Browser Test, Build
1212 runs-on: blacksmith-4vcpu-ubuntu-2404
1313 steps:
1414 - name: Checkout
......@@ -34,6 +34,14 @@ jobs:
3434 restore-keys: |
3535 ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-
3636
37 - name: Cache Playwright browsers
38 uses: actions/cache@v4
39 with:
40 path: ~/.cache/ms-playwright
41 key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }}
42 restore-keys: |
43 ${{ runner.os }}-playwright-
44
3745 - name: Install dependencies
3846 run: bun install --frozen-lockfile
3947
......@@ -46,6 +54,14 @@ jobs:
4654 - name: Test
4755 run: bun run test
4856
57 - name: Install browser test runtime
58 run: |
59 cd apps/web
60 bunx playwright install --with-deps chromium
61
62 - name: Browser test
63 run: bun run --cwd apps/web test:browser
64
4965 - name: Build desktop pipeline
5066 run: bun run build:desktop
5167
.gitignore+3-1
......@@ -11,4 +11,6 @@ packages/*/dist
1111build/
1212.logs/
1313release/
14.t3
\ No newline at end of file
14.t3
15apps/web/.playwright
16apps/web/playwright-report
apps/web/browser-tests/timelineHeight.browser.e2e.ts created+148
......@@ -0,0 +1,148 @@
1import { expect, test, type Page } from "@playwright/test";
2
3import { estimateTimelineMessageHeight } from "../src/components/timelineHeight";
4
5interface HeightCase {
6 timelineWidthPx: number;
7 text: string;
8 attachmentCount: number;
9}
10
11async function measureUserRowHeight(page: Page, testCase: HeightCase) {
12 const { timelineWidthPx, text, attachmentCount } = testCase;
13 await page.setContent("<!doctype html><html><body style='margin:0'></body></html>");
14
15 return page.evaluate(({ width, messageText, attachments }) => {
16 const timeline = document.createElement("div");
17 timeline.style.width = `${width}px`;
18 timeline.style.maxWidth = `${width}px`;
19
20 const row = document.createElement("div");
21 row.style.paddingBottom = "16px";
22
23 const alignment = document.createElement("div");
24 alignment.style.display = "flex";
25 alignment.style.justifyContent = "flex-end";
26
27 const bubble = document.createElement("div");
28 bubble.style.boxSizing = "border-box";
29 bubble.style.maxWidth = "80%";
30 bubble.style.padding = "12px 16px";
31 bubble.style.border = "1px solid rgba(0, 0, 0, 0.12)";
32 bubble.style.borderRadius = "16px";
33 bubble.style.background = "rgba(0, 0, 0, 0.03)";
34
35 if (attachments > 0) {
36 const attachmentGrid = document.createElement("div");
37 attachmentGrid.style.marginBottom = "8px";
38 attachmentGrid.style.maxWidth = "420px";
39 attachmentGrid.style.display = "grid";
40 attachmentGrid.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))";
41 attachmentGrid.style.gap = "8px";
42 for (let index = 0; index < attachments; index += 1) {
43 const tile = document.createElement("div");
44 tile.style.height = "220px";
45 tile.style.border = "1px solid rgba(0, 0, 0, 0.12)";
46 tile.style.borderRadius = "8px";
47 tile.style.background = "rgba(0, 0, 0, 0.06)";
48 attachmentGrid.append(tile);
49 }
50 bubble.append(attachmentGrid);
51 }
52
53 if (messageText.length > 0) {
54 const pre = document.createElement("pre");
55 pre.textContent = messageText;
56 pre.style.margin = "0";
57 pre.style.whiteSpace = "pre-wrap";
58 pre.style.overflowWrap = "anywhere";
59 pre.style.fontFamily =
60 "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace";
61 pre.style.fontSize = "14px";
62 pre.style.lineHeight = "22px";
63 bubble.append(pre);
64 }
65
66 const meta = document.createElement("div");
67 meta.style.marginTop = "6px";
68 meta.style.height = "16px";
69 bubble.append(meta);
70
71 alignment.append(bubble);
72 row.append(alignment);
73 timeline.append(row);
74 document.body.append(timeline);
75 return row.getBoundingClientRect().height;
76 }, { width: timelineWidthPx, messageText: text, attachments: attachmentCount });
77}
78
79function estimatedHeight(testCase: HeightCase): number {
80 return estimateTimelineMessageHeight(
81 {
82 role: "user",
83 text: testCase.text,
84 attachments: Array.from({ length: testCase.attachmentCount }, (_value, index) => ({
85 id: String(index),
86 })),
87 },
88 { timelineWidthPx: testCase.timelineWidthPx },
89 );
90}
91
92test.describe("timeline height estimator parity", () => {
93 test("tracks long wrapped text growth at desktop width", async ({ page }) => {
94 const baselineCase: HeightCase = { timelineWidthPx: 960, text: "", attachmentCount: 0 };
95 const longCase: HeightCase = {
96 timelineWidthPx: 960,
97 text: "x".repeat(1200),
98 attachmentCount: 0,
99 };
100
101 const baselineMeasured = await measureUserRowHeight(page, baselineCase);
102 const longMeasured = await measureUserRowHeight(page, longCase);
103 const measuredDelta = longMeasured - baselineMeasured;
104
105 const estimatedDelta = estimatedHeight(longCase) - estimatedHeight(baselineCase);
106 expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(22);
107 });
108
109 test("tracks additional wrapping when viewport narrows", async ({ page }) => {
110 const desktopCase: HeightCase = {
111 timelineWidthPx: 960,
112 text: "x".repeat(1000),
113 attachmentCount: 0,
114 };
115 const mobileCase: HeightCase = {
116 timelineWidthPx: 360,
117 text: desktopCase.text,
118 attachmentCount: 0,
119 };
120
121 const desktopMeasured = await measureUserRowHeight(page, desktopCase);
122 const mobileMeasured = await measureUserRowHeight(page, mobileCase);
123 const measuredDelta = mobileMeasured - desktopMeasured;
124
125 const estimatedDelta = estimatedHeight(mobileCase) - estimatedHeight(desktopCase);
126 expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(44);
127 });
128
129 test("tracks attachment row growth", async ({ page }) => {
130 const withoutAttachmentsCase: HeightCase = {
131 timelineWidthPx: 960,
132 text: "hello",
133 attachmentCount: 0,
134 };
135 const withAttachmentsCase: HeightCase = {
136 ...withoutAttachmentsCase,
137 attachmentCount: 3,
138 };
139
140 const withoutMeasured = await measureUserRowHeight(page, withoutAttachmentsCase);
141 const withMeasured = await measureUserRowHeight(page, withAttachmentsCase);
142 const measuredDelta = withMeasured - withoutMeasured;
143
144 const estimatedDelta =
145 estimatedHeight(withAttachmentsCase) - estimatedHeight(withoutAttachmentsCase);
146 expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(4);
147 });
148});
apps/web/package.json+4-1
......@@ -9,7 +9,9 @@
99 "prepare": "effect-language-service patch",
1010 "preview": "vite preview",
1111 "typecheck": "tsc --noEmit",
12 "test": "vitest run --passWithNoTests"
12 "test": "vitest run --passWithNoTests",
13 "test:browser": "playwright test -c playwright.config.ts",
14 "test:browser:install": "playwright install chromium"
1315 },
1416 "dependencies": {
1517 "@base-ui/react": "^1.2.0",
......@@ -36,6 +38,7 @@
3638 },
3739 "devDependencies": {
3840 "@effect/language-service": "catalog:",
41 "@playwright/test": "^1.58.2",
3942 "@tailwindcss/vite": "^4.0.0",
4043 "@tanstack/router-plugin": "^1.161.0",
4144 "@types/react": "^19.0.0",
apps/web/playwright.config.ts created+12
......@@ -0,0 +1,12 @@
1import { defineConfig } from "@playwright/test";
2
3export default defineConfig({
4 testDir: "./browser-tests",
5 testMatch: "*.browser.e2e.ts",
6 reporter: "list",
7 fullyParallel: true,
8 outputDir: "./.playwright/test-results",
9 use: {
10 headless: true,
11 },
12});
bun.lock+9
......@@ -87,6 +87,7 @@
8787 },
8888 "devDependencies": {
8989 "@effect/language-service": "catalog:",
90 "@playwright/test": "^1.58.2",
9091 "@tailwindcss/vite": "^4.0.0",
9192 "@tanstack/router-plugin": "^1.161.0",
9293 "@types/react": "^19.0.0",
......@@ -467,6 +468,8 @@
467468
468469 "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="],
469470
471 "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
472
470473 "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="],
471474
472475 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
......@@ -1249,6 +1252,10 @@
12491252
12501253 "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
12511254
1255 "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
1256
1257 "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
1258
12521259 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
12531260
12541261 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
......@@ -1595,6 +1602,8 @@
15951602
15961603 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
15971604
1605 "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
1606
15981607 "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
15991608
16001609 "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],