| author | |
| committer | |
| log | 5470801c4aecca55ea426be9b110bc879266be32 |
| tree | 6c9124d8a4908cb4a94bdc59af2a2bf96e7d4125 |
| parent | 2ad2d3e6f55d90da9107c9254423bbcaa9be4898 |
- 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 step6 files changed, 193 insertions(+), 3 deletions(-)
.github/workflows/ci.yml+17-1| ... | ... | @@ -8,7 +8,7 @@ on: |
| 8 | 8 | |
| 9 | 9 | jobs: |
| 10 | 10 | quality: |
| 11 | name: Lint, Typecheck, Test, Build | |
| 11 | name: Lint, Typecheck, Test, Browser Test, Build | |
| 12 | 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 13 | 13 | steps: |
| 14 | 14 | - name: Checkout |
| ... | ... | @@ -34,6 +34,14 @@ jobs: |
| 34 | 34 | restore-keys: | |
| 35 | 35 | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- |
| 36 | 36 | |
| 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 | ||
| 37 | 45 | - name: Install dependencies |
| 38 | 46 | run: bun install --frozen-lockfile |
| 39 | 47 | |
| ... | ... | @@ -46,6 +54,14 @@ jobs: |
| 46 | 54 | - name: Test |
| 47 | 55 | run: bun run test |
| 48 | 56 | |
| 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 | ||
| 49 | 65 | - name: Build desktop pipeline |
| 50 | 66 | run: bun run build:desktop |
| 51 | 67 |
.gitignore+3-1| ... | ... | @@ -11,4 +11,6 @@ packages/*/dist |
| 11 | 11 | build/ |
| 12 | 12 | .logs/ |
| 13 | 13 | release/ |
| 14 | .t3 | |
| \ No newline at end of file | ||
| 14 | .t3 | |
| 15 | apps/web/.playwright | |
| 16 | apps/web/playwright-report |
apps/web/browser-tests/timelineHeight.browser.e2e.ts created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | import { expect, test, type Page } from "@playwright/test"; | |
| 2 | ||
| 3 | import { estimateTimelineMessageHeight } from "../src/components/timelineHeight"; | |
| 4 | ||
| 5 | interface HeightCase { | |
| 6 | timelineWidthPx: number; | |
| 7 | text: string; | |
| 8 | attachmentCount: number; | |
| 9 | } | |
| 10 | ||
| 11 | async 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 | ||
| 79 | function 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 | ||
| 92 | test.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 @@ |
| 9 | 9 | "prepare": "effect-language-service patch", |
| 10 | 10 | "preview": "vite preview", |
| 11 | 11 | "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" | |
| 13 | 15 | }, |
| 14 | 16 | "dependencies": { |
| 15 | 17 | "@base-ui/react": "^1.2.0", |
| ... | ... | @@ -36,6 +38,7 @@ |
| 36 | 38 | }, |
| 37 | 39 | "devDependencies": { |
| 38 | 40 | "@effect/language-service": "catalog:", |
| 41 | "@playwright/test": "^1.58.2", | |
| 39 | 42 | "@tailwindcss/vite": "^4.0.0", |
| 40 | 43 | "@tanstack/router-plugin": "^1.161.0", |
| 41 | 44 | "@types/react": "^19.0.0", |
apps/web/playwright.config.ts created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | import { defineConfig } from "@playwright/test"; | |
| 2 | ||
| 3 | export 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 @@ |
| 87 | 87 | }, |
| 88 | 88 | "devDependencies": { |
| 89 | 89 | "@effect/language-service": "catalog:", |
| 90 | "@playwright/test": "^1.58.2", | |
| 90 | 91 | "@tailwindcss/vite": "^4.0.0", |
| 91 | 92 | "@tanstack/router-plugin": "^1.161.0", |
| 92 | 93 | "@types/react": "^19.0.0", |
| ... | ... | @@ -467,6 +468,8 @@ |
| 467 | 468 | |
| 468 | 469 | "@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=="], |
| 469 | 470 | |
| 471 | "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], | |
| 472 | ||
| 470 | 473 | "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], |
| 471 | 474 | |
| 472 | 475 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], |
| ... | ... | @@ -1249,6 +1252,10 @@ |
| 1249 | 1252 | |
| 1250 | 1253 | "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], |
| 1251 | 1254 | |
| 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 | ||
| 1252 | 1259 | "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=="], |
| 1253 | 1260 | |
| 1254 | 1261 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| ... | ... | @@ -1595,6 +1602,8 @@ |
| 1595 | 1602 | |
| 1596 | 1603 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], |
| 1597 | 1604 | |
| 1605 | "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], | |
| 1606 | ||
| 1598 | 1607 | "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 1599 | 1608 | |
| 1600 | 1609 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], |