diff --git a/docs/infrastructure/2026-10-20-browser-seeder-portability.md b/docs/infrastructure/2026-10-20-browser-seeder-portability.md new file mode 100644 index 0000000..25c735d --- /dev/null +++ b/docs/infrastructure/2026-10-20-browser-seeder-portability.md @@ -0,0 +1,9 @@ +# Browser fixture portability — 20 October 2026 + +The instrumented Linux release run on `32bc65c` exposed `spawnSync rtk ENOENT` in the Career Profile import-review and Notifications fixtures. Operations used the same invocation. RTK is a developer command wrapper, not an application or CI dependency. The runner has `python3`; Windows uses `python`. + +The three fixtures now invoke the configured `PLAYWRIGHT_PYTHON_PATH` directly, otherwise the platform's Python executable. The fixture arguments, isolated SQLite paths, owner checks, insert-only authorizer and assertions remain unchanged. No production data or application code is changed. + +All 17 affected native browser cases passed locally without retries, skips or flaky outcomes at 07:19:59–07:22:20 UTC. Browser TypeScript and lint passed. This is focused Windows evidence; the subsequent full Linux release run must establish runner compatibility. The failing Linux traces remain preserved under the original diagnostic run. There is no change to browser inventory or the existing release gates. + +The independent dialog accessibility and fixture-login rate-limit findings remain under investigation. This repair alone does not establish deployment success or Phase 7 acceptance. diff --git a/job-tracker-ui/e2e/phase7-career-import-review.spec.ts b/job-tracker-ui/e2e/phase7-career-import-review.spec.ts index b8fd565..96ab2f4 100644 --- a/job-tracker-ui/e2e/phase7-career-import-review.spec.ts +++ b/job-tracker-ui/e2e/phase7-career-import-review.spec.ts @@ -113,7 +113,7 @@ function seedPending(info: TestInfo, owner: Owner) { expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/); const helper = path.resolve('e2e/helpers/seed-career-review.py'); - const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', helper], { + const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', helper], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email, profile: incoming(), rawText: 'Synthetic pending extraction text — æøå', normalizedText: 'Synthetic pending normalized text; pending contact email; Norwegian B1.' }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024, diff --git a/job-tracker-ui/e2e/phase7-notifications.spec.ts b/job-tracker-ui/e2e/phase7-notifications.spec.ts index b5155c6..59d5012 100644 --- a/job-tracker-ui/e2e/phase7-notifications.spec.ts +++ b/job-tracker-ui/e2e/phase7-notifications.spec.ts @@ -42,7 +42,7 @@ function seedQueued(info: TestInfo, owner: Owner) { const metadataRoot = info.config.metadata.isolatedDataRoot; if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot'); expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/); - const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 }); + const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 }); expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId; } async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; } diff --git a/job-tracker-ui/e2e/phase7-operations.spec.ts b/job-tracker-ui/e2e/phase7-operations.spec.ts index bb57632..f93a273 100644 --- a/job-tracker-ui/e2e/phase7-operations.spec.ts +++ b/job-tracker-ui/e2e/phase7-operations.spec.ts @@ -42,7 +42,7 @@ function seedQueued(info: TestInfo, owner: Owner) { const metadataRoot = info.config.metadata.isolatedDataRoot; if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot'); expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/); - const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 }); + const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 }); expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId; } async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }