diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index 451dbc4df52..f7a23c58e64 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -103,6 +103,7 @@ const DEFAULT_BATCH_CHAR_BUDGET = 2_000; const TRANSLATE_MAX_ATTEMPTS = 2; const TRANSLATE_BASE_DELAY_MS = 15_000; const DEFAULT_PROMPT_TIMEOUT_MS = 120_000; +const RUN_PROCESS_OUTPUT_MAX_CHARS = 1024 * 1024; const PROGRESS_HEARTBEAT_MS = 30_000; const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER"; const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL"; @@ -950,10 +951,44 @@ function estimateBatchChars(items: readonly TranslationBatchItem[]): number { type RunProcessOptions = { cwd?: string; input?: string; + maxOutputChars?: number; rejectOnFailure?: boolean; }; -async function runProcess( +type ProcessOutputCapture = { + text: string; + truncatedChars: number; +}; + +function resolveRunProcessOutputLimit(options: RunProcessOptions): number { + const value = options.maxOutputChars; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return RUN_PROCESS_OUTPUT_MAX_CHARS; + } + return Math.max(1, Math.floor(value)); +} + +export function appendBoundedProcessOutput( + capture: ProcessOutputCapture, + chunk: unknown, + maxChars: number, +): ProcessOutputCapture { + const nextText = capture.text + String(chunk); + if (nextText.length <= maxChars) { + return { text: nextText, truncatedChars: capture.truncatedChars }; + } + const truncatedChars = capture.truncatedChars + nextText.length - maxChars; + return { text: nextText.slice(-maxChars), truncatedChars }; +} + +function formatProcessOutput(capture: ProcessOutputCapture): string { + if (capture.truncatedChars === 0) { + return capture.text; + } + return `[output truncated ${capture.truncatedChars} chars; showing tail]\n${capture.text}`; +} + +export async function runProcess( executable: string, args: string[], options: RunProcessOptions = {}, @@ -965,13 +1000,14 @@ async function runProcess( stdio: ["pipe", "pipe", "pipe"], }); - let stdout = ""; - let stderr = ""; + const maxOutputChars = resolveRunProcessOutputLimit(options); + let stdout: ProcessOutputCapture = { text: "", truncatedChars: 0 }; + let stderr: ProcessOutputCapture = { text: "", truncatedChars: 0 }; child.stdout.on("data", (chunk) => { - stdout += String(chunk); + stdout = appendBoundedProcessOutput(stdout, chunk, maxOutputChars); }); child.stderr.on("data", (chunk) => { - stderr += String(chunk); + stderr = appendBoundedProcessOutput(stderr, chunk, maxOutputChars); }); child.once("error", reject); if (options.input !== undefined) { @@ -980,13 +1016,25 @@ async function runProcess( child.stdin.end(); } child.once("close", (code) => { + const stdoutText = formatProcessOutput(stdout); + const stderrText = formatProcessOutput(stderr); if ((code ?? 1) !== 0 && options.rejectOnFailure) { reject( - new Error(`${executable} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`), + new Error( + `${executable} ${args.join(" ")} failed: ${stderrText.trim() || stdoutText.trim()}`, + ), ); return; } - resolve({ code: code ?? 1, stderr, stdout }); + if ((code ?? 1) === 0 && stdout.truncatedChars > 0) { + reject( + new Error( + `${executable} ${args.join(" ")} produced more than ${maxOutputChars} stdout chars`, + ), + ); + return; + } + resolve({ code: code ?? 1, stderr: stderrText, stdout: stdout.text }); }); }); } diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 11c17bd6a7e..513de73eb8f 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -361,6 +361,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], ["scripts/check-deadcode-unused-files.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"]], ["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]], + ["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]], [ "scripts/deadcode-unused-files.allowlist.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"], @@ -432,6 +433,7 @@ const TOOLING_TEST_TARGETS = new Map([ ["test/scripts/check-deadcode-unused-files.test.ts"], ], ["test/scripts/ci-docker-pull-retry.test.ts", ["test/scripts/ci-docker-pull-retry.test.ts"]], + ["test/scripts/control-ui-i18n.test.ts", ["test/scripts/control-ui-i18n.test.ts"]], ["test/scripts/docker-build-helper.test.ts", ["test/scripts/docker-build-helper.test.ts"]], ["test/scripts/docker-e2e-helper-cli.test.ts", ["test/scripts/docker-e2e-helper-cli.test.ts"]], ["test/scripts/kova-ci-summary.test.ts", ["test/scripts/kova-ci-summary.test.ts"]], diff --git a/test/scripts/control-ui-i18n.test.ts b/test/scripts/control-ui-i18n.test.ts new file mode 100644 index 00000000000..eabc672312b --- /dev/null +++ b/test/scripts/control-ui-i18n.test.ts @@ -0,0 +1,40 @@ +import process from "node:process"; +import { describe, expect, it } from "vitest"; +import { appendBoundedProcessOutput, runProcess } from "../../scripts/control-ui-i18n.ts"; + +describe("control-ui-i18n process runner", () => { + it("keeps a bounded process output tail", () => { + const first = appendBoundedProcessOutput({ text: "", truncatedChars: 0 }, "abcdef", 5); + const second = appendBoundedProcessOutput(first, "ghij", 5); + + expect(first).toEqual({ text: "bcdef", truncatedChars: 1 }); + expect(second).toEqual({ text: "fghij", truncatedChars: 5 }); + }); + + it("bounds failure diagnostics to the newest output", async () => { + await expect( + runProcess( + process.execPath, + [ + "-e", + [ + "process.stderr.write('stderr-begin-' + 'x'.repeat(128) + '-stderr-end', () => process.exit(2));", + ].join(" "), + ], + { maxOutputChars: 64, rejectOnFailure: true }, + ), + ).rejects.toThrow(/output truncated[\s\S]*stderr-end/u); + }); + + it("rejects successful commands before returning truncated stdout", async () => { + await expect( + runProcess( + process.execPath, + ["-e", "process.stdout.write('x'.repeat(128), () => process.exit(0));"], + { + maxOutputChars: 12, + }, + ), + ).rejects.toThrow("produced more than 12 stdout chars"); + }); +}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 8037219ff81..70ec10801fd 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -207,6 +207,13 @@ describe("scripts/test-projects changed-target routing", () => { }); }); + it("routes control UI i18n script changes through its regression test", () => { + expect(resolveChangedTestTargetPlan(["scripts/control-ui-i18n.ts"])).toEqual({ + mode: "targets", + targets: ["test/scripts/control-ui-i18n.test.ts"], + }); + }); + it("routes group visible reply config changes through channel delivery regressions", () => { expect( resolveChangedTestTargetPlan([