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
11 changes: 6 additions & 5 deletions src/chrome/headed-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ import { spawnProcessGuardian } from '../utils/process-guardian';
/** Default port offset from main Chrome port for the headed fallback */
const HEADED_PORT_OFFSET = 100;

let activeCleanupTarget: HeadedFallbackManager | null = null;
let activeCleanup: (() => void) | null = null;
let cleanupHandlersInstalled = false;

function runGlobalCleanup(): void {
activeCleanupTarget?.shutdown();
activeCleanup?.();
}

function installGlobalCleanupHandlers(): void {
Expand Down Expand Up @@ -85,9 +85,10 @@ class HeadedFallbackManager {
private port: number;
private alivePages: Map<string, Page> = new Map();
private profileDirectory?: string;
private readonly cleanup = (): void => { this.shutdown(); };
constructor(basePort: number = 9222) {
this.port = basePort + HEADED_PORT_OFFSET;
activeCleanupTarget = this;
activeCleanup = this.cleanup;
installGlobalCleanupHandlers();
}

Expand Down Expand Up @@ -304,8 +305,8 @@ class HeadedFallbackManager {

/** Shut down the headed Chrome instance */
shutdown(): void {
if (activeCleanupTarget === this) {
activeCleanupTarget = null;
if (activeCleanup === this.cleanup) {
activeCleanup = null;
}

// Close any kept-alive pages
Expand Down
23 changes: 18 additions & 5 deletions tests/cli/admin-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ interface RunResult {
exitCode: number | null;
}

/**
* Extract an `oc_live_*` plaintext token from captured stdout, ignoring any
* surrounding noise. The in-process harness shares its `process.stdout.write`
* hook with Jest's own console-capture rendering, so a leaked timer firing a
* console.error from a prior test in the same worker can prepend a decorated
* block ahead of the CLI's own single-line token emission. The CLI only ever
* emits exactly one token, so a regex match is both sufficient and robust.
*/
function extractToken(stdout: string): string {
const m = stdout.match(/oc_live_[A-Za-z0-9_]+/);
if (!m) throw new Error(`No oc_live_* token found in stdout: ${JSON.stringify(stdout)}`);
return m[0];
}

async function runCli(argv: string[]): Promise<RunResult> {
const program = new Command();
program.exitOverride((err) => {
Expand Down Expand Up @@ -153,8 +167,7 @@ describe('admin keys CLI', () => {
'--tenant', 'acme',
'--scope', 'read',
]);
const plaintext = created.stdout.trim();
expect(plaintext).toMatch(/^oc_live_/);
const plaintext = extractToken(created.stdout);
const keyIdMatch = created.stderr.match(/keyId: (k_\S+)/);
expect(keyIdMatch).not.toBeNull();
const keyId = keyIdMatch![1];
Expand All @@ -174,7 +187,7 @@ describe('admin keys CLI', () => {
'--tenant', 'acme',
'--scope', 'read',
]);
const plaintext = created.stdout.trim();
const plaintext = extractToken(created.stdout);

const listed = await runCli(['admin', 'keys', 'list', '--json']);
expect(listed.exitCode).toBeNull();
Expand Down Expand Up @@ -212,12 +225,12 @@ describe('admin keys CLI', () => {
'--tenant', 'acme',
'--scope', 'read',
]);
const firstPlaintext = created.stdout.trim();
const firstPlaintext = extractToken(created.stdout);
const firstKeyId = created.stderr.match(/keyId: (k_\S+)/)![1];

const rotated = await runCli(['admin', 'keys', 'rotate', firstKeyId]);
expect(rotated.exitCode).toBeNull();
const secondPlaintext = rotated.stdout.trim();
const secondPlaintext = extractToken(rotated.stdout);
expect(secondPlaintext).toMatch(/^oc_live_acme_/);
expect(secondPlaintext).not.toBe(firstPlaintext);
expect(rotated.stderr).toContain('SAVE THIS KEY NOW');
Expand Down
7 changes: 5 additions & 2 deletions tests/transports/http-abort-on-disconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,16 @@ describe('HTTPTransport — abort-on-disconnect (issue #8)', () => {

await postAndAbort(JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call' }), 80);

// The inner race timeout must absorb macOS CI event-loop jitter; the
// abort propagation path (socket close → req.on('close') → controller
// abort) is fast locally but can exceed 2s under loaded Actions runners.
const reason = (await Promise.race([
abortReason,
new Promise<unknown>((resolve) => setTimeout(() => resolve('TIMEOUT'), 2000)),
new Promise<unknown>((resolve) => setTimeout(() => resolve('TIMEOUT'), 8000)),
])) as unknown;

expect(reason).toBeInstanceOf(ClientDisconnectError);
});
}, 15000);

test('does NOT abort signal on normal completion', async () => {
let signalRef: AbortSignal | undefined;
Expand Down
9 changes: 9 additions & 0 deletions tests/transports/http-streamable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ function request(
});
}

// CI runners (especially ubuntu-20) occasionally hit a "socket hang up"
// on the first SSE request after `transport.start()`, which looks like a
// narrow connect/handshake race between the server's listen callback and
// the client's connect attempt rather than a functional defect — the
// response body is correctly produced when the connection succeeds.
// Retry twice so transient runner-level flakes don't block CI; a real
// regression would still fail all three attempts.
jest.retryTimes(2, { logErrorsBeforeRetry: true });

describe('Streamable HTTP - POST with Accept: text/event-stream', () => {
let transport: InstanceType<typeof HTTPTransport>;

Expand Down
9 changes: 6 additions & 3 deletions tests/utils/process-guardian.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ function isAlive(pid: number): boolean {
}
}

async function waitForDead(pid: number, timeoutMs = 4000): Promise<void> {
// Windows CI can spend several seconds on Node cold-start for the detached
// guardian process plus the `taskkill` execSync roundtrip before the child
// exits. Bump the default well past that to prevent spurious timeouts.
async function waitForDead(pid: number, timeoutMs = 15000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isAlive(pid)) return;
Expand All @@ -36,7 +39,7 @@ describe('spawnProcessGuardian', () => {

await waitForDead(child.pid);
expect(isAlive(child.pid)).toBe(false);
});
}, 20000);

test('does not delete a pid file that was rewritten for a newer process', async () => {
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });
Expand All @@ -57,5 +60,5 @@ describe('spawnProcessGuardian', () => {
expect(fs.existsSync(pidFile)).toBe(true);
expect(fs.readFileSync(pidFile, 'utf8').trim()).toBe('123456');
fs.rmSync(dir, { recursive: true, force: true });
});
}, 20000);
});
Loading