From ff5ee2367179e16c6937351335fc3777360c810d Mon Sep 17 00:00:00 2001 From: nopp Date: Fri, 24 Jul 2026 12:24:01 +0700 Subject: [PATCH 1/3] test: cover cross-process credential writes --- test/credentials-cross-process.test.ts | 98 ++++++++++++++++++++++++ test/helpers/credentials-write-child.mjs | 28 +++++++ 2 files changed, 126 insertions(+) create mode 100644 test/credentials-cross-process.test.ts create mode 100644 test/helpers/credentials-write-child.mjs diff --git a/test/credentials-cross-process.test.ts b/test/credentials-cross-process.test.ts new file mode 100644 index 0000000..6edf54e --- /dev/null +++ b/test/credentials-cross-process.test.ts @@ -0,0 +1,98 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { readCredentialsFile } from '../src/lib/credentials.js'; +import { execNpm } from './helpers/execNpm.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); +const CHILD_PATH = join(REPO_ROOT, 'test', 'helpers', 'credentials-write-child.mjs'); + +interface ChildResult { + code: number | null; + signal: NodeJS.Signals | null; + stderr: string; + stdout: string; +} + +function runCredentialWriter(env: Record): Promise { + return new Promise((resolveChild, reject) => { + const child = spawn(process.execPath, [CHILD_PATH], { + cwd: REPO_ROOT, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const timer = setTimeout(() => { + child.kill(); + }, 10_000); + + child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk))); + child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk))); + child.on('error', error => { + clearTimeout(timer); + reject(error); + }); + child.on('close', (code, signal) => { + clearTimeout(timer); + resolveChild({ + code, + signal, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }); + }); + }); +} + +describe('credentials cross-process writes', () => { + beforeAll(() => { + execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); + }); + + it('preserves every profile written by concurrent child processes', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-creds-race-')); + const credentialsPath = join(tmpRoot, 'credentials'); + const startPath = join(tmpRoot, 'start'); + + try { + const profiles = Array.from({ length: 8 }, (_, index) => ({ + apiKey: `sk-child-${index}`, + profile: `child-${index}`, + })); + const children = profiles.map(({ apiKey, profile }) => + runCredentialWriter({ + CRED_API_KEY: apiKey, + CRED_PATH: credentialsPath, + CRED_PROFILE: profile, + CRED_START_PATH: startPath, + }), + ); + + await new Promise(resolveDelay => setTimeout(resolveDelay, 50)); + writeFileSync(startPath, 'go'); + + const results = await Promise.all(children); + expect(results).toEqual( + profiles.map(() => ({ + code: 0, + signal: null, + stderr: '', + stdout: '', + })), + ); + + const credentials = readCredentialsFile({ path: credentialsPath }); + for (const { apiKey, profile } of profiles) { + expect(credentials[profile]).toEqual({ apiKey }); + } + expect(existsSync(`${credentialsPath}.lock`)).toBe(false); + } finally { + rmSync(tmpRoot, { force: true, recursive: true }); + } + }, 20_000); +}); diff --git a/test/helpers/credentials-write-child.mjs b/test/helpers/credentials-write-child.mjs new file mode 100644 index 0000000..833b9b2 --- /dev/null +++ b/test/helpers/credentials-write-child.mjs @@ -0,0 +1,28 @@ +import { existsSync } from 'node:fs'; +import { writeProfile } from '../../dist/lib/credentials.js'; + +const profile = process.env.CRED_PROFILE; +const credentialsPath = process.env.CRED_PATH; +const apiKey = process.env.CRED_API_KEY; +const startPath = process.env.CRED_START_PATH; + +if (!profile || !credentialsPath || !apiKey || !startPath) { + console.error('CRED_PROFILE, CRED_PATH, CRED_API_KEY, and CRED_START_PATH are required'); + process.exit(1); +} + +const deadline = Date.now() + 5_000; +while (!existsSync(startPath)) { + if (Date.now() >= deadline) { + console.error(`Timed out waiting for start marker: ${startPath}`); + process.exit(2); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); +} + +try { + writeProfile(profile, { apiKey }, { path: credentialsPath }); +} catch (error) { + console.error(error instanceof Error ? error.stack : String(error)); + process.exit(3); +} From 74f345ba782ec9c1735550420bf71271b2c55375 Mon Sep 17 00:00:00 2001 From: nopp Date: Wed, 29 Jul 2026 18:55:51 +0700 Subject: [PATCH 2/3] test: cover credential writer failure paths --- test/credentials-cross-process.test.ts | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/credentials-cross-process.test.ts b/test/credentials-cross-process.test.ts index 6edf54e..6977031 100644 --- a/test/credentials-cross-process.test.ts +++ b/test/credentials-cross-process.test.ts @@ -52,6 +52,72 @@ function runCredentialWriter(env: Record): Promise describe('credentials cross-process writes', () => { beforeAll(() => { execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); + }, 60_000); + + it('reports missing required child-process environment variables', async () => { + const result = await runCredentialWriter({ + CRED_API_KEY: '', + CRED_PATH: '', + CRED_PROFILE: '', + CRED_START_PATH: '', + }); + + expect(result).toMatchObject({ + code: 1, + signal: null, + stdout: '', + }); + expect(result.stderr).toContain( + 'CRED_PROFILE, CRED_PATH, CRED_API_KEY, and CRED_START_PATH are required', + ); + }); + + it('reports a timed out child-process start marker', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-creds-timeout-')); + + try { + const startPath = join(tmpRoot, 'missing-start'); + const result = await runCredentialWriter({ + CRED_API_KEY: 'sk-child-timeout', + CRED_PATH: join(tmpRoot, 'credentials'), + CRED_PROFILE: 'child-timeout', + CRED_START_PATH: startPath, + }); + + expect(result).toMatchObject({ + code: 2, + signal: null, + stdout: '', + }); + expect(result.stderr).toContain(`Timed out waiting for start marker: ${startPath}`); + } finally { + rmSync(tmpRoot, { force: true, recursive: true }); + } + }, 15_000); + + it('reports writeProfile failures from the child process', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-creds-write-error-')); + + try { + const startPath = join(tmpRoot, 'start'); + writeFileSync(startPath, 'go'); + + const result = await runCredentialWriter({ + CRED_API_KEY: 'sk-child-invalid-profile', + CRED_PATH: join(tmpRoot, 'credentials'), + CRED_PROFILE: 'bad]', + CRED_START_PATH: startPath, + }); + + expect(result).toMatchObject({ + code: 3, + signal: null, + stdout: '', + }); + expect(result.stderr).toContain('Invalid request.'); + } finally { + rmSync(tmpRoot, { force: true, recursive: true }); + } }); it('preserves every profile written by concurrent child processes', async () => { From f7926f4fc3182534dcc6b033e557d31fb556b275 Mon Sep 17 00:00:00 2001 From: nopp Date: Wed, 29 Jul 2026 22:15:36 +0700 Subject: [PATCH 3/3] test: make credential writer race deterministic --- test/credentials-cross-process.test.ts | 21 +++++++++++++++++++-- test/helpers/credentials-write-child.mjs | 23 ++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/test/credentials-cross-process.test.ts b/test/credentials-cross-process.test.ts index 6977031..3e00d14 100644 --- a/test/credentials-cross-process.test.ts +++ b/test/credentials-cross-process.test.ts @@ -49,6 +49,19 @@ function runCredentialWriter(env: Record): Promise }); } +async function waitForFiles(paths: string[], timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (true) { + const missing = paths.filter(path => !existsSync(path)); + if (missing.length === 0) return; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for files: ${missing.join(', ')}`); + } + await new Promise(resolveDelay => setTimeout(resolveDelay, 5)); + } +} + describe('credentials cross-process writes', () => { beforeAll(() => { execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); @@ -81,7 +94,9 @@ describe('credentials cross-process writes', () => { CRED_API_KEY: 'sk-child-timeout', CRED_PATH: join(tmpRoot, 'credentials'), CRED_PROFILE: 'child-timeout', + CRED_POLL_MS: '1', CRED_START_PATH: startPath, + CRED_START_TIMEOUT_MS: '100', }); expect(result).toMatchObject({ @@ -130,16 +145,18 @@ describe('credentials cross-process writes', () => { apiKey: `sk-child-${index}`, profile: `child-${index}`, })); - const children = profiles.map(({ apiKey, profile }) => + const readyPaths = profiles.map(({ profile }) => join(tmpRoot, `${profile}.ready`)); + const children = profiles.map(({ apiKey, profile }, index) => runCredentialWriter({ CRED_API_KEY: apiKey, CRED_PATH: credentialsPath, CRED_PROFILE: profile, + CRED_READY_PATH: readyPaths[index]!, CRED_START_PATH: startPath, }), ); - await new Promise(resolveDelay => setTimeout(resolveDelay, 50)); + await waitForFiles(readyPaths); writeFileSync(startPath, 'go'); const results = await Promise.all(children); diff --git a/test/helpers/credentials-write-child.mjs b/test/helpers/credentials-write-child.mjs index 833b9b2..2b6072a 100644 --- a/test/helpers/credentials-write-child.mjs +++ b/test/helpers/credentials-write-child.mjs @@ -1,23 +1,40 @@ -import { existsSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { writeProfile } from '../../dist/lib/credentials.js'; const profile = process.env.CRED_PROFILE; const credentialsPath = process.env.CRED_PATH; const apiKey = process.env.CRED_API_KEY; const startPath = process.env.CRED_START_PATH; +const readyPath = process.env.CRED_READY_PATH; +const startTimeoutMs = Number(process.env.CRED_START_TIMEOUT_MS ?? '5000'); +const pollMs = Number(process.env.CRED_POLL_MS ?? '5'); if (!profile || !credentialsPath || !apiKey || !startPath) { console.error('CRED_PROFILE, CRED_PATH, CRED_API_KEY, and CRED_START_PATH are required'); process.exit(1); } -const deadline = Date.now() + 5_000; +if ( + !Number.isInteger(startTimeoutMs) || + startTimeoutMs < 1 || + !Number.isInteger(pollMs) || + pollMs < 1 +) { + console.error('CRED_START_TIMEOUT_MS and CRED_POLL_MS must be positive integers'); + process.exit(1); +} + +if (readyPath) { + writeFileSync(readyPath, 'ready'); +} + +const deadline = Date.now() + startTimeoutMs; while (!existsSync(startPath)) { if (Date.now() >= deadline) { console.error(`Timed out waiting for start marker: ${startPath}`); process.exit(2); } - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, pollMs); } try {