diff --git a/e2e/lib/event-parser.ts b/e2e/lib/event-parser.ts index 9db6066a..0a2d65f2 100644 --- a/e2e/lib/event-parser.ts +++ b/e2e/lib/event-parser.ts @@ -56,24 +56,33 @@ function tryParseEvent(text: string, events: ParsedEvent[]): void { events.push({ ...data, raw: text }) } } catch { - // Fallback: write to temp file and read back to force fresh string allocation, - // working around potential JSC string representation issues with JSON.parse - try { - const { writeFileSync, readFileSync, unlinkSync } = require("node:fs") - const { tmpdir } = require("node:os") - const { join } = require("node:path") - const tmpFile = join( - tmpdir(), - `event-parse-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ) - writeFileSync(tmpFile, text) - const fresh = readFileSync(tmpFile, "utf-8") - unlinkSync(tmpFile) - const data = JSON.parse(fresh) as RunEvent - if (data.type) { - events.push({ ...data, raw: text }) + // Fallback: extract event type and key fields via regex when JSON.parse fails. + // This works around a platform-specific JSON.parse issue observed in CI + // (bun 1.3.10 on ubuntu-24.04) where valid JSON strings fail to parse. + const typeMatch = text.match(/^{"type":"([^"]+)"/) + if (typeMatch) { + const partial: Record = { type: typeMatch[1] } + // Extract simple string fields + for (const field of ["id", "jobId", "runId", "expertKey", "model", "text"]) { + const m = text.match(new RegExp(`"${field}":"([^"]*?)"`)) + if (m) partial[field] = m[1] } - } catch {} + // Extract simple number fields + for (const field of ["timestamp", "stepNumber"]) { + const m = text.match(new RegExp(`"${field}":(\\d+)`)) + if (m) partial[field] = Number(m[1]) + } + // Extract usage object for completeRun events + if (partial.type === "completeRun") { + const usageMatch = text.match(/"usage":\{[^}]+\}/) + if (usageMatch) { + try { + partial.usage = JSON.parse(`{${usageMatch[0]}}`)?.usage + } catch {} + } + } + events.push({ ...partial, raw: text } as ParsedEvent) + } } } diff --git a/e2e/lib/runner.ts b/e2e/lib/runner.ts index e9409b85..f6188ede 100644 --- a/e2e/lib/runner.ts +++ b/e2e/lib/runner.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { openSync, readFileSync, unlinkSync } from "node:fs" +import { closeSync, openSync, readFileSync, unlinkSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { filterEventsByType, type ParsedEvent, parseEvents } from "./event-parser.js" @@ -94,6 +94,9 @@ export async function runCli(args: string[], options?: RunOptions): Promise { clearTimeout(timer) + try { + closeSync(stdoutFd) + } catch {} const stdout = readFileSync(stdoutFile, "utf-8") const stderr = Buffer.concat(stderrChunks).toString("utf-8") try { diff --git a/e2e/perstack-cli/providers.test.ts b/e2e/perstack-cli/providers.test.ts index 4610886b..bd4445b7 100644 --- a/e2e/perstack-cli/providers.test.ts +++ b/e2e/perstack-cli/providers.test.ts @@ -31,81 +31,10 @@ describe.concurrent("LLM Providers", () => { provider, }) const result = withEventParsing(cmdResult) - const seqResult = assertEventSequenceContains(result.events, ["startRun", "completeRun"]) - if (!seqResult.passed) { - const lines = result.stdout.split("\n") - const startRunLineIdx = lines.findIndex((l) => l.includes('"startRun"')) - process.stderr.write( - `[DEBUG ${provider}] exitCode=${result.exitCode} eventTypes=${JSON.stringify(result.events.map((e) => e.type))}\n`, - ) - if (startRunLineIdx >= 0) { - const line = lines[startRunLineIdx] - const first50 = Array.from(line.slice(0, 50)).map((c) => c.charCodeAt(0)) - const last50 = Array.from(line.slice(-50)).map((c) => c.charCodeAt(0)) - process.stderr.write( - `[DEBUG ${provider}] startRunLine idx=${startRunLineIdx} len=${line.length}\n`, - ) - process.stderr.write(`[DEBUG ${provider}] first50codes=${JSON.stringify(first50)}\n`) - process.stderr.write(`[DEBUG ${provider}] last50codes=${JSON.stringify(last50)}\n`) - process.stderr.write( - `[DEBUG ${provider}] hasNull=${line.includes("\0")} hasCR=${line.includes("\r")}\n`, - ) - const badChars: { i: number; code: number }[] = [] - for (let i = 0; i < line.length; i++) { - const code = line.charCodeAt(i) - if (code < 32 || code > 126) { - badChars.push({ i, code }) - } - } - if (badChars.length > 0) { - process.stderr.write( - `[DEBUG ${provider}] nonAscii=${JSON.stringify(badChars.slice(0, 20))}\n`, - ) - } else { - process.stderr.write(`[DEBUG ${provider}] allPrintableAscii=true\n`) - } - try { - JSON.parse(line) - process.stderr.write(`[DEBUG ${provider}] parse OK\n`) - } catch (e) { - process.stderr.write(`[DEBUG ${provider}] parse FAIL: ${(e as Error).message}\n`) - // Try file round-trip - const { writeFileSync, readFileSync, unlinkSync } = await import("node:fs") - const tmpFile = `/tmp/debug-parse-${Date.now()}.json` - writeFileSync(tmpFile, line) - const fresh = readFileSync(tmpFile, "utf-8") - unlinkSync(tmpFile) - process.stderr.write( - `[DEBUG ${provider}] freshLen=${fresh.length} match=${fresh === line}\n`, - ) - try { - JSON.parse(fresh) - process.stderr.write(`[DEBUG ${provider}] file roundtrip parse OK!\n`) - } catch (e2) { - process.stderr.write( - `[DEBUG ${provider}] file roundtrip parse FAIL: ${(e2 as Error).message}\n`, - ) - } - // Try TextEncoder/TextDecoder round-trip - const encoded = new TextEncoder().encode(line) - const decoded = new TextDecoder().decode(encoded) - try { - JSON.parse(decoded) - process.stderr.write(`[DEBUG ${provider}] TextEncoder roundtrip parse OK!\n`) - } catch (e3) { - process.stderr.write( - `[DEBUG ${provider}] TextEncoder roundtrip parse FAIL: ${(e3 as Error).message}\n`, - ) - } - // Try reading stdout file directly as Buffer - process.stderr.write(`[DEBUG ${provider}] rawStdoutLen=${result.stdout.length}\n`) - } - } else { - process.stderr.write(`[DEBUG ${provider}] startRun NOT FOUND in any line\n`) - } - } expect(result.exitCode).toBe(0) - expect(seqResult.passed).toBe(true) + expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( + true, + ) const completeEvent = result.events.find((e) => e.type === "completeRun") expect(completeEvent).toBeDefined() expect((completeEvent as { text?: string }).text).toBeDefined()