diff --git a/.github/workflows/local-only-runner-guard.yml b/.github/workflows/local-only-runner-guard.yml index 651b8e326..892af15d3 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", "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::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 000000000..4c8414b45 --- /dev/null +++ b/.github/workflows/rate-web-playwright-trusted.yml @@ -0,0 +1,54 @@ +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" +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 diff --git a/.github/workflows/rate-web-playwright.yml b/.github/workflows/rate-web-playwright.yml new file mode 100644 index 000000000..2e172290f --- /dev/null +++ b/.github/workflows/rate-web-playwright.yml @@ -0,0 +1,53 @@ +name: Rate Web Playwright PR + +on: + pull_request: + paths: + - "src/rate_of_closure/web/**" + - ".github/workflows/rate-web-playwright.yml" + - ".github/workflows/rate-web-playwright-trusted.yml" + +concurrency: + group: rate-web-playwright-pr-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + production-worker-e2e: + name: Rate web production Worker E2E (ephemeral PR) + 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-pr-${{ 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/.gitignore b/.gitignore index 69c6c1a74..d1935e38c 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 c89696363..b1f456794 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,52 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Integrated localized torque and Playwright stack (#4142, 1.16.59) + +This branch now preserves the complete histories of localized-torque head +`10524cc2151c7b60c4a097939b29202158aff012` and reviewed Playwright head +`6df0ed09388ba36630c5fc6be7a31a334a4b6243` in a normal two-parent merge. +The localized Python execution/validation contracts and the Rate-web production +Worker/browser gates coexist without changing either scientific or browser +contract. Publication, protected CI, remaining localized UI/Rust/persistence +work, and full R14.5 visual/browser certification remain open. +Integrated local evidence is 171/171 localized changed-test cases, 18/18 +Playwright workflow/security tests, and 5/5 real Chromium tests, plus scoped +Ruff, Ruff format, documentation governance, workflow validation, and diff +hygiene. + +## 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 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`, 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 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 +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 Localized torque static-gate closure (#4142) The final cumulative changed-source MyPy blockers are closed without runtime diff --git a/SPEC.md b/SPEC.md index be72c8d98..c331e2bc4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,12 +26,57 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.58 | -| **Spec Version** | 1.16.58 | +| **Current Version** | 1.16.59 | +| **Spec Version** | 1.16.59 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission +### 2026-08-12 Integrated localized torque and Playwright stack (#4142) + +Version 1.16.59 merges the exact localized-torque history at +`10524cc2151c7b60c4a097939b29202158aff012` above the reviewed Playwright +history at `6df0ed09388ba36630c5fc6be7a31a334a4b6243` with a normal two-parent merge. +The strict Python localized-torque execution, validation, typed miss, and PyQt +filtering contracts coexist with the trust-separated production-Worker browser +gate; neither scientific nor browser authority is weakened. Protected +publication, full R14.5 certification, localized locus authoring, additional +sources, Rust parity, and complete state/event/torque persistence remain open. +Integrated verification passes 171 localized changed-test cases, 18 Playwright +workflow/security tests, and five real production-Worker Chromium tests, plus +scoped Ruff/format, documentation governance, workflow validation, and diff +hygiene. + +### 2026-08-12 Real-browser variation Worker foundation (#4142 R14.5) + +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 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, +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. + +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 Localized torque static-gate closure (#4142) Version 1.16.58 closes the final cumulative changed-source static-analysis @@ -3405,11 +3450,12 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-12 | 1.16.59 | merge(rate-of-closure, #4142): preserve the exact localized-torque and reviewed Playwright histories in a normal two-parent integration; retain strict localized scientific/source/UI contracts beside trust-separated production-Worker browser gates; and keep protected publication, full R14.5 certification, locus authoring, Rust parity, and complete archive authority open. | | 2026-08-12 | 1.16.58 | fix(rate-of-closure, #4142): close the cumulative 16-source static gate with explicit NumPy CSV array annotations and removal of redundant pipeline/source-config casts; restore the missing 1.16.55-1.16.58 append-only history while preserving runtime and wire behavior. | -| 2026-08-12 | 1.16.57 | fix(rate-of-closure, #4142): validate source run configurations before exact-`None` fallback; reject prescribed mode/profile, locks, and localized offsets on manual/triple sources; and require a genuine non-Boolean integer outer variation-dataset schema discriminator. | -| 2026-08-12 | 1.16.56 | fix(rate-of-closure, #4142): reject localized torque offsets on unsupported manual/triple sources; validate run-config offset collections before tuple conversion; and require genuine non-Boolean integer variation-plan schema versions. | -| 2026-08-12 | 1.16.55 | fix(rate-of-closure, #4142): fail closed on malformed localized numeric/collection domains; make the fixed-step effective RK4 duration authoritative for windows; and hide or atomically reject localized PyQt factors until locus authoring exists. | -| 2026-08-12 | 1.16.54 | feat(rate-of-closure, #4142): execute additive shoulder/wrist commanded-torque offsets over strict half-open one-point loci at every Python RK4 stage; bind deterministic variation samples to exact topological joint IDs; fail closed on unsupported source, locus, duration, and Rust contracts; preserve typed no-impact and distinct spatial provenance while keeping UI, persistence, protected release, and epic completion open. | +| 2026-08-12 | 1.16.57 | fix(rate-of-closure, #4142): validate source run configurations before exact-`None` fallback; reject prescribed mode/profile, locks, and localized offsets on manual/triple sources; require a genuine non-Boolean integer outer variation-dataset schema discriminator; remove manual dispatch from the self-hosted Playwright workflow; observe strict intermediate production-Worker progress; and prove cancellation terminates the old Worker before deterministic reruns accept results. | +| 2026-08-12 | 1.16.56 | fix(rate-of-closure, #4142): reject localized torque offsets on unsupported manual/triple sources; validate run-config offset collections before tuple conversion; require genuine non-Boolean integer variation-plan schema versions; split the ephemeral PR Playwright gate from the trusted main workflow; and pin all external actions to immutable SHAs. | +| 2026-08-12 | 1.16.55 | fix(rate-of-closure, #4142): fail closed on malformed localized numeric/collection domains; make the fixed-step effective RK4 duration authoritative for windows; hide or atomically reject localized PyQt factors until locus authoring exists; and isolate pull-request browser execution on ephemeral hosted runners while retaining the locked production gate. | +| 2026-08-12 | 1.16.54 | feat(rate-of-closure, #4142): execute additive shoulder/wrist commanded-torque offsets over strict half-open one-point loci at every Python RK4 stage; bind deterministic variation samples to exact topological joint IDs; fail closed on unsupported source, locus, duration, and Rust contracts; preserve typed no-impact and distinct spatial provenance; and add a pinned real-browser production-Worker foundation with seeded lifecycle, cancellation, unmount, and review-screenshot evidence while keeping UI, persistence, visual certification, protected release, and epic 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 775c8d3bf..1426bc7b1 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,51 @@ # Rate of Closure Campaign Handoff +## 2026-08-12 #4142 integrated localized torque and Playwright stack (1.16.59) + +- Normal two-parent integration retains exact localized-torque head + `10524cc2151c7b60c4a097939b29202158aff012` and reviewed Playwright head + `6df0ed09388ba36630c5fc6be7a31a334a4b6243` without rebase or history rewrite. +- Localized Python torque execution, validation, typed miss behavior, and PyQt + filtering remain intact beside the trust-separated production-Worker browser + workflows and real Chromium lifecycle evidence. +- Protected publication, full R14.5 certification, localized locus authoring, + Rust parity, and complete state/event/torque persistence remain open. +- Integrated evidence is 171/171 localized changed-test cases, 18/18 workflow + security/policy tests, and 5/5 real production-Worker Chromium tests, with + scoped Ruff/format, documentation governance, workflow validation, and diff + hygiene green. + +## 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. +- 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 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 + 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 + 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 localized torque static-gate closure - Explicit NumPy annotations on variation CSV input/success arrays close the diff --git a/scripts/check_local_only_workflows.py b/scripts/check_local_only_workflows.py index 81e5bcc87..6559ca482 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", "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 ceb3b12e7..d812ed151 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,46 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Integrated localized torque and Playwright stack (#4142, 1.16.59) + +Normal two-parent integration preserves exact localized-torque head +`10524cc2151c7b60c4a097939b29202158aff012` and reviewed Playwright head +`6df0ed09388ba36630c5fc6be7a31a334a4b6243`. The localized Python execution, +validation, typed no-impact, and current PyQt filtering contracts coexist with +the trust-separated production-Worker Chromium gate. No scientific or wire +contract was weakened. Protected publication, R14.5 completion, locus UI, +additional sources, Rust parity, and complete archive authority remain open. +Integrated evidence is 171/171 localized changed-test cases, 18/18 Playwright +workflow/security tests, and 5/5 real Chromium tests. Scoped Ruff/format, +documentation governance, workflow validation, and diff hygiene are green. + +## 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 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 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: 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: +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 Localized torque static-gate closure (#4142) Static cleanup now allows the full cumulative localized slice to pass the 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 000000000..895991b3c --- /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 000000000..e9be3378b --- /dev/null +++ b/src/rate_of_closure/web/e2e/variation-worker.spec.ts @@ -0,0 +1,147 @@ +import { expect, test, type Page, type Worker } from "@playwright/test"; + +import { + capturePageErrors, + openVariation, + setNumericField, +} from "./variationTestSupport"; + +const LONG_RUN_COUNT = "500"; +const PROGRESS_RUN_COUNT = "24"; + +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"); +} + +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, 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+\/24 joint runs/); + await expect(page.getByRole("progressbar", { name: "Variation execution progress" })) + .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+\/24 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); + 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 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([]); +}); + +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 000000000..9f8ae7bae --- /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 4c5469911..bd09ef3d6 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 588cbce09..48e6d300f 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 000000000..300d10b0c --- /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 d75b58894..71547b380 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, 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 000000000..2313fae6e --- /dev/null +++ b/tests/ops/test_rate_web_playwright_workflow.py @@ -0,0 +1,117 @@ +"""Security and reproducibility contracts for the Rate Playwright workflows.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +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(path: Path) -> dict[str, Any]: + loaded = yaml.safe_load(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 _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_is_main_push_only_without_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" 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"]) + assert "with" not in push_checkout or "ref" not in push_checkout["with"] + + +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 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 expected_commands["Exercise production Worker lifecycle and layouts"] == ( + "npm run test:e2e" + ) + + +def test_external_actions_are_immutable_and_artifacts_identify_attempts() -> None: + 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 = _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] + 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 e9b2e9800..be39b06b9 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_pr_playwright_job(tmp_path: Path) -> None: + allowed = _run_guard( + tmp_path, + "jobs:\n production-worker-e2e:\n runs-on: ubuntu-latest\n", + "rate-web-playwright.yml", + ) + rejected = _run_guard( + tmp_path, + "jobs:\n production-worker-e2e:\n runs-on: ubuntu-latest\n", + "rate-web-playwright-trusted.yml", + ) + + assert allowed.returncode == 0 + assert rejected.returncode == 1