From 5470801c4aecca55ea426be9b110bc879266be32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Mar 2026 12:18:54 -0800 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 18 ++- .gitignore | 4 +- .../timelineHeight.browser.e2e.ts | 148 ++++++++++++++++++ apps/web/package.json | 5 +- apps/web/playwright.config.ts | 12 ++ bun.lock | 9 ++ 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 apps/web/browser-tests/timelineHeight.browser.e2e.ts create mode 100644 apps/web/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f300605cc474d8b64d7de3703b25a996fb0fa448..787e07bce750cc143a7bb04923a603dfc52b1e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: quality: - name: Lint, Typecheck, Test, Build + name: Lint, Typecheck, Test, Browser Test, Build runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -34,6 +34,14 @@ jobs: restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install dependencies run: bun install --frozen-lockfile @@ -46,6 +54,14 @@ jobs: - name: Test run: bun run test + - name: Install browser test runtime + run: | + cd apps/web + bunx playwright install --with-deps chromium + + - name: Browser test + run: bun run --cwd apps/web test:browser + - name: Build desktop pipeline run: bun run build:desktop diff --git a/.gitignore b/.gitignore index 09bcb945a035e8441a7f89e576e2a1fe3f1611cb..5c2ec0b4a0b47998d272a0f9f10650892f9bae5f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ packages/*/dist build/ .logs/ release/ -.t3 \ No newline at end of file +.t3 +apps/web/.playwright +apps/web/playwright-report diff --git a/apps/web/browser-tests/timelineHeight.browser.e2e.ts b/apps/web/browser-tests/timelineHeight.browser.e2e.ts new file mode 100644 index 0000000000000000000000000000000000000000..185ca6ce0681ba1ef94aca7e93a3c58dc62315c0 --- /dev/null +++ b/apps/web/browser-tests/timelineHeight.browser.e2e.ts @@ -0,0 +1,148 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { estimateTimelineMessageHeight } from "../src/components/timelineHeight"; + +interface HeightCase { + timelineWidthPx: number; + text: string; + attachmentCount: number; +} + +async function measureUserRowHeight(page: Page, testCase: HeightCase) { + const { timelineWidthPx, text, attachmentCount } = testCase; + await page.setContent(""); + + return page.evaluate(({ width, messageText, attachments }) => { + const timeline = document.createElement("div"); + timeline.style.width = `${width}px`; + timeline.style.maxWidth = `${width}px`; + + const row = document.createElement("div"); + row.style.paddingBottom = "16px"; + + const alignment = document.createElement("div"); + alignment.style.display = "flex"; + alignment.style.justifyContent = "flex-end"; + + const bubble = document.createElement("div"); + bubble.style.boxSizing = "border-box"; + bubble.style.maxWidth = "80%"; + bubble.style.padding = "12px 16px"; + bubble.style.border = "1px solid rgba(0, 0, 0, 0.12)"; + bubble.style.borderRadius = "16px"; + bubble.style.background = "rgba(0, 0, 0, 0.03)"; + + if (attachments > 0) { + const attachmentGrid = document.createElement("div"); + attachmentGrid.style.marginBottom = "8px"; + attachmentGrid.style.maxWidth = "420px"; + attachmentGrid.style.display = "grid"; + attachmentGrid.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; + attachmentGrid.style.gap = "8px"; + for (let index = 0; index < attachments; index += 1) { + const tile = document.createElement("div"); + tile.style.height = "220px"; + tile.style.border = "1px solid rgba(0, 0, 0, 0.12)"; + tile.style.borderRadius = "8px"; + tile.style.background = "rgba(0, 0, 0, 0.06)"; + attachmentGrid.append(tile); + } + bubble.append(attachmentGrid); + } + + if (messageText.length > 0) { + const pre = document.createElement("pre"); + pre.textContent = messageText; + pre.style.margin = "0"; + pre.style.whiteSpace = "pre-wrap"; + pre.style.overflowWrap = "anywhere"; + pre.style.fontFamily = + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace"; + pre.style.fontSize = "14px"; + pre.style.lineHeight = "22px"; + bubble.append(pre); + } + + const meta = document.createElement("div"); + meta.style.marginTop = "6px"; + meta.style.height = "16px"; + bubble.append(meta); + + alignment.append(bubble); + row.append(alignment); + timeline.append(row); + document.body.append(timeline); + return row.getBoundingClientRect().height; + }, { width: timelineWidthPx, messageText: text, attachments: attachmentCount }); +} + +function estimatedHeight(testCase: HeightCase): number { + return estimateTimelineMessageHeight( + { + role: "user", + text: testCase.text, + attachments: Array.from({ length: testCase.attachmentCount }, (_value, index) => ({ + id: String(index), + })), + }, + { timelineWidthPx: testCase.timelineWidthPx }, + ); +} + +test.describe("timeline height estimator parity", () => { + test("tracks long wrapped text growth at desktop width", async ({ page }) => { + const baselineCase: HeightCase = { timelineWidthPx: 960, text: "", attachmentCount: 0 }; + const longCase: HeightCase = { + timelineWidthPx: 960, + text: "x".repeat(1200), + attachmentCount: 0, + }; + + const baselineMeasured = await measureUserRowHeight(page, baselineCase); + const longMeasured = await measureUserRowHeight(page, longCase); + const measuredDelta = longMeasured - baselineMeasured; + + const estimatedDelta = estimatedHeight(longCase) - estimatedHeight(baselineCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(22); + }); + + test("tracks additional wrapping when viewport narrows", async ({ page }) => { + const desktopCase: HeightCase = { + timelineWidthPx: 960, + text: "x".repeat(1000), + attachmentCount: 0, + }; + const mobileCase: HeightCase = { + timelineWidthPx: 360, + text: desktopCase.text, + attachmentCount: 0, + }; + + const desktopMeasured = await measureUserRowHeight(page, desktopCase); + const mobileMeasured = await measureUserRowHeight(page, mobileCase); + const measuredDelta = mobileMeasured - desktopMeasured; + + const estimatedDelta = estimatedHeight(mobileCase) - estimatedHeight(desktopCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(44); + }); + + test("tracks attachment row growth", async ({ page }) => { + const withoutAttachmentsCase: HeightCase = { + timelineWidthPx: 960, + text: "hello", + attachmentCount: 0, + }; + const withAttachmentsCase: HeightCase = { + ...withoutAttachmentsCase, + attachmentCount: 3, + }; + + const withoutMeasured = await measureUserRowHeight(page, withoutAttachmentsCase); + const withMeasured = await measureUserRowHeight(page, withAttachmentsCase); + const measuredDelta = withMeasured - withoutMeasured; + + const estimatedDelta = + estimatedHeight(withAttachmentsCase) - estimatedHeight(withoutAttachmentsCase); + expect(Math.abs(measuredDelta - estimatedDelta)).toBeLessThanOrEqual(4); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 97985eb8535a3cb3a4507d690a8959fbaa9a74e2..a9c4b5451d9f8fed557563f1bceea7fc09cda6d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,9 @@ "prepare": "effect-language-service patch", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:browser": "playwright test -c playwright.config.ts", + "test:browser:install": "playwright install chromium" }, "dependencies": { "@base-ui/react": "^1.2.0", @@ -36,6 +38,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0d93c553cf90c743fb735d5e9c36b4602ebd36d --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./browser-tests", + testMatch: "*.browser.e2e.ts", + reporter: "list", + fullyParallel: true, + outputDir: "./.playwright/test-results", + use: { + headless: true, + }, +}); diff --git a/bun.lock b/bun.lock index 2c717ea7dbf4aaf8e54acba40e0f9deaea405321..d5e9b1e534fe6175b435379b6f00fb61f11252bf 100644 --- a/bun.lock +++ b/bun.lock @@ -87,6 +87,7 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", @@ -467,6 +468,8 @@ "@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=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -1249,6 +1252,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "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=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -1595,6 +1602,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], -- 2.54.0