From c9b34ee5b242c1f4375cec25b49b4cd52cf835f4 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 8 Oct 2026 15:48:39 +0200 Subject: [PATCH 1/5] feat(e2e): re-submit each country's contract details in the browser The schema canary proves the schema, the SDK form logic and the backend agree, but not that the rendered Onboarding flow does. For each country whose submit check passed, the browser stage now opens the employment in the example app, continues through basic information, renders contract details from the saved values, and re-submits it. It fails when the PATCH is rejected, when Continue is blocked, or when the body differs from what the canary sent. - schema-canary --keep-submitted= keeps those employments and writes country, employment, company and the sent payload to ; schema-canary:archive archives them afterwards. - --country takes a comma-separated list. - ack_non_eligible_job_titles is not persisted, so the spec ticks it again on resume. KNOWN_UNSAVED_FIELDS entries (GBR overtime_eligible) must come back empty and are answered again from the sent value. - On PRs, schema-canary:affected runs only the countries whose pinned version, seed or known-unsaved entry changed, and every country when anything else the canary reads changed. Nightly runs every country. - example/src/flows/Onboarding/** now triggers the PR canary: its features and jsfModify change how contract details render. Verified locally: DEU, ESP, FRA and GBR pass in the browser; all 92 countries seed, submit and archive with --keep-submitted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../actions/schema-canary-browser/action.yml | 64 ++++++++ .github/workflows/schema-canary.yml | 95 ++++++++++- example/e2e/canary/contract-details.spec.ts | 147 ++++++++++++++++++ example/playwright.canary.config.ts | 19 +++ example/playwright.config.ts | 1 + package.json | 2 + scripts/schema-canary-affected.ts | 62 ++++++++ scripts/schema-canary-archive.ts | 49 ++++++ scripts/schema-canary.ts | 114 +++++++++----- .../schema-canary/affected-countries.test.ts | 86 ++++++++++ scripts/schema-canary/affected-countries.ts | 76 +++++++++ scripts/schema-canary/kept-employments.ts | 9 ++ scripts/schema-canary/seed-employment.ts | 14 +- .../submit-contract-details.test.ts | 10 +- .../schema-canary/submit-contract-details.ts | 6 +- 15 files changed, 699 insertions(+), 55 deletions(-) create mode 100644 .github/actions/schema-canary-browser/action.yml create mode 100644 example/e2e/canary/contract-details.spec.ts create mode 100644 example/playwright.canary.config.ts create mode 100644 scripts/schema-canary-affected.ts create mode 100644 scripts/schema-canary-archive.ts create mode 100644 scripts/schema-canary/affected-countries.test.ts create mode 100644 scripts/schema-canary/affected-countries.ts create mode 100644 scripts/schema-canary/kept-employments.ts diff --git a/.github/actions/schema-canary-browser/action.yml b/.github/actions/schema-canary-browser/action.yml new file mode 100644 index 000000000..d1e54a59c --- /dev/null +++ b/.github/actions/schema-canary-browser/action.yml @@ -0,0 +1,64 @@ +name: Schema canary browser stage +description: Opens each employment the canary kept in the example app and re-submits its contract details in Chromium. + +inputs: + employments-file: + description: File written by npm run schema-canary -- --keep-submitted + required: true + client-id: + required: true + client-secret: + required: true + refresh-token: + required: true + +runs: + using: composite + steps: + - name: Build the library + shell: bash + run: npm run build + + - name: Install example/ dependencies + shell: bash + working-directory: ./example + run: npm ci --include=optional --ignore-scripts + + - name: Install Chromium + shell: bash + working-directory: ./example + run: npx playwright install --with-deps chromium + + - name: Start the example app against sandbox + shell: bash + working-directory: ./example + env: + PORT: '3001' + VITE_REMOTE_GATEWAY: sandbox + VITE_CLIENT_ID: ${{ inputs.client-id }} + VITE_CLIENT_SECRET: ${{ inputs.client-secret }} + VITE_REFRESH_TOKEN: ${{ inputs.refresh-token }} + run: | + nohup npm run dev:env > "$RUNNER_TEMP/example-app.log" 2>&1 & + for _ in $(seq 1 60); do + if curl -sf -o /dev/null http://localhost:3001/; then exit 0; fi + sleep 2 + done + cat "$RUNNER_TEMP/example-app.log" + exit 1 + + - name: Re-submit contract details in the browser + shell: bash + working-directory: ./example + env: + PORT: '3001' + SCHEMA_CANARY_EMPLOYMENTS: ${{ inputs.employments-file }} + run: npx playwright test -c playwright.canary.config.ts + + - name: Upload the Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: schema-canary-browser-report + path: example/playwright-report/ + retention-days: 14 diff --git a/.github/workflows/schema-canary.yml b/.github/workflows/schema-canary.yml index d1ea1fc55..00d24c76c 100644 --- a/.github/workflows/schema-canary.yml +++ b/.github/workflows/schema-canary.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: pull_request: paths: - - 'example/src/flows/Onboarding/jsonSchemaVersions.ts' + - 'example/src/flows/Onboarding/**' - 'package.json' - 'package-lock.json' - 'src/common/createHeadlessForm.tsx' @@ -17,6 +17,10 @@ on: - 'scripts/fill-schema.ts' - 'scripts/schema-canary.ts' - 'scripts/schema-canary/**' + - 'scripts/schema-canary-archive.ts' + - 'example/e2e/canary/**' + - 'example/playwright.canary.config.ts' + - '.github/actions/schema-canary-browser/**' - '.github/workflows/schema-canary.yml' jobs: @@ -51,7 +55,36 @@ jobs: VITE_CLIENT_ID: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} VITE_CLIENT_SECRET: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} VITE_REFRESH_TOKEN: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} - run: npm run schema-canary -- --write --failures-out="$RUNNER_TEMP/schema-canary-failures.md" + run: npm run schema-canary -- --write --failures-out="$RUNNER_TEMP/schema-canary-failures.md" --keep-submitted="$RUNNER_TEMP/kept-employments.json" + + - name: Check which employments were kept + id: kept + if: ${{ !cancelled() }} + run: | + if [ -s "$RUNNER_TEMP/kept-employments.json" ] && [ "$(jq length "$RUNNER_TEMP/kept-employments.json")" -gt 0 ]; then + echo "any=true" >> "$GITHUB_OUTPUT" + fi + + - name: Re-submit contract details in the browser + id: browser + if: ${{ !cancelled() && steps.kept.outputs.any == 'true' }} + uses: ./.github/actions/schema-canary-browser + with: + employments-file: ${{ runner.temp }}/kept-employments.json + client-id: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} + client-secret: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} + refresh-token: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} + + - name: Archive the kept employments + if: always() + env: + VITE_CLIENT_ID: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} + VITE_CLIENT_SECRET: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} + VITE_REFRESH_TOKEN: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} + run: | + if [ -f "$RUNNER_TEMP/kept-employments.json" ]; then + npm run schema-canary:archive -- --from="$RUNNER_TEMP/kept-employments.json" + fi - name: Count failing checks in the report id: count @@ -77,16 +110,21 @@ jobs: scripts/reports/schema-canary.json - name: Open or update the failure issue - if: failure() && steps.canary.outcome == 'failure' + if: failure() && (steps.canary.outcome == 'failure' || steps.browser.outcome == 'failure') env: GH_TOKEN: ${{ github.token }} + BROWSER_OUTCOME: ${{ steps.browser.outcome }} run: | { echo "The nightly contract details schema canary is failing against sandbox." echo + if [ "$BROWSER_OUTCOME" = "failure" ]; then + echo "The browser stage failed: re-submitting contract details in the example app did not match what the canary saved. The Playwright report is attached to the run." + echo + fi if [ -s "$RUNNER_TEMP/schema-canary-failures.md" ]; then cat "$RUNNER_TEMP/schema-canary-failures.md" - else + elif [ "$BROWSER_OUTCOME" != "failure" ]; then echo "The canary stopped before it checked any country, for example because sandbox auth failed. The run log has the error." fi } > "$RUNNER_TEMP/issue-body.md" @@ -101,7 +139,7 @@ jobs: fi - name: Close the failure issue - if: always() && steps.canary.outcome == 'success' + if: always() && steps.canary.outcome == 'success' && contains(fromJSON('["success", "skipped"]'), steps.browser.outcome) env: GH_TOKEN: ${{ github.token }} run: | @@ -113,13 +151,14 @@ jobs: if: github.event_name == 'pull_request' name: Contract Details Schema Canary (pinned, PR) runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 30 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 @@ -129,9 +168,51 @@ jobs: - name: Install dependencies run: npm ci --include=optional --ignore-scripts + - name: Find the countries this PR affects + id: affected + env: + BASE_REF: ${{ github.base_ref }} + run: npm run -s schema-canary:affected -- --base="origin/$BASE_REF" + - name: Run schema canary (pinned and submit checks) against sandbox + if: steps.affected.outputs.countries != '' env: + COUNTRIES: ${{ steps.affected.outputs.countries }} VITE_CLIENT_ID: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} VITE_CLIENT_SECRET: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} VITE_REFRESH_TOKEN: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} - run: npm run schema-canary -- --checks=pinned,submit + run: | + if [ "$COUNTRIES" = "all" ]; then + npm run schema-canary -- --checks=pinned,submit --keep-submitted="$RUNNER_TEMP/kept-employments.json" + else + npm run schema-canary -- --checks=pinned,submit --keep-submitted="$RUNNER_TEMP/kept-employments.json" --country="$COUNTRIES" + fi + + - name: Check which employments were kept + id: kept + if: ${{ !cancelled() }} + run: | + if [ -s "$RUNNER_TEMP/kept-employments.json" ] && [ "$(jq length "$RUNNER_TEMP/kept-employments.json")" -gt 0 ]; then + echo "any=true" >> "$GITHUB_OUTPUT" + fi + + - name: Re-submit contract details in the browser + id: browser + if: ${{ !cancelled() && steps.kept.outputs.any == 'true' }} + uses: ./.github/actions/schema-canary-browser + with: + employments-file: ${{ runner.temp }}/kept-employments.json + client-id: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} + client-secret: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} + refresh-token: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} + + - name: Archive the kept employments + if: always() + env: + VITE_CLIENT_ID: ${{ secrets.SCHEMA_CANARY_CLIENT_ID }} + VITE_CLIENT_SECRET: ${{ secrets.SCHEMA_CANARY_CLIENT_SECRET }} + VITE_REFRESH_TOKEN: ${{ secrets.SCHEMA_CANARY_REFRESH_TOKEN }} + run: | + if [ -f "$RUNNER_TEMP/kept-employments.json" ]; then + npm run schema-canary:archive -- --from="$RUNNER_TEMP/kept-employments.json" + fi diff --git a/example/e2e/canary/contract-details.spec.ts b/example/e2e/canary/contract-details.spec.ts new file mode 100644 index 000000000..6a8f370ba --- /dev/null +++ b/example/e2e/canary/contract-details.spec.ts @@ -0,0 +1,147 @@ +import { readFileSync } from 'node:fs'; +import { expect, Page, test } from '@playwright/test'; + +type KeptEmployment = { + country: string; + employmentId: string; + companyId: string; + sent: Record; + knownUnsavedFields: string[]; +}; + +const employmentsFile = process.env.SCHEMA_CANARY_EMPLOYMENTS; +if (!employmentsFile) { + throw new Error( + 'Set SCHEMA_CANARY_EMPLOYMENTS to the file written by npm run schema-canary -- --keep-submitted=', + ); +} +const employments = JSON.parse( + readFileSync(employmentsFile, 'utf8'), +) as KeptEmployment[]; + +const MAX_STEPS_BEFORE_CONTRACT_DETAILS = 4; + +function fieldErrors(page: Page) { + return page + .locator('[data-slot="form-message"]') + .evaluateAll((messages) => + messages.map( + (message) => + `${message.closest('[data-field]')?.getAttribute('data-field')}: ${message.textContent}`, + ), + ); +} + +const NOT_PERSISTED_ACKNOWLEDGEMENTS = ['ack_non_eligible_job_titles']; + +async function acknowledgeAgain(page: Page) { + for (const field of NOT_PERSISTED_ACKNOWLEDGEMENTS) { + const box = page.locator( + `[data-field="${field}"] [role="checkbox"][data-state="unchecked"]`, + ); + if ((await box.count()) === 0) continue; + await box.click(); + test.info().annotations.push({ + type: 'acknowledged-again', + description: `${field} is not persisted, so it was ticked again`, + }); + } +} + +async function continueUntilContractDetails(page: Page) { + const stepTitle = page.getByTestId('onboarding-step-title'); + for (let step = 0; step < MAX_STEPS_BEFORE_CONTRACT_DETAILS; step++) { + await expect(stepTitle).not.toBeEmpty(); + const current = await stepTitle.innerText(); + if (current === 'Contract Details') return; + await test.step(`continue past ${current}`, async () => { + await acknowledgeAgain(page); + await page.locator('.submit-button').click(); + await expect(stepTitle, { + message: `${current} did not move on. Field errors: ${JSON.stringify(await fieldErrors(page))}`, + }).not.toHaveText(current); + }); + } + await expect(stepTitle).toHaveText('Contract Details'); +} + +async function answerKnownUnsavedFields( + page: Page, + fields: string[], + sent: Record, +) { + for (const field of fields) { + if (!(field in sent)) continue; + const container = page.locator(`[data-field="${field}"]`); + await expect( + container.locator('[data-slot="form-message"]'), + `${field} is listed in KNOWN_UNSAVED_FIELDS but came back filled in, so remove it from the list`, + ).toBeVisible(); + const radio = container.locator( + `[role="radio"][value="${String(sent[field])}"]`, + ); + await expect( + radio, + `${field} can only be answered again when it is a radio`, + ).toHaveCount(1); + await radio.click(); + test.info().annotations.push({ + type: 'known-unsaved-field', + description: `${field} came back empty and was answered again`, + }); + } +} + +for (const { + country, + employmentId, + companyId, + sent, + knownUnsavedFields, +} of employments) { + test(`${country} re-submits the saved contract details unchanged`, async ({ + page, + }) => { + await page.goto(`/?demo=onboarding-basic&employmentId=${employmentId}`); + await page.locator('#companyId').fill(companyId); + await page.locator('.onboarding-form-button').click(); + + await continueUntilContractDetails(page); + await expect( + page.locator('[data-field]').first(), + 'the contract details form rendered no fields', + ).toBeVisible(); + + if (knownUnsavedFields.length > 0) { + await page.locator('.submit-button').click(); + await answerKnownUnsavedFields(page, knownUnsavedFields, sent); + } + + const patch = page.waitForRequest( + (request) => + request.method() === 'PATCH' && + new URL(request.url()).pathname === `/v1/employments/${employmentId}`, + { timeout: 30_000 }, + ); + await page.locator('.submit-button').click(); + const request = await patch.catch(async () => { + throw new Error( + `Continue on contract details sent no PATCH. Field errors: ${JSON.stringify(await fieldErrors(page))}`, + ); + }); + const response = await request.response(); + + expect( + response?.ok(), + `PATCH /v1/employments/${employmentId} returned ${response?.status()}: ${await response?.text()}`, + ).toBe(true); + expect( + (request.postDataJSON() as { contract_details?: unknown }) + .contract_details, + 'the browser sent different contract_details than the canary saved', + ).toEqual(sent); + await expect(page.getByTestId('onboarding-step-title')).not.toHaveText( + 'Contract Details', + ); + }); +} diff --git a/example/playwright.canary.config.ts b/example/playwright.canary.config.ts new file mode 100644 index 000000000..9b6103260 --- /dev/null +++ b/example/playwright.canary.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.PORT || '3001'; + +export default defineConfig({ + testDir: './e2e/canary', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: process.env.CI ? [['html'], ['github']] : 'list', + use: { + baseURL: process.env.BASE_URL || `http://localhost:${PORT}`, + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/example/playwright.config.ts b/example/playwright.config.ts index ba60c9cad..de3ea93a0 100644 --- a/example/playwright.config.ts +++ b/example/playwright.config.ts @@ -15,6 +15,7 @@ const PORT = process.env.PORT || '3001'; */ export default defineConfig({ testDir: './e2e', + testIgnore: '**/canary/**', timeout: 60_000, expect: { timeout: 10_000 }, /* Run tests in files in parallel */ diff --git a/package.json b/package.json index 832e9898e..6a8786a61 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "release": "tsx scripts/release.ts", "release:fix": "tsx scripts/release-hotfix.ts", "schema-canary": "tsx scripts/schema-canary.ts", + "schema-canary:affected": "tsx scripts/schema-canary-affected.ts", + "schema-canary:archive": "tsx scripts/schema-canary-archive.ts", "seed:onboarding": "tsx scripts/seed-onboarding.ts", "size": "tsx scripts/analyze-bundle-size.ts", "size:check": "tsx scripts/check-size-limits.ts", diff --git a/scripts/schema-canary-affected.ts b/scripts/schema-canary-affected.ts new file mode 100644 index 000000000..9e0450833 --- /dev/null +++ b/scripts/schema-canary-affected.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env tsx +import { execFileSync } from 'node:child_process'; +import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + affectedCountries, + COUNTRY_SCOPED_FILES, + FileExports, +} from './schema-canary/affected-countries'; + +const baseArg = process.argv.find((arg) => arg.startsWith('--base=')); +if (!baseArg) { + throw new Error('Usage: npm run schema-canary:affected -- --base='); +} +const base = baseArg.slice('--base='.length); + +const git = (...args: string[]) => + execFileSync('git', args, { encoding: 'utf8' }); + +async function exportsAt( + ref: string | undefined, + file: string, +): Promise { + if (!ref) return import(pathToFileURL(path.resolve(file)).href); + let source: string; + try { + source = git('show', `${ref}:${file}`); + } catch { + return undefined; + } + const copy = path.join( + mkdtempSync(path.join(tmpdir(), 'schema-canary-')), + path.basename(file), + ); + writeFileSync(copy, source); + return import(pathToFileURL(copy).href); +} + +const changed = git('diff', '--name-only', `${base}...HEAD`) + .split('\n') + .filter(Boolean); + +const files = await Promise.all( + changed.map(async (file) => + COUNTRY_SCOPED_FILES[file] + ? { + path: file, + before: await exportsAt(base, file), + after: await exportsAt(undefined, file), + } + : { path: file }, + ), +); + +const result = affectedCountries(files); +const countries = result === 'all' ? 'all' : result.join(','); +console.log(`Affected countries: ${countries || 'none'}`); +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `countries=${countries}\n`); +} diff --git a/scripts/schema-canary-archive.ts b/scripts/schema-canary-archive.ts new file mode 100644 index 000000000..5ce4017bf --- /dev/null +++ b/scripts/schema-canary-archive.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env tsx +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import { createSandboxClient } from './schema-canary/auth'; +import { KeptEmployment } from './schema-canary/kept-employments'; +import { mapWithConcurrency } from './schema-canary/lib'; +import { archiveEmployment } from './schema-canary/seed-employment'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '..', '.env.sandbox') }); + +const fromArg = process.argv.find((arg) => arg.startsWith('--from=')); +if (!fromArg) { + throw new Error('Usage: npm run schema-canary:archive -- --from='); +} +const kept = JSON.parse( + readFileSync(fromArg.slice('--from='.length), 'utf8'), +) as KeptEmployment[]; + +const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REFRESH_TOKEN } = process.env; +if (!VITE_CLIENT_ID || !VITE_CLIENT_SECRET || !VITE_REFRESH_TOKEN) { + throw new Error( + 'Missing VITE_CLIENT_ID, VITE_CLIENT_SECRET, or VITE_REFRESH_TOKEN (set them in .env.sandbox at the repo root, or as env vars)', + ); +} +const client = createSandboxClient( + VITE_CLIENT_ID, + VITE_CLIENT_SECRET, + VITE_REFRESH_TOKEN, +); + +const failures = ( + await mapWithConcurrency(kept, 6, async ({ country, employmentId }) => { + try { + await archiveEmployment(client, employmentId); + console.log(`[${country}] archived employment ${employmentId}`); + return null; + } catch (error) { + console.warn( + `[${country}] failed to archive employment ${employmentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + return employmentId; + } + }) +).filter(Boolean); + +process.exitCode = failures.length > 0 ? 1 : 0; diff --git a/scripts/schema-canary.ts b/scripts/schema-canary.ts index 8c34c7d18..7cad5b71a 100644 --- a/scripts/schema-canary.ts +++ b/scripts/schema-canary.ts @@ -25,6 +25,7 @@ import { SchemaCheckType, } from './schema-canary/lib'; import { resolvePinnedVersion } from './schema-canary/pinned-versions'; +import { KeptEmployment } from './schema-canary/kept-employments'; import { archiveEmployment, seedEmploymentForCountry, @@ -49,7 +50,9 @@ function parseArgs(argv: string[]) { const args = parseArgs(process.argv.slice(2)); const COUNTRY_FILTER = - typeof args.country === 'string' ? args.country.toUpperCase() : undefined; + typeof args.country === 'string' + ? args.country.toUpperCase().split(',') + : undefined; const REQUESTED_CHECKS = typeof args.checks === 'string' ? (args.checks.split(',') as SchemaCheckType[]) @@ -67,6 +70,10 @@ if (!Number.isInteger(CONCURRENCY) || CONCURRENCY < 1) { const WRITE_REPORT = args.write === true; const FAILURES_OUT = typeof args['failures-out'] === 'string' ? args['failures-out'] : undefined; +const KEEP_SUBMITTED = + typeof args['keep-submitted'] === 'string' + ? args['keep-submitted'] + : undefined; const REPORT_PATH = path.resolve(__dirname, 'reports', 'schema-canary.json'); async function fetchLiveCountries(client: Client): Promise { @@ -80,7 +87,7 @@ async function fetchLiveCountries(client: Client): Promise { return (response.data.data ?? []) .filter((country) => country.eor_onboarding) .map((country) => country.code) - .filter((code) => !COUNTRY_FILTER || code === COUNTRY_FILTER); + .filter((code) => !COUNTRY_FILTER || COUNTRY_FILTER.includes(code)); } function versionFor(country: string, check: SchemaCheckType) { @@ -113,16 +120,23 @@ async function fetchLiveSchema( return response.data.data ?? null; } +type CountryResult = { rows: SchemaCanaryRow[]; kept?: KeptEmployment }; + async function checkCountry( client: Client, country: string, -): Promise { +): Promise { const strategy = getContractDetailsStrategy(country); const rows: SchemaCanaryRow[] = []; + let kept: KeptEmployment | undefined; let employmentId: string; + let companyId: string; try { - employmentId = await seedEmploymentForCountry(client, country); + ({ employmentId, companyId } = await seedEmploymentForCountry( + client, + country, + )); console.log(`[${country}] seeded employment ${employmentId}`); } catch (error) { const reason = `employment seeding failed: ${error instanceof Error ? error.message : String(error)}`; @@ -138,7 +152,7 @@ async function checkCountry( error: skipEntry?.reason ?? reason, }); } - return rows; + return { rows }; } const schemas = new Map< @@ -187,22 +201,38 @@ async function checkCountry( try { const schema = await schemaFor(version); - const result = - check === 'submit' - ? await submitContractDetails( - client, - employmentId, - schema, - resolvePinnedVersion(country, DEFAULT_VERSION), - { - strategy, - seed: seedFor(country), - seedValues: CONTRACT_DETAILS_SEEDS[country], - knownUnsavedFields: KNOWN_UNSAVED_FIELDS[country], - }, - ) - : await checkSchemaBuildsAndValidates(schema, strategy); - if (check === 'pinned' && !result.ok) pinnedBuildFailed = true; + let result: { ok: true } | { ok: false; error: string }; + if (check === 'submit') { + const submitted = await submitContractDetails( + client, + employmentId, + schema, + resolvePinnedVersion(country, DEFAULT_VERSION), + { + strategy, + seed: seedFor(country), + seedValues: CONTRACT_DETAILS_SEEDS[country], + knownUnsavedFields: KNOWN_UNSAVED_FIELDS[country], + }, + ); + if (submitted.ok) { + kept = { + country, + employmentId, + companyId, + version, + strategy, + sent: submitted.sent, + knownUnsavedFields: Object.keys( + KNOWN_UNSAVED_FIELDS[country] ?? {}, + ), + }; + } + result = submitted; + } else { + result = await checkSchemaBuildsAndValidates(schema, strategy); + if (check === 'pinned' && !result.ok) pinnedBuildFailed = true; + } console.log( `[${country}] ${check}@${version} -> ${result.ok ? 'pass' : `fail: ${result.error}`}`, ); @@ -227,20 +257,24 @@ async function checkCountry( } } } finally { - try { - await archiveEmployment(client, employmentId); - console.log(`[${country}] archived employment ${employmentId}`); - } catch (error) { - console.warn( - `[${country}] failed to archive employment ${employmentId}: ${error instanceof Error ? error.message : String(error)}`, - ); + if (KEEP_SUBMITTED && kept) { + console.log(`[${country}] kept employment ${employmentId}`); + } else { + try { + await archiveEmployment(client, employmentId); + console.log(`[${country}] archived employment ${employmentId}`); + } catch (error) { + console.warn( + `[${country}] failed to archive employment ${employmentId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } } - return rows; + return { rows, kept }; } -async function runLive(): Promise { +async function runLive(): Promise { const clientId = process.env.VITE_CLIENT_ID; const clientSecret = process.env.VITE_CLIENT_SECRET; const refreshToken = process.env.VITE_REFRESH_TOKEN; @@ -252,12 +286,9 @@ async function runLive(): Promise { const client = createSandboxClient(clientId, clientSecret, refreshToken); const countries = await fetchLiveCountries(client); - const rowsByCountry = await mapWithConcurrency( - countries, - CONCURRENCY, - (country) => checkCountry(client, country), + return mapWithConcurrency(countries, CONCURRENCY, (country) => + checkCountry(client, country), ); - return rowsByCountry.flat(); } function report(rows: SchemaCanaryRow[]) { @@ -288,9 +319,20 @@ function writeReport(rows: SchemaCanaryRow[]) { } async function main() { - const rows = await runLive(); + const results = await runLive(); + const rows = results.flatMap((result) => result.rows); report(rows); + if (KEEP_SUBMITTED) { + const kept = results.flatMap((result) => + result.kept ? [result.kept] : [], + ); + writeFileSync(KEEP_SUBMITTED, `${JSON.stringify(kept, null, 2)}\n`); + console.log( + `\nKept ${kept.length} submitted employment(s) in ${KEEP_SUBMITTED}`, + ); + } + if (WRITE_REPORT) { writeReport(rows); } diff --git a/scripts/schema-canary/affected-countries.test.ts b/scripts/schema-canary/affected-countries.test.ts new file mode 100644 index 000000000..5e4981f6e --- /dev/null +++ b/scripts/schema-canary/affected-countries.test.ts @@ -0,0 +1,86 @@ +import { affectedCountries } from './affected-countries'; + +const VERSIONS = 'example/src/flows/Onboarding/jsonSchemaVersions.ts'; + +describe('affectedCountries', () => { + it('returns only the countries whose pinned version changed', () => { + expect( + affectedCountries([ + { + path: VERSIONS, + before: { + ONBOARDING_JSON_SCHEMA_VERSION: { employment_basic_information: 4 }, + ONBOARDING_JSON_SCHEMA_VERSION_BY_COUNTRY: { + CHN: { contract_details: 7 }, + DEU: { contract_details: 7 }, + }, + }, + after: { + ONBOARDING_JSON_SCHEMA_VERSION: { employment_basic_information: 4 }, + ONBOARDING_JSON_SCHEMA_VERSION_BY_COUNTRY: { + CHN: { contract_details: 8 }, + DEU: { contract_details: 7 }, + PRT: { contract_details: 2 }, + }, + }, + }, + { path: 'docs/schema-changes/eor/contract-details/countries/CHN.md' }, + { path: 'src/components/JsonSchemaComparison/schemaVersions.ts' }, + ]), + ).toEqual(['CHN', 'PRT']); + }); + + it('merges the countries from every per-country file', () => { + expect( + affectedCountries([ + { + path: 'scripts/contract-details-seeds.ts', + before: { CONTRACT_DETAILS_SEEDS: { JAM: { a: 1 } } }, + after: { CONTRACT_DETAILS_SEEDS: { JAM: { a: 2 } } }, + }, + { + path: 'scripts/schema-canary/known-unsaved-fields.ts', + before: { KNOWN_UNSAVED_FIELDS: { GBR: { overtime_eligible: 'x' } } }, + after: { KNOWN_UNSAVED_FIELDS: {} }, + }, + ]), + ).toEqual(['GBR', 'JAM']); + }); + + it('runs every country when another export of a per-country file changed', () => { + expect( + affectedCountries([ + { + path: VERSIONS, + before: { + ONBOARDING_JSON_SCHEMA_VERSION: { employment_basic_information: 4 }, + }, + after: { + ONBOARDING_JSON_SCHEMA_VERSION: { employment_basic_information: 5 }, + }, + }, + ]), + ).toBe('all'); + }); + + it('runs every country when code the canary depends on changed', () => { + expect( + affectedCountries([ + { + path: VERSIONS, + before: { ONBOARDING_JSON_SCHEMA_VERSION_BY_COUNTRY: {} }, + after: { + ONBOARDING_JSON_SCHEMA_VERSION_BY_COUNTRY: { + CHN: { contract_details: 8 }, + }, + }, + }, + { path: 'example/src/flows/Onboarding/constants.ts' }, + ]), + ).toBe('all'); + }); + + it('returns no countries when nothing the canary reads changed', () => { + expect(affectedCountries([{ path: 'README.md' }])).toEqual([]); + }); +}); diff --git a/scripts/schema-canary/affected-countries.ts b/scripts/schema-canary/affected-countries.ts new file mode 100644 index 000000000..aa6e25ad7 --- /dev/null +++ b/scripts/schema-canary/affected-countries.ts @@ -0,0 +1,76 @@ +import { isDeepStrictEqual } from 'node:util'; + +export const COUNTRY_SCOPED_FILES: Record = { + 'example/src/flows/Onboarding/jsonSchemaVersions.ts': + 'ONBOARDING_JSON_SCHEMA_VERSION_BY_COUNTRY', + 'scripts/contract-details-seeds.ts': 'CONTRACT_DETAILS_SEEDS', + 'scripts/schema-canary/known-unsaved-fields.ts': 'KNOWN_UNSAVED_FIELDS', +}; + +const ALL_COUNTRIES_PATHS = [ + 'package.json', + 'package-lock.json', + 'src/common/createHeadlessForm.tsx', + 'src/common/headlessForm.ts', + 'src/components/form/', + 'src/flows/Onboarding/', + 'scripts/fill-schema.ts', + 'scripts/schema-canary.ts', + 'scripts/schema-canary/', + 'scripts/schema-canary-archive.ts', + 'example/src/flows/Onboarding/', + 'example/e2e/canary/', + 'example/playwright.canary.config.ts', + '.github/actions/schema-canary-browser/', + '.github/workflows/schema-canary.yml', +]; + +export type FileExports = Record; + +export type ChangedFile = { + path: string; + before?: FileExports; + after?: FileExports; +}; + +export function changedCountries( + exportName: string, + before: FileExports = {}, + after: FileExports = {}, +): string[] | 'all' { + const names = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const name of names) { + if (name !== exportName && !isDeepStrictEqual(before[name], after[name])) { + return 'all'; + } + } + const beforeByCountry = (before[exportName] ?? {}) as FileExports; + const afterByCountry = (after[exportName] ?? {}) as FileExports; + const countries = new Set([ + ...Object.keys(beforeByCountry), + ...Object.keys(afterByCountry), + ]); + return [...countries].filter( + (country) => + !isDeepStrictEqual(beforeByCountry[country], afterByCountry[country]), + ); +} + +export function affectedCountries(files: ChangedFile[]): string[] | 'all' { + const countries = new Set(); + for (const file of files) { + const exportName = COUNTRY_SCOPED_FILES[file.path]; + if (exportName) { + const changed = changedCountries(exportName, file.before, file.after); + if (changed === 'all') return 'all'; + changed.forEach((country) => countries.add(country)); + } else if ( + ALL_COUNTRIES_PATHS.some((path) => + path.endsWith('/') ? file.path.startsWith(path) : file.path === path, + ) + ) { + return 'all'; + } + } + return [...countries].sort(); +} diff --git a/scripts/schema-canary/kept-employments.ts b/scripts/schema-canary/kept-employments.ts new file mode 100644 index 000000000..4cceb4c0b --- /dev/null +++ b/scripts/schema-canary/kept-employments.ts @@ -0,0 +1,9 @@ +export type KeptEmployment = { + country: string; + employmentId: string; + companyId: string; + version: number | 'latest'; + strategy: 'rebuild' | 'buildOnce'; + sent: Record; + knownUnsavedFields: string[]; +}; diff --git a/scripts/schema-canary/seed-employment.ts b/scripts/schema-canary/seed-employment.ts index a05fc0ec1..ec2be2dea 100644 --- a/scripts/schema-canary/seed-employment.ts +++ b/scripts/schema-canary/seed-employment.ts @@ -68,10 +68,12 @@ async function fetchSchema( * standalone in CI. Shares its field-filling logic with seed-onboarding.ts * via scripts/fill-schema.ts. */ +export type SeededEmployment = { employmentId: string; companyId: string }; + export async function seedEmploymentForCountry( client: Client, country: string, -): Promise { +): Promise { const basicInfoSchema = await fetchSchema( client, country, @@ -101,10 +103,12 @@ export async function seedEmploymentForCountry( if (created.error) { throw new Error(`POST /v1/employments -> ${JSON.stringify(created.error)}`); } - const employmentId = (created.data as $TSFixMe)?.data?.employment?.id; - if (!employmentId) { + const employment = created.data?.data?.employment; + const employmentId = employment?.id; + const companyId = employment?.company_id; + if (!employmentId || !companyId) { throw new Error( - `Could not find employment id in response: ${JSON.stringify(created.data)}`, + `Could not find the employment and company ids in the response: ${JSON.stringify(created.data)}`, ); } @@ -121,7 +125,7 @@ export async function seedEmploymentForCountry( throw error; } - return employmentId; + return { employmentId, companyId }; } async function completePreContractSteps( diff --git a/scripts/schema-canary/submit-contract-details.test.ts b/scripts/schema-canary/submit-contract-details.test.ts index 0718ac39e..878686628 100644 --- a/scripts/schema-canary/submit-contract-details.test.ts +++ b/scripts/schema-canary/submit-contract-details.test.ts @@ -182,7 +182,7 @@ describe('submitContractDetails', () => { { strategy: 'buildOnce' }, ); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, sent: { overtime_eligible: 'yes' } }); }); it('fails when a forced value is saved as something else', async () => { @@ -261,7 +261,7 @@ describe('submitContractDetails', () => { }, ); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, sent: { overtime_eligible: 'no' } }); }); it('fails when a known unsaved field is saved now', async () => { @@ -311,7 +311,7 @@ describe('submitContractDetails', () => { strategy: 'rebuild', }); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, sent: { has_signing_bonus: 'no' } }); const url = new URL(request!.url); expect(url.pathname).toBe('/v1/employments/emp-1'); expect(Object.fromEntries(url.searchParams)).toEqual({ @@ -345,7 +345,7 @@ describe('submitContractDetails', () => { seedValues: { has_signing_bonus: 'yes' }, }); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, sent: { has_signing_bonus: 'yes' } }); expect(body).toEqual({ contract_details: { has_signing_bonus: 'yes' }, pricing_plan_details: { frequency: 'monthly' }, @@ -363,7 +363,7 @@ describe('submitContractDetails', () => { { strategy: 'buildOnce' }, ); - expect(result).toEqual({ ok: true }); + expect(result).toEqual({ ok: true, sent: { annual_gross_salary: 100000 } }); }); it('fails when the employment saved something else', async () => { diff --git a/scripts/schema-canary/submit-contract-details.ts b/scripts/schema-canary/submit-contract-details.ts index 7d2355785..0cd251c80 100644 --- a/scripts/schema-canary/submit-contract-details.ts +++ b/scripts/schema-canary/submit-contract-details.ts @@ -18,7 +18,9 @@ import { } from '@/src/components/form/utils'; import { fillSchema } from '../fill-schema'; -export type SubmitResult = { ok: true } | { ok: false; error: string }; +export type SubmitResult = + | { ok: true; sent: FieldValues } + | { ok: false; error: string }; type SdkPayloadResult = | { ok: true; payload: FieldValues; forcedFields: string[] } @@ -172,5 +174,5 @@ export async function submitContractDetails( error: `known unsaved field(s) are saved now, remove them from KNOWN_UNSAVED_FIELDS: ${nowSaved.join(', ')}`, }; } - return { ok: true }; + return { ok: true, sent: sdk.payload }; } From df1605aedc1a11157bac75ecd237e40a9e22326e Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 8 Oct 2026 16:08:19 +0200 Subject: [PATCH 2/5] fix(e2e): keep the browser canary under the sandbox rate limit The first CI run was blocked by the sandbox CloudFront firewall (403 "too much traffic"), which failed the proxy, left Select Country empty and stopped archiving, leaking employments. - The example app's proxy fetched a new OAuth token for every proxied request. It now caches each token until a minute before it expires. - archiveEmployment retries four times with backoff, so a temporary block can't leak employments. - The browser stage runs 3 workers on CI instead of 4. The browser stage also caught the canary drifting from the real form: on rebuild, a default that a condition turns on (ECU forces work_hours_per_week to 40 for full time) was only read from the first build, so the canary never sent it while the form did. The canary now re-reads defaults from the rebuilt fields until they stop changing. Co-Authored-By: Claude Opus 5.5 (1M context) --- example/api/get_token.js | 36 +++++++++++++- example/playwright.canary.config.ts | 2 +- scripts/schema-canary/seed-employment.ts | 29 ++++++------ .../submit-contract-details.test.ts | 47 +++++++++++++++++++ .../schema-canary/submit-contract-details.ts | 17 +++++-- 5 files changed, 109 insertions(+), 22 deletions(-) diff --git a/example/api/get_token.js b/example/api/get_token.js index a5885881e..052bcc592 100644 --- a/example/api/get_token.js +++ b/example/api/get_token.js @@ -1,7 +1,7 @@ const { buildGatewayURL } = require('./utils.js'); const { fetchCompanyManagerToken } = require('./jwt_auth.js'); -async function fetchAccessToken() { +async function requestAccessToken() { const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, @@ -56,7 +56,7 @@ async function fetchAccessToken() { return { accessToken: data.access_token, expiresIn: data.expires_in }; } -async function fetchClientCredentialsAccessToken() { +async function requestClientCredentialsAccessToken() { const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY } = process.env; @@ -97,6 +97,38 @@ async function fetchClientCredentialsAccessToken() { return { accessToken: data.access_token, expiresIn: data.expires_in }; } +const EXPIRY_MARGIN_SECONDS = 60; + +function cacheUntilExpiry(requestToken) { + let cached; + return async () => { + if (cached && Date.now() < cached.refreshAt) { + const { accessToken } = await cached.token; + return { + accessToken, + expiresIn: Math.floor((cached.expiresAt - Date.now()) / 1000), + }; + } + const token = requestToken(); + cached = { token, refreshAt: Infinity, expiresAt: Infinity }; + try { + const { accessToken, expiresIn } = await token; + const expiresAt = Date.now() + expiresIn * 1000; + cached.expiresAt = expiresAt; + cached.refreshAt = expiresAt - EXPIRY_MARGIN_SECONDS * 1000; + return { accessToken, expiresIn }; + } catch (error) { + cached = undefined; + throw error; + } + }; +} + +const fetchAccessToken = cacheUntilExpiry(requestAccessToken); +const fetchClientCredentialsAccessToken = cacheUntilExpiry( + requestClientCredentialsAccessToken, +); + // Express route handler async function getToken(req, res) { const { NODE_ENV } = process.env; diff --git a/example/playwright.canary.config.ts b/example/playwright.canary.config.ts index 9b6103260..ef63651a5 100644 --- a/example/playwright.canary.config.ts +++ b/example/playwright.canary.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, - workers: process.env.CI ? 4 : undefined, + workers: process.env.CI ? 3 : undefined, reporter: process.env.CI ? [['html'], ['github']] : 'list', use: { baseURL: process.env.BASE_URL || `http://localhost:${PORT}`, diff --git a/scripts/schema-canary/seed-employment.ts b/scripts/schema-canary/seed-employment.ts index ec2be2dea..35c835156 100644 --- a/scripts/schema-canary/seed-employment.ts +++ b/scripts/schema-canary/seed-employment.ts @@ -183,23 +183,24 @@ async function completePreContractSteps( } } -/** - * Archives a sandbox-created employment. Only works in Sandbox - * (deleteV1SandboxEmploymentsEmploymentId 404s elsewhere). Best-effort: - * a cleanup failure shouldn't fail the country's checks, which already ran. - */ +const ARCHIVE_ATTEMPTS = 4; + export async function archiveEmployment( client: Client, employmentId: string, ): Promise { - const response = await deleteV1SandboxEmploymentsEmploymentId({ - client, - headers: { Authorization: '' }, - path: { employment_id: employmentId }, - }); - if (response.error) { - throw new Error( - `DELETE /v1/sandbox/employments/${employmentId} -> ${JSON.stringify(response.error)}`, - ); + for (let attempt = 1; ; attempt++) { + const response = await deleteV1SandboxEmploymentsEmploymentId({ + client, + headers: { Authorization: '' }, + path: { employment_id: employmentId }, + }); + if (!response.error) return; + if (attempt === ARCHIVE_ATTEMPTS) { + throw new Error( + `DELETE /v1/sandbox/employments/${employmentId} -> ${JSON.stringify(response.error)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 2000 * 2 ** attempt)); } } diff --git a/scripts/schema-canary/submit-contract-details.test.ts b/scripts/schema-canary/submit-contract-details.test.ts index 878686628..3a6ea7b80 100644 --- a/scripts/schema-canary/submit-contract-details.test.ts +++ b/scripts/schema-canary/submit-contract-details.test.ts @@ -72,6 +72,53 @@ describe('sdkPayloadFor', () => { }, ); + it('fills a default that a condition turns on, as the rebuilt form does', async () => { + const result = await sdkPayloadFor( + { + type: 'object', + properties: { + work_schedule: { + type: 'string', + title: 'Work schedule', + oneOf: [ + { const: 'full_time', title: 'Full time' }, + { const: 'part_time', title: 'Part time' }, + ], + 'x-jsf-presentation': { inputType: 'radio' }, + }, + work_hours_per_week: { + type: 'number', + title: 'Work hours per week', + 'x-jsf-presentation': { inputType: 'number' }, + }, + }, + required: ['work_schedule'], + allOf: [ + { + if: { + properties: { work_schedule: { const: 'full_time' } }, + required: ['work_schedule'], + }, + then: { + properties: { + work_hours_per_week: { const: 40, default: 40 }, + }, + required: ['work_hours_per_week'], + }, + }, + ], + }, + 'rebuild', + { work_schedule: 'full_time' }, + ); + + expect(result).toEqual({ + ok: true, + payload: { work_schedule: 'full_time', work_hours_per_week: 40 }, + forcedFields: ['work_hours_per_week'], + }); + }); + it('fails when the SDK form rejects values the schema accepts', async () => { const result = await sdkPayloadFor( { diff --git a/scripts/schema-canary/submit-contract-details.ts b/scripts/schema-canary/submit-contract-details.ts index 0cd251c80..a816a2fbe 100644 --- a/scripts/schema-canary/submit-contract-details.ts +++ b/scripts/schema-canary/submit-contract-details.ts @@ -32,6 +32,8 @@ export function seedFor(country: string): number { return Math.abs(hash); } +const MAX_REBUILD_PASSES = 5; + export async function sdkPayloadFor( schema: Record, strategy: HeadlessFormStrategy, @@ -43,11 +45,16 @@ export async function sdkPayloadFor( strategy, strategy === 'buildOnce' ? savedValues : {}, ); - const formValues = getInitialValues(initialForm.fields, savedValues); - const form = - strategy === 'buildOnce' - ? initialForm - : buildHeadlessForm(schema, strategy, formValues); + let formValues = getInitialValues(initialForm.fields, savedValues); + let form = initialForm; + if (strategy === 'rebuild') { + for (let pass = 0; pass < MAX_REBUILD_PASSES; pass++) { + form = buildHeadlessForm(schema, strategy, formValues); + const nextValues = getInitialValues(form.fields, savedValues); + if (isDeepStrictEqual(nextValues, formValues)) break; + formValues = nextValues; + } + } const parsed = await parseValuesForValidation(form, strategy, formValues); const formErrors = Object.fromEntries( Object.entries( From ffd78f88b8b87f9a5c62907c82239fe1ad797c4e Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 8 Oct 2026 16:09:55 +0200 Subject: [PATCH 3/5] fix(e2e): write kept employments as they are kept The kept file was only written at the end of the run, so a timeout or cancel during the canary left kept employments off disk and the always() archive step had nothing to clean up. It's now rewritten after every kept country. Also run the PR canary, and every country, when the affected-countries script changes, and pin upload-artifact's comment to its exact tag. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/schema-canary-browser/action.yml | 2 +- .github/workflows/schema-canary.yml | 1 + scripts/schema-canary.ts | 14 +++++++++----- scripts/schema-canary/affected-countries.ts | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/actions/schema-canary-browser/action.yml b/.github/actions/schema-canary-browser/action.yml index d1e54a59c..de199cb87 100644 --- a/.github/actions/schema-canary-browser/action.yml +++ b/.github/actions/schema-canary-browser/action.yml @@ -57,7 +57,7 @@ runs: - name: Upload the Playwright report if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: schema-canary-browser-report path: example/playwright-report/ diff --git a/.github/workflows/schema-canary.yml b/.github/workflows/schema-canary.yml index 00d24c76c..ddbe6569e 100644 --- a/.github/workflows/schema-canary.yml +++ b/.github/workflows/schema-canary.yml @@ -18,6 +18,7 @@ on: - 'scripts/schema-canary.ts' - 'scripts/schema-canary/**' - 'scripts/schema-canary-archive.ts' + - 'scripts/schema-canary-affected.ts' - 'example/e2e/canary/**' - 'example/playwright.canary.config.ts' - '.github/actions/schema-canary-browser/**' diff --git a/scripts/schema-canary.ts b/scripts/schema-canary.ts index 7cad5b71a..5d6fc4b54 100644 --- a/scripts/schema-canary.ts +++ b/scripts/schema-canary.ts @@ -122,6 +122,13 @@ async function fetchLiveSchema( type CountryResult = { rows: SchemaCanaryRow[]; kept?: KeptEmployment }; +const keptSoFar: KeptEmployment[] = []; + +function recordKept(employment: KeptEmployment, keepFile: string) { + keptSoFar.push(employment); + writeFileSync(keepFile, `${JSON.stringify(keptSoFar, null, 2)}\n`); +} + async function checkCountry( client: Client, country: string, @@ -258,6 +265,7 @@ async function checkCountry( } } finally { if (KEEP_SUBMITTED && kept) { + recordKept(kept, KEEP_SUBMITTED); console.log(`[${country}] kept employment ${employmentId}`); } else { try { @@ -324,12 +332,8 @@ async function main() { report(rows); if (KEEP_SUBMITTED) { - const kept = results.flatMap((result) => - result.kept ? [result.kept] : [], - ); - writeFileSync(KEEP_SUBMITTED, `${JSON.stringify(kept, null, 2)}\n`); console.log( - `\nKept ${kept.length} submitted employment(s) in ${KEEP_SUBMITTED}`, + `\nKept ${keptSoFar.length} submitted employment(s) in ${KEEP_SUBMITTED}`, ); } diff --git a/scripts/schema-canary/affected-countries.ts b/scripts/schema-canary/affected-countries.ts index aa6e25ad7..2008464cf 100644 --- a/scripts/schema-canary/affected-countries.ts +++ b/scripts/schema-canary/affected-countries.ts @@ -18,6 +18,7 @@ const ALL_COUNTRIES_PATHS = [ 'scripts/schema-canary.ts', 'scripts/schema-canary/', 'scripts/schema-canary-archive.ts', + 'scripts/schema-canary-affected.ts', 'example/src/flows/Onboarding/', 'example/e2e/canary/', 'example/playwright.canary.config.ts', From 2e87ebf93d8e212f4a112cb930affe35446b9527 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 8 Oct 2026 16:18:11 +0200 Subject: [PATCH 4/5] ci(e2e): print each country as it finishes and cancel superseded PR canary runs The html and github reporters print nothing until the end, so the browser stage looked stuck. A newer push now cancels the older PR run instead of running both against sandbox at once; the always() archive step still cleans up the cancelled run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/schema-canary.yml | 3 +++ example/playwright.canary.config.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/schema-canary.yml b/.github/workflows/schema-canary.yml index ddbe6569e..52a2e62bb 100644 --- a/.github/workflows/schema-canary.yml +++ b/.github/workflows/schema-canary.yml @@ -152,6 +152,9 @@ jobs: if: github.event_name == 'pull_request' name: Contract Details Schema Canary (pinned, PR) runs-on: ubuntu-latest + concurrency: + group: schema-canary-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true timeout-minutes: 30 permissions: contents: read diff --git a/example/playwright.canary.config.ts b/example/playwright.canary.config.ts index ef63651a5..792d6d20c 100644 --- a/example/playwright.canary.config.ts +++ b/example/playwright.canary.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 3 : undefined, - reporter: process.env.CI ? [['html'], ['github']] : 'list', + reporter: process.env.CI ? [['list'], ['html'], ['github']] : 'list', use: { baseURL: process.env.BASE_URL || `http://localhost:${PORT}`, trace: 'retain-on-failure', From dd1981cace4a762712f301617ab98a9daf83481d Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Thu, 8 Oct 2026 16:23:29 +0200 Subject: [PATCH 5/5] ci(e2e): cache Playwright browsers in the browser canary like the e2e job Reuses ci-main's key (runner OS + Playwright version) so the canary shares the cached Chromium, and retries the system-dependency and browser installs instead of failing the run on one apt or download hiccup. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../actions/schema-canary-browser/action.yml | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/actions/schema-canary-browser/action.yml b/.github/actions/schema-canary-browser/action.yml index de199cb87..ac756c615 100644 --- a/.github/actions/schema-canary-browser/action.yml +++ b/.github/actions/schema-canary-browser/action.yml @@ -24,10 +24,39 @@ runs: working-directory: ./example run: npm ci --include=optional --ignore-scripts + - name: Get Playwright version + id: playwright-version + shell: bash + working-directory: ./example + run: echo "version=$(node -p "require('./package.json').devDependencies['@playwright/test']")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright system dependencies + shell: bash + working-directory: ./example + run: | + for i in 1 2 3; do + if npx playwright install-deps chromium; then exit 0; fi + sleep $((i * 10)) + done + exit 1 + - name: Install Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' shell: bash working-directory: ./example - run: npx playwright install --with-deps chromium + run: | + for i in 1 2 3; do + if npx playwright install chromium; then exit 0; fi + sleep $((i * 10)) + done + exit 1 - name: Start the example app against sandbox shell: bash