fix(scripts): bound control UI i18n process output

This commit is contained in:
Vincent Koc
2026-05-28 16:44:00 +02:00
parent 79e733cc34
commit e707b452c0
4 changed files with 104 additions and 7 deletions

View File

@@ -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 });
});
});
}

View File

@@ -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"]],

View File

@@ -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");
});
});

View File

@@ -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([