From f9f94e7dcd23acb88e698d0fba1cd575df10c6c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 20 Jun 2026 07:16:14 +0800 Subject: [PATCH] fix(test): stream QA Lab stdout artifacts (#95119) * fix(test): bound QA Lab stdout artifact reads * fix(test): scan QA Lab stdout artifacts incrementally --- .../qa-lab/runtime/qa-otel-smoke-runtime.ts | 32 +++++++++- .../qa-lab/runtime/qa-otel-smoke.e2e.test.ts | 61 ++++++++++++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/test/e2e/qa-lab/runtime/qa-otel-smoke-runtime.ts b/test/e2e/qa-lab/runtime/qa-otel-smoke-runtime.ts index c0e97ea78baf..1210e8a49dcd 100644 --- a/test/e2e/qa-lab/runtime/qa-otel-smoke-runtime.ts +++ b/test/e2e/qa-lab/runtime/qa-otel-smoke-runtime.ts @@ -3,7 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { Socket } from "node:net"; import { tmpdir } from "node:os"; @@ -158,6 +158,7 @@ const MAX_STDOUT_DIAGNOSTIC_LINE_BYTES = readPositiveIntegerEnv( "OPENCLAW_QA_OTEL_MAX_STDOUT_DIAGNOSTIC_LINE_BYTES", 512 * 1024, ); +const GATEWAY_STDOUT_ARTIFACT_READ_CHUNK_BYTES = 64 * 1024; function readPositiveIntegerEnv( name: string, @@ -1075,7 +1076,11 @@ async function appendGatewayStdoutArtifactLogs(params: { "gateway.stdout.log", ); try { - params.capture.append(await readFile(gatewayStdoutPath, "utf8")); + await appendUtf8FileToStdoutDiagnosticCapture( + gatewayStdoutPath, + params.capture, + GATEWAY_STDOUT_ARTIFACT_READ_CHUNK_BYTES, + ); params.capture.flush(); } catch (error) { if (!isErrnoCode(error, "ENOENT")) { @@ -1084,6 +1089,26 @@ async function appendGatewayStdoutArtifactLogs(params: { } } +async function appendUtf8FileToStdoutDiagnosticCapture( + filePath: string, + capture: ReturnType, + chunkBytes = GATEWAY_STDOUT_ARTIFACT_READ_CHUNK_BYTES, +): Promise { + const file = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(Math.max(1, chunkBytes)); + for (;;) { + const { bytesRead } = await file.read(buffer, 0, buffer.length); + if (bytesRead === 0) { + break; + } + capture.append(buffer.subarray(0, bytesRead)); + } + } finally { + await file.close(); + } +} + async function stopDockerContainer(name: string): Promise { await new Promise((resolve) => { const child = spawn("docker", ["stop", name], { @@ -1882,14 +1907,17 @@ async function main() { } export const testing = { + appendGatewayStdoutArtifactLogs, appendCapturedBodyText, assertSmoke, createBoundedTextAccumulator, + createStdoutDiagnosticLogCapture, decodeRequestBody, parseArgs, parseStdoutDiagnosticLogLine, readPositiveIntegerEnv, readRequestBody, + appendUtf8FileToStdoutDiagnosticCapture, startLocalOtlpReceiver, startDockerOtelCollector, terminateChildTree, diff --git a/test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts b/test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts index e34e91965a06..f3f26ae98818 100644 --- a/test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/qa-otel-smoke.e2e.test.ts @@ -1,7 +1,7 @@ // QA OTEL Smoke tests cover QA Lab telemetry evidence. import { spawn, spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { createConnection as createNetConnection } from "node:net"; import os from "node:os"; import path from "node:path"; @@ -501,6 +501,65 @@ describe("qa-otel-smoke receiver bounds", () => { expect(output.text()).not.toContain("DO_NOT_RETAIN_COLLECTOR_PREFIX"); }); + it("streams gateway stdout artifact records without requiring them in the tail", async () => { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-otel-stdout-stream-")); + const logPath = path.join(tempRoot, "gateway.stdout.log"); + const capture = testing.createStdoutDiagnosticLogCapture(); + const record = { + signal: "openclaw.diagnostic.log", + ts: "2026-06-18T00:00:00.000Z", + "service.name": "openclaw-qa-lab-otel-smoke", + severityText: "INFO", + severityNumber: 9, + body: "early log", + attributes: {}, + }; + try { + writeFileSync( + logPath, + `${JSON.stringify(record)}\n${"x".repeat(256 * 1024)}\nGATEWAY_STDOUT_TAIL\n`, + ); + + await testing.appendUtf8FileToStdoutDiagnosticCapture(logPath, capture); + capture.flush(); + + expect(capture.records).toEqual([record]); + expect(capture.lines).toHaveLength(1); + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + } + }); + + it("keeps gateway stdout artifact fallback parsing bounded", async () => { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-otel-stdout-artifact-")); + const outputDir = path.join(tempRoot, "output"); + const artifactDir = path.join(outputDir, "artifacts", "gateway-runtime"); + const record = { + signal: "openclaw.diagnostic.log", + ts: "2026-06-18T00:00:00.000Z", + "service.name": "openclaw-qa-lab-otel-smoke", + severityText: "INFO", + severityNumber: 9, + body: "tail log", + attributes: {}, + }; + try { + mkdirSync(artifactDir, { recursive: true }); + writeFileSync( + path.join(artifactDir, "gateway.stdout.log"), + `${JSON.stringify(record)}\n${"x".repeat(256 * 1024)}\n`, + ); + const capture = testing.createStdoutDiagnosticLogCapture(); + + await testing.appendGatewayStdoutArtifactLogs({ capture, outputDir }); + + expect(capture.records).toEqual([record]); + expect(capture.lines).toHaveLength(1); + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + } + }); + it("times out and kills a wedged QA suite child with a detached gateway", async () => { if (process.platform === "win32") { return;