mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-30 04:31:11 +08:00
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
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user