From 40a3b135cf6a9c614fa6a0f7407299c6cfc90733 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 19 Sep 2026 15:29:45 -0400 Subject: [PATCH 1/2] ci: run Playwright smoke with two workers --- .github/workflows/ci.yaml | 25 ++++++++++++++++++++++++- AGENTS.md | 4 ++-- QUALITY_GATES.md | 7 ++++--- README.md | 3 ++- playwright.config.ts | 2 +- playwright.visual.config.ts | 2 +- 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5e2c75db..1b9935e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -82,6 +82,29 @@ jobs: if-no-files-found: ignore retention-days: 14 + browser-smoke: + name: browser-smoke + runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.61.1-noble + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm run test:e2e + - name: Upload smoke artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: browser-smoke-artifacts + path: | + test-results + playwright-report + if-no-files-found: ignore + retention-days: 14 + governance: # Repo-owned gates: godfile ratchet, changed-file maintainability, # Terraform fmt/validate, import-plan guard, HCP run guard, CloudFront @@ -150,7 +173,7 @@ jobs: ci-complete: name: ci-complete if: always() - needs: [static, build, unit, visual, governance] + needs: [static, build, unit, visual, browser-smoke, governance] runs-on: ubuntu-latest steps: - name: All required jobs passed diff --git a/AGENTS.md b/AGENTS.md index 51a13d7b..e774bafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,8 @@ build (`tsc -b && vite build`), unit tests (`vitest run`), and the governance checks (`npm run governance`), including G13 app/Terraform isolation. **Do not claim a task is done until `npm run verify` is green locally.** CI runs the same gates as parallel jobs in [`.github/workflows/ci.yaml`](.github/workflows/ci.yaml) -(`static`, `build`, `unit`, `visual`, `governance`) with `ci-complete` failing if -any of those jobs did not succeed. +(`static`, `build`, `unit`, `visual`, `browser-smoke`, `governance`) with +`ci-complete` failing if any of those jobs did not succeed. ## Non-negotiable rules (enforced; do not work around) diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index a5596f8b..bbfbdf1c 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -63,9 +63,10 @@ isolation). A task is not done until this is green. - **CI ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)):** this repository owns every job. `static` (`format:check` + `lint`), `build` (`tsc -b && vite build`), `unit` (`vitest run` in four shards), `visual` - (Playwright visual), and `governance` (`npm run governance`, with Terraform - 1.16.0) run in parallel. `ci-complete` fails unless all of those jobs - succeeded and is the required merge-queue check. Live G13 runs on + (Playwright visual), `browser-smoke` (`npm run test:e2e`), and `governance` + (`npm run governance`, with Terraform 1.16.0) run in parallel. `ci-complete` + fails unless all of those jobs succeeded and is the required merge-queue + check. Live G13 runs on `pull_request` (merge-base range), `merge_group` (each queued PR as a first-parent commit), and locally. It skips `push`. A Terraform-only PR stacked with an app-only PR still passes; a mixed change set still fails. diff --git a/README.md b/README.md index 974e0bf1..3ee3c5f9 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ No stored AWS keys — OIDC only. Infrastructure and content deploy separately: - **CI** ([`.github/workflows/ci.yaml`](.github/workflows/ci.yaml)) — on push and PRs to `main`, runs format, lint, build, sharded unit tests, visual - regression, and `npm run governance` as parallel jobs, then `ci-complete`. + regression, Playwright smoke, and `npm run governance` as parallel jobs, then + `ci-complete`. Conventions and gates are documented under [`AGENTS.md`](AGENTS.md), [`QUALITY_GATES.md`](QUALITY_GATES.md), [`ARCHITECTURE_AND_CODE_QUALITY.md`](ARCHITECTURE_AND_CODE_QUALITY.md), and diff --git a/playwright.config.ts b/playwright.config.ts index c37b71ed..66715338 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, reporter: process.env.CI ? "github" : "list", use: { // Port 4173 isolates Playwright from the app's normal port 3000 server. diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index f442c886..1d89a168 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: false, forbidOnly: true, retries: 0, - workers: 1, + workers: 2, reporter: [["list"], ["html", { outputFolder: "playwright-report-visual", open: "never" }]], outputDir: "test-results/visual", snapshotPathTemplate: "{testDir}/__screenshots__/{testFilePath}/{arg}{ext}", From c5e01c7491bacd73ae8b7c2a033d989f51df2748 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 21:37:34 +0000 Subject: [PATCH 2/2] test(e2e): match completion media uploads by filename Assert concurrent JPG, MP4, and MOV multipart requests by filename instead of arrival order so browser-smoke stays deterministic with two workers. --- .../completion-uploads-mobile.spec.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/e2e/work-orders/completion-uploads-mobile.spec.ts b/e2e/work-orders/completion-uploads-mobile.spec.ts index a8fde88f..f778567b 100644 --- a/e2e/work-orders/completion-uploads-mobile.spec.ts +++ b/e2e/work-orders/completion-uploads-mobile.spec.ts @@ -50,6 +50,10 @@ function multipartBoundary(request: Request): string | undefined { return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1]; } +function multipartFileName(body: string): string | undefined { + return /filename="([^"]+)"/.exec(body)?.[1]; +} + async function hideQueryDevtools(page: Page) { await page.addStyleTag({ content: @@ -141,7 +145,7 @@ test.describe("Completion uploads on a mobile viewport", () => { const request = route.request(); const body = request.postDataBuffer()?.toString("latin1") ?? ""; uploads.push({ contentType: request.headers()["content-type"] ?? "", body }); - const fileName = /filename="([^"]+)"/.exec(body)?.[1] ?? "file"; + const fileName = multipartFileName(body) ?? "file"; const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName }; media.push(item); return fulfillJson(route, item); @@ -156,18 +160,18 @@ test.describe("Completion uploads on a mobile viewport", () => { const panel = await openCompletionTab(page, row.woNumber); const chooser = page.waitForEvent("filechooser"); await panel.getByRole("button", { name: /Drag files here or click to browse/ }).click(); - await ( - await chooser - ).setFiles([ + const files = [ { name: "IMG_0001.jpg", mimeType: "image/jpeg", buffer: JPEG }, { name: "VID_0002.MP4", mimeType: "video/mp4", buffer: MP4 }, { name: "IMG_1587.MOV", mimeType: "video/quicktime", buffer: MOV }, - ]); + ]; + await (await chooser).setFiles(files); - await expect.poll(() => uploads.length).toBe(3); - for (const [index, name] of ["IMG_0001.jpg", "VID_0002.MP4", "IMG_1587.MOV"].entries()) { - expect(uploads[index]!.contentType).toMatch(/^multipart\/form-data; boundary=/); - expect(uploads[index]!.body).toContain(`filename="${name}"`); + await expect.poll(() => uploads.length).toBe(files.length); + for (const { name } of files) { + const upload = uploads.find((item) => multipartFileName(item.body) === name); + expect(upload, `multipart upload for ${name}`).toBeTruthy(); + expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/); } await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");