diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fe2cdd1..a20f79ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Checkout the sealed source commit - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.source_commit }} fetch-depth: 0 @@ -48,18 +48,18 @@ jobs: run: corepack enable - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Setup pinned Python descriptor helper - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: ${{ env.PYTHON_VERSION }} @@ -81,7 +81,7 @@ jobs: pnpm --filter @pulse/extension verify-manifest --expected-version "$EXPECTED_VERSION" - name: Verify successful seal-candidate run provenance - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd env: EVIDENCE_RUN_ID: ${{ inputs.evidence_run_id }} SOURCE_COMMIT: ${{ inputs.source_commit }} @@ -106,7 +106,7 @@ jobs: } - name: Download sealed candidate evidence - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: missionpulse-sealed-candidate path: ${{ github.workspace }}/release-input @@ -173,7 +173,7 @@ jobs: echo "artifact_name=missionpulse-package-validated-${VERSION}" >> "$GITHUB_OUTPUT" - name: Upload package_validated evidence - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: ${{ steps.package-metadata.outputs.artifact_name }} path: | @@ -193,7 +193,7 @@ jobs: steps: - name: Checkout verifier at the sealed source commit - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.source_commit }} @@ -201,18 +201,18 @@ jobs: run: corepack enable - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Setup pinned Python descriptor helper - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 with: python-version: ${{ env.PYTHON_VERSION }} @@ -223,7 +223,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Download package_validated evidence - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: ${{ needs.package-validated.outputs.artifact_name }} path: ${{ github.workspace }}/consumer-input diff --git a/apps/extension/playwright.mv3.config.ts b/apps/extension/playwright.mv3.config.ts index 34c4bf82..287dacca 100644 --- a/apps/extension/playwright.mv3.config.ts +++ b/apps/extension/playwright.mv3.config.ts @@ -3,6 +3,14 @@ import { resolve } from 'node:path'; const artifactRoot = resolve(import.meta.dirname, '../../output/playwright'); +// The packaged MV3 gate drives a real Chromium + MV3 service-worker lifecycle, +// which has inherent transient failure modes (non-atomic DevToolsActivePort +// writes, service-worker restart races, worker bootstrap timing). A single +// retry in CI absorbs those without masking consistent failures (a real bug +// fails on both attempts and still fails the gate). Locally we keep 0 so a +// flake is loud during development. This mirrors the browser-e2e config. +const isCI = Boolean(process.env.CI); + export default defineConfig({ testDir: './tests', testMatch: ['mv3/**/*.test.ts', 'e2e-extension/**/*.test.ts'], @@ -13,7 +21,7 @@ export default defineConfig({ }, fullyParallel: false, workers: 1, - retries: 0, + retries: isCI ? 1 : 0, reporter: [ ['list'], ['html', { open: 'never', outputFolder: resolve(artifactRoot, 'mv3-report') }], diff --git a/apps/extension/src/models/mv3-packaged-harness.model.md b/apps/extension/src/models/mv3-packaged-harness.model.md index 5c99ffef..83790ca2 100644 --- a/apps/extension/src/models/mv3-packaged-harness.model.md +++ b/apps/extension/src/models/mv3-packaged-harness.model.md @@ -1009,6 +1009,13 @@ deadline: 5. the derived endpoint is exactly `ws://127.0.0.1:`. +While polling under that deadline, absence (`ENOENT`) and a zero-byte regular +file are the same transient admission state: the child has not yet written the +canonical bytes. A zero-byte file is retried exactly like absence and never +parsed. A non-empty file whose bytes do not satisfy the canonical shape +(one/three-line, invalid port/path, CR/NUL) is terminal. The deadline itself is +terminal: a file that never admits within it fails closed. + This parsing emits `ENDPOINT_PARSED`; it opens no socket and does not claim that the endpoint belongs to the child. The endpoint becomes verified only after the machine reserves a raw lease, enters `raw_connecting`, opens its sole tracked @@ -1885,7 +1892,9 @@ Implementation is not reviewable as complete until deterministic tests prove: rejected before opening a transport; 2. `DevToolsActivePort` absent, stale, symlinked, non-regular, one/three-line, whitespace/control-bearing, invalid port/path, wrong generation and child - exit before verification all fail closed; + exit before verification all fail closed; absence and a zero-byte regular + file during the polling window are retried until the deadline, then fail + closed; 3. the manual argument allowlist contains one exact profile, loopback debugging, sealed extension paths and deterministic mode, with no shell or passthrough; raw initialization proves sentinel and zero pre-arm attachments, then diff --git a/apps/extension/src/models/packaged-tab-scenarios.model.md b/apps/extension/src/models/packaged-tab-scenarios.model.md index 84c2def3..86585081 100644 --- a/apps/extension/src/models/packaged-tab-scenarios.model.md +++ b/apps/extension/src/models/packaged-tab-scenarios.model.md @@ -1460,8 +1460,12 @@ The fixture is fresh. It contains no profile, mission, tracking, first-scan flag legacy onboarding key or confirmed consent. No seed operation writes business data. -1. Open the packaged panel and require `page-onboarding`, `Premier lancement` - and no navigation bar. +1. Open the packaged panel and require `page-onboarding`, the welcome hero + heading `Toutes vos missions freelance, centralisées et scorées.` and no + navigation bar. The hero is rendered only by `OnboardingWelcome` once the + lazily-imported `OnboardingPage` chunk resolves; the pre-hydration skeleton + renders a different placeholder (`Configurez votre premier scan`), so the + hero assertion also proves the real onboarding mounted (not the skeleton). 2. Click `Configurer le radar`. 3. Select the first visible connector from the build-filtered catalogue and click `Continuer avec `. The selected connector ID/name are captured as diff --git a/apps/extension/tests/e2e-extension/navigation.test.ts b/apps/extension/tests/e2e-extension/navigation.test.ts index 23261bc0..45ac923b 100644 --- a/apps/extension/tests/e2e-extension/navigation.test.ts +++ b/apps/extension/tests/e2e-extension/navigation.test.ts @@ -55,7 +55,13 @@ test( await expect(page).toHaveURL(extension.sidePanelUrl); await expect(page.getByTestId('page-onboarding')).toBeVisible(); - await expect(page.getByText('Premier lancement', { exact: true })).toBeVisible(); + // The welcome hero is rendered only by OnboardingWelcome once the lazily- + // imported OnboardingPage chunk resolves. The pre-hydration skeleton + // (App.svelte) renders a different placeholder, so this also proves the + // real onboarding mounted — not the skeleton. + await expect( + page.getByRole('heading', { name: /Toutes vos missions freelance/ }) + ).toBeVisible(); await assertNoBlankOrLoadError(page); const devState = await page.evaluate(() => ({ diff --git a/apps/extension/tests/mv3/harness/devtools-endpoint.ts b/apps/extension/tests/mv3/harness/devtools-endpoint.ts index 1e2fd3ff..480755d5 100644 --- a/apps/extension/tests/mv3/harness/devtools-endpoint.ts +++ b/apps/extension/tests/mv3/harness/devtools-endpoint.ts @@ -34,6 +34,10 @@ class EndpointChangedDuringCaptureError extends Error { readonly code = 'DEVTOOLS_ENDPOINT_CHANGED'; } +class EndpointEmptyDuringCaptureError extends Error { + readonly code = 'DEVTOOLS_ENDPOINT_EMPTY'; +} + function sha256(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); } @@ -133,6 +137,11 @@ export async function readDevToolsEndpointFile( 'DevToolsActivePort changed while it was being captured.' ); } + if (stat.size === 0) { + throw new EndpointEmptyDuringCaptureError( + 'DevToolsActivePort is empty; the browser may still be writing it.' + ); + } return parseDevToolsActivePort(raw, options); } finally { await handle.close(); @@ -188,7 +197,11 @@ export async function waitForDevToolsEndpoint( return outcome.endpoint; } const code = errorCode(outcome.error); - if (code !== 'ENOENT' && code !== 'DEVTOOLS_ENDPOINT_CHANGED') { + if ( + code !== 'ENOENT' && + code !== 'DEVTOOLS_ENDPOINT_CHANGED' && + code !== 'DEVTOOLS_ENDPOINT_EMPTY' + ) { throw outcome.error; } diff --git a/apps/extension/tests/unit/mv3/devtools-endpoint.test.ts b/apps/extension/tests/unit/mv3/devtools-endpoint.test.ts index c5a506dd..80088ac0 100644 --- a/apps/extension/tests/unit/mv3/devtools-endpoint.test.ts +++ b/apps/extension/tests/unit/mv3/devtools-endpoint.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -158,6 +158,31 @@ describe('strict DevToolsActivePort capability', () => { await expect(pending).resolves.toMatchObject({ port: 9333, processGeneration: 4 }); }); + it('waits through an empty mid-write file and resolves once content lands', async () => { + const profile = await mkdtemp(join(tmpdir(), 'missionpulse-endpoint-')); + cleanupPaths.push(profile); + const profileRealPath = await realpath(profile); + const endpointPath = join(profile, 'DevToolsActivePort'); + const neverExits = new Promise(() => undefined); + + const pending = waitForDevToolsEndpoint({ + childExited: neverExits, + endpointPath, + pollIntervalMs: 2, + processGeneration: 8, + profileRealPath, + timeoutMs: 500, + }); + // Simulate the real Chromium open-then-write window: an empty regular file + // exists before the canonical bytes land. This must be retried, not fatal. + const empty = await open(endpointPath, 'w'); + await empty.close(); + await new Promise((resolve) => setTimeout(resolve, 5)); + await writeFile(endpointPath, `9444\n/devtools/browser/${BROWSER_UUID}\n`, 'utf8'); + + await expect(pending).resolves.toMatchObject({ port: 9444, processGeneration: 8 }); + }); + it('fails immediately when the owned child exits before endpoint admission', async () => { const profile = await mkdtemp(join(tmpdir(), 'missionpulse-endpoint-')); cleanupPaths.push(profile);