| 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,7 +8,7 @@ on: |
| 8 | 8 | ||
| 9 | jobs: | 9 | jobs: |
| 10 | quality: | 10 | quality: |
| 11 | name: Lint, Typecheck, Test, Build | 11 | name: Lint, Typecheck, Test, Browser Test, Build |
| 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 | 12 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 13 | steps: | 13 | steps: |
| 14 | - name: Checkout | 14 | - name: Checkout |
| ... | @@ -34,6 +34,14 @@ jobs: | ... | @@ -34,6 +34,14 @@ jobs: |
| 34 | restore-keys: | | 34 | restore-keys: | |
| 35 | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- | 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 | - name: Install dependencies | 45 | - name: Install dependencies |
| 38 | run: bun install --frozen-lockfile | 46 | run: bun install --frozen-lockfile |
| 39 | 47 | ||
| ... | @@ -46,6 +54,14 @@ jobs: | ... | @@ -46,6 +54,14 @@ jobs: |
| 46 | - name: Test | 54 | - name: Test |
| 47 | run: bun run test | 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 | - name: Build desktop pipeline | 65 | - name: Build desktop pipeline |
| 50 | run: bun run build:desktop | 66 | run: bun run build:desktop |
| 51 | 67 |
.gitignore+3-1| ... | @@ -11,4 +11,6 @@ packages/*/dist | ... | @@ -11,4 +11,6 @@ packages/*/dist |
| 11 | build/ | 11 | build/ |
| 12 | .logs/ | 12 | .logs/ |
| 13 | release/ | 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,7 +9,9 @@ |
| 9 | "prepare": "effect-language-service patch", | 9 | "prepare": "effect-language-service patch", |
| 10 | "preview": "vite preview", | 10 | "preview": "vite preview", |
| 11 | "typecheck": "tsc --noEmit", | 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 | "dependencies": { | 16 | "dependencies": { |
| 15 | "@base-ui/react": "^1.2.0", | 17 | "@base-ui/react": "^1.2.0", |
| ... | @@ -36,6 +38,7 @@ | ... | @@ -36,6 +38,7 @@ |
| 36 | }, | 38 | }, |
| 37 | "devDependencies": { | 39 | "devDependencies": { |
| 38 | "@effect/language-service": "catalog:", | 40 | "@effect/language-service": "catalog:", |
| 41 | "@playwright/test": "^1.58.2", | ||
| 39 | "@tailwindcss/vite": "^4.0.0", | 42 | "@tailwindcss/vite": "^4.0.0", |
| 40 | "@tanstack/router-plugin": "^1.161.0", | 43 | "@tanstack/router-plugin": "^1.161.0", |
| 41 | "@types/react": "^19.0.0", | 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,6 +87,7 @@ |
| 87 | }, | 87 | }, |
| 88 | "devDependencies": { | 88 | "devDependencies": { |
| 89 | "@effect/language-service": "catalog:", | 89 | "@effect/language-service": "catalog:", |
| 90 | "@playwright/test": "^1.58.2", | ||
| 90 | "@tailwindcss/vite": "^4.0.0", | 91 | "@tailwindcss/vite": "^4.0.0", |
| 91 | "@tanstack/router-plugin": "^1.161.0", | 92 | "@tanstack/router-plugin": "^1.161.0", |
| 92 | "@types/react": "^19.0.0", | 93 | "@types/react": "^19.0.0", |
| ... | @@ -467,6 +468,8 @@ | ... | @@ -467,6 +468,8 @@ |
| 467 | 468 | ||
| 468 | "@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 | "@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 | "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], | 473 | "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], |
| 471 | 474 | ||
| 472 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], | 475 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], |
| ... | @@ -1249,6 +1252,10 @@ | ... | @@ -1249,6 +1252,10 @@ |
| 1249 | 1252 | ||
| 1250 | "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], | 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 | "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=="], | 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 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], | 1261 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| ... | @@ -1595,6 +1602,8 @@ | ... | @@ -1595,6 +1602,8 @@ |
| 1595 | 1602 | ||
| 1596 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], | 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 | "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | 1607 | "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 1599 | 1608 | ||
| 1600 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | 1609 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], |