mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants
978 lines
33 KiB
JavaScript
978 lines
33 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');
|
|
let descendant;
|
|
t.after(() => {
|
|
if (descendant)
|
|
try {
|
|
process.kill(descendant, 'SIGKILL');
|
|
} catch {}
|
|
});
|
|
const grandchild =
|
|
'process.on("SIGINT",()=>{});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),100);`;
|
|
const result = await runProcess(process.execPath, ['-e', parent], {
|
|
timeoutMs: 350,
|
|
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);
|
|
},
|
|
);
|
|
|
|
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('ordinary shell tasks share unexported variables and functions across before script and 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,
|
|
'script_function\nprintf "after:%s:%s:%s\\n" "$before_value" "$script_value" "$_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\nscript:changed\nafter:private:changed: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:from_script: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);
|
|
});
|