Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}

Expand All @@ -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 }}
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -193,26 +193,26 @@ jobs:

steps:
- name: Checkout verifier at the sealed source commit
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ inputs.source_commit }}

- name: Enable Corepack
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 }}

Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion apps/extension/playwright.mv3.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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') }],
Expand Down
11 changes: 10 additions & 1 deletion apps/extension/src/models/mv3-packaged-harness.model.md
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,13 @@ deadline:
5. the derived endpoint is exactly
`ws://127.0.0.1:<port><browser-path>`.

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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions apps/extension/src/models/packaged-tab-scenarios.model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`. The selected connector ID/name are captured as
Expand Down
8 changes: 7 additions & 1 deletion apps/extension/tests/e2e-extension/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
15 changes: 14 additions & 1 deletion apps/extension/tests/mv3/harness/devtools-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down
27 changes: 26 additions & 1 deletion apps/extension/tests/unit/mv3/devtools-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<never>(() => 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<void>((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);
Expand Down
Loading