Files
qinglong/cli/test/internal/local.test.cjs
T
whyour e8b6a30bd9 feat: ship verified platform upgrades with channel bundle releases
Package production dependencies per architecture for online and offline upgrades.
Validate dependencies and isolated backend startup before readiness, repair once
when permitted, and preserve service/data through failed preparation or rollback.

Publish release bundles with master images and expose an opt-in develop build
sharing the development index across Alpine and Debian images.
2026-10-18 22:19:49 +08:00

987 lines
34 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
const { runProcess } = require('../../dist/internal/runtime/process');
const { pruneLogs } = require('../../dist/internal/maintenance/maintenance');
const { parse } = require('../helpers/commands.cjs');
function sandbox(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-local-'));
t.after(() => fs.rmSync(root, { force: true, recursive: true }));
return root;
}
test('native argument schema accepts standard syntax, short options, aliases and scoped help', () => {
const result = parse([
'--json',
'task',
'list',
'--search=a b',
'-p',
'2',
'--size=10',
]);
assert.equal(result.json, true);
assert.equal(result.values.search, 'a b');
assert.equal(result.values.page, '2');
assert.equal(
parse(['login', '--url=https://example.com']).name,
'auth login',
);
assert.match(parse(['task', 'logs', '--help']).help, /--tail/);
assert.doesNotMatch(parse(['task', 'logs', '--help']).help, /--url/);
assert.throws(() => parse(['task', 'list', '-p', '1', '--page', '2']), {
exitCode: 2,
});
});
test('user shell config evaluates variables without polluting the parent environment', async (t) => {
const root = sandbox(t);
const context = createContext(
{ root },
{
PATH: process.env.PATH,
QL_DATA_DIR: path.join(root, 'separate data'),
KEEP: 'parent',
},
);
const config = path.join(root, 'config with spaces.sh');
fs.writeFileSync(
config,
'UnexportedValue="two words"\nexport KEEP="child"\nexport QUOTED="value with = and \\n newline"\n',
);
const env = await sourceEnvironment(context.env, [config]);
assert.equal(env.UnexportedValue, 'two words');
assert.equal(env.KEEP, 'child');
assert.equal(context.env.KEEP, 'parent');
assert.equal(env.dir_scripts, path.join(root, 'separate data/scripts'));
});
test('subprocess argv, output, exit status and bounded capture are independent of shell quoting', async (t) => {
const root = sandbox(t);
const result = await runProcess(
process.execPath,
[
'-e',
'process.stdout.write(JSON.stringify(process.argv.slice(1)));process.exit(7)',
'a b',
'$(touch unwanted)',
'*.js',
],
{ cwd: root, capture: true },
);
assert.equal(result.code, 7);
assert.deepEqual(JSON.parse(result.stdout), [
'a b',
'$(touch unwanted)',
'*.js',
]);
assert.equal(fs.existsSync(path.join(root, 'unwanted')), false);
await assert.rejects(
runProcess(
process.execPath,
['-e', 'process.stdout.write("x".repeat(100000))'],
{ capture: true, maxCaptureBytes: 10 },
),
/capture limit|捕获上限/,
);
});
test('timeout escalates and terminates a process that ignores SIGINT', async () => {
const result = await runProcess(
process.execPath,
['-e', 'process.on("SIGINT",()=>{});setInterval(()=>{},1000)'],
{ capture: true, timeoutMs: 200, graceMs: 100 },
);
assert.equal(result.code, 124);
assert.equal(result.timedOut, true);
});
test('log cleanup respects active references, age and symlink boundaries', async (t) => {
const root = sandbox(t);
const context = createContext({ root });
const requests = [];
const server = http.createServer((req, res) => {
requests.push(req.url);
assert.equal(req.headers.authorization, 'Bearer local-test-token');
const log = new URL(req.url, 'http://localhost').searchParams.get(
'log_path',
);
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
code: 200,
data: log.includes('active') ? { id: 1, name: 'running' } : null,
}),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => server.close());
context.env.QlPort = String(server.address().port);
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
context.paths.file_auth_token,
JSON.stringify({
value: 'local-test-token',
expiration: Date.now() / 1000 + 1000,
}),
);
const logDir = path.join(context.paths.dir_log, 'nested');
fs.mkdirSync(logDir, { recursive: true });
for (const name of [
'2000-01-01-old.log',
'2000-01-01-active.log',
'2999-01-01-future.log',
])
fs.writeFileSync(path.join(logDir, name), name);
const outside = path.join(root, '2000-01-01-outside.log');
fs.writeFileSync(outside, 'keep');
fs.symlinkSync(outside, path.join(logDir, 'link.log'));
const result = await pruneLogs(context, 7);
assert.deepEqual(result.removed, ['nested/2000-01-01-old.log']);
assert.deepEqual(result.retained, ['nested/2000-01-01-active.log']);
assert.equal(fs.existsSync(outside), true);
assert.equal(requests.length, 2);
});
test('task engine runs scripts with exact arguments, log output, failure status and account selection', async (t) => {
const {
executeTask,
selectedAccounts,
durationMs,
} = require('../../dist/internal/execution/taskRunner');
const root = sandbox(t);
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
const script = path.join(context.paths.dir_scripts, 'example.js');
fs.writeFileSync(
script,
'console.log(JSON.stringify({args:process.argv.slice(2),account:process.env.ACCOUNTS,cwd:process.cwd()}));process.exit(7)',
);
const result = await executeTask(context, {
argv: ['example.js'],
scriptArgs: ['space value', '--json'],
mode: 'now',
});
assert.equal(result.exitCode, 7);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /space value/);
assert.match(log, /--json/);
assert.match(log, /退出码 7/);
assert.deepEqual(selectedAccounts('3-1 2 max', 3), [3, 2, 1]);
assert.throws(() => selectedAccounts('0', 3, { QL_LANG: 'en' }), /outside/);
assert.equal(durationMs('1.5m'), 90000);
context.env.ACCOUNTS = 'one&two&three';
const designated = await executeTask(context, {
argv: ['example.js'],
mode: 'desi',
variable: 'ACCOUNTS',
selection: '3 1',
});
assert.match(
fs.readFileSync(
path.join(context.paths.dir_log, designated.logPath),
'utf8',
),
/three&one/,
);
});
test('task log flags keep realtime output off disk and override no_tee', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
for (const flags of [
{},
{ no_tee: 'true' },
{ real_time: 'true' },
{ real_time: 'true', no_tee: 'true' },
]) {
const context = createContext(
{ root: sandbox(t) },
{
PATH: process.env.PATH,
...flags,
},
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'flags.js'),
'console.log("stdout-marker");console.error("stderr-marker");',
);
let output = '';
const result = await executeTask(context, {
argv: ['flags.js'],
mode: 'now',
output: (chunk) => {
output += chunk;
},
});
assert.equal(result.exitCode, 0);
const logFile = path.join(context.paths.dir_log, result.logPath);
if (flags.real_time === 'true') {
assert.equal(fs.existsSync(logFile), false);
assert.equal(fs.existsSync(context.paths.dir_log), false);
} else {
const log = fs.readFileSync(logFile, 'utf8');
assert.match(log, /stdout-marker/);
assert.match(log, /stderr-marker/);
}
if (flags.no_tee === 'true' && flags.real_time !== 'true') {
assert.equal(output, '');
} else {
assert.match(output, /stdout-marker/);
assert.match(output, /stderr-marker/);
}
}
});
test('concurrent accounts finish independently but merge logs in selection order', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = sandbox(t);
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true', ACCOUNTS: 'slow&fast' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'parallel.js'),
`
const account=process.env.ACCOUNTS;
console.log(account+':start');
setTimeout(()=>{console.log(account+':end');process.exit(account==='fast'?7:0)},account==='slow'?200:10);
`,
);
const result = await executeTask(context, {
argv: ['parallel.js'],
mode: 'conc',
variable: 'ACCOUNTS',
selection: '1 2',
});
assert.equal(result.exitCode, 7);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /slow:start\nslow:end\nfast:start\nfast:end/);
assert.deepEqual(fs.readdirSync(context.paths.dir_list_tmp), []);
});
test('concurrent log spool waits for peers and cleans up on invocation failure', async (t) => {
const { orderedConcurrent } = require('../../dist/internal/execution/concurrent');
const root = sandbox(t);
const chunks = [];
let peerFinished = false;
await assert.rejects(
orderedConcurrent(
root,
[1, 2],
async (account, output) => {
if (account === 1) throw new Error('fixture invocation failure');
await new Promise((resolve) => setTimeout(resolve, 30));
output(Buffer.from('peer output'));
peerFinished = true;
},
(chunk) => chunks.push(chunk.toString()),
),
/fixture invocation failure/,
);
assert.equal(peerFinished, true);
assert.equal(chunks.join(''), 'peer output');
assert.deepEqual(fs.readdirSync(root), []);
});
test('failed output sink rejects the process and terminates the writer', async () => {
const { runProcess } = require('../../dist/internal/runtime/process');
await assert.rejects(
runProcess(
process.execPath,
['-e', 'process.stdout.write("data");setInterval(()=>{},1000)'],
{
output: () => {
throw new Error('fixture full disk');
},
timeoutMs: 3000,
},
),
/fixture full disk/,
);
});
test(
'timeout kills descendants holding pipes after their leader has exited',
{ timeout: 5000 },
async (t) => {
if (process.platform === 'win32') return t.skip('POSIX process groups');
const { runProcess } = require('../../dist/internal/runtime/process');
const marker = path.join(sandbox(t), 'timeout');
const controller = new AbortController();
const guard = setTimeout(() => controller.abort(), 4000);
t.after(() => clearTimeout(guard));
let descendant;
t.after(() => {
if (descendant)
try {
process.kill(descendant, 'SIGKILL');
} catch {}
});
// Start the deadline only after the descendant installed its handlers.
// Cold Node startup under parallel load need not fit in 350 milliseconds.
const grandchild =
'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild,
)}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`;
const result = await runProcess(process.execPath, ['-e', parent], {
timeoutMs: 350,
timeoutControl: { marker },
signal: controller.signal,
graceMs: 100,
output: (chunk) => {
const pid = Number(chunk.toString().trim());
if (pid) descendant = pid;
},
});
assert.equal(result.code, 124);
assert.equal(result.timedOut, true);
assert.ok(descendant);
assert.equal(fs.existsSync(marker), true);
},
);
test(
'cancellation remains cancellation after the process leader exits',
{ timeout: 5000 },
async (t) => {
if (process.platform === 'win32') return t.skip('POSIX process groups');
const { runProcess } = require('../../dist/internal/runtime/process');
const controller = new AbortController();
let descendant;
t.after(() => {
if (descendant)
try {
process.kill(descendant, 'SIGKILL');
} catch {}
});
const grandchild =
'process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)';
const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
grandchild,
)}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),50);`;
const timer = setTimeout(() => controller.abort(), 300);
t.after(() => clearTimeout(timer));
const result = await runProcess(process.execPath, ['-e', parent], {
signal: controller.signal,
graceMs: 100,
output: (chunk) => {
descendant = Number(chunk.toString().trim());
},
});
assert.equal(result.code, 143);
assert.equal(result.timedOut, false);
},
);
test('already cancelled execution never starts the requested program', async (t) => {
const { runProcess } = require('../../dist/internal/runtime/process');
const root = sandbox(t),
marker = path.join(root, 'must-not-exist');
for (const reason of [
undefined,
'SIGINT',
'SIGHUP',
'SIGTERM',
'SIGKILL',
new Error('cancel'),
]) {
const controller = new AbortController();
controller.abort(reason);
const result = await runProcess(
process.execPath,
[
'-e',
`require('node:fs').writeFileSync(${JSON.stringify(marker)},'started')`,
],
{ signal: controller.signal },
);
const expectedSignal =
reason === 'SIGINT' || reason === 'SIGHUP' ? reason : 'SIGTERM';
assert.equal(result.code, 128 + os.constants.signals[expectedSignal]);
assert.equal(result.signal, expectedSignal);
assert.equal(fs.existsSync(marker), false);
}
});
test('hook exports reach scripts and after hooks share shell state with inline commands', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = sandbox(t);
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
context.paths.file_task_before,
`export FROM_BEFORE=before\ntask_before='export FROM_INLINE=inline'\n`,
);
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'hooks.js'),
'console.log(process.env.FROM_BEFORE+":"+process.env.FROM_INLINE);process.exit(7)',
);
fs.writeFileSync(
context.paths.file_task_after,
`local_value=after\nhook_function() { printf 'hook:%s:%s:%s:%s\\n' "$FROM_BEFORE" "$FROM_INLINE" "$local_value" "$_task_exit_code"; }\ntask_after=hook_function\n`,
);
const result = await executeTask(context, {
argv: ['hooks.js'],
mode: 'now',
});
assert.equal(result.exitCode, 7);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /before:inline/);
assert.match(log, /hook:before:inline:after:7/);
});
test('shell tasks inherit before-hook state without leaking script state into after hooks', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = sandbox(t);
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
context.paths.file_task_before,
'before_value=private\nbefore_function() { printf "before:%s\\n" "$before_value"; }\n',
);
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'shared.sh'),
'before_function\nprintf "arg:%s\\n" "$1"\nscript_value=changed\nscript_function() { printf "script:%s\\n" "$script_value"; }\nreturn 7\n',
);
fs.writeFileSync(
context.paths.file_task_after,
'if declare -F script_function >/dev/null; then exit 99; fi\nprintf "after:%s:%s:%s\\n" "$before_value" "${script_value-unset}" "$_task_exit_code"\n',
);
const result = await executeTask(context, {
argv: ['shared.sh'],
scriptArgs: ['space value'],
mode: 'now',
});
assert.equal(result.exitCode, 7);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(
log,
/before:private\narg:space value\nafter:private:unset:7/,
);
});
test('task resolves modules from pnpm global root and restores the previous path for after hooks', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = sandbox(t),
bin = path.join(root, 'bin'),
global = path.join(root, 'global modules');
fs.mkdirSync(bin);
fs.mkdirSync(path.join(global, 'fixture-module'), { recursive: true });
fs.writeFileSync(
path.join(global, 'fixture-module/index.js'),
'module.exports="global module loaded"',
);
fs.writeFileSync(
path.join(bin, 'pnpm'),
`#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(global)});`,
{ mode: 0o755 },
);
const context = createContext(
{ root },
{ PATH: `${bin}:/usr/bin:/bin`, NODE_PATH: '/previous', no_tee: 'true' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'dependency.js'),
'console.log(require("fixture-module"));console.log(process.env.QL_NODE_GLOBAL_PATH)',
);
fs.writeFileSync(
context.paths.file_task_after,
'printf "after-path:%s:%s\\n" "$NODE_PATH" "${QL_NODE_GLOBAL_PATH-unset}"',
);
const result = await executeTask(context, {
argv: ['dependency.js'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /global module loaded/);
assert.ok(log.includes(global));
assert.match(log, /after-path:\/previous:unset/);
assert.equal(context.env.NODE_PATH, '/previous');
assert.equal(context.env.QL_NODE_GLOBAL_PATH, undefined);
});
test('missing optional pnpm still provides user dependency resolution', async (t) => {
const { taskDependencyEnvironment } = require('../../dist/internal/execution/dependencies');
const context = createContext(
{ root: sandbox(t) },
{
PATH: '/missing-bin',
NODE_PATH: '/previous',
QL_NODE_GLOBAL_PATH: '/stale',
},
);
const env = await taskDependencyEnvironment(context);
assert.equal(
env.NODE_PATH,
`/previous${path.delimiter}${context.paths.dir_dep}`,
);
assert.equal(env.QL_NODE_GLOBAL_PATH, undefined);
assert.equal(context.env.QL_NODE_GLOBAL_PATH, '/stale');
});
test('missing script directory never falls back to an unrelated same-name script', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const context = createContext(
{ root: sandbox(t) },
{ PATH: process.env.PATH, no_tee: 'true' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'same.js'),
'console.log("WRONG SCRIPT EXECUTED")',
);
const result = await executeTask(context, {
argv: ['missing/same.js'],
mode: 'now',
});
assert.notEqual(result.exitCode, 0);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.doesNotMatch(log, /WRONG SCRIPT EXECUTED/);
});
test('nested script paths and explicit working directories preserve legacy selection and exact arguments', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = fs.realpathSync(sandbox(t));
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true' },
);
const nested = path.join(context.paths.dir_scripts, 'space directory');
const work = path.join(context.paths.dir_scripts, 'work');
fs.mkdirSync(nested, { recursive: true });
fs.mkdirSync(work);
const script =
'console.log("RESULT:"+JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}))';
fs.writeFileSync(path.join(nested, 'file.js'), script);
fs.writeFileSync(path.join(work, 'file.js'), script);
for (const [argv, working, expected] of [
[['space directory/file.js'], undefined, nested],
[[path.join(nested, 'file.js')], undefined, nested],
[['space directory/file.js'], 'work', work],
]) {
context.env.work_dir = working;
const result = await executeTask(context, {
argv,
scriptArgs: ['a b', '--flag=value'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
const record = JSON.parse(
log
.split('\n')
.find((line) => line.startsWith('RESULT:'))
.slice(7),
);
assert.deepEqual(record, { cwd: expected, args: ['a b', '--flag=value'] });
}
});
test('task lifecycle logs honor QL_LANG with Chinese fallback', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const { translate } = require('../../dist/shared/i18n');
const root = sandbox(t);
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true', QL_LANG: 'en' },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'language.js'),
'process.exit(3)',
);
const result = await executeTask(context, {
argv: ['language.js'],
mode: 'now',
});
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /## Starting/);
assert.match(log, /Failed.*exit code 3/);
assert.equal(translate({ QL_LANG: 'unknown' }, '完成'), '完成');
assert.equal(translate({ QL_LANG: 'en' }, '完成'), 'Completed');
assert.equal(
translate({ QL_LANG: 'en' }, 'unknown %s %%', 'value'),
'unknown value %',
);
});
test('designated shell accounts retain hook functions and script state in a shared session', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const context = createContext(
{ root: sandbox(t) },
{ PATH: process.env.PATH, no_tee: 'true' },
);
for (const dir of [
context.paths.dir_scripts,
context.paths.dir_config,
context.paths.dir_preload,
])
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
context.paths.file_env,
'export ACCOUNTS="first&second&third"\n',
);
fs.writeFileSync(
context.paths.file_task_before,
'private_function() { printf "selected:%s\\n" "$ACCOUNTS"; }\n',
);
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'accounts.sh'),
'private_function\nprivate_result=from_script\nreturn 4\n',
);
fs.writeFileSync(
context.paths.file_task_after,
'printf "after:%s:%s:%s\\n" "$ACCOUNTS" "$private_result" "$_task_exit_code"\n',
);
const result = await executeTask(context, {
argv: ['accounts.sh'],
mode: 'desi',
variable: 'ACCOUNTS',
selection: '3 1',
});
assert.equal(result.exitCode, 4);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /selected:third&first\nafter:third&first::4/);
});
test('lifecycle reports share execution identity and statistics survive a rejected final status', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const context = createContext(
{ root: sandbox(t) },
{
PATH: process.env.PATH,
no_tee: 'true',
QL_CLI_LIFECYCLE: 'extended',
ID: '12',
QL_EXECUTION_ORIGIN: 'scheduled_system',
},
);
const requests = [];
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString());
requests.push({ url: req.url, method: req.method, body });
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({ code: body.status === '1' ? 500 : 200, data: {} }),
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
context.env.QlPort = String(server.address().port);
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(
context.paths.file_auth_token,
JSON.stringify({
value: 'fixture-token',
expiration: Date.now() / 1000 + 1000,
}),
);
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'failed.js'),
'process.exit(9)',
);
const result = await executeTask(context, {
argv: ['failed.js'],
mode: 'now',
});
assert.equal(result.exitCode, 9);
assert.equal(requests.length, 3);
const [start, end, stat] = requests;
assert.equal(start.url, '/open/crons/status');
assert.equal(end.url, start.url);
assert.equal(start.method, 'PUT');
assert.equal(end.method, 'PUT');
assert.deepEqual(start.body.ids, [12]);
assert.equal(start.body.status, '0');
assert.equal(end.body.status, '1');
assert.equal(start.body.exit_code, undefined);
assert.equal(end.body.exit_code, 9);
assert.equal(start.body.execution_id, end.body.execution_id);
assert.equal(end.body.execution_id, result.executionId);
assert.match(result.executionId, /^legacy-system:\d+:[0-9a-f-]{36}$/);
assert.equal(start.body.log_path, result.logPath);
assert.equal(end.body.last_execution_time, start.body.last_execution_time);
assert.equal(stat.url, '/open/dashboard/record');
assert.equal(stat.method, 'POST');
assert.deepEqual(stat.body, {
ref_id: 12,
code: 9,
elapsed: result.durationSeconds,
});
assert.match(
fs.readFileSync(path.join(context.paths.dir_log, result.logPath), 'utf8'),
/任务状态上报失败/,
);
});
test('configuration functions survive the environment bridge without sourcing config twice', async (t) => {
const { localContext } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = fs.realpathSync(sandbox(t));
const initial = createContext({ root });
fs.mkdirSync(initial.paths.dir_config, { recursive: true });
fs.mkdirSync(initial.paths.dir_scripts, { recursive: true });
const marker = path.join(root, 'config-loads');
fs.writeFileSync(
initial.paths.file_config_user,
`no_tee=true\nprintf 'loaded\\n' >> '${marker}'\nconfiguration_function() { printf 'configuration-function\\n'; }\n`,
);
fs.writeFileSync(
path.join(initial.paths.dir_scripts, 'configuration.sh'),
'configuration_function\n',
);
const context = await localContext({
values: { root },
positionals: ['configuration.sh'],
});
const result = await executeTask(context, {
argv: ['configuration.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
assert.equal(fs.readFileSync(marker, 'utf8'), 'loaded\n');
assert.match(
fs.readFileSync(path.join(context.paths.dir_log, result.logPath), 'utf8'),
/configuration-function/,
);
});
test('concurrent shell tasks inherit hook functions but isolate script state and run after hook once', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const context = createContext(
{ root: fs.realpathSync(sandbox(t)) },
{ PATH: process.env.PATH, no_tee: 'true', ACCOUNTS: 'one&two' },
);
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
const marker = path.join(context.root, 'hooks');
fs.writeFileSync(
context.paths.file_task_before,
`printf 'before\\n' >> '${marker}'\nshared_value=before\nhook_function() { printf 'account:%s:%s\\n' "$ACCOUNTS" "$shared_value"; }\n`,
);
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'concurrent.sh'),
'hook_function\nshared_value=script_changed\n',
);
fs.writeFileSync(
context.paths.file_task_after,
`printf 'after:%s\\n' "$shared_value" >> '${marker}'\n`,
);
const result = await executeTask(context, {
argv: ['concurrent.sh'],
mode: 'conc',
variable: 'ACCOUNTS',
});
assert.equal(result.exitCode, 0);
assert.equal(fs.readFileSync(marker, 'utf8'), 'before\nafter:before\n');
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.match(log, /account:one:before\naccount:two:before/);
});
test('cancelling after hooks preserves the signal and final task status', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
for (const extension of ['js', 'sh']) {
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
await t.test(`${extension}: ${signal}`, async () => {
const context = createContext(
{ root: sandbox(t) },
{ PATH: process.env.PATH },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, `done.${extension}`),
extension === 'js' ? 'process.exit(7)' : 'return 7\n',
);
fs.writeFileSync(
context.paths.file_task_after,
"trap 'echo received:SIGINT; exit 23' INT\n" +
"trap 'echo received:SIGTERM; exit 23' TERM\n" +
"trap 'echo received:SIGHUP; exit 23' HUP\n" +
'echo after-ready:$_task_exit_code\nwhile :; do sleep 0.1; done\n',
);
const controller = new AbortController();
let output = '',
cancelled = false;
const timer = setTimeout(() => controller.abort(), 5000);
try {
const result = await executeTask(context, {
argv: [`done.${extension}`],
mode: 'now',
signal: controller.signal,
output: (chunk) => {
output += chunk;
if (!cancelled && output.includes('after-ready:7')) {
cancelled = true;
controller.abort(signal);
}
},
});
assert.equal(cancelled, true, output);
assert.equal(result.exitCode, 128 + os.constants.signals[signal]);
assert.equal(result.timedOut, false);
assert.deepEqual(output.match(/received:SIG\w+/g), [
`received:${signal}`,
]);
const log = fs.readFileSync(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.ok(log.includes(`退出码 ${result.exitCode}`));
} finally {
clearTimeout(timer);
}
});
}
}
});
test('cancelled script does not start an independent after hook', async (t) => {
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const context = createContext(
{ root: sandbox(t) },
{ PATH: process.env.PATH },
);
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'cancel.js'),
'console.log("script-ready");setInterval(()=>{},1000)',
);
fs.writeFileSync(context.paths.file_task_after, 'echo after-must-not-run\n');
const controller = new AbortController();
let output = '',
cancelled = false;
const timer = setTimeout(() => controller.abort(), 5000);
try {
const result = await executeTask(context, {
argv: ['cancel.js'],
mode: 'now',
signal: controller.signal,
output: (chunk) => {
output += chunk;
if (!cancelled && output.includes('script-ready')) {
cancelled = true;
controller.abort('SIGTERM');
}
},
});
assert.equal(cancelled, true);
assert.equal(result.exitCode, 143);
assert.doesNotMatch(output, /after-must-not-run/);
} finally {
clearTimeout(timer);
}
});
test('fd3 capture keeps configuration data separate and enforces its own size limit', async () => {
let output = '';
const result = await runProcess(
process.execPath,
[
'-e',
'console.log("user-output");console.error("user-error");require("fs").writeSync(3,"private-data")',
],
{
capture: true,
captureFd: 3,
maxCaptureBytes: 12,
output: (chunk) => {
output += chunk;
},
},
);
assert.equal(result.code, 0);
assert.equal(result.stdout, 'private-data');
assert.match(output, /user-output/);
assert.match(output, /user-error/);
assert.doesNotMatch(output, /private-data/);
await assert.rejects(
runProcess(
process.execPath,
[
'-e',
'require("fs").writeSync(3,Buffer.alloc(1024));setInterval(()=>{},1000)',
],
{
capture: true,
captureFd: 3,
maxCaptureBytes: 64,
output: () => {},
},
),
/capture limit|捕获上限/,
);
});
test('pre-cancelled configuration is never sourced', async (t) => {
const root = sandbox(t),
file = path.join(root, 'config.sh'),
marker = path.join(root, 'marker');
fs.writeFileSync(file, 'touch "$MARKER"\n');
const controller = new AbortController();
controller.abort('SIGINT');
await assert.rejects(
sourceEnvironment({ PATH: process.env.PATH, MARKER: marker }, [file], [], {
signal: controller.signal,
}),
{ name: 'AbortError', code: 'ABORT_ERR' },
);
assert.equal(fs.existsSync(marker), false);
});