import { spawn } from 'node:child_process'; import { once } from 'node:events'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; import { afterEach, describe, expect, it } from 'vitest'; import { buildProcessSessionFixtureSource } from '../scripts/process-session-real-e2e-fixture.mjs'; const readyPrefix = 'GENARRATIVE_PROCESS_READY'; const echoPrefix = 'GENARRATIVE_PROCESS_ECHO'; const stoppedMarker = 'GENARRATIVE_PROCESS_STOPPED'; const temporaryRoots = new Set(); afterEach(async () => { await Promise.all( [...temporaryRoots].map((root) => fs.rm(root, { recursive: true, force: true }), ), ); temporaryRoots.clear(); }); describe('process-session real E2E fixture', () => { it('runs as a pure PTY-style protocol without project control files or sockets', async () => { const root = await fs.mkdtemp( path.join(os.tmpdir(), 'genarrative-process-fixture-test-'), ); temporaryRoots.add(root); const fixturePath = path.join(root, 'fixture.mjs'); await fs.writeFile( fixturePath, buildProcessSessionFixtureSource({ readyPrefix, echoPrefix, stoppedMarker, }), ); const child = spawn(process.execPath, [fixturePath], { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], }); const exitPromise = once(child, 'exit'); const output = readline.createInterface({ input: child.stdout }); const lines: string[] = []; const waiters = new Set<{ predicate: (line: string) => boolean; resolve: (line: string) => void; }>(); output.on('line', (line) => { const normalized = line.endsWith('\r') ? line.slice(0, -1) : line; lines.push(normalized); for (const waiter of [...waiters]) { if (waiter.predicate(normalized)) waiter.resolve(normalized); } }); const waitForLine = ( predicate: (line: string) => boolean, label: string, ): Promise => { const existing = lines.find(predicate); if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { const waiter = { predicate, resolve: (line) => { clearTimeout(timer); waiters.delete(waiter); resolve(line); }, }; const timer = setTimeout(() => { waiters.delete(waiter); reject(new Error(`process fixture did not emit ${label}`)); }, 3_000); waiters.add(waiter); }); }; try { const readyLine = await waitForLine( (line) => line.startsWith(`${readyPrefix} challenge=`), 'readiness', ); const match = readyLine.match( /^GENARRATIVE_PROCESS_READY challenge=([0-9a-f]{36})$/u, ); expect(match).not.toBeNull(); if (!match?.[1]) throw new Error('process fixture challenge is missing'); expect( await fs.stat(path.join(root, '.agent')).catch(() => null), ).toBeNull(); const challenge = match[1]; child.stdin.write(`${challenge}\n`); await expect( waitForLine((line) => line === `${echoPrefix} ${challenge}`, 'echo'), ).resolves.toBe(`${echoPrefix} ${challenge}`); if (process.platform === 'win32') { child.stdin.write(`${challenge}:stop\n`); } else { expect(child.kill('SIGTERM')).toBe(true); } await expect( waitForLine((line) => line === stoppedMarker, 'stopped marker'), ).resolves.toBe(stoppedMarker); const [exitCode, signal] = await exitPromise; expect({ exitCode, signal }).toEqual({ exitCode: 0, signal: null }); expect(await fs.readdir(root)).toEqual(['fixture.mjs']); } finally { output.close(); child.stdin.destroy(); if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); } }, 10_000); });