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
43 changes: 26 additions & 17 deletions e2e/lib/event-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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)
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion e2e/lib/runner.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -94,6 +94,9 @@ export async function runCli(args: string[], options?: RunOptions): Promise<Comm
})
proc.on("close", (code) => {
clearTimeout(timer)
try {
closeSync(stdoutFd)
} catch {}
const stdout = readFileSync(stdoutFile, "utf-8")
const stderr = Buffer.concat(stderrChunks).toString("utf-8")
try {
Expand Down
77 changes: 3 additions & 74 deletions e2e/perstack-cli/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down