Files
larksuite__cli/scripts/run.test.js
2026-08-04 15:59:55 +08:00

535 lines
19 KiB
JavaScript

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync, spawn, execFileSync } = require("child_process");
const MARKER = "failed to launch the native binary";
const IS_WINDOWS = process.platform === "win32";
const IS_ROOT =
!IS_WINDOWS && typeof process.getuid === "function" && process.getuid() === 0;
const BIN_NAME = "lark-cli" + (IS_WINDOWS ? ".exe" : "");
const SENTINEL = "--sentinel=LEAKCANARY";
const SENTINEL_TOKEN = "LEAKCANARY";
// Single-line, easily-flippable platform gate for the win32 NTSTATUS tests
// below (spec 6.2 row 1b). To confirm their assertions are real (not a
// vacuous skip), temporarily hardcode `true` here on a non-Windows host and
// run the suite: the test then executes for real, and it MUST fail, because
// run.js still checks the real process.platform (so its crash branch stays
// dark) and this OS truncates process.exit() codes to 8 bits before Node
// ever sees them (for example, 0xC0000005 -> 5). Restore this line to
// `IS_WINDOWS` before committing.
const IS_WIN32_CRASH_TESTABLE = IS_WINDOWS;
// run.js resolves the native binary relative to its own directory
// (<dir>/../bin/lark-cli), so a fixture needs a full scripts/ + bin/ layout.
// Building that under fs.mkdtempSync keeps the developer's real ./bin/lark-cli
// untouched and keeps the location unpredictable. Never replace this with a
// fixed path such as %TEMP%\lark-cli-test — dropping a copy of the node
// executable into a predictable user-writable directory changes Windows' DLL
// search order.
function makeSandbox(t) {
// fs.realpathSync canonicalises the temp root. assertLaunchFailure checks
// res.stderr.includes(sandbox.bin), but the path run.js prints comes from
// __dirname, and Node canonicalises a main module's path. On macOS the
// difference is /var -> /private/var, a prefix extension, so substring
// containment still holds and the tests pass unnoticed. On Windows it is
// NOT a prefix extension: libuv's realpath uses GetFinalPathNameByHandle
// with FILE_NAME_NORMALIZED, which expands 8.3 short names, and
// GitHub-hosted Windows runners set TEMP to
// C:\Users\RUNNER~1\AppData\Local\Temp while the profile directory is
// runneradmin. Short-name expansion is a substitution, not a prefix
// extension, so includes() returns false and every launch-failure case
// fails on the one platform the shim-test-windows job exists to cover. Do
// not "simplify" this back to the bare mkdtempSync result.
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-run-"))
);
t.after(() =>
fs.rmSync(root, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
);
fs.mkdirSync(path.join(root, "scripts"));
fs.mkdirSync(path.join(root, "bin"));
fs.copyFileSync(
path.join(__dirname, "run.js"),
path.join(root, "scripts", "run.js")
);
// run.js runs install.js when the binary is missing. A stub that exits 0
// without producing one lets the "installer succeeded but produced nothing"
// path reach the launch attempt instead of stopping earlier.
const installJs = path.join(root, "scripts", "install.js");
fs.writeFileSync(installJs, "process.exit(0);\n");
return {
root,
bin: path.join(root, "bin", BIN_NAME),
installJs,
runJs: path.join(root, "scripts", "run.js"),
};
}
function runShim(sandbox, args) {
return spawnSync(process.execPath, [sandbox.runJs, ...args], {
encoding: "utf8",
});
}
// A fixture that actually launches: a copy of the running node executable.
// A #!/bin/sh script cannot be started as lark-cli.exe on Windows, which would
// make every "the binary ran" case inert there.
function installRunnableBinary(sandbox) {
fs.copyFileSync(process.execPath, sandbox.bin);
if (!IS_WINDOWS) {
fs.chmodSync(sandbox.bin, 0o755);
}
}
// `--` stops node from claiming the sentinel as one of its own options; without
// it node bails out with "bad option" and exits 9, which would silently
// invalidate the fixture instead of exercising the intended exit status.
function runRanBinary(sandbox, code) {
return runShim(sandbox, ["-e", code, "--", SENTINEL]);
}
function assertBinaryRan(res) {
assert.ok(
!res.stderr.includes(MARKER),
`launch-failure diagnostic on a binary that ran: ${res.stderr}`
);
assert.ok(
!res.stderr.includes(SENTINEL_TOKEN),
`caller arguments leaked into stderr: ${res.stderr}`
);
}
function assertLaunchFailure(res, sandbox) {
assert.equal(res.status, 1);
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.ok(
res.stderr.includes(MARKER),
`missing diagnostic, stderr was: ${res.stderr}`
);
assert.ok(
res.stderr.includes(sandbox.bin),
`diagnostic omits the binary path: ${res.stderr}`
);
const line = res.stderr.match(/^ {2}error: (.+)$/m);
assert.ok(line, `diagnostic omits an error line: ${res.stderr}`);
const reason = line[1].trim();
assert.notEqual(reason, "", "error line is empty");
// Keep failure output factual and local. A launch failure is not necessarily
// a lark-cli bug, and prompting users to paste local paths into an issue adds
// noise and may expose machine-specific information.
assert.ok(
!res.stderr.includes("github.com/larksuite/cli/issues"),
`diagnostic should not prompt issue creation: ${res.stderr}`
);
assert.ok(
!res.stderr.includes("Please include"),
`diagnostic should not ask users to share local details: ${res.stderr}`
);
// The sentinel check for launch-failure cases is not redundant with the
// check in assertBinaryRan: when the launch fails, e.message contains only
// the errno, not the full argv (measured: String(e) is
// "Error: spawnSync <path> ENOENT" and e.path is just the bin path --
// neither carries argv). However, the error object also carries
// e.spawnargs, which holds the complete argument list even on launch
// failure. This assertion catches any future edit that prints e.spawnargs
// or the whole error object.
assert.ok(
!res.stderr.includes(SENTINEL_TOKEN),
`caller arguments leaked into stderr: ${res.stderr}`
);
return reason;
}
describe("run.js launch-failure diagnostics", () => {
for (const [name, content] of [
["zero-byte", Buffer.alloc(0)],
["whitespace-only", Buffer.from(" \n")],
]) {
it(`rejects a ${name} binary instead of silently succeeding`, (t) => {
const sandbox = makeSandbox(t);
fs.writeFileSync(sandbox.bin, content);
if (!IS_WINDOWS) {
fs.chmodSync(sandbox.bin, 0o755);
}
const res = runShim(sandbox, ["--version", SENTINEL]);
assert.equal(assertLaunchFailure(res, sandbox), "INVALID_BINARY");
});
}
it("rejects a non-file binary path", (t) => {
const sandbox = makeSandbox(t);
fs.mkdirSync(sandbox.bin);
const res = runShim(sandbox, ["--version", SENTINEL]);
assert.equal(assertLaunchFailure(res, sandbox), "INVALID_BINARY");
});
it(
"reports a binary that cannot be executed",
{
skip: IS_WINDOWS
? "POSIX execute bit"
: IS_ROOT
? "mode 0000 does not deny exec for uid 0, so this fixture cannot " +
"produce EACCES when running as root"
: false,
},
(t) => {
const sandbox = makeSandbox(t);
fs.writeFileSync(sandbox.bin, "#!/bin/sh\nexit 0\n");
fs.chmodSync(sandbox.bin, 0o000);
const res = runShim(sandbox, ["--version", SENTINEL]);
assert.equal(assertLaunchFailure(res, sandbox), "EACCES");
}
);
it("reports a missing binary when the installer produced nothing", (t) => {
const sandbox = makeSandbox(t);
const res = runShim(sandbox, ["--version", SENTINEL]);
assert.equal(assertLaunchFailure(res, sandbox), "ENOENT");
});
it("reports an auto-install process failure", (t) => {
const sandbox = makeSandbox(t);
fs.writeFileSync(sandbox.installJs, "process.exit(7);\n");
const res = runShim(sandbox, ["--version", SENTINEL]);
assert.equal(res.status, 1);
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.ok(
res.stderr.includes("Failed to auto-install lark-cli binary"),
`missing auto-install diagnostic: ${res.stderr}`
);
assert.ok(
res.stderr.includes(sandbox.installJs),
`auto-install diagnostic omits the install path: ${res.stderr}`
);
assert.ok(
!res.stderr.includes(SENTINEL_TOKEN),
`caller arguments leaked into stderr: ${res.stderr}`
);
});
});
describe("run.js pass-through when the binary runs", () => {
it("forwards a non-zero exit status unchanged", (t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const res = runRanBinary(sandbox, "process.exit(7)");
assert.equal(res.status, 7);
assertBinaryRan(res);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
it(
`exits 1 without a diagnostic when the binary receives ${signal}`,
{ skip: IS_WINDOWS ? "POSIX signals" : false },
(t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const res = runRanBinary(
sandbox,
`process.kill(process.pid, '${signal}')`
);
assert.equal(res.status, 1);
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.equal(res.stderr, "", `stderr should stay empty: ${res.stderr}`);
assertBinaryRan(res);
}
);
}
it(
"reports the signal when the binary crashes",
{ skip: IS_WINDOWS ? "POSIX signals" : false },
(t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const res = runRanBinary(sandbox, "process.kill(process.pid, 'SIGKILL')");
const expected =
`\nlark-cli: the native binary was terminated by signal SIGKILL.\n` +
` path: ${sandbox.bin}\n`;
assert.equal(res.status, 1);
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.equal(res.stderr, expected);
assert.ok(
!res.stderr.includes(MARKER),
`crash signal was misreported as a launch failure: ${res.stderr}`
);
assert.ok(
!res.stderr.includes(SENTINEL_TOKEN),
`caller arguments leaked into stderr: ${res.stderr}`
);
}
);
for (const [name, status] of [
["STATUS_FATAL_APP_EXIT", 0x40000015],
["STATUS_BREAKPOINT", 0x80000003],
["STATUS_ACCESS_VIOLATION", 0xc0000005],
]) {
it(
`reports ${name} and preserves its win32 NTSTATUS`,
{
skip: IS_WIN32_CRASH_TESTABLE
? false
: "win32-only: POSIX truncates process exit codes to 8 bits, " +
"so NTSTATUS passthrough cannot be reproduced outside win32",
},
(t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const res = runRanBinary(sandbox, `process.exit(${status})`);
assert.equal(res.status, status, "the raw NTSTATUS must be preserved");
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.equal(
res.stderr,
`\nlark-cli: the native binary crashed (status 0x${status.toString(16)}).\n` +
` path: ${sandbox.bin}\n`
);
assert.ok(
!res.stderr.includes(MARKER),
`win32 crash was misreported as a launch failure: ${res.stderr}`
);
assert.ok(
!res.stderr.includes(SENTINEL_TOKEN),
`caller arguments leaked into stderr: ${res.stderr}`
);
}
);
}
it(
"keeps STATUS_CONTROL_C_EXIT quiet and preserves its win32 NTSTATUS",
{
skip: IS_WIN32_CRASH_TESTABLE
? false
: "win32-only: POSIX truncates NTSTATUS values to 8 bits",
},
(t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const status = 0xc000013a;
const res = runRanBinary(sandbox, `process.exit(${status})`);
assert.equal(res.status, status, "the raw NTSTATUS must be preserved");
assert.equal(res.stdout, "", `stdout should stay empty: ${res.stdout}`);
assert.equal(res.stderr, "", `stderr should stay empty: ${res.stderr}`);
assertBinaryRan(res);
}
);
it("passes stdout and stderr through and exits 0", (t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
const res = runRanBinary(
sandbox,
"console.log('shim-passthrough'); console.error('shim-stderr')"
);
assert.equal(res.status, 0);
assert.match(res.stdout, /shim-passthrough/);
// A shim that swallowed child stderr would be a variant of the very bug
// this branch repairs (issue #2053: launch failures with no evidence).
// This does not collide with assertBinaryRan's checks below: it asserts
// MARKER and the argv sentinel are absent from stderr, and "shim-stderr"
// is neither.
assert.match(res.stderr, /shim-stderr/);
assertBinaryRan(res);
});
});
// Regression for the flush-safety fix: `process.exitCode = 1` + natural
// return (not `process.exit(1)`) after the diagnostic console.error() calls.
// Writes to a piped stderr are asynchronous on POSIX; `process.exit()` can
// tear the process down before that write reaches the pipe. This is the
// exact scenario an AI agent / log wrapper hits: it captures the shim's
// stderr through a pipe, the child fills it, and the diagnostic must still
// arrive.
//
// To make this deterministic, the fixture is killed from OUTSIDE (by this
// test, via its discovered pid) while it is still blocked mid-write on a
// completely unread pipe, instead of self-signalling. Measured: if the
// fixture kills itself after its own blocking write loop returns, that
// return is proof room just opened up, so the diagnostic that follows
// almost always finds room too and the bug never reproduces -- on both
// Linux and macOS, self-signalling passed 100% regardless of
// process.exit(1) vs process.exitCode, i.e. it never actually exercised the
// backpressure path. An external kill has no such tell: the fixture can be
// stopped while genuinely full, so run.js's own diagnostic write is
// guaranteed to need the async path this fix protects.
//
// Linux-only: this exact harness reproduces the drop 100% reliably on Linux
// (matching the script-test CI job, ubuntu-latest, and the AI-agent/
// container use case this fix targets). On macOS the underlying run.js fix
// is not in question -- a separate check confirmed the fixture genuinely
// blocks on a full, unread pipe through run.js's own execFileSync/"inherit"
// chain, same as Linux -- but child_process's "close" event was observed to
// fire as soon as the shim process exits, before this test's reader had
// drained (or in some runs, before it had even attached), independent of
// process.exit(1) vs process.exitCode. That is a difference in how Node's
// child_process "close" event interacts with unread pipe backlog once the
// writer has exited (epoll vs kqueue), not evidence about run.js's
// correctness, so it is not treated as a platform for this regression test.
const BACKPRESSURE_TEST_OPTIONS = {
skip:
process.platform === "linux"
? false
: "Linux-only: this harness's determinism relies on Linux pipe/close semantics",
timeout: 15000,
};
// Bigger than any realistic OS pipe buffer (measured ~256KB on the Linux CI
// container). The child is killed while blocked in this write, before it can
// return and create room for the shim's diagnostic.
const BLOCKING_CHILD_CODE =
"const fs = require('fs');" +
"const buf = Buffer.alloc(5000000, 0x78);" +
"let off = 0;" +
"while (off < buf.length) { off += fs.writeSync(2, buf, off, buf.length - off); }" +
"console.error('unreachable: should have been killed first');";
function expectDiagnosticUnderBackpressure(
t,
sandbox,
shimArgs,
expectedDiagnostic,
expectedPath
) {
return new Promise((resolve, reject) => {
const shim = spawn(process.execPath, [sandbox.runJs, ...shimArgs], {
stdio: ["ignore", "ignore", "pipe"],
// A separate process group lets the cleanup hook stop both the shim and
// its blocked child on every rejection or timeout path.
detached: true,
});
t.after(() => {
try {
process.kill(-shim.pid, "SIGKILL");
} catch (err) {
if (err.code !== "ESRCH") {
throw err;
}
}
});
// Attach up front: a broken implementation may exit before the reader is
// attached, which is precisely the failure this helper must observe.
const closed = new Promise((res) => shim.on("close", res));
shim.on("error", reject);
setTimeout(() => {
let fixturePid = null;
try {
const out = execFileSync("pgrep", ["-P", String(shim.pid)])
.toString()
.trim();
fixturePid = out ? Number(out.split("\n")[0]) : null;
} catch (err) {
reject(new Error(`could not discover the fixture pid: ${err.message}`));
return;
}
if (!fixturePid) {
reject(new Error("fixture pid not found under the shim"));
return;
}
process.kill(fixturePid, "SIGKILL");
// Keep the pipe unread while run.js queues its diagnostic, then drain it.
setTimeout(() => {
let stderr = "";
shim.stderr.on("data", (chunk) => {
stderr += chunk;
});
closed.then((code) => {
try {
assert.equal(code, 1);
assert.ok(
stderr.includes(expectedDiagnostic),
`diagnostic dropped under stderr backpressure ` +
`(captured ${stderr.length} bytes): ` +
JSON.stringify(stderr.slice(-300))
);
assert.ok(
stderr.includes(expectedPath),
`diagnostic omits its relevant path: ${stderr.slice(-300)}`
);
assert.ok(
!stderr.includes(SENTINEL_TOKEN),
"caller arguments leaked into stderr under backpressure"
);
resolve();
} catch (err) {
reject(err);
}
}, reject);
}, 300);
}, 300);
});
}
describe("run.js flush safety under stderr backpressure", () => {
it(
"keeps the crash-signal diagnostic when stderr is a slow-draining pipe",
BACKPRESSURE_TEST_OPTIONS,
(t) => {
const sandbox = makeSandbox(t);
installRunnableBinary(sandbox);
return expectDiagnosticUnderBackpressure(
t,
sandbox,
["-e", BLOCKING_CHILD_CODE, "--", SENTINEL],
"the native binary was terminated by signal SIGKILL",
sandbox.bin
);
}
);
it(
"keeps the auto-install diagnostic when stderr is a slow-draining pipe",
BACKPRESSURE_TEST_OPTIONS,
(t) => {
const sandbox = makeSandbox(t);
fs.writeFileSync(sandbox.installJs, BLOCKING_CHILD_CODE);
return expectDiagnosticUnderBackpressure(
t,
sandbox,
["--version", SENTINEL],
"Failed to auto-install lark-cli binary",
sandbox.installJs
);
}
);
});