Files
qinglong/cli/test/integration/differential.test.cjs
T
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* 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
2026-09-25 23:24:41 +08:00

571 lines
19 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { execFileSync, spawnSync } = require('node:child_process');
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('unmodified legacy Shell and TS agree on shell hook state and account modes', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.symlink(process.execPath, path.join(bin, 'node'));
await fs.writeFile(path.join(bin, 'pnpm'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
const context = createContext(
{ root },
{ PATH: `${bin}:/usr/local/bin:/usr/bin:/bin`, no_tee: 'true' },
);
for (const directory of [
context.paths.dir_shell,
context.paths.dir_preload,
context.paths.dir_config,
context.paths.dir_scripts,
context.paths.dir_log,
path.join(root, 'static/build'),
])
await fs.mkdir(directory, { recursive: true });
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
await fs.copyFile(
path.resolve(__dirname, '../../../shell', name),
path.join(context.paths.dir_shell, name),
);
await fs.mkdir(path.join(context.paths.dir_shell, 'lang'));
for (const name of ['zh.sh', 'en.sh'])
await fs.copyFile(
path.resolve(__dirname, '../../../shell/lang', name),
path.join(context.paths.dir_shell, 'lang', name),
);
await fs.writeFile(
path.join(root, 'static/build/token.js'),
'process.stdout.write("fixture-local-token")',
);
await fs.writeFile(
context.paths.file_config_user,
'no_tee=true\nconfiguration_function() { printf "function"; }\n',
);
await fs.writeFile(context.paths.file_env, 'export ACCOUNTS="one&two"\n');
await fs.writeFile(context.paths.list_crontab_user, '');
await fs.writeFile(
context.paths.file_task_before,
'state=before\nprintf "before\\n" >> "$TRACE"\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after:%s:%s\\n" "$state" "$_task_exit_code" >> "$TRACE"\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'fixture.sh'),
'printf "run:%s:%s:%s\\n" "$ACCOUNTS" "$state" "$(configuration_function)" >> "$TRACE"\nstate=script\n',
);
for (const mode of ['now', 'desi', 'conc'])
await t.test(mode, async () => {
const legacy = path.join(root, `${mode}-legacy`),
modern = path.join(root, `${mode}-modern`);
const args =
mode === 'now'
? ['fixture.sh', 'now']
: ['fixture.sh', mode, 'ACCOUNTS', '2', '1'];
execFileSync(
'/bin/bash',
[path.join(context.paths.dir_shell, 'task.sh'), ...args],
{
cwd: root,
env: { ...context.env, TRACE: legacy },
timeout: 15000,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const env = await sourceEnvironment({ ...context.env, TRACE: modern }, [
context.paths.file_config_user,
]);
const result = await executeTask(
{ ...context, env },
{
argv: ['fixture.sh'],
mode,
variable: mode === 'now' ? undefined : 'ACCOUNTS',
selection: mode === 'now' ? undefined : '2 1',
},
);
assert.equal(result.exitCode, 0);
const oldLines = (await fs.readFile(legacy, 'utf8')).trim().split('\n');
const newLines = (await fs.readFile(modern, 'utf8')).trim().split('\n');
assert.equal(oldLines[0], 'before');
assert.equal(newLines[0], 'before');
assert.equal(newLines.at(-1), oldLines.at(-1));
assert.deepEqual(
newLines.slice(1, -1).sort(),
oldLines.slice(1, -1).sort(),
);
});
await t.test(
'Shell failure, explicit exit and EXIT traps preserve hook behavior',
async () => {
const cases = [
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
{ name: 'exit', script: 'exit 7', code: 7, legacy: 7, after: false },
{
name: 'errexit',
script: 'set -e; false',
code: 1,
legacy: 1,
after: false,
},
{
name: 'trap-return',
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; return 7',
code: 7,
legacy: 0,
after: true,
trap: true,
},
{
name: 'trap-exit',
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
code: 7,
legacy: 7,
after: false,
trap: true,
},
];
await fs.writeFile(context.paths.file_config_user, 'no_tee=true\n');
await fs.writeFile(
context.paths.file_task_before,
'printf "before\\n" >> "$TRACE"\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after:%s\\n" "$_task_exit_code" >> "$TRACE"\n',
);
for (const item of cases) {
await fs.writeFile(
path.join(context.paths.dir_scripts, 'failure.sh'),
item.script + '\n',
);
const legacyTrace = path.join(root, `${item.name}-old`);
const modernTrace = path.join(root, `${item.name}-new`);
const legacy = spawnSync(
'/bin/bash',
[path.join(context.paths.dir_shell, 'task.sh'), 'failure.sh', 'now'],
{
cwd: root,
env: { ...context.env, TRACE: legacyTrace },
encoding: 'utf8',
timeout: 15000,
},
);
assert.ifError(legacy.error);
assert.equal(legacy.status, item.legacy, item.name);
const env = await sourceEnvironment(
{ ...context.env, TRACE: modernTrace },
[context.paths.file_config_user],
);
const result = await executeTask(
{ ...context, env },
{ argv: ['failure.sh'], mode: 'now' },
);
assert.equal(result.exitCode, item.code, item.name);
const expected = [
'before',
...(item.after ? [`after:${item.code}`] : []),
...(item.trap ? ['exit-trap'] : []),
];
for (const file of [legacyTrace, modernTrace])
assert.deepEqual(
(await fs.readFile(file, 'utf8')).trim().split('\n'),
expected,
`${item.name}: ${file}`,
);
}
},
);
await t.test('/dev/null preserves legacy stream routing', async () => {
await fs.writeFile(
context.paths.file_task_before,
'printf "before-out-marker\\n"; printf "before-err-marker\\n" >&2\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after-out-marker\\n"; printf "after-err-marker\\n" >&2\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'streams.sh'),
'printf "child-out-marker\\n"; printf "child-err-marker\\n" >&2\n',
);
for (const mode of ['now', 'desi', 'conc']) {
for (const realtime of [false, true]) {
await fs.writeFile(
context.paths.file_config_user,
`log_name=/dev/null\nno_tee=true\nreal_time=${realtime}\n`,
);
const legacy = spawnSync(
'/bin/bash',
[
path.join(context.paths.dir_shell, 'task.sh'),
'streams.sh',
mode,
...(mode === 'now' ? [] : ['ACCOUNTS', '2', '1']),
],
{ cwd: root, env: context.env, encoding: 'utf8', timeout: 15000 },
);
assert.ifError(legacy.error);
assert.equal(legacy.status, 0, legacy.stderr);
const env = await sourceEnvironment(context.env, [
context.paths.file_config_user,
]);
let output = '';
const result = await executeTask(
{ ...context, env },
{
argv: ['streams.sh'],
mode,
variable: mode === 'now' ? undefined : 'ACCOUNTS',
selection: mode === 'now' ? undefined : '2 1',
output: (chunk) => {
output += chunk;
},
},
);
assert.equal(result.exitCode, 0);
assert.equal(result.logPath, '/dev/null');
const markers = (text) =>
text.match(/(?:before|after|child)-(?:out|err)-marker/g) ?? [];
assert.deepEqual(markers(legacy.stdout), []);
assert.deepEqual(
markers(output),
markers(legacy.stderr),
`${mode}, realtime=${realtime}`,
);
assert.ok(markers(output).includes('before-err-marker'));
assert.equal(
markers(output).includes('child-err-marker'),
mode !== 'conc',
);
}
}
});
});
test('empty ignored-minute settings match legacy random delay at minute zero', async (t) => {
const source = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const legacyFunction = source.match(/random_delay\(\) \{[\s\S]*?\n\}/)[0];
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-parity-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/scripts/fixture.sh'),
'printf TASK_RAN',
);
const OriginalDate = global.Date;
class MinuteZeroDate extends OriginalDate {
getMinutes() {
return 0;
}
}
global.Date = MinuteZeroDate;
t.after(() => {
global.Date = OriginalDate;
});
for (const ignored of [undefined, '', ' ', '0', ' 30 ']) {
const env = {
PATH: process.env.PATH,
RandomDelay: '1',
RandomDelayFileExtensions: 'sh',
...(ignored === undefined ? {} : { RandomDelayIgnoredMinutes: ignored }),
};
const legacy = execFileSync(
'/bin/bash',
[
'-c',
`${legacyFunction}
date() { printf 0; }
gen_random_num() { printf 0; }
t() { :; }
sleep() { printf DELAYED; }
random_delay fixture.sh
`,
],
{ env, encoding: 'utf8' },
);
let output = '';
const result = await executeTask(createContext({ root }, env), {
argv: ['fixture.sh'],
output: (chunk) => {
output += chunk.toString();
},
});
assert.equal(result.exitCode, 0, output);
assert.match(output, /TASK_RAN/);
assert.equal(
output.includes('任务随机延迟'),
legacy.includes('DELAYED'),
`ignored=${JSON.stringify(ignored)}`,
);
}
});
test('random delay follows legacy dispatch for script arguments and executable commands', async (t) => {
const source = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const functions = ['random_delay', 'run_normal', 'main']
.map(
(name) =>
source.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\}`))[0],
)
.join('\n');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-dispatch-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.writeFile(
path.join(root, 'data/scripts/fixture.sh'),
'printf TASK_RAN',
);
const env = {
PATH: process.env.PATH,
RandomDelay: '1',
RandomDelayFileExtensions: '',
RandomDelayIgnoredMinutes: '99',
};
for (const args of [
['fixture.sh'],
['fixture.sh', 'argument'],
['fixture.sh', 'now'],
['true'],
]) {
const legacy = execFileSync(
'/bin/bash',
[
'-c',
`${functions}
date() { printf 0; }
gen_random_num() { printf 0; }
t() { :; }
sleep() { printf DELAYED; }
enter_script_workdir() { :; }
run_else() { :; }
which_program=true
main "$@"
`,
'fixture',
...args,
],
{ env, encoding: 'utf8' },
);
let output = '';
const parsed = require('../../dist/runner').parseExecution(args);
const result = await executeTask(createContext({ root }, env), {
...parsed.execution,
output: (chunk) => {
output += chunk.toString();
},
});
assert.equal(result.exitCode, 0, output);
assert.equal(
output.includes('任务随机延迟'),
legacy.includes('DELAYED'),
JSON.stringify(args),
);
}
});
test('configuration shell options survive the environment bridge for pipeline and glob behavior', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-options-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
const probe =
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; eval "$2"',
'fixture',
config,
probe,
],
{ env: base, encoding: 'utf8' },
);
const env = await sourceEnvironment(base, [config]);
assert.ok(env.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!env.SHELLOPTS.split(':').includes('allexport'));
// Bash 3.x cannot import BASHOPTS itself; exercise the actual CLI bridge.
const execution = await require('../../dist/internal/runtime/process').runProcess(
'bash', ['--noprofile', '--norc', '-c', probe], { env, capture: true },
);
assert.equal(execution.code, 0);
const modern = execution.stdout;
assert.equal(modern, legacy);
assert.match(modern, /pipeline=1/);
const context = createContext({ root }, { ...env, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.writeFile(
path.join(context.paths.dir_scripts, 'pipeline.sh'),
'false | true\n',
);
const result = await executeTask(context, {
argv: ['pipeline.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 1);
});
test('configuration errexit and nounset retain task and after-hook semantics', async (t) => {
for (const option of ['errexit', 'nounset']) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-task-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, `set -o ${option}\n`);
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
assert.ok(env.SHELLOPTS.split(':').includes(option));
const context = createContext({ root }, { ...env, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.mkdir(context.paths.dir_config, { recursive: true });
const marker = path.join(root, 'after');
context.env.AFTER_MARKER = marker;
await fs.writeFile(
context.paths.file_task_after,
'printf "%s" "$-" > "$AFTER_MARKER"\n',
);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'options.sh'),
option === 'errexit'
? 'false\nprintf SHOULD_NOT_RUN\n'
: 'printf "%s" "$QL_UNSET_FIXTURE"\n',
);
const result = await executeTask(context, {
argv: ['options.sh'],
mode: 'now',
});
if (option === 'errexit') {
assert.equal(result.exitCode, 1);
await assert.rejects(fs.access(marker));
assert.doesNotMatch(
await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
),
/SHOULD_NOT_RUN/,
);
} else {
assert.equal(result.exitCode, 0);
assert.match(await fs.readFile(marker, 'utf8'), /u/);
}
}
});
test('later hooks can disable previously enabled shell options without mutating the prior snapshot', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-reset-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
const hook = path.join(root, 'before.sh');
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
await fs.writeFile(hook, 'set +o pipefail\nshopt -u nullglob\n');
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
const enabled = await sourceEnvironment(base, [config]);
const disabled = await sourceEnvironment(enabled, [hook]);
assert.ok(enabled.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!disabled.SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!disabled.BASHOPTS.split(':').includes('nullglob'));
const probe =
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; . "$2"; eval "$3"',
'fixture',
config,
hook,
probe,
],
{ env: base, encoding: 'utf8' },
);
const modern = execFileSync('bash', ['--noprofile', '--norc', '-c', probe], {
env: disabled,
encoding: 'utf8',
});
assert.equal(modern, legacy);
assert.equal(modern, 'pipeline=0\nmatches=1\n');
const context = createContext({ root }, { ...enabled, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.mkdir(context.paths.dir_config, { recursive: true });
await fs.copyFile(hook, context.paths.file_task_before);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'pipeline.sh'),
'false | true\n',
);
await fs.writeFile(
context.paths.file_task_after,
'false | true; printf "%s" "$?" > "$AFTER_RESULT"\n',
);
context.env.AFTER_RESULT = path.join(root, 'after-result');
const result = await executeTask(context, {
argv: ['pipeline.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
assert.equal(await fs.readFile(context.env.AFTER_RESULT, 'utf8'), '0');
});
test('disabled default shell options stay disabled across configuration and task bridges', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-default-options-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const config = path.join(root, 'config.sh');
await fs.writeFile(config, 'set +o braceexpand\nshopt -u extquote\n');
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
const refreshed = await sourceEnvironment(env, []);
assert.ok(!refreshed.SHELLOPTS.split(':').includes('braceexpand'));
assert.ok(!refreshed.BASHOPTS.split(':').includes('extquote'));
const context = createContext({ root }, { ...refreshed, no_tee: 'true' });
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
const script =
'printf "%s\\n" {a,b}; shopt -q extquote && printf WRONG; true\n';
await fs.writeFile(
path.join(context.paths.dir_scripts, 'options.sh'),
script,
);
const legacy = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
'. "$1"; eval "$2"',
'fixture',
config,
script,
],
{ env: { PATH: process.env.PATH }, encoding: 'utf8' },
);
assert.equal(legacy, '{a,b}\n');
const result = await executeTask(context, {
argv: ['options.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 0);
const log = await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.ok(log.includes(legacy));
assert.doesNotMatch(log, /WRONG/);
});