From 0e551b507a16847b6c038ced3dbac29ac3fe4b87 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 20:48:35 -0700 Subject: [PATCH 1/4] test(rate-of-closure): add real-browser variation worker gate --- .github/workflows/rate-web-playwright.yml | 58 +++++++++++ .gitignore | 2 + AGENT_HANDOFF.md | 24 +++++ SPEC.md | 28 +++++- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 22 +++++ src/rate_of_closure/AGENT_HANDOFF.md | 20 ++++ .../web/e2e/variation-layout.spec.ts | 31 ++++++ .../web/e2e/variation-worker.spec.ts | 95 +++++++++++++++++++ .../web/e2e/variationTestSupport.ts | 24 +++++ src/rate_of_closure/web/package-lock.json | 64 +++++++++++++ src/rate_of_closure/web/package.json | 3 + src/rate_of_closure/web/playwright.config.ts | 57 +++++++++++ src/rate_of_closure/web/vite.config.ts | 2 + 13 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/rate-web-playwright.yml create mode 100644 src/rate_of_closure/web/e2e/variation-layout.spec.ts create mode 100644 src/rate_of_closure/web/e2e/variation-worker.spec.ts create mode 100644 src/rate_of_closure/web/e2e/variationTestSupport.ts create mode 100644 src/rate_of_closure/web/playwright.config.ts diff --git a/.github/workflows/rate-web-playwright.yml b/.github/workflows/rate-web-playwright.yml new file mode 100644 index 0000000000..f9fb7a0a41 --- /dev/null +++ b/.github/workflows/rate-web-playwright.yml @@ -0,0 +1,58 @@ +name: Rate Web Playwright + +on: + pull_request: + paths: + - "src/rate_of_closure/web/**" + - ".github/workflows/rate-web-playwright.yml" + push: + branches: [main] + paths: + - "src/rate_of_closure/web/**" + - ".github/workflows/rate-web-playwright.yml" + workflow_dispatch: + +concurrency: + group: rate-web-playwright-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + production-worker-e2e: + name: Rate web production Worker E2E + runs-on: d-sorg-fleet + timeout-minutes: 20 + defaults: + run: + working-directory: src/rate_of_closure/web + steps: + - uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: src/rate_of_closure/web/package-lock.json + + - name: Install locked web dependencies + run: npm ci + + - name: Install locked Chromium runtime + run: npx playwright install --with-deps chromium + + - name: Exercise production Worker lifecycle and layouts + run: npm run test:e2e + + - name: Retain Playwright evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: rate-web-playwright-report + path: | + src/rate_of_closure/web/playwright-report/ + src/rate_of_closure/web/test-results/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 69c6c1a740..d1935e38cd 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,8 @@ flask_pid.txt node_modules/ dist/ build/ +playwright-report/ +test-results/ # Cache .cache/ diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index bd8dd2992f..b760e170b7 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,30 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) + +Local branch `codex/4142-rate-web-playwright` starts from exact integrated +commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The Rate web package pins +Playwright Test 1.62.1 in its own manifest/lock and owns a deterministic +Chromium configuration plus a path-filtered `d-sorg-fleet` CI workflow. + +The gate builds and serves the Vite production bundle, then uses role/label +locators against the actual bundled module Worker. It proves seeded three-run +progress/completion and deterministic rerun, cancels a 500-run swing/OAT job +without accepting partial or late results, terminates active work on primary- +tab unmount, and rejects browser page errors. Blocking service workers in the +test context does not replace or disable the dedicated module Worker; every +lifecycle case observes the hashed production Worker chunk. Desktop 1440x1000 +and narrow 390x844 checks enforce zero document-level horizontal overflow and +attach full-page screenshots to the retained Playwright report. + +This is a narrow R14.5 foundation, not R14.5 completion or complete visual +certification. It covers bundled Chromium only; screenshots are review +artifacts, not cross-platform golden baselines. WebKit, Firefox, assistive- +technology automation, PyQt interaction, protected runner evidence, and a +CI-authority visual baseline remain open. Local evidence is 5/5 Playwright and +743/743 Vitest tests, plus TypeScript, ESLint, and the Vite production build. + ## 2026-08-12 Bounded ensemble chunk lifecycle foundation (#4142 R11.5) Local child `codex/4142-ensemble-chunks` starts from published #4405 head diff --git a/SPEC.md b/SPEC.md index b3c318331a..2e86650b54 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,12 +26,35 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.53 | -| **Spec Version** | 1.16.53 | +| **Current Version** | 1.16.54 | +| **Spec Version** | 1.16.54 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission +### 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) + +Version 1.16.54 pins Playwright Test 1.62.1 inside the Rate web package and +adds a dedicated deterministic Chromium configuration and path-filtered fleet +workflow. The gate builds and previews the production Vite output. Role/label +locators drive the real hashed module Worker through a seeded three-run study, +completion and identical rerun, 500-run swing/OAT cancellation with no partial +or late result, and primary-tab navigation which terminates active work before +the Variation panel unmounts. The test context blocks service workers but not +the tested dedicated module Worker. + +Desktop 1440x1000 and narrow 390x844 projects enforce zero document-level +horizontal overflow and attach deterministic full-page screenshots. Reports, +failure screenshots, traces, and videos are retained as CI artifacts. Local +evidence is 5/5 Playwright tests and 743/743 Vitest tests, with TypeScript, +ESLint, and the Vite production build green. + +This is a narrow R14.5 foundation rather than R14.5 completion. The screenshots +are review artifacts, not CI-authority or cross-platform pixel baselines. Only +bundled Chromium is exercised; WebKit, Firefox, assistive-technology automation, +PyQt interaction, protected runner execution, and an approved visual baseline +remain open. No scientific, plan, result, or persistence contract changed. + ### 2026-08-12 Bounded ensemble chunk lifecycle foundation (#4142 R11.5) Version 1.16.53 adds a bounded in-process execution lifecycle for complete Rate @@ -3309,6 +3332,7 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-12 | 1.16.54 | test(rate-of-closure, #4142 R14.5): pin a Rate-web-local Playwright/Chromium contract; exercise the real production module Worker for seeded progress/completion/rerun, cancellation without stale results, and navigation/unmount cleanup; retain desktop/narrow screenshots as review artifacts while keeping golden cross-platform visual approval and full R14.5 completion open. | | 2026-08-12 | 1.16.53 | feat(rate-of-closure, #4142 R11.5): add immutable resource-bounded ensemble stream headers/result chunks and an injected commit/abort sink lifecycle; project and release one chunk of complete runs at a time; retain the existing materialized API through a compatibility collector; and keep durable streaming/archive/memory claims explicitly open. | | 2026-08-12 | 1.16.52 | fix(rate-of-closure, #4142): satisfy the exact protected Python 3.12 / NumPy 2.3.5 / Mypy 1.13 typing boundary with explicit array annotations/casts and built-in-float `finfo` normalization; retain unchanged numerical and wire behavior. | | 2026-08-12 | 1.16.51 | fix(rate-of-closure, #4142 R11.4): require complete trial output scalars to be finite real non-booleans; normalize accepted NumPy real scalars to built-in floats; and prove typed-object writer/reader domain closure with five TDD cases and 39 focused persistence tests. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index f51a2cc22b..eea591bf49 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,27 @@ # Rate of Closure Campaign Handoff +## 2026-08-12 #4142 R14.5 real-browser variation Worker foundation + +- Local branch `codex/4142-rate-web-playwright` starts from exact integrated + commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. +- The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock; + the dedicated configuration serves the production Vite build in deterministic + Chromium desktop and narrow projects. +- Role/label-driven tests observe the hashed production module Worker and prove + seeded progress/completion/reproducible rerun, long-run cancellation without + partial or stale result acceptance, and Worker termination on tab unmount. + `serviceWorkers: "block"` suppresses unrelated service-worker state only; the + tested dedicated module Worker remains active and is never mocked. +- Desktop 1440x1000 and narrow 390x844 layouts have no document-level + horizontal overflow. Both full-page screenshots and all failure traces, + screenshots, and videos are retained in the Playwright report artifact. +- Local evidence: 5/5 Playwright tests, 743/743 Vitest tests, TypeScript, + ESLint, and Vite production build. Chromium installed locally without error. +- This is not R14.5 completion: screenshots are review artifacts rather than + CI-authority golden comparisons, and WebKit, Firefox, assistive-technology, + PyQt interaction, protected runner evidence, and cross-platform visual + baselines remain open. + ## 2026-08-12 #4142 R11.5 bounded ensemble chunk lifecycle foundation - Local child `codex/4142-ensemble-chunks` starts from exact published #4405 diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index a3ff414ef1..ae0ae0a8fc 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,26 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) + +Local branch `codex/4142-rate-web-playwright` starts from exact integrated +commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The web package now owns a +Playwright Test 1.62.1 pin/lock, deterministic Chromium config, and a dedicated +path-filtered fleet workflow which builds and serves the production Vite output. + +Five role/label-driven browser tests exercise the hashed dedicated module Worker +without mocks: seeded progress/completion and identical rerun, cancellation of +a 500-run swing/OAT request without partial or late results, and Worker cleanup +when navigation unmounts Variation. Blocking service workers does not block the +dedicated Worker. Desktop 1440x1000 and narrow 390x844 layouts enforce zero +document horizontal overflow and attach review screenshots. + +Local gates are 5/5 Playwright and 743/743 Vitest tests, TypeScript, ESLint, and +Vite production build. This remains a foundation, not R14.5 completion: +Chromium is the only browser, screenshots are review artifacts instead of +cross-platform golden baselines, and PyQt/assistive-technology/protected-runner +evidence remains open. + ## 2026-08-12 Bounded complete-ensemble chunk execution seam (#4142 R11.5) Local child `codex/4142-ensemble-chunks` is based on exact #4405 head diff --git a/src/rate_of_closure/web/e2e/variation-layout.spec.ts b/src/rate_of_closure/web/e2e/variation-layout.spec.ts new file mode 100644 index 0000000000..895991b3ca --- /dev/null +++ b/src/rate_of_closure/web/e2e/variation-layout.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +import { capturePageErrors, openVariation } from "./variationTestSupport"; + +test("variation workspace fits the deterministic desktop and narrow viewports", async ( + { page }, + testInfo, +) => { + const pageErrors = capturePageErrors(page); + await openVariation(page); + + await expect(page.getByRole("heading", { name: "Study Setup" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Run Variation Study" })).toBeVisible(); + const overflow = await page.evaluate(() => ({ + body: document.body.scrollWidth - document.body.clientWidth, + root: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); + expect(overflow).toEqual({ body: 0, root: 0 }); + + const screenshot = await page.screenshot({ + animations: "disabled", + caret: "hide", + fullPage: true, + }); + expect(screenshot.byteLength).toBeGreaterThan(10_000); + await testInfo.attach(`variation-${testInfo.project.name}`, { + body: screenshot, + contentType: "image/png", + }); + expect(pageErrors).toEqual([]); +}); diff --git a/src/rate_of_closure/web/e2e/variation-worker.spec.ts b/src/rate_of_closure/web/e2e/variation-worker.spec.ts new file mode 100644 index 0000000000..586fc47a32 --- /dev/null +++ b/src/rate_of_closure/web/e2e/variation-worker.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page, type Worker } from "@playwright/test"; + +import { + capturePageErrors, + openVariation, + setNumericField, +} from "./variationTestSupport"; + +const LONG_RUN_COUNT = "500"; + +async function configureSeededDelivery(page: Page): Promise { + await setNumericField(page, "Runs", "3"); + await setNumericField(page, "Seed", "20260812"); + await page.getByRole("combobox", { name: "Analysis execution" }) + .selectOption("all_together"); +} + +async function configureLongSwing(page: Page): Promise { + await page.getByRole("combobox", { name: "Pipeline" }).selectOption("swing"); + await setNumericField(page, "Runs", LONG_RUN_COUNT); + await page.getByRole("combobox", { name: "Analysis execution" }).selectOption("both"); +} + +function captureWorkers(page: Page): Worker[] { + const workers: Worker[] = []; + page.on("worker", (worker) => workers.push(worker)); + return workers; +} + +test("production Worker completes a seeded study and reruns deterministically", async ({ page }) => { + const pageErrors = capturePageErrors(page); + const workers = captureWorkers(page); + await openVariation(page); + await configureSeededDelivery(page); + + const runButton = page.getByRole("button", { name: "Run Variation Study" }); + const status = page.getByRole("status", { name: "Variation status" }); + await runButton.click(); + await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect(page.getByRole("progressbar", { name: "Variation execution progress" })) + .toHaveAttribute("value", "3"); + await expect(page.getByRole("heading", { name: "Summary — Dispersion per Output" })) + .toBeVisible(); + await expect.poll(() => workers.length).toBe(1); + expect(workers[0].url()).toMatch(/variationExecution\.worker-[\w-]+\.js$/); + const firstSummary = await page.getByRole("table").first().innerText(); + + await runButton.click(); + await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect.poll(() => workers.length).toBe(2); + expect(await page.getByRole("table").first().innerText()).toBe(firstSummary); + expect(pageErrors).toEqual([]); +}); + +test("cancelling a long Worker run rejects every partial and stale result", async ({ page }) => { + const pageErrors = capturePageErrors(page); + const workers = captureWorkers(page); + await openVariation(page); + await configureLongSwing(page); + + await page.getByRole("button", { name: "Run Variation Study" }).click(); + await expect.poll(() => workers.length).toBe(1); + const cancelButton = page.getByRole("button", { name: "Cancel Variation Study" }); + await expect(cancelButton).toBeEnabled(); + await cancelButton.click(); + + const status = page.getByRole("status", { name: "Variation status" }); + await expect(status).toHaveText("Cancelled: no partial variation result was accepted."); + await expect(page.getByRole("heading", { name: "Ready to Analyze Variation" })).toBeVisible(); + await page.waitForTimeout(300); + await expect(status).toHaveText("Cancelled: no partial variation result was accepted."); + await expect(page.getByRole("heading", { name: "Summary — Dispersion per Output" })) + .toHaveCount(0); + expect(pageErrors).toEqual([]); +}); + +test("primary-view navigation terminates an active Worker before unmount", async ({ page }) => { + const pageErrors = capturePageErrors(page); + const workers = captureWorkers(page); + await openVariation(page); + await configureLongSwing(page); + await page.getByRole("button", { name: "Run Variation Study" }).click(); + await expect.poll(() => workers.length).toBe(1); + let workerClosed = false; + workers[0].on("close", () => { workerClosed = true; }); + + await page.getByRole("tab", { name: "Explorer", exact: true }).click(); + await expect(page.getByRole("tabpanel", { name: "Explorer", exact: true })).toBeVisible(); + await expect.poll(() => workerClosed).toBe(true); + + await page.getByRole("tab", { name: "Variation" }).click(); + await expect(page.getByRole("status", { name: "Variation status" })).toHaveText("Ready."); + await expect(page.getByRole("heading", { name: "Ready to Analyze Variation" })).toBeVisible(); + expect(pageErrors).toEqual([]); +}); diff --git a/src/rate_of_closure/web/e2e/variationTestSupport.ts b/src/rate_of_closure/web/e2e/variationTestSupport.ts new file mode 100644 index 0000000000..9f8ae7bae0 --- /dev/null +++ b/src/rate_of_closure/web/e2e/variationTestSupport.ts @@ -0,0 +1,24 @@ +import { expect, type Page } from "@playwright/test"; + +export async function openVariation(page: Page): Promise { + await page.goto("./"); + await page.getByRole("tab", { name: "Variation" }).click(); + await expect(page.getByRole("region", { name: "Variation setup" })).toBeVisible(); +} + +export async function setNumericField( + page: Page, + label: string, + value: string, +): Promise { + const field = page.getByRole("textbox", { name: label }); + await field.fill(value); + await field.blur(); + await expect(field).toHaveValue(value); +} + +export function capturePageErrors(page: Page): Error[] { + const errors: Error[] = []; + page.on("pageerror", (error) => errors.push(error)); + return errors; +} diff --git a/src/rate_of_closure/web/package-lock.json b/src/rate_of_closure/web/package-lock.json index 4c54699114..bd09ef3d6e 100644 --- a/src/rate_of_closure/web/package-lock.json +++ b/src/rate_of_closure/web/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", + "@playwright/test": "1.62.1", "@tauri-apps/cli": "latest", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", @@ -1257,6 +1258,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -4373,6 +4390,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", diff --git a/src/rate_of_closure/web/package.json b/src/rate_of_closure/web/package.json index 588cbce090..48e6d300f2 100644 --- a/src/rate_of_closure/web/package.json +++ b/src/rate_of_closure/web/package.json @@ -8,6 +8,8 @@ "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", @@ -21,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", + "@playwright/test": "1.62.1", "@tauri-apps/cli": "latest", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", diff --git a/src/rate_of_closure/web/playwright.config.ts b/src/rate_of_closure/web/playwright.config.ts new file mode 100644 index 0000000000..300d10b0c9 --- /dev/null +++ b/src/rate_of_closure/web/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from "@playwright/test"; + +const previewUrl = "http://127.0.0.1:4173"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + timeout: 45_000, + expect: { timeout: 10_000 }, + outputDir: "test-results", + preserveOutput: "always", + reporter: process.env.CI + ? [["line"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + use: { + baseURL: previewUrl, + colorScheme: "dark", + deviceScaleFactor: 1, + headless: true, + locale: "en-US", + reducedMotion: "reduce", + serviceWorkers: "block", + timezoneId: "UTC", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + launchOptions: { + args: [ + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-features=MediaRouter,Translate", + "--force-color-profile=srgb", + ], + }, + }, + projects: [ + { + name: "chromium-desktop", + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 1000 } }, + }, + { + name: "chromium-narrow", + testMatch: /variation-layout\.spec\.ts/, + use: { ...devices["Desktop Chrome"], viewport: { width: 390, height: 844 } }, + }, + ], + webServer: { + command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4173 --strictPort", + url: previewUrl, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/rate_of_closure/web/vite.config.ts b/src/rate_of_closure/web/vite.config.ts index d75b588949..71547b3804 100644 --- a/src/rate_of_closure/web/vite.config.ts +++ b/src/rate_of_closure/web/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; +import { configDefaults } from "vitest/config"; import { morrisAuthorityProxy } from "./morrisAuthorityProxy"; @@ -14,6 +15,7 @@ export default defineConfig(({ mode }) => { environment: "jsdom", setupFiles: "./src/test/setup.ts", globals: true, + exclude: [...configDefaults.exclude, "e2e/**"], // Physics optimization and Monte Carlo cases contend under Vitest's // parallel pool; retain a bounded but CI-realistic per-test ceiling. testTimeout: 15_000, From 807d3f72b9e74b9a96174b412673b93e417da2f6 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 21:07:26 -0700 Subject: [PATCH 2/4] ci(rate-web): isolate fork Playwright runs --- .github/workflows/local-only-runner-guard.yml | 8 +- .github/workflows/rate-web-playwright.yml | 59 +++++++++++-- AGENT_HANDOFF.md | 6 +- SPEC.md | 18 ++-- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 5 ++ scripts/check_local_only_workflows.py | 1 + src/rate_of_closure/AGENT_HANDOFF.md | 7 +- .../ops/test_rate_web_playwright_workflow.py | 88 +++++++++++++++++++ .../test_check_local_only_workflows.py | 22 ++++- 9 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 tests/ops/test_rate_web_playwright_workflow.py diff --git a/.github/workflows/local-only-runner-guard.yml b/.github/workflows/local-only-runner-guard.yml index 651b8e326e..47a9e694e5 100644 --- a/.github/workflows/local-only-runner-guard.yml +++ b/.github/workflows/local-only-runner-guard.yml @@ -29,12 +29,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python env: AGENT_TOOLSDIRECTORY: ${{ runner.temp }}/_tool_cache - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" @@ -69,6 +69,7 @@ jobs: } ALLOWLIST_JOBS = { ("ci-standard.yml", "quality-gate"), + ("rate-web-playwright.yml", "fork-production-worker-e2e"), } HOSTED = re.compile(r"^(ubuntu|macos|windows)(-latest|-\d+)?$") @@ -148,7 +149,8 @@ jobs: print(f"{len(violations)} workflow(s) leak to hosted runners.") print( "Allowed: local-only-runner-guard.yml, runner-health-alert.yml, " - "ci-standard.yml::quality-gate, and jobs literally named " + "ci-standard.yml::quality-gate, " + "rate-web-playwright.yml::fork-production-worker-e2e, and jobs literally named " "'Reject hosted runner routing'." ) sys.exit(1) diff --git a/.github/workflows/rate-web-playwright.yml b/.github/workflows/rate-web-playwright.yml index f9fb7a0a41..f66a98d792 100644 --- a/.github/workflows/rate-web-playwright.yml +++ b/.github/workflows/rate-web-playwright.yml @@ -20,18 +20,21 @@ permissions: contents: read jobs: - production-worker-e2e: - name: Rate web production Worker E2E + trusted-production-worker-e2e: + name: Rate web production Worker E2E (trusted fleet) + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository runs-on: d-sorg-fleet timeout-minutes: 20 defaults: run: working-directory: src/rate_of_closure/web steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" cache: npm @@ -40,17 +43,57 @@ jobs: - name: Install locked web dependencies run: npm ci - - name: Install locked Chromium runtime - run: npx playwright install --with-deps chromium + - name: Install Playwright-pinned Chromium runtime + run: npx --no-install playwright install --with-deps chromium - name: Exercise production Worker lifecycle and layouts run: npm run test:e2e - name: Retain Playwright evidence if: ${{ !cancelled() }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: rate-web-playwright-report + name: rate-web-playwright-report-${{ github.run_id }}-${{ github.run_attempt }} + path: | + src/rate_of_closure/web/playwright-report/ + src/rate_of_closure/web/test-results/ + if-no-files-found: error + retention-days: 14 + + fork-production-worker-e2e: + name: Rate web production Worker E2E (ephemeral fork) + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: src/rate_of_closure/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: src/rate_of_closure/web/package-lock.json + + - name: Install locked web dependencies + run: npm ci + + - name: Install Playwright-pinned Chromium runtime + run: npx --no-install playwright install --with-deps chromium + + - name: Exercise production Worker lifecycle and layouts + run: npm run test:e2e + + - name: Retain Playwright evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rate-web-playwright-report-${{ github.run_id }}-${{ github.run_attempt }} path: | src/rate_of_closure/web/playwright-report/ src/rate_of_closure/web/test-results/ diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index b760e170b7..45470b9c75 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -8,7 +8,11 @@ Local branch `codex/4142-rate-web-playwright` starts from exact integrated commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock and owns a deterministic -Chromium configuration plus a path-filtered `d-sorg-fleet` CI workflow. +Chromium configuration plus a path-filtered CI workflow. Same-repository PRs, +pushes, and manual runs may use `d-sorg-fleet`; fork PRs run the equivalent +locked gate on ephemeral `ubuntu-latest` and cannot reach the persistent +fleet. Checkout, Node setup, and artifact upload actions are pinned to full +immutable SHAs. Artifact names include the workflow run and attempt IDs. The gate builds and serves the Vite production bundle, then uses role/label locators against the actual bundled module Worker. It proves seeded three-run diff --git a/SPEC.md b/SPEC.md index 2e86650b54..080f85a2d3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,17 +26,22 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.54 | -| **Spec Version** | 1.16.54 | +| **Current Version** | 1.16.55 | +| **Spec Version** | 1.16.55 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission ### 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) -Version 1.16.54 pins Playwright Test 1.62.1 inside the Rate web package and -adds a dedicated deterministic Chromium configuration and path-filtered fleet -workflow. The gate builds and previews the production Vite output. Role/label +Version 1.16.55 pins Playwright Test 1.62.1 inside the Rate web package and +adds a dedicated deterministic Chromium configuration and path-filtered +workflow. Same-repository pull requests, pushes, and manual runs execute on the +trusted fleet. Fork pull requests execute the equivalent locked gate on an +ephemeral GitHub-hosted runner and cannot reach the persistent fleet. Every +external action reference in this Playwright workflow is pinned to a full +immutable commit SHA. The gate +builds and previews the production Vite output. Role/label locators drive the real hashed module Worker through a seeded three-run study, completion and identical rerun, 500-run swing/OAT cancellation with no partial or late result, and primary-tab navigation which terminates active work before @@ -45,7 +50,8 @@ the tested dedicated module Worker. Desktop 1440x1000 and narrow 390x844 projects enforce zero document-level horizontal overflow and attach deterministic full-page screenshots. Reports, -failure screenshots, traces, and videos are retained as CI artifacts. Local +failure screenshots, traces, and videos are retained as attempt-identified CI +artifacts. Local evidence is 5/5 Playwright tests and 743/743 Vitest tests, with TypeScript, ESLint, and the Vite production build green. diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index eea591bf49..02591a2e07 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -7,6 +7,11 @@ - The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock; the dedicated configuration serves the production Vite build in deterministic Chromium desktop and narrow projects. +- Same-repository PRs, pushes, and manual dispatches run on `d-sorg-fleet`. + Fork PRs run the equivalent locked production gate on ephemeral + `ubuntu-latest` and cannot execute on the persistent fleet. Every external + action in the Playwright workflow is pinned to a full immutable SHA; evidence + artifacts include the run and attempt IDs. - Role/label-driven tests observe the hashed production module Worker and prove seeded progress/completion/reproducible rerun, long-run cancellation without partial or stale result acceptance, and Worker termination on tab unmount. diff --git a/scripts/check_local_only_workflows.py b/scripts/check_local_only_workflows.py index 81e5bcc87f..df2d1c4084 100644 --- a/scripts/check_local_only_workflows.py +++ b/scripts/check_local_only_workflows.py @@ -33,6 +33,7 @@ } HOSTED_RUNNER_ALLOWLIST = { (".github/workflows/ci-standard.yml", "quality-gate"), + (".github/workflows/rate-web-playwright.yml", "fork-production-worker-e2e"), } HOSTED_RUNNER = re.compile(r"^(ubuntu|macos|windows)(-latest|-\d+(?:\.\d+)*)$") diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index ae0ae0a8fc..032fbcda63 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -8,7 +8,12 @@ Local branch `codex/4142-rate-web-playwright` starts from exact integrated commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The web package now owns a Playwright Test 1.62.1 pin/lock, deterministic Chromium config, and a dedicated -path-filtered fleet workflow which builds and serves the production Vite output. +path-filtered workflow which builds and serves the production Vite output. +Same-repository PRs, pushes, and manual runs may use `d-sorg-fleet`; fork PRs +run the same locked production gate on ephemeral `ubuntu-latest` and cannot +reach the persistent fleet. All external actions in the Playwright workflow are +pinned to immutable full SHAs, and artifacts identify both workflow run and +attempt. Five role/label-driven browser tests exercise the hashed dedicated module Worker without mocks: seeded progress/completion and identical rerun, cancellation of diff --git a/tests/ops/test_rate_web_playwright_workflow.py b/tests/ops/test_rate_web_playwright_workflow.py new file mode 100644 index 0000000000..191ab555d4 --- /dev/null +++ b/tests/ops/test_rate_web_playwright_workflow.py @@ -0,0 +1,88 @@ +"""Security and reproducibility contracts for the Rate Playwright workflow.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "rate-web-playwright.yml" +RUNNER_GUARD_PATH = REPO_ROOT / ".github" / "workflows" / "local-only-runner-guard.yml" +FULL_ACTION_SHA = re.compile(r"^[^@]+@[0-9a-f]{40}$") + + +def _workflow() -> dict[str, Any]: + loaded = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return loaded + + +def _run_steps(job: dict[str, Any]) -> dict[str, str]: + return { + str(step["name"]): str(step["run"]) + for step in job["steps"] + if isinstance(step, dict) and "name" in step and "run" in step + } + + +def test_fork_pull_requests_cannot_reach_the_persistent_fleet() -> None: + jobs = _workflow()["jobs"] + trusted = jobs["trusted-production-worker-e2e"] + fork = jobs["fork-production-worker-e2e"] + + assert trusted["runs-on"] == "d-sorg-fleet" + assert "head.repo.full_name == github.repository" in trusted["if"] + assert fork["runs-on"] == "ubuntu-latest" + assert "head.repo.full_name != github.repository" in fork["if"] + assert "github.event_name == 'pull_request'" in fork["if"] + + +def test_fork_and_trusted_jobs_run_the_same_locked_production_gate() -> None: + jobs = _workflow()["jobs"] + trusted = jobs["trusted-production-worker-e2e"] + fork = jobs["fork-production-worker-e2e"] + + assert trusted["steps"] == fork["steps"] + assert _run_steps(trusted) == _run_steps(fork) + commands = _run_steps(fork) + assert commands["Install locked web dependencies"] == "npm ci" + assert commands["Install Playwright-pinned Chromium runtime"] == ( + "npx --no-install playwright install --with-deps chromium" + ) + assert commands["Exercise production Worker lifecycle and layouts"] == ( + "npm run test:e2e" + ) + + +def test_external_actions_are_immutable_and_artifacts_identify_attempts() -> None: + workflow = _workflow() + assert workflow["permissions"] == {"contents": "read"} + + for job in workflow["jobs"].values(): + for step in job["steps"]: + if "uses" in step: + assert FULL_ACTION_SHA.fullmatch(str(step["uses"])) + artifact = next( + step + for step in job["steps"] + if step.get("name") == "Retain Playwright evidence" + ) + name = artifact["with"]["name"] + assert "${{ github.run_id }}" in name + assert "${{ github.run_attempt }}" in name + assert artifact["with"]["path"] == ( + "src/rate_of_closure/web/playwright-report/\n" + "src/rate_of_closure/web/test-results/\n" + ) + + +def test_touched_hosted_runner_guard_uses_only_immutable_actions() -> None: + loaded = yaml.safe_load(RUNNER_GUARD_PATH.read_text(encoding="utf-8")) + steps = loaded["jobs"]["reject-hosted-runner-routing"]["steps"] + + action_uses = [str(step["uses"]) for step in steps if "uses" in step] + assert action_uses + assert all(FULL_ACTION_SHA.fullmatch(value) for value in action_uses) diff --git a/tests/scripts/test_check_local_only_workflows.py b/tests/scripts/test_check_local_only_workflows.py index e9b2e98004..5ffc22086e 100644 --- a/tests/scripts/test_check_local_only_workflows.py +++ b/tests/scripts/test_check_local_only_workflows.py @@ -5,10 +5,12 @@ SCRIPT = Path("scripts/check_local_only_workflows.py").resolve() -def _run_guard(tmp_path: Path, workflow: str) -> subprocess.CompletedProcess[str]: +def _run_guard( + tmp_path: Path, workflow: str, filename: str = "ci-standard.yml" +) -> subprocess.CompletedProcess[str]: workflow_dir = tmp_path / ".github" / "workflows" workflow_dir.mkdir(parents=True, exist_ok=True) - (workflow_dir / "ci-standard.yml").write_text(workflow, encoding="utf-8") + (workflow_dir / filename).write_text(workflow, encoding="utf-8") return subprocess.run( [sys.executable, str(SCRIPT)], cwd=tmp_path, @@ -30,3 +32,19 @@ def test_guard_allows_only_hosted_ci_standard_quality_gate(tmp_path: Path) -> No assert allowed.returncode == 0 assert rejected.returncode == 1 + + +def test_guard_allows_only_ephemeral_fork_playwright_job(tmp_path: Path) -> None: + allowed = _run_guard( + tmp_path, + "jobs:\n fork-production-worker-e2e:\n runs-on: ubuntu-latest\n", + "rate-web-playwright.yml", + ) + rejected = _run_guard( + tmp_path, + "jobs:\n trusted-production-worker-e2e:\n runs-on: ubuntu-latest\n", + "rate-web-playwright.yml", + ) + + assert allowed.returncode == 0 + assert rejected.returncode == 1 From 5d5f5ee796ba57a12060d539928cdeabd8407fd2 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 21:17:50 -0700 Subject: [PATCH 3/4] ci(rate-web): separate PR and trusted Playwright gates --- .github/workflows/local-only-runner-guard.yml | 4 +- .../workflows/rate-web-playwright-trusted.yml | 96 ++++++++++++++ .github/workflows/rate-web-playwright.yml | 60 +-------- AGENT_HANDOFF.md | 12 +- SPEC.md | 19 +-- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 12 +- scripts/check_local_only_workflows.py | 2 +- src/rate_of_closure/AGENT_HANDOFF.md | 15 +-- .../ops/test_rate_web_playwright_workflow.py | 118 +++++++++++------- .../test_check_local_only_workflows.py | 8 +- 10 files changed, 215 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/rate-web-playwright-trusted.yml diff --git a/.github/workflows/local-only-runner-guard.yml b/.github/workflows/local-only-runner-guard.yml index 47a9e694e5..892af15d3d 100644 --- a/.github/workflows/local-only-runner-guard.yml +++ b/.github/workflows/local-only-runner-guard.yml @@ -69,7 +69,7 @@ jobs: } ALLOWLIST_JOBS = { ("ci-standard.yml", "quality-gate"), - ("rate-web-playwright.yml", "fork-production-worker-e2e"), + ("rate-web-playwright.yml", "production-worker-e2e"), } HOSTED = re.compile(r"^(ubuntu|macos|windows)(-latest|-\d+)?$") @@ -150,7 +150,7 @@ jobs: print( "Allowed: local-only-runner-guard.yml, runner-health-alert.yml, " "ci-standard.yml::quality-gate, " - "rate-web-playwright.yml::fork-production-worker-e2e, and jobs literally named " + "rate-web-playwright.yml::production-worker-e2e, and jobs literally named " "'Reject hosted runner routing'." ) sys.exit(1) diff --git a/.github/workflows/rate-web-playwright-trusted.yml b/.github/workflows/rate-web-playwright-trusted.yml new file mode 100644 index 0000000000..d154712461 --- /dev/null +++ b/.github/workflows/rate-web-playwright-trusted.yml @@ -0,0 +1,96 @@ +name: Rate Web Playwright Trusted + +on: + push: + branches: [main] + paths: + - "src/rate_of_closure/web/**" + - ".github/workflows/rate-web-playwright.yml" + - ".github/workflows/rate-web-playwright-trusted.yml" + workflow_dispatch: + +concurrency: + group: rate-web-playwright-trusted-main + cancel-in-progress: true + +permissions: + contents: read + +jobs: + push-production-worker-e2e: + name: Rate web production Worker E2E (trusted push) + if: github.event_name == 'push' + runs-on: d-sorg-fleet + timeout-minutes: 20 + defaults: + run: + working-directory: src/rate_of_closure/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: src/rate_of_closure/web/package-lock.json + + - name: Install locked web dependencies + run: npm ci + + - name: Install Playwright-pinned Chromium runtime + run: npx --no-install playwright install --with-deps chromium + + - name: Exercise production Worker lifecycle and layouts + run: npm run test:e2e + + - name: Retain Playwright evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rate-web-playwright-trusted-push-${{ github.run_id }}-${{ github.run_attempt }} + path: | + src/rate_of_closure/web/playwright-report/ + src/rate_of_closure/web/test-results/ + if-no-files-found: error + retention-days: 14 + + manual-production-worker-e2e: + name: Rate web production Worker E2E (trusted manual) + if: github.event_name == 'workflow_dispatch' + runs-on: d-sorg-fleet + timeout-minutes: 20 + defaults: + run: + working-directory: src/rate_of_closure/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: main + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: src/rate_of_closure/web/package-lock.json + + - name: Install locked web dependencies + run: npm ci + + - name: Install Playwright-pinned Chromium runtime + run: npx --no-install playwright install --with-deps chromium + + - name: Exercise production Worker lifecycle and layouts + run: npm run test:e2e + + - name: Retain Playwright evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rate-web-playwright-trusted-manual-${{ github.run_id }}-${{ github.run_attempt }} + path: | + src/rate_of_closure/web/playwright-report/ + src/rate_of_closure/web/test-results/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/rate-web-playwright.yml b/.github/workflows/rate-web-playwright.yml index f66a98d792..2e172290fc 100644 --- a/.github/workflows/rate-web-playwright.yml +++ b/.github/workflows/rate-web-playwright.yml @@ -1,70 +1,22 @@ -name: Rate Web Playwright +name: Rate Web Playwright PR on: pull_request: paths: - "src/rate_of_closure/web/**" - ".github/workflows/rate-web-playwright.yml" - push: - branches: [main] - paths: - - "src/rate_of_closure/web/**" - - ".github/workflows/rate-web-playwright.yml" - workflow_dispatch: + - ".github/workflows/rate-web-playwright-trusted.yml" concurrency: - group: rate-web-playwright-${{ github.ref }} + group: rate-web-playwright-pr-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: - trusted-production-worker-e2e: - name: Rate web production Worker E2E (trusted fleet) - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: d-sorg-fleet - timeout-minutes: 20 - defaults: - run: - working-directory: src/rate_of_closure/web - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "22" - cache: npm - cache-dependency-path: src/rate_of_closure/web/package-lock.json - - - name: Install locked web dependencies - run: npm ci - - - name: Install Playwright-pinned Chromium runtime - run: npx --no-install playwright install --with-deps chromium - - - name: Exercise production Worker lifecycle and layouts - run: npm run test:e2e - - - name: Retain Playwright evidence - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: rate-web-playwright-report-${{ github.run_id }}-${{ github.run_attempt }} - path: | - src/rate_of_closure/web/playwright-report/ - src/rate_of_closure/web/test-results/ - if-no-files-found: error - retention-days: 14 - - fork-production-worker-e2e: - name: Rate web production Worker E2E (ephemeral fork) - if: >- - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name != github.repository + production-worker-e2e: + name: Rate web production Worker E2E (ephemeral PR) runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -93,7 +45,7 @@ jobs: if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: rate-web-playwright-report-${{ github.run_id }}-${{ github.run_attempt }} + name: rate-web-playwright-pr-${{ github.run_id }}-${{ github.run_attempt }} path: | src/rate_of_closure/web/playwright-report/ src/rate_of_closure/web/test-results/ diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 45470b9c75..2e1bf95b51 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -8,11 +8,13 @@ Local branch `codex/4142-rate-web-playwright` starts from exact integrated commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock and owns a deterministic -Chromium configuration plus a path-filtered CI workflow. Same-repository PRs, -pushes, and manual runs may use `d-sorg-fleet`; fork PRs run the equivalent -locked gate on ephemeral `ubuntu-latest` and cannot reach the persistent -fleet. Checkout, Node setup, and artifact upload actions are pinned to full -immutable SHAs. Artifact names include the workflow run and attempt IDs. +Chromium configuration across two path-filtered workflows. Every PR runs only +on ephemeral `ubuntu-latest`; the PR YAML has no fleet/self-hosted reference. +The separate trusted workflow runs only for pushes to `main` and manual +dispatch, using the push event checkout or fixed `main` checkout respectively. +It has no PR trigger or caller-controlled ref seam. Checkout, Node setup, and +artifact upload actions are pinned to full immutable SHAs. Artifact names +include the workflow run and attempt IDs. The gate builds and serves the Vite production bundle, then uses role/label locators against the actual bundled module Worker. It proves seeded three-run diff --git a/SPEC.md b/SPEC.md index 080f85a2d3..a38865f51e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,21 +26,22 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.55 | -| **Spec Version** | 1.16.55 | +| **Current Version** | 1.16.56 | +| **Spec Version** | 1.16.56 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission ### 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) -Version 1.16.55 pins Playwright Test 1.62.1 inside the Rate web package and -adds a dedicated deterministic Chromium configuration and path-filtered -workflow. Same-repository pull requests, pushes, and manual runs execute on the -trusted fleet. Fork pull requests execute the equivalent locked gate on an -ephemeral GitHub-hosted runner and cannot reach the persistent fleet. Every -external action reference in this Playwright workflow is pinned to a full -immutable commit SHA. The gate +Version 1.16.56 pins Playwright Test 1.62.1 inside the Rate web package and +adds a dedicated deterministic Chromium configuration with separate trust +domains. The pull-request workflow contains only one ephemeral `ubuntu-latest` +job and no persistent-fleet or self-hosted reference. A separate trusted +workflow runs only for pushes to `main` and manual dispatch. Push checkout uses +the event SHA by default; manual checkout is fixed to `main`, with no PR trigger +or caller-controlled ref. Every external action reference in both Playwright +workflows is pinned to a full immutable commit SHA. The gate builds and previews the production Vite output. Role/label locators drive the real hashed module Worker through a seeded three-run study, completion and identical rerun, 500-run swing/OAT cancellation with no partial diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 02591a2e07..5a0bb4fd14 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -7,11 +7,13 @@ - The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock; the dedicated configuration serves the production Vite build in deterministic Chromium desktop and narrow projects. -- Same-repository PRs, pushes, and manual dispatches run on `d-sorg-fleet`. - Fork PRs run the equivalent locked production gate on ephemeral - `ubuntu-latest` and cannot execute on the persistent fleet. Every external - action in the Playwright workflow is pinned to a full immutable SHA; evidence - artifacts include the run and attempt IDs. +- Every PR runs the equivalent locked production gate only on ephemeral + `ubuntu-latest`; its workflow file has no fleet/self-hosted reference. A + separate trusted workflow runs only for `main` pushes and manual dispatches. + Push checkout uses the event SHA by default; manual checkout is fixed to + `main`, and the trusted workflow exposes no PR trigger or caller-selected ref. + Every external action in both Playwright workflows is pinned to a full + immutable SHA; evidence artifacts include the run and attempt IDs. - Role/label-driven tests observe the hashed production module Worker and prove seeded progress/completion/reproducible rerun, long-run cancellation without partial or stale result acceptance, and Worker termination on tab unmount. diff --git a/scripts/check_local_only_workflows.py b/scripts/check_local_only_workflows.py index df2d1c4084..6559ca482e 100644 --- a/scripts/check_local_only_workflows.py +++ b/scripts/check_local_only_workflows.py @@ -33,7 +33,7 @@ } HOSTED_RUNNER_ALLOWLIST = { (".github/workflows/ci-standard.yml", "quality-gate"), - (".github/workflows/rate-web-playwright.yml", "fork-production-worker-e2e"), + (".github/workflows/rate-web-playwright.yml", "production-worker-e2e"), } HOSTED_RUNNER = re.compile(r"^(ubuntu|macos|windows)(-latest|-\d+(?:\.\d+)*)$") diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 032fbcda63..c470c16a95 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -7,13 +7,14 @@ Local branch `codex/4142-rate-web-playwright` starts from exact integrated commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The web package now owns a -Playwright Test 1.62.1 pin/lock, deterministic Chromium config, and a dedicated -path-filtered workflow which builds and serves the production Vite output. -Same-repository PRs, pushes, and manual runs may use `d-sorg-fleet`; fork PRs -run the same locked production gate on ephemeral `ubuntu-latest` and cannot -reach the persistent fleet. All external actions in the Playwright workflow are -pinned to immutable full SHAs, and artifacts identify both workflow run and -attempt. +Playwright Test 1.62.1 pin/lock, deterministic Chromium config, and two +path-filtered workflows which build and serve the production Vite output. The +PR workflow is ephemeral `ubuntu-latest` only and contains no fleet/self-hosted +reference. The separate trusted workflow has only `main` push and manual +triggers; push checkout uses its event SHA, while manual checkout is fixed to +`main`. It has no PR trigger or caller-controlled ref. All external actions in +both Playwright workflows are pinned to immutable full SHAs, and artifacts +identify both workflow run and attempt. Five role/label-driven browser tests exercise the hashed dedicated module Worker without mocks: seeded progress/completion and identical rerun, cancellation of diff --git a/tests/ops/test_rate_web_playwright_workflow.py b/tests/ops/test_rate_web_playwright_workflow.py index 191ab555d4..7d4ab3b588 100644 --- a/tests/ops/test_rate_web_playwright_workflow.py +++ b/tests/ops/test_rate_web_playwright_workflow.py @@ -1,4 +1,4 @@ -"""Security and reproducibility contracts for the Rate Playwright workflow.""" +"""Security and reproducibility contracts for the Rate Playwright workflows.""" from __future__ import annotations @@ -9,13 +9,19 @@ import yaml REPO_ROOT = Path(__file__).resolve().parents[2] -WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "rate-web-playwright.yml" -RUNNER_GUARD_PATH = REPO_ROOT / ".github" / "workflows" / "local-only-runner-guard.yml" +WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" +PR_WORKFLOW_PATH = WORKFLOW_DIR / "rate-web-playwright.yml" +TRUSTED_WORKFLOW_PATH = WORKFLOW_DIR / "rate-web-playwright-trusted.yml" +RUNNER_GUARD_PATH = WORKFLOW_DIR / "local-only-runner-guard.yml" FULL_ACTION_SHA = re.compile(r"^[^@]+@[0-9a-f]{40}$") +EVIDENCE_PATHS = ( + "src/rate_of_closure/web/playwright-report/\n" + "src/rate_of_closure/web/test-results/\n" +) -def _workflow() -> dict[str, Any]: - loaded = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) +def _workflow(path: Path) -> dict[str, Any]: + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) assert isinstance(loaded, dict) return loaded @@ -28,59 +34,83 @@ def _run_steps(job: dict[str, Any]) -> dict[str, str]: } -def test_fork_pull_requests_cannot_reach_the_persistent_fleet() -> None: - jobs = _workflow()["jobs"] - trusted = jobs["trusted-production-worker-e2e"] - fork = jobs["fork-production-worker-e2e"] +def _checkout(job: dict[str, Any]) -> dict[str, Any]: + return next( + step + for step in job["steps"] + if str(step.get("uses", "")).startswith("actions/checkout@") + ) + + +def test_pull_request_workflow_is_hosted_only_without_fleet_vocabulary() -> None: + text = PR_WORKFLOW_PATH.read_text(encoding="utf-8") + workflow = _workflow(PR_WORKFLOW_PATH) + + assert "d-sorg-fleet" not in text.lower() + assert "self-hosted" not in text.lower() + assert "\n pull_request:" in text + assert "\n push:" not in text + assert "workflow_dispatch" not in text + assert set(workflow["jobs"]) == {"production-worker-e2e"} + assert workflow["jobs"]["production-worker-e2e"]["runs-on"] == "ubuntu-latest" + + +def test_trusted_workflow_has_no_pull_request_or_untrusted_ref_seam() -> None: + text = TRUSTED_WORKFLOW_PATH.read_text(encoding="utf-8") + jobs = _workflow(TRUSTED_WORKFLOW_PATH)["jobs"] + + assert "pull_request" not in text + assert "github.event.pull_request" not in text + assert "inputs." not in text + assert "${{ github.ref" not in text + assert "${{ github.head_ref" not in text + assert "${{ github.sha" not in text + assert "\n push:" in text + assert "workflow_dispatch:" in text + assert all(job["runs-on"] == "d-sorg-fleet" for job in jobs.values()) - assert trusted["runs-on"] == "d-sorg-fleet" - assert "head.repo.full_name == github.repository" in trusted["if"] - assert fork["runs-on"] == "ubuntu-latest" - assert "head.repo.full_name != github.repository" in fork["if"] - assert "github.event_name == 'pull_request'" in fork["if"] + push_checkout = _checkout(jobs["push-production-worker-e2e"]) + manual_checkout = _checkout(jobs["manual-production-worker-e2e"]) + assert "with" not in push_checkout or "ref" not in push_checkout["with"] + assert manual_checkout["with"]["ref"] == "main" -def test_fork_and_trusted_jobs_run_the_same_locked_production_gate() -> None: - jobs = _workflow()["jobs"] - trusted = jobs["trusted-production-worker-e2e"] - fork = jobs["fork-production-worker-e2e"] +def test_pr_and_trusted_jobs_run_the_same_locked_production_gate() -> None: + pr_job = _workflow(PR_WORKFLOW_PATH)["jobs"]["production-worker-e2e"] + trusted_jobs = _workflow(TRUSTED_WORKFLOW_PATH)["jobs"] + expected_commands = _run_steps(pr_job) - assert trusted["steps"] == fork["steps"] - assert _run_steps(trusted) == _run_steps(fork) - commands = _run_steps(fork) - assert commands["Install locked web dependencies"] == "npm ci" - assert commands["Install Playwright-pinned Chromium runtime"] == ( + assert all(_run_steps(job) == expected_commands for job in trusted_jobs.values()) + assert expected_commands["Install locked web dependencies"] == "npm ci" + assert expected_commands["Install Playwright-pinned Chromium runtime"] == ( "npx --no-install playwright install --with-deps chromium" ) - assert commands["Exercise production Worker lifecycle and layouts"] == ( + assert expected_commands["Exercise production Worker lifecycle and layouts"] == ( "npm run test:e2e" ) def test_external_actions_are_immutable_and_artifacts_identify_attempts() -> None: - workflow = _workflow() - assert workflow["permissions"] == {"contents": "read"} - - for job in workflow["jobs"].values(): - for step in job["steps"]: - if "uses" in step: - assert FULL_ACTION_SHA.fullmatch(str(step["uses"])) - artifact = next( - step - for step in job["steps"] - if step.get("name") == "Retain Playwright evidence" - ) - name = artifact["with"]["name"] - assert "${{ github.run_id }}" in name - assert "${{ github.run_attempt }}" in name - assert artifact["with"]["path"] == ( - "src/rate_of_closure/web/playwright-report/\n" - "src/rate_of_closure/web/test-results/\n" - ) + for path in (PR_WORKFLOW_PATH, TRUSTED_WORKFLOW_PATH): + workflow = _workflow(path) + assert workflow["permissions"] == {"contents": "read"} + for job in workflow["jobs"].values(): + for step in job["steps"]: + if "uses" in step: + assert FULL_ACTION_SHA.fullmatch(str(step["uses"])) + artifact = next( + step + for step in job["steps"] + if step.get("name") == "Retain Playwright evidence" + ) + name = artifact["with"]["name"] + assert "${{ github.run_id }}" in name + assert "${{ github.run_attempt }}" in name + assert artifact["with"]["path"] == EVIDENCE_PATHS def test_touched_hosted_runner_guard_uses_only_immutable_actions() -> None: - loaded = yaml.safe_load(RUNNER_GUARD_PATH.read_text(encoding="utf-8")) + loaded = _workflow(RUNNER_GUARD_PATH) steps = loaded["jobs"]["reject-hosted-runner-routing"]["steps"] action_uses = [str(step["uses"]) for step in steps if "uses" in step] diff --git a/tests/scripts/test_check_local_only_workflows.py b/tests/scripts/test_check_local_only_workflows.py index 5ffc22086e..be39b06b95 100644 --- a/tests/scripts/test_check_local_only_workflows.py +++ b/tests/scripts/test_check_local_only_workflows.py @@ -34,16 +34,16 @@ def test_guard_allows_only_hosted_ci_standard_quality_gate(tmp_path: Path) -> No assert rejected.returncode == 1 -def test_guard_allows_only_ephemeral_fork_playwright_job(tmp_path: Path) -> None: +def test_guard_allows_only_ephemeral_pr_playwright_job(tmp_path: Path) -> None: allowed = _run_guard( tmp_path, - "jobs:\n fork-production-worker-e2e:\n runs-on: ubuntu-latest\n", + "jobs:\n production-worker-e2e:\n runs-on: ubuntu-latest\n", "rate-web-playwright.yml", ) rejected = _run_guard( tmp_path, - "jobs:\n trusted-production-worker-e2e:\n runs-on: ubuntu-latest\n", - "rate-web-playwright.yml", + "jobs:\n production-worker-e2e:\n runs-on: ubuntu-latest\n", + "rate-web-playwright-trusted.yml", ) assert allowed.returncode == 0 From 6df0ed09388ba36630c5fc6be7a31a334a4b6243 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 21:33:18 -0700 Subject: [PATCH 4/4] fix(rate-web): close Playwright trust and lifecycle gaps --- .../workflows/rate-web-playwright-trusted.yml | 42 ------------ AGENT_HANDOFF.md | 20 +++--- SPEC.md | 30 ++++---- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 12 ++-- src/rate_of_closure/AGENT_HANDOFF.md | 21 +++--- .../web/e2e/variation-worker.spec.ts | 68 ++++++++++++++++--- .../ops/test_rate_web_playwright_workflow.py | 7 +- 7 files changed, 109 insertions(+), 91 deletions(-) diff --git a/.github/workflows/rate-web-playwright-trusted.yml b/.github/workflows/rate-web-playwright-trusted.yml index d154712461..4c8414b456 100644 --- a/.github/workflows/rate-web-playwright-trusted.yml +++ b/.github/workflows/rate-web-playwright-trusted.yml @@ -7,8 +7,6 @@ on: - "src/rate_of_closure/web/**" - ".github/workflows/rate-web-playwright.yml" - ".github/workflows/rate-web-playwright-trusted.yml" - workflow_dispatch: - concurrency: group: rate-web-playwright-trusted-main cancel-in-progress: true @@ -54,43 +52,3 @@ jobs: src/rate_of_closure/web/test-results/ if-no-files-found: error retention-days: 14 - - manual-production-worker-e2e: - name: Rate web production Worker E2E (trusted manual) - if: github.event_name == 'workflow_dispatch' - runs-on: d-sorg-fleet - timeout-minutes: 20 - defaults: - run: - working-directory: src/rate_of_closure/web - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: main - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "22" - cache: npm - cache-dependency-path: src/rate_of_closure/web/package-lock.json - - - name: Install locked web dependencies - run: npm ci - - - name: Install Playwright-pinned Chromium runtime - run: npx --no-install playwright install --with-deps chromium - - - name: Exercise production Worker lifecycle and layouts - run: npm run test:e2e - - - name: Retain Playwright evidence - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: rate-web-playwright-trusted-manual-${{ github.run_id }}-${{ github.run_attempt }} - path: | - src/rate_of_closure/web/playwright-report/ - src/rate_of_closure/web/test-results/ - if-no-files-found: error - retention-days: 14 diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 2e1bf95b51..df8466f36b 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -10,17 +10,19 @@ commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The Rate web package pins Playwright Test 1.62.1 in its own manifest/lock and owns a deterministic Chromium configuration across two path-filtered workflows. Every PR runs only on ephemeral `ubuntu-latest`; the PR YAML has no fleet/self-hosted reference. -The separate trusted workflow runs only for pushes to `main` and manual -dispatch, using the push event checkout or fixed `main` checkout respectively. -It has no PR trigger or caller-controlled ref seam. Checkout, Node setup, and -artifact upload actions are pinned to full immutable SHAs. Artifact names -include the workflow run and attempt IDs. +The separate trusted workflow runs only for pushes to `main`, checks out the +push event commit, and has neither a PR nor manual-dispatch ref seam. Checkout, +Node setup, and artifact upload actions are pinned to full immutable SHAs. +Artifact names include the workflow run and attempt IDs. The gate builds and serves the Vite production bundle, then uses role/label -locators against the actual bundled module Worker. It proves seeded three-run -progress/completion and deterministic rerun, cancels a 500-run swing/OAT job -without accepting partial or late results, terminates active work on primary- -tab unmount, and rejects browser page errors. Blocking service workers in the +locators against the actual bundled module Worker. It observes strict +intermediate and terminal progress during a seeded 24-run study and proves a +deterministic rerun. Cancellation of a 500-run swing/OAT job observes actual +Worker termination before two identical seeded reruns, proving the cancelled +generation cannot publish a partial or late result. Navigation terminates +active work on primary-tab unmount, and every case rejects browser page errors. +Blocking service workers in the test context does not replace or disable the dedicated module Worker; every lifecycle case observes the hashed production Worker chunk. Desktop 1440x1000 and narrow 390x844 checks enforce zero document-level horizontal overflow and diff --git a/SPEC.md b/SPEC.md index a38865f51e..0a257d76ef 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,28 +26,29 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.56 | -| **Spec Version** | 1.16.56 | +| **Current Version** | 1.16.57 | +| **Spec Version** | 1.16.57 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission ### 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) -Version 1.16.56 pins Playwright Test 1.62.1 inside the Rate web package and +Version 1.16.57 pins Playwright Test 1.62.1 inside the Rate web package and adds a dedicated deterministic Chromium configuration with separate trust domains. The pull-request workflow contains only one ephemeral `ubuntu-latest` job and no persistent-fleet or self-hosted reference. A separate trusted -workflow runs only for pushes to `main` and manual dispatch. Push checkout uses -the event SHA by default; manual checkout is fixed to `main`, with no PR trigger -or caller-controlled ref. Every external action reference in both Playwright -workflows is pinned to a full immutable commit SHA. The gate -builds and previews the production Vite output. Role/label -locators drive the real hashed module Worker through a seeded three-run study, -completion and identical rerun, 500-run swing/OAT cancellation with no partial -or late result, and primary-tab navigation which terminates active work before -the Variation panel unmounts. The test context blocks service workers but not -the tested dedicated module Worker. +workflow runs only for pushes to `main` and checks out the push event commit. +It has no PR or manual-dispatch workflow-definition ref seam. Every external +action reference in both Playwright workflows is pinned to a full immutable +commit SHA. The gate builds and previews the production Vite output. Role/label +locators drive the real hashed module Worker through a seeded 24-run study, +observe at least one strict intermediate progress value before completion, and +prove an identical rerun. A 500-run swing/OAT cancellation observes Worker +termination before two identical seeded reruns, proving the cancelled generation +cannot publish a partial, late, or stale result. Primary-tab navigation also +terminates active work before the Variation panel unmounts. The test context +blocks service workers but not the tested dedicated module Worker. Desktop 1440x1000 and narrow 390x844 projects enforce zero document-level horizontal overflow and attach deterministic full-page screenshots. Reports, @@ -3339,6 +3340,9 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-12 | 1.16.57 | fix(rate-of-closure, #4142 R14.5): remove manual dispatch from the self-hosted Playwright workflow so only main-push-owned workflow definitions reach the trusted fleet; observe strict intermediate production-Worker progress; and prove cancellation terminates the old Worker before deterministic reruns can accept results. | +| 2026-08-12 | 1.16.56 | ci(rate-of-closure, #4142 R14.5): split the ephemeral pull-request Playwright gate from the trusted main workflow and pin all external actions to immutable SHAs. | +| 2026-08-12 | 1.16.55 | ci(rate-of-closure, #4142 R14.5): isolate fork-origin pull-request browser execution on ephemeral hosted runners while retaining the locked production gate. | | 2026-08-12 | 1.16.54 | test(rate-of-closure, #4142 R14.5): pin a Rate-web-local Playwright/Chromium contract; exercise the real production module Worker for seeded progress/completion/rerun, cancellation without stale results, and navigation/unmount cleanup; retain desktop/narrow screenshots as review artifacts while keeping golden cross-platform visual approval and full R14.5 completion open. | | 2026-08-12 | 1.16.53 | feat(rate-of-closure, #4142 R11.5): add immutable resource-bounded ensemble stream headers/result chunks and an injected commit/abort sink lifecycle; project and release one chunk of complete runs at a time; retain the existing materialized API through a compatibility collector; and keep durable streaming/archive/memory claims explicitly open. | | 2026-08-12 | 1.16.52 | fix(rate-of-closure, #4142): satisfy the exact protected Python 3.12 / NumPy 2.3.5 / Mypy 1.13 typing boundary with explicit array annotations/casts and built-in-float `finfo` normalization; retain unchanged numerical and wire behavior. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index 5a0bb4fd14..8db8fadda9 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -9,14 +9,16 @@ Chromium desktop and narrow projects. - Every PR runs the equivalent locked production gate only on ephemeral `ubuntu-latest`; its workflow file has no fleet/self-hosted reference. A - separate trusted workflow runs only for `main` pushes and manual dispatches. - Push checkout uses the event SHA by default; manual checkout is fixed to - `main`, and the trusted workflow exposes no PR trigger or caller-selected ref. + separate trusted workflow runs only for `main` pushes and checks out the + event commit. It exposes neither a PR trigger nor manual-dispatch definition + ref, so untrusted or caller-selected workflow code cannot reach the fleet. Every external action in both Playwright workflows is pinned to a full immutable SHA; evidence artifacts include the run and attempt IDs. - Role/label-driven tests observe the hashed production module Worker and prove - seeded progress/completion/reproducible rerun, long-run cancellation without - partial or stale result acceptance, and Worker termination on tab unmount. + strict intermediate and terminal progress plus reproducible rerun for a + seeded 24-run study. Long-run cancellation observes Worker termination before + two identical seeded reruns, rejecting partial, late, or stale result + acceptance; navigation separately proves Worker termination on tab unmount. `serviceWorkers: "block"` suppresses unrelated service-worker state only; the tested dedicated module Worker remains active and is never mocked. - Desktop 1440x1000 and narrow 390x844 layouts have no document-level diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index c470c16a95..65655446a2 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -10,18 +10,19 @@ commit `11a699155588d3d948990c5f08b72c5cc8d2c746`. The web package now owns a Playwright Test 1.62.1 pin/lock, deterministic Chromium config, and two path-filtered workflows which build and serve the production Vite output. The PR workflow is ephemeral `ubuntu-latest` only and contains no fleet/self-hosted -reference. The separate trusted workflow has only `main` push and manual -triggers; push checkout uses its event SHA, while manual checkout is fixed to -`main`. It has no PR trigger or caller-controlled ref. All external actions in -both Playwright workflows are pinned to immutable full SHAs, and artifacts -identify both workflow run and attempt. +reference. The separate trusted workflow has only a `main` push trigger and +checks out that event commit; it has no PR or caller-selected manual workflow +definition seam. All external actions in both Playwright workflows are pinned +to immutable full SHAs, and artifacts identify both workflow run and attempt. Five role/label-driven browser tests exercise the hashed dedicated module Worker -without mocks: seeded progress/completion and identical rerun, cancellation of -a 500-run swing/OAT request without partial or late results, and Worker cleanup -when navigation unmounts Variation. Blocking service workers does not block the -dedicated Worker. Desktop 1440x1000 and narrow 390x844 layouts enforce zero -document horizontal overflow and attach review screenshots. +without mocks: a seeded 24-run study records strict intermediate progress, +completion, and identical rerun; cancellation of a 500-run swing/OAT request +observes Worker closure before two identical seeded reruns can complete, proving +the cancelled generation cannot publish partial or late results. Navigation +also proves Worker cleanup when Variation unmounts. Blocking service workers +does not block the dedicated Worker. Desktop 1440x1000 and narrow 390x844 +layouts enforce zero document horizontal overflow and attach review screenshots. Local gates are 5/5 Playwright and 743/743 Vitest tests, TypeScript, ESLint, and Vite production build. This remains a foundation, not R14.5 completion: diff --git a/src/rate_of_closure/web/e2e/variation-worker.spec.ts b/src/rate_of_closure/web/e2e/variation-worker.spec.ts index 586fc47a32..e9be3378b6 100644 --- a/src/rate_of_closure/web/e2e/variation-worker.spec.ts +++ b/src/rate_of_closure/web/e2e/variation-worker.spec.ts @@ -7,9 +7,42 @@ import { } from "./variationTestSupport"; const LONG_RUN_COUNT = "500"; +const PROGRESS_RUN_COUNT = "24"; -async function configureSeededDelivery(page: Page): Promise { - await setNumericField(page, "Runs", "3"); +interface ProgressObservationWindow extends Window { + variationProgressValues?: number[]; +} + +async function observeProgressValues(page: Page): Promise { + await page.evaluate(() => { + const observed: number[] = []; + const record = () => { + const progress = document.querySelector( + 'progress[aria-label="Variation execution progress"]', + ); + if (progress !== null) observed.push(progress.value); + }; + new MutationObserver(record).observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }); + (window as ProgressObservationWindow).variationProgressValues = observed; + }); +} + +async function progressValues(page: Page): Promise { + return page.evaluate( + () => (window as ProgressObservationWindow).variationProgressValues ?? [], + ); +} + +async function configureSeededDelivery( + page: Page, + runs = "3", +): Promise { + await page.getByRole("combobox", { name: "Pipeline" }).selectOption("delivery"); + await setNumericField(page, "Runs", runs); await setNumericField(page, "Seed", "20260812"); await page.getByRole("combobox", { name: "Analysis execution" }) .selectOption("all_together"); @@ -31,22 +64,26 @@ test("production Worker completes a seeded study and reruns deterministically", const pageErrors = capturePageErrors(page); const workers = captureWorkers(page); await openVariation(page); - await configureSeededDelivery(page); + await configureSeededDelivery(page, PROGRESS_RUN_COUNT); + await observeProgressValues(page); const runButton = page.getByRole("button", { name: "Run Variation Study" }); const status = page.getByRole("status", { name: "Variation status" }); await runButton.click(); - await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect(status).toContainText(/Done: \d+\/24 joint runs/); await expect(page.getByRole("progressbar", { name: "Variation execution progress" })) - .toHaveAttribute("value", "3"); + .toHaveAttribute("value", PROGRESS_RUN_COUNT); await expect(page.getByRole("heading", { name: "Summary — Dispersion per Output" })) .toBeVisible(); await expect.poll(() => workers.length).toBe(1); expect(workers[0].url()).toMatch(/variationExecution\.worker-[\w-]+\.js$/); + expect((await progressValues(page)).some( + (value) => value > 0 && value < Number(PROGRESS_RUN_COUNT), + )).toBe(true); const firstSummary = await page.getByRole("table").first().innerText(); await runButton.click(); - await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect(status).toContainText(/Done: \d+\/24 joint runs/); await expect.poll(() => workers.length).toBe(2); expect(await page.getByRole("table").first().innerText()).toBe(firstSummary); expect(pageErrors).toEqual([]); @@ -60,17 +97,32 @@ test("cancelling a long Worker run rejects every partial and stale result", asyn await page.getByRole("button", { name: "Run Variation Study" }).click(); await expect.poll(() => workers.length).toBe(1); + let workerClosed = false; + workers[0].on("close", () => { workerClosed = true; }); const cancelButton = page.getByRole("button", { name: "Cancel Variation Study" }); await expect(cancelButton).toBeEnabled(); await cancelButton.click(); const status = page.getByRole("status", { name: "Variation status" }); await expect(status).toHaveText("Cancelled: no partial variation result was accepted."); + await expect.poll(() => workerClosed).toBe(true); await expect(page.getByRole("heading", { name: "Ready to Analyze Variation" })).toBeVisible(); - await page.waitForTimeout(300); - await expect(status).toHaveText("Cancelled: no partial variation result was accepted."); await expect(page.getByRole("heading", { name: "Summary — Dispersion per Output" })) .toHaveCount(0); + + await configureSeededDelivery(page); + const runButton = page.getByRole("button", { name: "Run Variation Study" }); + await runButton.click(); + await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect.poll(() => workers.length).toBe(2); + const firstSummary = await page.getByRole("table").first().innerText(); + await runButton.click(); + await expect(status).toContainText(/Done: \d+\/3 joint runs/); + await expect.poll(() => workers.length).toBe(3); + expect(await page.getByRole("table").first().innerText()).toBe(firstSummary); + expect(workers.slice(1).every((worker) => ( + /variationExecution\.worker-[\w-]+\.js$/.test(worker.url()) + ))).toBe(true); expect(pageErrors).toEqual([]); }); diff --git a/tests/ops/test_rate_web_playwright_workflow.py b/tests/ops/test_rate_web_playwright_workflow.py index 7d4ab3b588..2313fae6e0 100644 --- a/tests/ops/test_rate_web_playwright_workflow.py +++ b/tests/ops/test_rate_web_playwright_workflow.py @@ -55,7 +55,7 @@ def test_pull_request_workflow_is_hosted_only_without_fleet_vocabulary() -> None assert workflow["jobs"]["production-worker-e2e"]["runs-on"] == "ubuntu-latest" -def test_trusted_workflow_has_no_pull_request_or_untrusted_ref_seam() -> None: +def test_trusted_workflow_is_main_push_only_without_untrusted_ref_seam() -> None: text = TRUSTED_WORKFLOW_PATH.read_text(encoding="utf-8") jobs = _workflow(TRUSTED_WORKFLOW_PATH)["jobs"] @@ -66,13 +66,12 @@ def test_trusted_workflow_has_no_pull_request_or_untrusted_ref_seam() -> None: assert "${{ github.head_ref" not in text assert "${{ github.sha" not in text assert "\n push:" in text - assert "workflow_dispatch:" in text + assert "workflow_dispatch" not in text + assert set(jobs) == {"push-production-worker-e2e"} assert all(job["runs-on"] == "d-sorg-fleet" for job in jobs.values()) push_checkout = _checkout(jobs["push-production-worker-e2e"]) - manual_checkout = _checkout(jobs["manual-production-worker-e2e"]) assert "with" not in push_checkout or "ref" not in push_checkout["with"] - assert manual_checkout["with"]["ref"] == "main" def test_pr_and_trusted_jobs_run_the_same_locked_production_gate() -> None: