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:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+237
View File
@@ -0,0 +1,237 @@
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 { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-proc-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const proc = path.join(root, 'proc');
const panel = path.join(root, 'panel with spaces'),
other = path.join(root, 'other');
const entry = path.join(panel, 'static/build/app.js');
for (const base of [panel, other]) {
await fs.mkdir(path.join(base, 'static/build'), { recursive: true });
await fs.writeFile(path.join(base, 'static/build/app.js'), '');
}
await fs.mkdir(proc);
const alias = path.join(root, 'alias');
await fs.symlink(panel, alias);
async function processFixture(pid, args, cwd = panel) {
const directory = path.join(proc, String(pid));
await fs.mkdir(directory);
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
await fs.symlink(cwd, path.join(directory, 'cwd'));
}
// Use synthetic PIDs far outside ordinary host ranges; no signals are sent.
await processFixture(900001, ['node', 'static/build/app.js']);
await processFixture(900002, ['/usr/bin/node', entry], other);
await processFixture(900003, ['node', './static/build/app.js'], alias);
await processFixture(900004, ['node', 'static/build/app.js'], other);
await processFixture(900005, ['node', '-e', 'static/build/app.js']);
await processFixture(900006, ['bash', entry]);
await processFixture(900007, ['node', entry, 'extra']);
await processFixture(900008, ['node', 'missing.js']);
await processFixture(
900009,
['node', 'static/build/app.js'],
path.join(root, 'gone'),
);
await processFixture(process.pid, ['node', entry]);
await fs.mkdir(path.join(proc, '900010')); // Exited before cmdline could be read.
await fs.mkdir(path.join(proc, 'self'));
assert.deepEqual(
(await findDirectBackendPids(entry, proc)).sort(),
[900001, 900002, 900003],
);
assert.deepEqual(
await findDirectBackendPids(path.join(root, 'missing/app.js'), proc),
[],
);
});
test(
'real Linux stop and direct startup isolate panel installations',
{
skip: process.platform !== 'linux',
timeout: 15000,
},
async (t) => {
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { createContext } = require('../../dist/internal/runtime/context');
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
const children = [];
let fallbackPid;
t.after(async () => {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await once(child, 'close');
}
}
if (fallbackPid) {
try {
process.kill(fallbackPid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
});
const contexts = [];
for (const name of ['panel with spaces', 'other-panel']) {
await fs.mkdir(path.join(root, name));
const context = createContext(
{ root: path.join(root, name) },
{ PATH: '' },
);
contexts.push(context);
const entry = path.join(context.paths.dir_static, 'build/app.js');
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
'process.on("SIGTERM",()=>{console.log("terminated");process.exit(0)});console.log("ready");setInterval(()=>{},1000)',
);
const child = spawn(process.execPath, ['static/build/app.js'], {
cwd: context.root,
stdio: ['ignore', 'pipe', 'pipe'],
});
children.push(child);
await once(child.stdout, 'data');
assert.equal(child.exitCode, null);
}
const [target, other] = contexts;
const entry = path.join(target.paths.dir_static, 'build/app.js');
assert.deepEqual(await findDirectBackendPids(entry), [children[0].pid]);
const stopped = once(children[0], 'close');
await stopPanel(target);
assert.deepEqual(await stopped, [0, null]);
assert.equal(children[1].exitCode, null);
assert.equal(children[1].signalCode, null);
assert.deepEqual(
await findDirectBackendPids(
path.join(other.paths.dir_static, 'build/app.js'),
),
[children[1].pid],
);
const started = await startPanel(target);
fallbackPid = started.pid;
assert.equal(started.manager, 'node');
assert.ok(Number.isSafeInteger(fallbackPid));
assert.deepEqual(await findDirectBackendPids(entry), [fallbackPid]);
assert.match(
await fs.readFile(
path.join(target.paths.dir_log, 'qinglong.log'),
'utf8',
),
/ready/,
);
await stopPanel(target);
const deadline = Date.now() + 2000;
while ((await findDirectBackendPids(entry)).length && Date.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(await findDirectBackendPids(entry), []);
fallbackPid = undefined;
assert.equal(children[1].exitCode, null);
},
);
test(
'Linux direct restart waits for port release and refuses an unresponsive old service',
{
skip: process.platform !== 'linux',
timeout: 20000,
},
async (t) => {
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const { createContext } = require('../../dist/internal/runtime/context');
const { startPanel } = require('../../dist/internal/maintenance/operator');
for (const mode of ['delayed', 'ignore']) {
await t.test(mode, async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-port-handoff-'),
);
const context = createContext(
{ root },
{ PATH: '', MODE: mode, QL_LANG: 'en' },
);
const entry = path.join(context.paths.dir_static, 'build/app.js');
const marker = path.join(root, 'starts');
context.env.STARTS = marker;
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(
entry,
'require("fs").appendFileSync(process.env.STARTS,"start\\n");' +
'const server=require("http").createServer((q,s)=>s.end(String(process.pid)));' +
'process.on("SIGTERM",()=>{if(process.env.MODE!=="ignore")setTimeout(()=>server.close(()=>process.exit(0)),400)});' +
'server.listen(Number(process.env.PORT||0),"127.0.0.1",()=>console.log(server.address().port));',
);
const old = spawn(process.execPath, ['static/build/app.js'], {
cwd: root,
env: context.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let pid;
try {
const [chunk] = await once(old.stdout, 'data');
context.env.PORT = chunk.toString().trim();
assert.match(context.env.PORT, /^\d+$/);
if (mode === 'ignore') {
await assert.rejects(startPanel(context), /did not stop/);
assert.equal(await fs.readFile(marker, 'utf8'), 'start\n');
assert.equal(old.exitCode, null);
} else {
const result = await startPanel(context);
pid = result.pid;
assert.equal(result.manager, 'node');
assert.equal(old.exitCode, 0);
const response = await fetch(
`http://127.0.0.1:${context.env.PORT}`,
);
assert.equal(await response.text(), String(pid));
assert.equal(await fs.readFile(marker, 'utf8'), 'start\nstart\n');
}
} finally {
if (old.exitCode === null && old.signalCode === null) {
old.kill('SIGKILL');
await once(old, 'close');
}
if (pid) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
await fs.rm(root, { recursive: true, force: true });
}
});
}
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(
`direct backend startup failure retains localized exit detail: ${language}`,
{ skip: process.platform !== 'linux' },
async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-start-language-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const { createContext } = require('../../dist/internal/runtime/context');
const { startPanel } = require('../../dist/internal/maintenance/operator');
const context = createContext({ root }, { PATH: '', QL_LANG: language });
const entry = path.join(context.paths.dir_static, 'build/app.js');
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(entry, 'process.exitCode=9;');
await assert.rejects(
startPanel(context),
language === 'en' ? /startup \(9\)/ : /启动期间退出(9)/,
);
},
);
}
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { runProcess } = require('../../dist/internal/runtime/process');
const { sourceEnvironment } = require('../../dist/internal/runtime/context');
test('shell option restoration never rewrites -c passed as a script argument', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bash-argv-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const script = path.join(root, 'script.sh');
await fs.writeFile(script, 'printf "%s\\0" "$@"\n');
const env = await sourceEnvironment({ PATH: process.env.PATH }, []);
const parameters = ['-c', 'literal $(printf injected)', '', 'space value'];
for (const prefix of [
[],
['--'],
['--noprofile', '--norc'],
['-o', 'pipefail'],
]) {
const result = await runProcess(
'bash',
[...prefix, script, ...parameters],
{ env, capture: true },
);
assert.equal(result.code, 0);
assert.deepEqual(result.stdout.split('\0'), [...parameters, '']);
}
});
test('known Bash command forms restore options and preserve positional command arguments', async () => {
const env = await sourceEnvironment({ PATH: process.env.PATH }, [], [], {
command: 'set +o braceexpand',
});
for (const prefix of [[], ['--noprofile', '--norc']]) {
const result = await runProcess(
'bash',
[
...prefix,
'-c',
'printf "%s\\0" {a,b} "$@"',
'fixture',
'-c',
'original command data',
],
{ env, capture: true },
);
assert.equal(result.code, 0);
assert.deepEqual(result.stdout.split('\0'), [
'{a,b}',
'-c',
'original command data',
'',
]);
}
});
+257
View File
@@ -0,0 +1,257 @@
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 { createContext } = require('../../dist/internal/runtime/context');
const {
bootstrapPanel,
bootstrapPackages,
} = require('../../dist/internal/maintenance/bootstrap');
const { parse } = require('../helpers/commands.cjs');
async function fixture(t) {
const base = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bootstrap-')),
);
t.after(() => fs.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'panel');
await fs.mkdir(root);
const bin = path.join(base, 'bin');
await fs.mkdir(bin);
const env = {
PATH: `${bin}:/usr/bin:/bin`,
QL_OS_TYPE: 'debian',
CALLS: path.join(base, 'calls'),
AutoStartBot: 'true',
EnableExtraShell: 'true',
};
const ctx = createContext(
{ root, 'data-dir': path.join(base, 'external/data') },
env,
);
await fs.mkdir(path.join(root, 'sample'));
for (const file of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.js',
'ql_sample.py',
])
await fs.writeFile(path.join(root, 'sample', file), '# fixture');
await fs.writeFile(path.join(root, '.env.example'), 'SAMPLE=true');
const program = `
const fs=require('node:fs'),p=require('node:path');
const program=p.basename(process.argv[1]),args=process.argv.slice(2);
fs.appendFileSync(process.env.CALLS,JSON.stringify({program,args,python:process.env.PYTHON_HOME,node:process.env.PNPM_HOME})+'\\n');
if(program==='python3') process.stdout.write('3.12\\n');
if(program==='nginx' && args.includes('-s')) process.exit(1);
`;
for (const name of [
'sudo',
'apt-get',
'apk',
'npm',
'pip3',
'python3',
'pm2',
'nginx',
])
await fs.writeFile(
path.join(bin, name),
`#!${process.execPath}\n${program}`,
{ mode: 0o755 },
);
const hooks = [];
const registrations = [];
const system = {
registerServices: async (context, os) => {
registrations.push({ data: context.data, os });
},
nginxConfig: path.join(base, 'nginx/nginx.conf'),
nginxIncludes: path.join(base, 'nginx/conf.d'),
nginxRun: path.join(base, 'run/nginx'),
background: async (context, action) => {
hooks.push({ action, data: context.data });
return 100 + hooks.length;
},
};
return { ctx, system, hooks, base, registrations };
}
test('bootstrap installs prerequisites, uses the configured data directory and starts services in order', async (t) => {
const { ctx, system, hooks, registrations } = await fixture(t);
await fs.writeFile(path.join(ctx.root, '.env'), 'KEEP=true');
const result = await bootstrapPanel(ctx, false, system);
assert.equal(result.mode, 'install');
assert.equal(result.service.manager, 'pm2');
assert.deepEqual(registrations, [{ data: ctx.data, os: 'debian' }]);
assert.deepEqual(
hooks.map((h) => h.action),
['bot', 'extra'],
);
assert.ok(hooks.every((h) => h.data === ctx.data));
assert.equal(
await fs.readFile(path.join(ctx.root, '.env'), 'utf8'),
'KEEP=true',
);
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
calls.map((c) => c.program),
[
process.getuid() === 0 ? 'apt-get' : 'sudo',
process.getuid() === 0 ? 'apt-get' : 'sudo',
'npm',
'python3',
'pip3',
'pm2',
'nginx',
'nginx',
'pm2',
'pm2',
'pm2',
'pm2',
],
);
assert.deepEqual(calls[4].args, [
'install',
'--prefix',
path.join(ctx.data, 'dep_cache/python3'),
'requests',
]);
assert.deepEqual(calls[6].args, ['-c', system.nginxConfig, '-s', 'reload']);
assert.deepEqual(calls[7].args, ['-c', system.nginxConfig]);
assert.deepEqual(
calls.slice(-4).map((c) => c.args),
[
['flush'],
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
['startup'],
['save'],
],
);
assert.ok(
calls
.slice(4)
.every(
(c) =>
c.python === path.join(ctx.data, 'dep_cache/python3') &&
c.node === path.join(ctx.data, 'dep_cache/node'),
),
);
assert.equal(bootstrapPackages('alpine').program, 'apk');
assert.throws(
() => bootstrapPackages('darwin', { QL_LANG: 'en' }),
/Unsupported deployment OS/,
);
assert.throws(() => parse(['start'], 'public'));
assert.equal(parse(['start', '--reload'], 'local').values.reload, true);
});
test('startup reload skips package installation, optional hooks and PM2 startup registration', async (t) => {
const { ctx, system, hooks, registrations } = await fixture(t);
await bootstrapPanel(ctx, true, system);
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
calls.map((c) => c.program),
['python3', 'pm2', 'nginx', 'nginx', 'pm2', 'pm2'],
);
assert.deepEqual(hooks, []);
assert.deepEqual(registrations, []);
assert.equal(
await fs.readFile(path.join(ctx.root, '.env'), 'utf8'),
'SAMPLE=true',
);
assert.equal(
(await fs.stat(path.join(ctx.root, '.env'))).mode & 0o777,
0o600,
);
await assert.rejects(
bootstrapPanel(
{
...ctx,
env: { ...ctx.env, QL_LANG: 'en' },
data: path.join(ctx.root, 'wrong'),
},
true,
system,
),
/ending in \/data/,
);
});
test('container startup explicitly skips boot registration but starts services and saves PM2 state', async (t) => {
const { ctx, system, registrations } = await fixture(t);
const result = await bootstrapPanel(ctx, false, system, {
registerStartup: false,
});
assert.equal(result.startup, 'skipped');
assert.deepEqual(registrations, []);
assert.equal(result.service.manager, 'pm2');
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.ok(
!calls.some((call) => call.program === 'pm2' && call.args[0] === 'startup'),
);
assert.ok(
calls.some((call) => call.program === 'pm2' && call.args[0] === 'save'),
);
assert.ok(calls.some((call) => call.program === 'npm'));
});
for (const language of ['zh', 'en', 'unsupported']) {
test(`bootstrap rejects invalid setup before package or service work: ${language}`, async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-bootstrap-invalid-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const ctx = createContext(
{ root },
{ QL_LANG: language, QL_OS_TYPE: 'unsupported', PATH: '' },
);
await assert.rejects(bootstrapPanel(ctx), (error) => {
assert.equal(error.exitCode, 2);
assert.match(
error.message,
language === 'en'
? /Unsupported deployment OS: unsupported/
: /不支持此部署操作系统:unsupported/,
);
return true;
});
await assert.rejects(
bootstrapPanel({ ...ctx, data: path.join(root, 'invalid') }),
(error) => {
assert.equal(error.exitCode, 2);
assert.match(
error.message,
language === 'en' ? /ending in \/data/ : /以 \/data 结尾/,
);
return true;
},
);
const { runtimeEnvironment } = require('../../dist/internal/maintenance/bootstrap');
assert.throws(
() => runtimeEnvironment(ctx, 'bad'),
(error) => {
assert.equal(error.exitCode, 1);
assert.match(
error.message,
language === 'en' ? /invalid version/ : /无效版本/,
);
return true;
},
);
assert.deepEqual(await fs.readdir(root), []);
});
}
+271
View File
@@ -0,0 +1,271 @@
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 } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const {
botSystemPackages,
installAndStartBot,
prepareBot,
launchBot,
} = require('../../dist/internal/maintenance/bot');
const { parse } = require('../helpers/commands.cjs');
async function fixture(t) {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
const ctx = createContext(
{ root },
{
PATH: `${bin}:/usr/bin:/bin`,
QL_OS_TYPE: 'debian',
CALLS: path.join(root, 'calls'),
},
);
const recorder =
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify({program:require("node:path").basename(process.argv[1]),args:process.argv.slice(2)})+"\\n");';
for (const program of ['sudo', 'apt-get', 'apk', 'pip3'])
await fs.writeFile(
path.join(bin, program),
`#!${process.execPath}\n${recorder}`,
{ mode: 0o755 },
);
const repo = path.join(root, 'source');
await fs.mkdir(path.join(repo, 'jbot'), { recursive: true });
await fs.mkdir(path.join(repo, 'config'));
await fs.writeFile(
path.join(repo, 'jbot/requirements.txt'),
'example==1.2\n',
);
await fs.writeFile(path.join(repo, 'jbot/__main__.py'), 'print("fixture")');
await fs.writeFile(path.join(repo, 'config/bot.json'), '{"token":"sample"}');
for (const args of [
['init', '-q', '-b', 'main'],
['add', '.'],
[
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.invalid',
'commit',
'-qm',
'fixture',
],
])
execFileSync('/usr/bin/git', args, { cwd: repo });
ctx.env.BotRepoUrl = repo;
return ctx;
}
test('bot prepares local repository and dependencies while preserving user configuration', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(ctx.paths.dir_config, { recursive: true });
const config = path.join(ctx.paths.dir_config, 'bot.json');
await fs.writeFile(config, '{"token":"keep"}', { mode: 0o600 });
const lifecycle = [];
await installAndStartBot(ctx, {
stop: async () => {
lifecycle.push('stop');
},
start: async () => {
lifecycle.push('start');
assert.equal(
await fs.readFile(path.join(ctx.data, 'jbot/__main__.py'), 'utf8'),
'print("fixture")',
);
return { pid: 123 };
},
});
assert.deepEqual(lifecycle, ['stop', 'start']);
assert.equal(await fs.readFile(config, 'utf8'), '{"token":"keep"}');
assert.equal(
await fs.readFile(path.join(ctx.data, 'requirements.txt'), 'utf8'),
'example==1.2\n',
);
assert.ok(
!(await fs.readdir(ctx.data)).some((name) =>
name.startsWith('.bot-stage-'),
),
);
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
const install = calls.find((c) => c.program === 'pip3');
assert.deepEqual(install.args.slice(0, 3), [
'--default-timeout=100',
'install',
'-r',
]);
assert.ok(install.args[3].endsWith('/jbot/requirements.txt'));
const packages = botSystemPackages('debian');
assert.deepEqual(
calls[0].args,
process.getuid() === 0
? packages.args
: [packages.program, ...packages.args],
);
assert.equal(botSystemPackages('alpine').program, 'apk');
assert.throws(
() => botSystemPackages('darwin', { QL_LANG: 'en' }),
/does not support/,
);
assert.throws(() => parse(['bot'], 'public'));
assert.equal(parse(['bot'], 'local').name, 'local bot');
});
test('bot startup failure restores old source and requirements before restarting it', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(path.join(ctx.data, 'jbot'), { recursive: true });
await fs.writeFile(path.join(ctx.data, 'jbot/old'), 'old source');
await fs.writeFile(
path.join(ctx.data, 'requirements.txt'),
'old requirements',
);
let starts = 0;
await assert.rejects(
installAndStartBot(ctx, {
stop: async () => {},
start: async () => {
if (++starts === 1) throw new Error('startup failure');
assert.equal(
await fs.readFile(path.join(ctx.data, 'jbot/old'), 'utf8'),
'old source',
);
assert.equal(
await fs.readFile(path.join(ctx.data, 'requirements.txt'), 'utf8'),
'old requirements',
);
return { pid: 123 };
},
}),
/startup failure/,
);
assert.equal(starts, 2);
assert.ok(
!(await fs.readdir(ctx.data)).some(
(name) => name.includes('previous-') || name.includes('ql-backup'),
),
);
});
test('failed bot clone preserves previous repository and running files', async (t) => {
const ctx = await fixture(t);
ctx.env.BotRepoUrl = path.join(ctx.root, 'absent-repository');
const old = path.join(ctx.paths.dir_repo, 'diybot');
await fs.mkdir(old, { recursive: true });
await fs.writeFile(path.join(old, 'old'), 'keep');
await assert.rejects(
prepareBot(ctx),
/Required executable failed|必要的可执行程序失败/,
);
assert.equal(await fs.readFile(path.join(old, 'old'), 'utf8'), 'keep');
assert.ok(
!(await fs.readdir(ctx.paths.dir_repo)).some((name) =>
name.startsWith('.bot-clone-'),
),
);
});
test('bot launch detects immediate interpreter failure without reporting a running service', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(ctx.data, { recursive: true });
await fs.writeFile(
path.join(ctx.root, 'bin/python3'),
`#!${process.execPath}\nprocess.stderr.write('fixture startup failed');process.exit(9);`,
{ mode: 0o755 },
);
ctx.env.QL_LANG = 'en';
await assert.rejects(launchBot(ctx), /startup \(9\)/);
assert.equal(
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
'fixture startup failed',
);
});
test('bot installation keeps internal relative links valid after staging cleanup', async (t) => {
const ctx = await fixture(t);
const source = ctx.env.BotRepoUrl;
await fs.symlink('__main__.py', path.join(source, 'jbot/alias.py'));
execFileSync('/usr/bin/git', ['add', '.'], { cwd: source });
execFileSync(
'/usr/bin/git',
[
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.invalid',
'commit',
'-qm',
'internal link',
],
{ cwd: source },
);
await installAndStartBot(ctx, {
stop: async () => {},
start: async () => ({ pid: 123 }),
});
assert.equal(
await fs.readlink(path.join(ctx.data, 'jbot/alias.py')),
'__main__.py',
);
assert.equal(
await fs.readFile(path.join(ctx.data, 'jbot/alias.py'), 'utf8'),
'print("fixture")',
);
});
for (const language of ['zh', 'en', 'unsupported']) {
test(`bot validation and startup failures honor the operation language: ${language}`, async (t) => {
const ctx = await fixture(t);
ctx.env.QL_LANG = language;
const expected = (zh, en) => (language === 'en' ? en : zh);
assert.throws(
() => botSystemPackages('unsupported-os', ctx.env),
expected(
/不支持此操作系统:unsupported-os/,
/does not support OS: unsupported-os/,
),
);
const prepared = await prepareBot(ctx);
await fs.rm(prepared.staged, { recursive: true });
const userConfig = path.join(ctx.paths.dir_config, 'bot.json');
await fs.writeFile(userConfig, 'retain-user-config');
const source = path.join(prepared.repository, 'jbot');
const outside = path.join(source, 'outside');
await fs.symlink(userConfig, outside);
await assert.rejects(
prepareBot(ctx),
expected(/外部的符号链接/, /external symlink/),
);
await fs.unlink(outside);
const template = path.join(prepared.repository, 'config/bot.json');
await fs.unlink(template);
await fs.symlink(userConfig, template);
await assert.rejects(
prepareBot(ctx),
expected(/模板必须为普通文件/, /template must be a regular file/),
);
assert.equal(await fs.readFile(userConfig, 'utf8'), 'retain-user-config');
assert.ok(
!(await fs.readdir(ctx.data)).some((name) =>
name.startsWith('.bot-stage-'),
),
);
await fs.writeFile(
path.join(ctx.root, 'bin/python3'),
`#!${process.execPath}\nprocess.exitCode=9;`,
{ mode: 0o755 },
);
await assert.rejects(
launchBot(ctx),
expected(/启动期间退出(9)/, /startup \(9\)/),
);
});
}
+26
View File
@@ -0,0 +1,26 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
test('bot integration failure does not print environment-derived error details', async () => {
const messages = [];
const childProcess = { env: { QL_PANEL_INTEGRATION: '1', QL_CLIENT_SECRET: 'sensitive-marker' }, exitCode: 0 };
const source = fs.readFileSync(path.join(__dirname, '../linux/bot-install.cjs'), 'utf8');
vm.runInNewContext(source, {
process: childProcess,
console: { error: (message) => messages.push(message) },
require: (name) => {
if (name === 'node:fs/promises') return { mkdtemp: async () => '/fixture' };
if (name === '../../dist/internal/runtime/context') return { createContext: () => { throw new Error(childProcess.env.QL_CLIENT_SECRET); } };
if (name === '../../dist/internal/maintenance/bot') return {};
return require(name);
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(childProcess.exitCode, 1);
assert.equal(messages.length, 1);
assert.match(messages[0], /integration test failed/);
assert.doesNotMatch(messages[0], /sensitive-marker/);
});
@@ -0,0 +1,162 @@
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 {
prepareContainerEnvironment,
} = require('../../dist/internal/runtime/containerEnvironment');
async function fixture(t) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-env-'));
t.after(() => fs.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'panel');
const data = path.join(base, 'external/data');
const systemDirectory = path.join(base, 'etc');
for (const dir of [root, data, systemDirectory])
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(systemDirectory, 'alpine-release'), '3.20');
return {
root,
data,
systemDirectory,
env: { PATH: process.env.PATH, HOME: path.join(base, 'absent') },
};
}
test('container preparation retains data and existing temporary files, and does not mutate caller environment', async (t) => {
const options = await fixture(t);
const original = { ...options.env };
await fs.mkdir(path.join(options.root, '.tmp'));
await fs.writeFile(path.join(options.root, '.tmp/keep'), 'pending upgrade');
await fs.writeFile(path.join(options.data, 'keep'), 'user data');
const result = await prepareContainerEnvironment(options);
assert.equal(result.env.HOME, path.join(options.root, '.tmp'));
assert.deepEqual(options.env, original);
assert.deepEqual(await fs.readdir(options.data), ['keep']);
assert.equal(
await fs.readFile(path.join(options.root, '.tmp/keep'), 'utf8'),
'pending upgrade',
);
assert.deepEqual(result.warnings, []);
});
test('network initialization appends complete lines once and matches localhost as a hostname token', async (t) => {
const options = await fixture(t);
const hosts = path.join(options.systemDirectory, 'hosts');
const resolv = path.join(options.systemDirectory, 'resolv.conf');
await fs.writeFile(
hosts,
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost',
);
await fs.writeFile(resolv, 'nameserver 127.0.0.11');
await prepareContainerEnvironment(options);
const expectedHosts =
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost\n127.0.0.1 localhost\n';
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'nameserver 127.0.0.11\noptions ndots:0\n',
);
await prepareContainerEnvironment(options);
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'nameserver 127.0.0.11\noptions ndots:0\n',
);
});
test('existing HOME, DNS options and non-Alpine DNS are preserved', async (t) => {
const options = await fixture(t);
options.env.HOME = options.root;
const resolv = path.join(options.systemDirectory, 'resolv.conf');
await fs.writeFile(resolv, 'options timeout:1 ndots:0 # configured\n');
assert.equal(
(await prepareContainerEnvironment(options)).env.HOME,
options.root,
);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'options timeout:1 ndots:0 # configured\n',
);
await fs.rm(path.join(options.systemDirectory, 'alpine-release'));
await fs.writeFile(resolv, 'nameserver 1.2.3.4');
await prepareContainerEnvironment(options);
assert.equal(await fs.readFile(resolv, 'utf8'), 'nameserver 1.2.3.4');
});
test('network failures are reported without hiding permission preflight errors', async (t) => {
const options = await fixture(t);
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
const result = await prepareContainerEnvironment(options);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /hosts[::] ?EISDIR/);
const absent = path.join(options.root, 'absent');
await assert.rejects(
prepareContainerEnvironment({ ...options, data: absent }),
/not writable\/searchable|不可写或不可访问/,
);
await assert.rejects(fs.stat(absent), { code: 'ENOENT' });
await assert.rejects(
prepareContainerEnvironment({ ...options, root: 'relative' }),
/absolute paths|必须为绝对路径/,
);
});
test(
'non-root preflight rejects an inaccessible data directory without creating volume files',
{ skip: process.getuid?.() === 0 },
async (t) => {
const options = await fixture(t);
await fs.chmod(options.data, 0o400);
try {
await assert.rejects(
prepareContainerEnvironment(options),
/not writable\/searchable|不可写或不可访问/,
);
} finally {
await fs.chmod(options.data, 0o700);
}
assert.deepEqual(await fs.readdir(options.data), []);
assert.deepEqual(await fs.readdir(options.systemDirectory), [
'alpine-release',
]);
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(`container preflight and network warnings preserve language and data: ${language}`, async (t) => {
const options = await fixture(t);
options.env.QL_LANG = language;
const expected = (zh, en) => (language === 'en' ? en : zh);
await assert.rejects(
prepareContainerEnvironment({ ...options, root: 'relative' }),
(error) => {
assert.equal(error.exitCode, 2);
assert.match(
error.message,
expected(/必须为绝对路径/, /absolute paths/),
);
return true;
},
);
const absent = path.join(options.root, 'missing-data');
await assert.rejects(
prepareContainerEnvironment({ ...options, data: absent }),
expected(/不可写或不可访问/, /not writable\/searchable/),
);
await assert.rejects(fs.access(absent), { code: 'ENOENT' });
await fs.writeFile(path.join(options.data, 'retained'), 'keep');
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
const result = await prepareContainerEnvironment(options);
assert.equal(result.warnings.length, 1);
assert.match(
result.warnings[0],
expected(/无法初始化.*EISDIR/, /Cannot initialize.*EISDIR/),
);
assert.equal(
await fs.readFile(path.join(options.data, 'retained'), 'utf8'),
'keep',
);
});
}
+280
View File
@@ -0,0 +1,280 @@
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 { setTimeout: delay } = require('node:timers/promises');
const { createContext } = require('../../dist/internal/runtime/context');
const { runContainer } = require('../../dist/internal/runtime/containerRuntime');
async function fixture(t, mode = 'node') {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-container-runtime-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
for (const name of ['data', 'sample', 'etc', 'bin'])
await fs.mkdir(path.join(root, name));
for (const name of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.py',
'ql_sample.js',
])
await fs.writeFile(path.join(root, 'sample', name), '\n');
const context = createContext(
{ root },
{
...process.env,
HOME: root,
PATH: `${root}/bin:${process.env.PATH}`,
QL_SCHEDULER: mode,
},
);
const calls = [];
const services = {
start: async (runtime) => {
calls.push({ action: 'start', env: runtime.env });
return { manager: 'node' };
},
stop: async () => {
calls.push({ action: 'stop' });
},
hook: async () => {
throw new Error('unexpected hook');
},
};
const controller = new AbortController();
return {
root,
context,
calls,
controller,
options: { services, systemDirectory: path.join(root, 'etc') },
};
}
async function waitFile(filename) {
for (let i = 0; i < 100; i++) {
if (await fs.stat(filename).catch(() => false)) return;
await delay(20);
}
throw new Error(`Timed out waiting for ${filename}`);
}
test('container loads repaired configuration and exports ports/scheduler before service start', async (t) => {
const f = await fixture(t);
await fs.writeFile(
path.join(f.root, 'sample/config.sample.sh'),
'QlPort=5799\nQlGrpcPort=5599\n',
);
const events = [];
f.options.event = (event) => {
events.push(event);
if (event.event === 'started') f.controller.abort('SIGTERM');
};
assert.equal(
await runContainer(f.context, f.controller.signal, f.options),
143,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
assert.equal(f.calls[0].env.BACK_PORT, '5799');
assert.equal(f.calls[0].env.GRPC_PORT, '5599');
assert.equal(f.calls[0].env.QL_SCHEDULER, 'node');
assert.equal(f.context.env.BACK_PORT, process.env.BACK_PORT);
assert.deepEqual(events, [
{ event: 'started', scheduler: 'node', manager: 'node' },
]);
});
test('system scheduler is auto-selected, receives termination and exits before service cleanup', async (t) => {
const f = await fixture(t, '');
const started = path.join(f.root, 'scheduler-started');
const stopped = path.join(f.root, 'scheduler-stopped');
f.context.env.QL_TEST_STARTED = started;
f.context.env.QL_TEST_STOPPED = stopped;
await fs.writeFile(
path.join(f.root, 'bin/crond'),
`#!${process.execPath}\nconst fs=require('node:fs'); fs.writeFileSync(process.env.QL_TEST_STARTED, JSON.stringify(process.argv.slice(2))); process.on('SIGTERM',()=>{fs.writeFileSync(process.env.QL_TEST_STOPPED, 'stopped');process.exit(0)}); setInterval(()=>{},1000);\n`,
{ mode: 0o755 },
);
f.options.services.stop = async () => {
assert.equal(await fs.readFile(stopped, 'utf8'), 'stopped');
f.calls.push({ action: 'stop' });
};
const running = runContainer(f.context, f.controller.signal, f.options);
t.after(() => f.controller.abort('SIGTERM'));
await waitFile(started);
f.controller.abort('SIGTERM');
assert.equal(await running, 143);
assert.equal(await fs.readFile(started, 'utf8'), '["-f"]');
assert.equal(f.calls[0].env.QL_SCHEDULER, 'system');
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test('unexpected scheduler exit is a failure and stops the panel', async (t) => {
const f = await fixture(t, 'system');
await fs.writeFile(path.join(f.root, 'bin/crond'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/scheduler exited unexpectedly \(0\)|调度器意外退出(0)/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test('partial startup failure cleans services and invalid scheduler fails before startup', async (t) => {
const f = await fixture(t);
f.options.services.start = async () => {
f.calls.push({ action: 'start' });
throw new Error('partial startup');
};
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/partial startup/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
f.calls.length = 0;
f.context.env.QL_SCHEDULER = 'invalid';
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
/must be node or system|必须为 node 或 system/,
);
assert.deepEqual(f.calls, []);
});
test('cancellation during service startup still cleans the partially started panel', async (t) => {
const f = await fixture(t);
f.options.services.start = async () => {
f.calls.push({ action: 'start' });
f.controller.abort('SIGINT');
return { manager: 'node' };
};
assert.equal(
await runContainer(f.context, f.controller.signal, f.options),
130,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
test(
'actual extra startup hook finishes subprocess escalation before its supervisor is killed',
{ timeout: 20000 },
async (t) => {
const f = await fixture(t);
const { launchStartupHook } = require('../../dist/internal/maintenance/bootstrap');
const pidFile = path.join(f.root, 'extra-pid');
f.context.env.QL_TEST_EXTRA_PID = pidFile;
await fs.writeFile(
path.join(f.root, 'sample/config.sample.sh'),
'EnableExtraShell=true\n',
);
await fs.writeFile(
path.join(f.root, 'sample/extra.sample.sh'),
'trap "" TERM\nprintf "%s" "$$" > "$QL_TEST_EXTRA_PID"\nwhile :; do sleep 1; done\n',
);
let hookPid;
f.options.services.hook = async (runtime, action) => {
hookPid = await launchStartupHook(runtime, action);
return hookPid;
};
const live = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
};
const running = runContainer(f.context, f.controller.signal, f.options);
let extraPid;
t.after(async () => {
f.controller.abort('SIGTERM');
for (const pid of [extraPid, hookPid])
if (pid) {
try {
process.kill(-pid, 'SIGKILL');
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
}
await running.catch(() => {});
});
await waitFile(pidFile);
extraPid = Number(await fs.readFile(pidFile, 'utf8'));
assert.ok(extraPid > 1 && hookPid > 1);
assert.equal(live(extraPid), true);
f.controller.abort('SIGTERM');
assert.equal(await running, 143);
for (let i = 0; i < 20 && (live(extraPid) || live(hookPid)); i++)
await delay(50);
assert.equal(
live(extraPid),
false,
'extra subprocess must not outlive container cleanup',
);
assert.equal(live(hookPid), false, 'startup supervisor must exit');
const log = await fs.readFile(
path.join(f.root, 'data/log/extra.log'),
'utf8',
);
const result = log
.trim()
.split('\n')
.filter((line) => line.startsWith('{'))
.map((line) => JSON.parse(line))
.at(-1);
assert.equal(result.code, 143);
assert.equal(
result.message,
f.context.env.QL_LANG === 'en'
? 'CLI operation cancelled.'
: 'CLI 操作已取消。',
);
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(`container scheduler errors are localized and preserve cleanup: ${language}`, async (t) => {
const f = await fixture(t, 'invalid');
f.context.env.QL_LANG = language;
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
language === 'en' ? /must be node or system/ : /必须为 node 或 system/,
);
assert.deepEqual(f.calls, []);
f.context.env.QL_SCHEDULER = 'system';
await fs.writeFile(
path.join(f.root, 'bin/crond'),
`#!${process.execPath}\nprocess.exitCode=7;`,
{ mode: 0o755 },
);
await assert.rejects(
runContainer(f.context, f.controller.signal, f.options),
language === 'en' ? /unexpectedly \(7\)/ : /意外退出(7)/,
);
assert.deepEqual(
f.calls.map((call) => call.action),
['start', 'stop'],
);
});
}
@@ -0,0 +1,98 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { sourceEnvironment } = require('../../dist/internal/runtime/context');
test('all local entrypoints report missing, relative and non-directory roots before doing work', async (t) => {
const base = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-context-diagnostics-'),
);
t.after(() => fs.rm(base, { recursive: true, force: true }));
const file = path.join(base, 'file');
await fs.writeFile(file, 'unchanged');
for (const [entry, args] of [
['admin', ['extra', '--json']],
['startup', ['--json']],
['runner', ['--json', 'sample.js']],
['compat', ['extra']],
['subscription-worker', ['raw', 'https://example.invalid/script.js']],
]) {
for (const root of [
'',
'relative-root',
path.join(base, 'missing'),
file,
]) {
for (const language of ['zh', 'en']) {
const child = spawnSync(
process.execPath,
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args],
{
env: {
...process.env,
QL_LANG: language,
QL_DIR: root,
QL_DATA_DIR: '',
SUB_ID: '',
},
encoding: 'utf8',
timeout: 5000,
},
);
assert.equal(child.status, 2, `${entry}: ${child.stderr}`);
assert.equal(child.stdout, '');
const error = JSON.parse(child.stderr);
assert.equal(error.code, 2);
assert.match(
error.message,
path.isAbsolute(root)
? language === 'en'
? /Panel root does not exist/
: /面板安装目录不存在或不是目录/
: language === 'en'
? /absolute --root or QL_DIR/
: /--root 或 QL_DIR 指定绝对路径/,
);
}
}
}
assert.equal(await fs.readFile(file, 'utf8'), 'unchanged');
assert.deepEqual(await fs.readdir(base), ['file']);
});
test('configuration bridge localizes execution, invalid-data and cancellation errors without changing their types', async () => {
for (const language of ['zh', 'en']) {
const env = { PATH: process.env.PATH, QL_LANG: language };
for (const [command, chinese, english] of [
['exit 7', /用户配置加载失败/, /User configuration evaluation failed/],
['exit 0', /无效的环境数据/, /invalid environment data/],
]) {
await assert.rejects(
sourceEnvironment(env, [], [], { command }),
(error) => {
assert.equal(error.name, 'Error');
assert.match(error.message, language === 'en' ? english : chinese);
return true;
},
);
}
const controller = new AbortController();
const execution = sourceEnvironment(env, [], [], {
command: 'printf ready; sleep 30',
signal: controller.signal,
output: () => controller.abort(),
});
await assert.rejects(execution, (error) => {
assert.equal(error.name, 'AbortError');
assert.equal(error.code, 'ABORT_ERR');
assert.match(
error.message,
language === 'en' ? /evaluation cancelled/ : /用户配置加载已取消/,
);
return true;
});
}
});
+80
View File
@@ -0,0 +1,80 @@
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 { spawnSync } = require('node:child_process');
const { installCronEntrypoint } = require('../../dist/local/cronEntrypoint');
test('cron bridge scopes environment to the panel table and preserves native reads, failures and unrelated tables', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-cron-entry-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const native = path.join(root, 'native'),
bin = path.join(root, 'selected bin');
await fs.mkdir(native);
await fs.mkdir(bin);
const state = path.join(root, 'installed');
await fs.writeFile(
path.join(native, 'crontab'),
`#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='-l')process.stdout.write(fs.readFileSync(process.env.TABLE));else if(args[0]==='-r'){process.stderr.write('native failure');process.exitCode=9;}else fs.writeFileSync(process.env.TABLE,fs.readFileSync(args[0]));`,
{ mode: 0o755 },
);
const source = path.join(root, 'panel table');
const original =
'# retain this original comment as is\n* * * * * task "job with spaces.js"\n';
await fs.writeFile(source, original);
const env = {
QL_LANG: 'en',
PATH: `${bin}:${native}`,
QL_DIR: root,
QL_DATA_DIR: path.join(root, 'data'),
PYTHONPATH: "/python 'path' $(literal)",
QL_CLIENT_SECRET: 'must-not-be-captured',
};
const install = () =>
installCronEntrypoint(bin, path.resolve(__dirname, '../..'), source, env);
await install();
await install();
const wrapper = await fs.readFile(path.join(bin, 'crontab'), 'utf8');
assert.doesNotMatch(wrapper, /must-not-be-captured|QL_CLIENT_SECRET/);
const run = (args) =>
spawnSync(path.join(bin, 'crontab'), args, {
env: { TABLE: state },
encoding: 'utf8',
});
assert.equal(run([source]).status, 0);
const installed = await fs.readFile(state, 'utf8');
assert.ok(installed.endsWith('task "job with spaces.js"\n'));
assert.ok(installed.includes(`PATH='${bin}:${native}'`));
assert.ok(installed.includes('# retain this original comment as is\n'));
const job = installed
.split('\n')
.find((line) => line.startsWith('* * * * * '));
const prefix = job.slice('* * * * * '.length, job.indexOf('task "'));
const shell = spawnSync(
'/bin/sh',
['-c', prefix + 'printf "%s" "$PYTHONPATH"'],
{ encoding: 'utf8', env: {} },
);
assert.equal(shell.status, 0, shell.stderr);
assert.equal(shell.stdout, env.PYTHONPATH);
assert.equal(await fs.readFile(source, 'utf8'), original);
assert.equal(run(['-l']).stdout, original);
const failed = run(['-r']);
assert.equal(failed.status, 9);
assert.equal(failed.stderr, 'native failure');
const other = path.join(root, 'other');
await fs.writeFile(other, 'OTHER\n');
assert.equal(run([other]).status, 0);
assert.equal(await fs.readFile(state, 'utf8'), 'OTHER\n');
env.QL_DIR = 'invalid\nvalue';
await install();
assert.equal(run([source]).status, 1);
assert.equal(await fs.readFile(state, 'utf8'), 'OTHER\n');
await fs.writeFile(path.join(bin, 'crontab'), '# unrelated command\n');
await assert.rejects(install(), /Refusing to replace/);
assert.equal(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
'# unrelated command\n',
);
});
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { sourceEnvironment } = require('../../dist/internal/runtime/context');
const { runProcess } = require('../../dist/internal/runtime/process');
test('environment transport preserves multiline values, empty values and exported functions', async () => {
const value = '中文\nsecond=line\r\n"quoted"\\literal';
const env = await sourceEnvironment({ PATH: process.env.PATH, VALUE: value }, [], [], {
command: 'export EMPTY=""; exported_fn() { printf "%s" "$VALUE"; }; export -f exported_fn; set -o pipefail; shopt -s nullglob; printf "user output"',
output: () => {},
});
assert.equal(env.VALUE, value);
assert.equal(env.EMPTY, '');
assert.ok(env.QL_CLI_SHELLOPTS.split(':').includes('pipefail'));
assert.ok(!env.QL_CLI_SHELLOPTS.split(':').includes('allexport'));
assert.ok(env.QL_CLI_BASHOPTS.split(':').includes('nullglob'));
assert.ok(!Object.keys(env).some(key => key.startsWith('__ql_')));
const child = await runProcess('bash', ['--noprofile', '--norc', '-c', 'exported_fn'], { env, capture: true });
assert.equal(child.code, 0);
assert.equal(child.stdout, value);
});
test('environment transport does not execute a Node runtime from user configuration', async () => {
const env = await sourceEnvironment({ PATH: process.env.PATH }, [], [], {
command: 'export NODE_OPTIONS="--this-option-does-not-exist"; export PATH="/nonexistent"',
});
assert.equal(env.NODE_OPTIONS, '--this-option-does-not-exist');
assert.equal(env.PATH, '/nonexistent');
});
for (const redirect of ['exec 3>/dev/null 9>/dev/null', 'exec 3>&-']) {
test(`environment transport survives user descriptors: ${redirect}`, async () => {
const env = await sourceEnvironment({ PATH: process.env.PATH }, [], [], {
command: `${redirect}; export FIXTURE_VALUE=present`,
});
assert.equal(env.FIXTURE_VALUE, 'present');
});
}
+152
View File
@@ -0,0 +1,152 @@
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 { createContext } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('ESM resolves global packages and exported subpaths with native import conditions and local fallback', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-esm-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const global = path.join(root, 'global # percent % space', 'node_modules');
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.writeFile(
path.join(bin, 'pnpm'),
`#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(global)});`,
{ mode: 0o755 },
);
const context = createContext(
{ root },
{ PATH: `${bin}${path.delimiter}${process.env.PATH}`, no_tee: 'true' },
);
for (const dir of [
context.paths.dir_preload,
context.paths.dir_config,
context.paths.dir_scripts,
])
await fs.mkdir(dir, { recursive: true });
for (const [name, contents] of Object.entries({
'env.sh': '',
'env.js': '',
'client.js': 'module.exports={};',
'__ql_notify__.js': 'exports.sendNotify=()=>{};',
}))
await fs.writeFile(path.join(context.paths.dir_preload, name), contents);
await fs.copyFile(
path.resolve(__dirname, '../../../shell/preload/esm-loader.mjs'),
path.join(context.paths.dir_preload, 'esm-loader.mjs'),
);
await fs.writeFile(context.paths.file_task_before, 'true\n');
await fs.writeFile(context.paths.file_task_before_js, '');
const packages = [
[
global,
'ql-fixture',
{
type: 'module',
exports: {
'.': { import: './entry.mjs', require: './entry.cjs' },
'./feature': './actual/feature.mjs',
},
},
{
'entry.mjs': 'export default "global-import";',
'entry.cjs': 'module.exports="global-require";',
'actual/feature.mjs': 'export default "global-feature";',
'private.mjs': 'export default "private";',
},
],
[
global,
'@ql/fixture',
{
type: 'module',
exports: { '.': './entry.mjs', './feature': './feature.mjs' },
imports: { '#internal': './internal.mjs' },
},
{
'entry.mjs': 'import value from "#internal"; export default value;',
'internal.mjs': 'export default "scoped";',
'feature.mjs': 'export default "scoped-feature";',
},
],
[
context.paths.dir_scripts + '/node_modules',
'ql-fixture',
{ type: 'module', main: 'index.mjs' },
{ 'index.mjs': 'export default "local-shadow";' },
],
[
context.paths.dir_scripts + '/node_modules',
'local-only',
{ type: 'module', main: 'index.mjs' },
{ 'index.mjs': 'export default "local-only";' },
],
[
global,
'common-fixture',
{ main: 'index.cjs' },
{ 'index.cjs': 'module.exports="commonjs";' },
],
];
for (const [base, name, manifest, files] of packages) {
const dir = path.join(base, name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'package.json'),
JSON.stringify({ name, ...manifest }),
);
for (const [file, source] of Object.entries(files)) {
await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true });
await fs.writeFile(path.join(dir, file), source);
}
}
// A pnpm-style global link points into the content store, not a copied package.
const link = path.join(global, '@ql/fixture');
const stored = path.join(
global,
'.pnpm',
'@ql+fixture@1',
'node_modules',
'@ql',
'fixture',
);
await fs.mkdir(path.dirname(stored), { recursive: true });
await fs.rename(link, stored);
await fs.symlink(stored, link);
await fs.writeFile(
path.join(context.paths.dir_scripts, 'relative.mjs'),
'export default "relative";',
);
const expected = {
'ql-fixture': 'global-import',
'ql-fixture/feature': 'global-feature',
'@ql/fixture': 'scoped',
'@ql/fixture/feature': 'scoped-feature',
'local-only': 'local-only',
'common-fixture': 'commonjs',
'./relative.mjs': 'relative',
'data:text/javascript,export default "data"': 'data',
'ql-fixture/private.mjs': 'ERR_PACKAGE_PATH_NOT_EXPORTED',
};
await fs.writeFile(
path.join(context.paths.dir_scripts, 'fixture.mjs'),
`import fs from 'node:fs'; import path from 'path'; const result={}; for (const name of ${JSON.stringify(
Object.keys(expected),
)}) { try { result[name]=(await import(name)).default; } catch(error) { result[name]=error.code; } } console.log('RESULT:'+JSON.stringify(result));`,
);
const result = await executeTask(context, {
argv: ['fixture.mjs'],
mode: 'now',
});
const log = await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.equal(result.exitCode, 0, log);
const line = log.split('\n').find((line) => line.startsWith('RESULT:'));
assert.ok(line, log);
assert.deepEqual(JSON.parse(line.slice(7)), expected);
});
@@ -0,0 +1,71 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { inspectPanel } = require('../../dist/internal/maintenance/check');
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
for (const language of ['zh', 'en', 'unsupported']) {
test(`helper validation and generated wrappers honor locale: ${language}`, async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-helper-language-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const env = { QL_LANG: language, QlPort: '65536' };
const context = createContext({ root }, env);
const expected = (zh, en) => (language === 'en' ? en : zh);
await assert.rejects(
inspectPanel(context),
expected(/必须在 1 到 65535/, /between 1 and 65535/),
);
let called = false;
await assert.rejects(
loggedOperation(context, '../outside', async () => {
called = true;
}),
expected(/日志目录无效/, /Invalid command log directory/),
);
assert.equal(called, false);
assert.deepEqual(await fs.readdir(root), []);
const bin = path.join(root, 'bin');
await assert.rejects(
installCliEntrypoints(bin, 'relative', env),
expected(/必须为绝对路径/, /must be absolute/),
);
await fs.mkdir(bin);
await fs.writeFile(path.join(bin, 'ql'), 'retained');
await fs.mkdir(path.join(root, 'dist/ql.js'), { recursive: true });
await assert.rejects(
installCliEntrypoints(bin, root, env),
expected(/必须为普通文件/, /not a regular file/),
);
assert.equal(await fs.readFile(path.join(bin, 'ql'), 'utf8'), 'retained');
await fs.rm(path.join(root, 'dist/ql.js'), { recursive: true });
for (const [file, name] of [
['ql', 'qlMain'],
['task', 'taskMain'],
])
await fs.writeFile(
path.join(root, 'dist', file + '.js'),
`exports.${name}=async()=>{throw new Error('private-detail')};exports.taskMain=exports.${name};`,
);
await installCliEntrypoints(bin, root, env);
for (const name of ['ql', 'task']) {
const result = spawnSync(path.join(bin, name), [], {
env,
encoding: 'utf8',
});
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(
result.stderr,
expected(/CLI 调用失败/, /CLI invocation failed/),
);
assert.doesNotMatch(result.stderr, /private-detail/);
}
});
}
+72
View File
@@ -0,0 +1,72 @@
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 { registerHostServices } = require('../../dist/internal/runtime/hostServices');
const { createContext } = require('../../dist/internal/runtime/context');
for (const manager of ['openrc', 'systemd', 'missing']) {
test(`host startup registration uses ${manager} and propagates service failures`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-host-services-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const calls = path.join(root, 'calls');
const programs =
manager === 'openrc'
? ['rc-update', 'rc-service']
: manager === 'systemd'
? ['apt-get', 'systemctl']
: [];
for (const program of [...programs, 'sudo'])
await fs.writeFile(
path.join(root, program),
`#!${process.execPath}\nconst fs=require('node:fs'),path=require('node:path');let program=path.basename(process.argv[1]),args=process.argv.slice(2);if(program==='sudo')program=args.shift();fs.appendFileSync(process.env.CALLS,JSON.stringify([program,...args])+'\\n');if(process.env.FAIL===program)process.exit(9);`,
{ mode: 0o755 },
);
const context = createContext({ root }, { PATH: root, CALLS: calls });
if (manager === 'missing') {
await assert.rejects(
registerHostServices(context, 'alpine'),
(error) => error.exitCode === 2,
);
await assert.rejects(fs.stat(calls), { code: 'ENOENT' });
return;
}
await registerHostServices(
context,
manager === 'openrc' ? 'alpine' : 'debian',
);
const actual = (await fs.readFile(calls, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
actual,
manager === 'openrc'
? [
['rc-update', 'add', 'nginx', 'default'],
['rc-update', 'add', 'crond', 'default'],
['rc-service', 'crond', 'start'],
]
: [
['apt-get', 'install', '-y', 'cron'],
['systemctl', 'enable', 'nginx'],
['systemctl', 'enable', '--now', 'cron'],
],
);
await fs.writeFile(calls, '');
context.env.FAIL = manager === 'openrc' ? 'rc-service' : 'apt-get';
await assert.rejects(
registerHostServices(context, manager === 'openrc' ? 'alpine' : 'ubuntu'),
);
const failed = (await fs.readFile(calls, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.equal(
failed.length,
manager === 'openrc' ? 3 : 1,
'failure must stop subsequent actions',
);
});
}
@@ -0,0 +1,90 @@
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 { createContext } = require('../../dist/internal/runtime/context');
const { withOperationOutput } = require('../../dist/internal/runtime/output');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('language hooks receive literal script arguments and temporary adapters are removed', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql language literal '));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{ PATH: process.env.PATH, no_tee: 'true' },
);
for (const dir of [
context.paths.dir_preload,
context.paths.dir_config,
context.paths.dir_scripts,
])
await fs.mkdir(dir, { recursive: true });
for (const [name, contents] of Object.entries({
'env.sh': '',
'env.js': '',
'env.py': '',
'client.js': 'module.exports={};',
'client.py': 'class Client:\n pass\n',
'__ql_notify__.js': 'exports.sendNotify=()=>{};',
'__ql_notify__.py': 'def send(*args, **kwargs):\n pass\n',
}))
await fs.writeFile(path.join(context.paths.dir_preload, name), contents);
await fs.copyFile(
path.resolve(__dirname, '../../../shell/preload/esm-loader.mjs'),
path.join(context.paths.dir_preload, 'esm-loader.mjs'),
);
await fs.writeFile(
context.paths.file_task_before,
`export HOOK_ARGS="$(node -e 'console.log(JSON.stringify(process.argv.slice(1)))' "$@")"\n`,
);
await fs.writeFile(context.paths.file_task_before_js, '');
await fs.writeFile(context.paths.file_task_before_py, '');
for (const extension of ['js', 'py']) {
const actualName = `job ' "$HOME" ;.${extension}`;
const args = [
'two words',
'"quote"',
"a'b",
'$(touch SHOULD_NOT_EXIST)',
'',
'--flag',
];
const script =
extension === 'js'
? 'console.log("RESULT:"+JSON.stringify([JSON.parse(process.env.HOOK_ARGS), Object.keys(require.cache).find(file=>file.endsWith("/sitecustomize.js")), process.env.NODE_OPTIONS]));'
: 'import os,json\nprint("RESULT:"+json.dumps([json.loads(os.environ["HOOK_ARGS"]),__import__("sitecustomize").__file__,os.environ.get("PYTHONPATH")]))\n';
await fs.writeFile(
path.join(context.paths.dir_scripts, actualName),
script,
);
let diagnostics = '';
const result = await withOperationOutput(
(chunk) => {
diagnostics += chunk;
},
() =>
executeTask(context, {
argv: [actualName],
scriptArgs: args,
mode: 'now',
}),
);
const log = await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
assert.equal(result.exitCode, 0, log + diagnostics);
const line = log.split('\n').find((line) => line.startsWith('RESULT:'));
assert.ok(line, log);
const data = JSON.parse(line.slice(7));
assert.deepEqual(data[0], [actualName, ...args]);
assert.equal(data[2], '');
await assert.rejects(fs.stat(path.dirname(data[1])), { code: 'ENOENT' });
assert.doesNotMatch(log, /run task before error|run builtin code error/);
}
await assert.rejects(
fs.stat(path.join(context.paths.dir_scripts, 'SHOULD_NOT_EXIST')),
{ code: 'ENOENT' },
);
});
+134
View File
@@ -0,0 +1,134 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { createContext } = require('../../dist/internal/runtime/context');
const { extendedLifecycle } = require('../../dist/internal/runtime/lifecycle');
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
test('installed panel version selects lifecycle schema and unknown versions stay conservative', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-lifecycle-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext({ root }, {});
assert.equal(await extendedLifecycle(context), false);
for (const [version, expected] of [
['2.20.1', false],
['2.19.0', false],
['2.21.0-14', true],
['3.0.0', false],
['invalid', false],
]) {
await fs.writeFile(
path.join(root, 'package.json'),
JSON.stringify({ version }),
);
assert.equal(await extendedLifecycle(context), expected);
}
context.env.QL_CLI_LIFECYCLE = 'extended';
assert.equal(await extendedLifecycle(context), true);
context.env.QL_CLI_LIFECYCLE = 'legacy';
assert.equal(await extendedLifecycle(context), false);
});
test('command log preserves scheduler path and rejects escaping the log directory', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-scheduler-log-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{ real_log_path: 'ql/scheduled.log', no_tee: 'true' },
);
const result = await loggedOperation(context, 'extra', async () => 'done');
assert.equal(result.logPath, 'ql/scheduled.log');
assert.match(
await fs.readFile(path.join(context.paths.dir_log, result.logPath), 'utf8'),
/执行结束/,
);
context.env.real_log_path = '../outside.log';
await assert.rejects(
loggedOperation(context, 'extra', async () => assert.fail('must not run')),
);
});
test('released version.yaml selects lifecycle only when package version is absent', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-release-version-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext({ root }, {});
await fs.writeFile(
path.join(root, 'package.json'),
'{"name":"released-panel"}',
);
for (const [release, expected] of [
['version: 2.20.1\nchangeLog: |\n version: 2.99.0\n', false],
['version: 2.21.0-14\npublishTime: 2026-09-25\n', true],
['version: "2.21.1" # release\n', true],
["\ufeffversion: '2.22.0'\r\n", true],
['version: 3.0.0\n', false],
['version: 2.21.invalid\n', false],
['version: 2.21.0\nversion: 2.20.1\n', false],
['changeLog: |\n version: 2.21.0\n', false],
['version: !custom 2.21.0\n', false],
]) {
await fs.writeFile(path.join(root, 'version.yaml'), release);
assert.equal(await extendedLifecycle(context), expected, release);
}
await fs.writeFile(path.join(root, 'version.yaml'), 'version: 2.21.1\n');
await fs.writeFile(path.join(root, 'package.json'), '{"version":"2.20.1"}');
assert.equal(await extendedLifecycle(context), false);
context.env.QL_CLI_LIFECYCLE = 'extended';
assert.equal(await extendedLifecycle(context), true);
context.env.QL_CLI_LIFECYCLE = 'legacy';
assert.equal(await extendedLifecycle(context), false);
delete context.env.QL_CLI_LIFECYCLE;
await fs.rm(path.join(root, 'package.json'));
assert.equal(await extendedLifecycle(context), true);
await fs.rm(path.join(root, 'version.yaml'));
assert.equal(await extendedLifecycle(context), false);
});
test('release-only version metadata controls actual task and maintenance status payloads', async (t) => {
const { LocalApi } = require('../../dist/internal/runtime/api');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-release-payload-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{ PATH: process.env.PATH, ID: '7', no_tee: 'true' },
);
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
await fs.writeFile(
path.join(context.paths.dir_scripts, 'fixture.sh'),
'return 7',
);
const calls = [];
t.mock.method(LocalApi.prototype, 'call', async (endpoint, method, body) => {
calls.push({ endpoint, body });
return { code: 200 };
});
for (const version of ['2.20.1', '2.21.0-14']) {
await fs.writeFile(
path.join(root, 'version.yaml'),
`version: ${version}\n`,
);
calls.length = 0;
const result = await executeTask(context, {
argv: ['fixture.sh'],
mode: 'now',
});
assert.equal(result.exitCode, 7);
const final = calls
.filter((row) => row.endpoint === 'crons/status')
.at(-1).body;
assert.equal(final.exit_code, version === '2.20.1' ? undefined : 7);
assert.equal(
calls.some((row) => row.endpoint === 'dashboard/record'),
version !== '2.20.1',
);
calls.length = 0;
await loggedOperation(context, 'extra', async () => true);
assert.equal(
calls.at(-1).body.exit_code,
version === '2.20.1' ? undefined : 0,
);
}
});
+977
View File
@@ -0,0 +1,977 @@
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);
});
+289
View File
@@ -0,0 +1,289 @@
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 http = require('node:http');
const { LocalApi } = require('../../dist/internal/runtime/api');
const { createContext } = require('../../dist/internal/runtime/context');
async function fixture(t, handler) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-local-api-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const server = http.createServer(handler);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(
() =>
new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
}),
);
const context = createContext(
{ root },
{ PATH: process.env.PATH, QlPort: String(server.address().port) },
);
await fs.mkdir(context.paths.dir_config, { recursive: true });
return context;
}
async function token(
context,
value = 'fixture-token',
expiration = Date.now() / 1000 + 3600,
) {
await fs.writeFile(
context.paths.file_auth_token,
JSON.stringify({ value, expiration }),
);
}
test('local API preserves every legacy request shape and JSON special characters', async (t) => {
const requests = [];
const context = await fixture(t, async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
requests.push({
url: req.url,
method: req.method,
authorization: req.headers.authorization,
body: body ? JSON.parse(body) : undefined,
});
res.end(JSON.stringify({ code: 200, data: null }));
});
await token(context);
const api = new LocalApi(context);
const name = '任务 "quoted" \\ newline\n';
const command = 'task "owner/repo name.js" -- "a:b"';
const cases = [
['crons', 'POST', { name, command, schedule: '0 1 * * *', sub_id: 4 }],
['crons', 'PUT', { name, command, schedule: '0 2 * * *', id: '7' }],
['crons', 'PUT', { command, id: '7' }],
['crons', 'DELETE', [7, 8]],
[
'crons/status',
'PUT',
{
ids: [7],
status: '1',
pid: '12',
log_path: 'a/1.log',
last_execution_time: 123,
last_running_time: 5,
exit_code: 7,
execution_id: 'legacy-system:123:fixture',
},
],
['system/notify', 'PUT', { title: name, content: 'line1\nline2\\"' }],
[
`crons/detail?${new URLSearchParams({ log_path: 'a space/日志.log' })}`,
'GET',
undefined,
],
['system/auth/reset', 'PUT', { retries: 0 }],
['system/auth/reset', 'PUT', { twoFactorActivated: false }],
['system/auth/reset', 'PUT', { password: name }],
['system/auth/reset', 'PUT', { username: name }],
['dashboard/record', 'POST', { ref_id: 7, code: 7, elapsed: 5 }],
];
for (const [endpoint, method, body] of cases)
assert.deepEqual(await api.call(endpoint, method, body), {
code: 200,
data: null,
});
assert.deepEqual(
requests,
cases.map(([endpoint, method, body]) => ({
url: `/open/${endpoint}`,
method,
body,
authorization: 'Bearer fixture-token',
})),
);
});
test('local credentials reuse valid cache and regenerate missing, expired or malformed files', async (t) => {
for (const state of ['valid', 'missing', 'expired', 'malformed']) {
await t.test(state, async (t) => {
const auth = [];
const context = await fixture(t, (req, res) => {
auth.push(req.headers.authorization);
res.end(JSON.stringify({ code: 200 }));
});
const marker = path.join(context.root, 'generated');
context.env.TOKEN_FILE = context.paths.file_auth_token;
context.env.GENERATOR_MARKER = marker;
await fs.mkdir(path.join(context.root, 'static/build'), {
recursive: true,
});
await fs.writeFile(
path.join(context.root, 'static/build/token.js'),
'const fs=require("fs");fs.appendFileSync(process.env.GENERATOR_MARKER,"generated\\n");' +
'fs.writeFileSync(process.env.TOKEN_FILE,JSON.stringify({value:"generated-token",expiration:Date.now()/1000+3600}));' +
'console.log("generated-token")',
);
if (state === 'valid') await token(context);
if (state === 'expired') await token(context, 'expired-token', 1);
if (state === 'malformed')
await fs.writeFile(context.paths.file_auth_token, 'invalid-json');
const api = new LocalApi(context);
await api.call('crons');
await api.call('crons');
assert.deepEqual(
auth,
Array(2).fill(
`Bearer ${state === 'valid' ? 'fixture-token' : 'generated-token'}`,
),
);
if (state === 'valid')
await assert.rejects(fs.stat(marker), { code: 'ENOENT' });
else assert.equal(await fs.readFile(marker, 'utf8'), 'generated\n');
});
}
});
test('local API rejects bad responses and redirects without replaying mutations', async (t) => {
const seen = [];
const context = await fixture(t, (req, res) => {
seen.push(req.url);
if (req.url === '/open/redirect') {
res.writeHead(307, { location: '/open/redirect-target' });
res.end();
} else if (req.url === '/open/http-error') {
res.writeHead(500);
res.end('fixture-token');
} else if (req.url === '/open/api-error') {
res.end(JSON.stringify({ code: 403, message: 'fixture-token' }));
} else res.end('not-json fixture-token');
});
await token(context);
const api = new LocalApi(context);
for (const endpoint of [
'redirect',
'http-error',
'api-error',
'invalid-json',
]) {
await assert.rejects(
api.call(endpoint, 'PUT', { value: 'secret-input' }),
(error) => {
assert.doesNotMatch(error.message, /fixture-token|secret-input/);
return true;
},
);
}
assert.deepEqual(seen, [
'/open/redirect',
'/open/http-error',
'/open/api-error',
'/open/invalid-json',
]);
});
test(
'cancelled local API requests do not replay mutations and final lifecycle still reports',
{ timeout: 5000 },
async (t) => {
const { cancellableOperation } = require('../../dist/internal/runtime/cancellation');
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
for (const partialBody of [false, true]) {
const controller = new AbortController();
const calls = [];
const context = await fixture(t, async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
calls.push({ url: req.url, body: JSON.parse(body) });
if (req.url === '/open/crons') {
if (partialBody) {
res.writeHead(200);
res.write('{"code":');
}
controller.abort('SIGTERM');
return;
}
res.end(JSON.stringify({ code: 200 }));
});
await token(context);
context.env.QL_CLI_LIFECYCLE = 'extended';
context.env.ID = '7';
context.env.no_tee = 'true';
await assert.rejects(
loggedOperation(
context,
'raw',
() => new LocalApi(context).call('crons', 'POST', { name: 'once' }),
controller.signal,
),
/do not retry a mutation|不要直接重试写入/,
);
assert.deepEqual(
calls.map((call) => call.url),
['/open/crons/status', '/open/crons', '/open/crons/status'],
);
assert.equal(calls[2].body.exit_code, 143);
assert.equal(calls[2].body.status, '1');
await assert.rejects(
cancellableOperation(controller.signal, () =>
new LocalApi(context).call('crons', 'POST', {}),
),
);
assert.equal(calls.length, 3);
}
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(`local API errors preserve language, status and no-replay behavior: ${language}`, async (t) => {
const seen = [];
const context = await fixture(t, (req, res) => {
seen.push(req.url);
if (req.url.endsWith('/http')) {
res.statusCode = 503;
res.end('private-server-body');
} else
res.end(JSON.stringify({ code: 403, message: 'private-server-body' }));
});
context.env.QL_LANG = language;
const expected = (zh, en) => (language === 'en' ? en : zh);
const check = (code, zh, en) => (error) => {
assert.equal(error.exitCode, code);
assert.match(error.message, expected(zh, en));
assert.doesNotMatch(
error.message,
/fixture-token|private-server-body|private-input/,
);
return true;
};
await fs.mkdir(path.join(context.root, 'static/build'), {
recursive: true,
});
await fs.writeFile(path.join(context.root, 'static/build/token.js'), '');
await assert.rejects(
new LocalApi(context).call('http'),
check(3, /无法获取本机系统令牌/, /Cannot obtain a local system token/),
);
assert.deepEqual(seen, []);
await token(context);
const port = context.env.QlPort;
context.env.QlPort = 'invalid';
await assert.rejects(
new LocalApi(context).call('http'),
check(2, /端口无效/, /Invalid local panel port/),
);
assert.deepEqual(seen, []);
context.env.QlPort = port;
await assert.rejects(
new LocalApi(context).call('http', 'PUT', { value: 'private-input' }),
check(1, /不要直接重试写入/, /do not retry a mutation/),
);
await assert.rejects(
new LocalApi(context).call('rejected', 'PUT', { value: 'private-input' }),
check(
1,
/拒绝请求.*面板日志和权限/,
/rejected request.*logs and permissions/,
),
);
assert.deepEqual(seen, ['/open/http', '/open/rejected']);
});
}
+76
View File
@@ -0,0 +1,76 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { LocalApi } = require('../../dist/internal/runtime/api');
const { pruneLogs } = require('../../dist/internal/maintenance/maintenance');
test('undated logs use modification calendar date and preserve active references at retention boundary', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const now = new Date(2026, 0, 8, 12).getTime();
const modified = new Date(2026, 0, 1, 18);
t.mock.method(Date, 'now', () => now);
const queries = [];
t.mock.method(LocalApi.prototype, 'call', async (endpoint) => {
const log = new URL(`http://fixture/${endpoint}`).searchParams.get(
'log_path',
);
queries.push(log);
return { data: log.includes('active') ? { id: 1 } : null };
});
const createLogs = async (directory) => {
await fs.mkdir(directory, { recursive: true });
for (const name of [
'undated.log',
'active.log',
'2026-01-01-dated.log',
'ignore.txt',
]) {
await fs.writeFile(path.join(directory, name), 'fixture');
await fs.utimes(path.join(directory, name), modified, modified);
}
};
const context = createContext({ root }, {});
await createLogs(context.paths.dir_log);
const result = await pruneLogs(context, 7);
assert.deepEqual(result.removed, ['2026-01-01-dated.log', 'undated.log']);
assert.deepEqual(result.retained, ['active.log']);
assert.deepEqual(queries.sort(), [
'2026-01-01-dated.log',
'active.log',
'undated.log',
]);
assert.deepEqual((await fs.readdir(context.paths.dir_log)).sort(), [
'active.log',
'ignore.txt',
]);
if (process.platform === 'linux') {
const legacy = path.join(root, 'legacy');
await createLogs(legacy);
execFileSync(
'/bin/bash',
[
path.join(__dirname, '../fixtures/log-retention.sh'),
path.resolve(__dirname, '../../../shell/rmlog.sh'),
],
{
env: {
PATH: '/usr/bin:/bin',
dir_log: legacy,
is_macos: '0',
FIXED_NOW: String(now / 1000),
TZ: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
stdio: 'pipe',
},
);
assert.deepEqual((await fs.readdir(legacy)).sort(), [
'active.log',
'ignore.txt',
]);
}
});
@@ -0,0 +1,335 @@
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 http = require('node:http');
const https = require('node:https');
const net = require('node:net');
const { randomUUID } = require('node:crypto');
const { execFileSync, spawn } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
for (const tls of [false, true])
test(
`authenticated Git ${
tls ? 'HTTPS/CONNECT' : 'HTTP/proxy'
} transport preserves checkout after access failure`,
{ timeout: 30000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-git-http-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const execPath = execFileSync(git, ['--exec-path'], {
encoding: 'utf8',
}).trim();
await fs.access(path.join(execPath, 'git-http-backend')).catch(() => {
throw new Error(
'Test prerequisite missing: git-http-backend (Alpine: git-daemon).',
);
});
const source = path.join(root, 'owner/repo.git');
await fs.mkdir(source, { recursive: true });
const run = (...args) =>
execFileSync(git, ['-C', source, ...args], { stdio: 'pipe' });
run('init', '-b', 'main');
run('config', 'user.name', 'Fixture');
run('config', 'user.email', 'fixture@example.invalid');
await fs.writeFile(path.join(source, 'wrong.js'), 'wrong branch');
run('add', '.');
run('commit', '-m', 'main');
run('checkout', '-b', 'selected');
await fs.rm(path.join(source, 'wrong.js'));
await fs.writeFile(path.join(source, 'job.js'), 'version one');
await fs.writeFile(path.join(source, 'helper.js'), 'dependency');
run('add', '-A');
run('commit', '-m', 'selected');
const authorization = `Basic ${Buffer.from(
`fixture:${randomUUID()}`,
).toString('base64')}`;
let failure = false;
const requests = [];
const children = new Set();
let certificate;
let tlsOptions;
if (tls) {
certificate = path.join(root, 'certificate.pem');
const key = path.join(root, 'key.pem');
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
key,
'-out',
certificate,
'-subj',
'/CN=127.0.0.1',
'-addext',
'subjectAltName=IP:127.0.0.1',
'-days',
'1',
],
{ stdio: 'pipe' },
);
tlsOptions = {
key: await fs.readFile(key),
cert: await fs.readFile(certificate),
};
}
const serveGit = (req, res) => {
const url = new URL(req.url, 'http://fixture.invalid');
requests.push({
method: req.method,
path: url.pathname,
proxy: req.url.startsWith('http://'),
});
if (failure) {
res.writeHead(503).end();
req.resume();
return;
}
if (req.headers.authorization !== authorization) {
res
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
.end();
req.resume();
return;
}
const child = spawn(git, ['http-backend'], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: req.method,
PATH_INFO: url.pathname,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers['content-type'] || '',
CONTENT_LENGTH: req.headers['content-length'] || '',
REMOTE_USER: 'fixture',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
children.add(child);
child.on('error', () => res.destroy());
child.on('close', () => children.delete(child));
child.stderr.resume();
child.stdin.on('error', () => {});
req.pipe(child.stdin);
let pending = Buffer.alloc(0),
headersSent = false;
child.stdout.on('data', (chunk) => {
if (headersSent) {
res.write(chunk);
return;
}
pending = Buffer.concat([pending, chunk]);
const boundary = pending.indexOf('\r\n\r\n');
if (boundary < 0) return;
let status = 200;
const headers = {};
for (const line of pending
.subarray(0, boundary)
.toString()
.split('\r\n')) {
const colon = line.indexOf(':');
const key = line.slice(0, colon),
value = line.slice(colon + 1).trim();
if (key.toLowerCase() === 'status')
status = Number(value.split(' ')[0]);
else headers[key] = value;
}
res.writeHead(status, headers);
headersSent = true;
res.write(pending.subarray(boundary + 4));
});
child.stdout.on('end', () => res.end());
};
const server = tls
? https.createServer(tlsOptions, serveGit)
: http.createServer(serveGit);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
for (const child of children) child.kill('SIGKILL');
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const endpoint = `${tls ? 'https' : 'http'}://127.0.0.1:${
server.address().port
}`;
let proxyEndpoint = endpoint;
let tunnels = 0;
if (tls) {
const sockets = new Set();
const tunnel = http.createServer((req, res) =>
res.writeHead(405).end(),
);
tunnel.on('connect', (req, client, head) => {
if (req.url !== `127.0.0.1:${server.address().port}`) {
client.end('HTTP/1.1 403 Forbidden\r\n\r\n');
return;
}
tunnels++;
const upstream = net.connect(
server.address().port,
'127.0.0.1',
() => {
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length) upstream.write(head);
client.pipe(upstream);
upstream.pipe(client);
},
);
for (const socket of [client, upstream]) {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
}
client.on('error', () => upstream.destroy());
upstream.on('error', () => client.destroy());
client.on('close', () => upstream.destroy());
upstream.on('close', () => client.destroy());
});
await new Promise((resolve) => tunnel.listen(0, '127.0.0.1', resolve));
proxyEndpoint = `http://127.0.0.1:${tunnel.address().port}`;
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => tunnel.close(resolve));
});
}
for (const [proxy, credentialMode] of [
[false, 'header'],
[true, 'header'],
[false, 'helper'],
[true, 'helper'],
]) {
failure = false;
const panel = path.join(
root,
`${credentialMode}-${proxy ? 'proxied-panel' : 'direct-panel'}`,
);
await fs.mkdir(panel);
const env = {
PATH: `/usr/bin:/bin:${process.env.PATH}`,
GIT_TERMINAL_PROMPT: '0',
...(tls ? { GIT_SSL_CAINFO: certificate } : {}),
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: os.devNull,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: `Authorization: ${authorization}`,
http_proxy: '',
https_proxy: '',
all_proxy: '',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '',
no_proxy: '',
};
if (credentialMode === 'helper') {
const credentialFile = path.join(panel, 'credentials');
const credential = new URL(
proxy && !tls ? 'http://repository.invalid' : endpoint,
);
const [username, password] = Buffer.from(
authorization.slice(6),
'base64',
)
.toString()
.split(':');
credential.username = username;
credential.password = password;
await fs.writeFile(credentialFile, credential.toString() + '\n', {
mode: 0o600,
});
env.GIT_CONFIG_KEY_0 = 'credential.helper';
env.GIT_CONFIG_VALUE_0 =
"store --file='" + credentialFile.replaceAll("'", "'\"'\"'") + "'";
}
const context = createContext({ root: panel }, env);
const input = {
url: `${
proxy && !tls ? 'http://repository.invalid' : endpoint
}/owner/repo.git`,
branch: 'selected',
include: '^job',
dependencies: '^helper',
autoAdd: false,
autoDelete: false,
...(proxy ? { proxy: proxyEndpoint } : {}),
};
const start = requests.length;
const previousTunnels = tunnels;
const result = await syncRepository(context, input);
const destination = path.join(
context.paths.dir_scripts,
result.repository,
);
assert.equal(
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
'version one',
);
assert.equal(
await fs.readFile(path.join(destination, 'helper.js'), 'utf8'),
'dependency',
);
await assert.rejects(fs.access(path.join(destination, 'wrong.js')));
assert.ok(
requests
.slice(start)
.some(
(row) =>
row.method === 'POST' &&
row.path.endsWith('/git-upload-pack') &&
row.proxy === (proxy && !tls),
),
);
if (tls && proxy)
assert.ok(
tunnels > previousTunnels,
'HTTPS must traverse the CONNECT proxy',
);
const checkout = path.join(context.paths.dir_repo, result.repository);
const head = execFileSync(git, [
'-C',
checkout,
'rev-parse',
'HEAD',
]).toString();
for (const kind of [
'unauthorized',
'unavailable',
...(tls ? ['untrusted'] : []),
]) {
failure = kind === 'unavailable';
const failedContext =
kind === 'unauthorized'
? createContext(
{ root: panel },
{ ...env, GIT_CONFIG_COUNT: '0' },
)
: kind === 'untrusted'
? createContext(
{ root: panel },
{ ...env, GIT_SSL_CAINFO: undefined },
)
: context;
await assert.rejects(syncRepository(failedContext, input));
assert.equal(
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
'version one',
);
assert.equal(
execFileSync(git, ['-C', checkout, 'rev-parse', 'HEAD']).toString(),
head,
);
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
}
}
},
);
+799
View File
@@ -0,0 +1,799 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { createContext } = require('../../dist/internal/runtime/context');
const {
repairConfiguration,
installPanelDependencies,
startPanel,
} = require('../../dist/internal/maintenance/operator');
const {
replaceAndReload,
stageUpgrade,
reloadPanel,
} = require('../../dist/internal/maintenance/upgrade');
const { parse } = require('../helpers/commands.cjs');
async function fixture(t) {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-operator-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{ PATH: '/usr/bin:/bin', QL_LANG: 'en' },
);
await fs.mkdir(path.join(root, 'sample'));
for (const file of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.js',
'ql_sample.py',
])
await fs.writeFile(path.join(root, 'sample', file), `sample:${file}`);
return context;
}
async function stub(context, program, body) {
const bin = path.join(context.root, 'bin');
await fs.mkdir(bin, { recursive: true });
await fs.writeFile(
path.join(bin, program),
`#!${process.execPath}\n${body}`,
{ mode: 0o755 },
);
context.env.PATH = `${bin}:/usr/bin:/bin`;
}
test('configuration repair preserves custom content and intentionally empty hooks', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(ctx.paths.dir_config, { recursive: true });
await fs.writeFile(ctx.paths.file_config_user, 'custom configuration');
await fs.writeFile(ctx.paths.file_task_before, '');
assert.equal((await repairConfiguration(ctx)).length, 8);
assert.equal(
await fs.readFile(ctx.paths.file_config_user, 'utf8'),
'custom configuration',
);
assert.equal(await fs.readFile(ctx.paths.file_task_before, 'utf8'), '');
assert.deepEqual(await repairConfiguration(ctx), []);
await fs.writeFile(ctx.paths.file_notify_py, '');
assert.deepEqual(await repairConfiguration(ctx), [ctx.paths.file_notify_py]);
});
test('dependency installation selects pnpm or Termux npm argv without shell interpolation', async (t) => {
const ctx = await fixture(t);
const output = path.join(ctx.root, 'calls.jsonl');
ctx.env.CALLS = output;
const record =
'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()})+"\\n");';
await stub(ctx, 'pnpm', record);
await stub(ctx, 'npm', record);
await installPanelDependencies(ctx);
ctx.env.is_termux = '1';
await installPanelDependencies(ctx);
const calls = (await fs.readFile(output, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
calls.map((c) => c.args),
[
['install', '--loglevel', 'error', '--production'],
['install', '--production', '--no-bin-links'],
],
);
assert.ok(calls.every((c) => c.cwd === ctx.root));
});
test('PM2 reload uses installation cwd and exact process-manager arguments', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'pm2.jsonl');
await stub(
ctx,
'pm2',
'require("node:fs").appendFileSync(process.env.CALLS,JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()})+"\\n");',
);
assert.deepEqual(await startPanel(ctx), { manager: 'pm2' });
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(
calls.map((c) => c.args),
[
['flush'],
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
],
);
assert.ok(calls.every((c) => c.cwd === ctx.root));
});
test('PM2 restarts retain configured ports without mutating legacy environment', async (t) => {
const ctx = await fixture(t);
ctx.env.CALLS = path.join(ctx.root, 'ports.jsonl');
ctx.env.BACK_PORT = '5600';
ctx.env.GRPC_PORT = '5400';
await stub(ctx, 'pm2', 'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");');
await startPanel(ctx);
ctx.env.QlPort = '5799';
ctx.env.QlGrpcPort = '5599';
await startPanel(ctx);
assert.deepEqual((await fs.readFile(ctx.env.CALLS, 'utf8')).trim().split('\n').map(JSON.parse), [
['5700', '5500'], ['5700', '5500'], ['5799', '5599'], ['5799', '5599'],
]);
assert.equal(ctx.env.BACK_PORT, '5600');
assert.equal(ctx.env.GRPC_PORT, '5400');
});
test('failed service start restores all replaced files and restarts the previous version', async (t) => {
const ctx = await fixture(t);
const target = path.join(ctx.root, 'installed');
const source = path.join(ctx.root, 'staged');
await fs.mkdir(target);
await fs.mkdir(source);
await fs.writeFile(path.join(target, 'old'), 'old');
await fs.writeFile(path.join(source, 'new'), 'new');
let starts = 0,
stops = 0;
await assert.rejects(
replaceAndReload(ctx, [{ source, target }], {
stop: async () => {
stops++;
},
start: async () => {
if (++starts === 1) {
assert.equal(
await fs.readFile(path.join(target, 'new'), 'utf8'),
'new',
);
throw new Error('startup failed');
}
assert.equal(
await fs.readFile(path.join(target, 'old'), 'utf8'),
'old',
);
},
}),
/startup failed/,
);
assert.equal(stops, 2);
assert.equal(starts, 2);
assert.deepEqual(await fs.readdir(target), ['old']);
assert.ok(
!(await fs.readdir(ctx.root)).some((name) => name.includes('ql-backup')),
);
await replaceAndReload(ctx, [{ source, target }], {
stop: async () => {},
start: async () => ({ manager: 'fixture' }),
});
assert.deepEqual(await fs.readdir(target), ['new']);
});
test('incomplete upgrade download cannot stop services or change installed files', async (t) => {
const ctx = await fixture(t);
await fs.writeFile(path.join(ctx.root, 'package.json'), '{"name":"old"}');
await stub(ctx, 'curl', 'process.exit(22);');
await assert.rejects(stageUpgrade(ctx, 'github'), /exit 22/);
assert.equal(
await fs.readFile(path.join(ctx.root, 'package.json'), 'utf8'),
'{"name":"old"}',
);
assert.deepEqual(await fs.readdir(ctx.paths.dir_tmp), []);
assert.throws(() => parse(['update'], 'public'));
assert.equal(
parse(['update', '--download-only'], 'local').values['download-only'],
true,
);
assert.throws(() => parse(['reload', '--target', 'arbitrary'], 'local'));
});
test('upgrade staging extracts both archives before publishing readiness and rejects archive symlinks', async (t) => {
const { execFileSync } = require('node:child_process');
const ctx = await fixture(t);
const payloads = path.join(ctx.root, 'payloads');
const archives = path.join(ctx.root, 'archives');
await fs.mkdir(archives);
await fs.mkdir(path.join(payloads, 'qinglong-master'), { recursive: true });
await fs.mkdir(path.join(payloads, 'qinglong-static-master/build'), {
recursive: true,
});
const manifest = '{"name":"fixture"}';
await fs.writeFile(path.join(ctx.root, 'package.json'), manifest);
await fs.writeFile(
path.join(payloads, 'qinglong-master/package.json'),
manifest,
);
await fs.writeFile(
path.join(payloads, 'qinglong-static-master/build/app.js'),
'console.log("fixture")',
);
for (const repo of ['qinglong', 'qinglong-static'])
execFileSync(
'/usr/bin/zip',
['-qry', path.join(archives, `${repo}.zip`), `${repo}-master`],
{ cwd: payloads },
);
ctx.env.ARCHIVES = archives;
await stub(
ctx,
'curl',
'const fs=require("node:fs"),p=require("node:path"),args=process.argv.slice(2),out=args[args.indexOf("--output")+1]; fs.copyFileSync(p.join(process.env.ARCHIVES,p.basename(out)),out);',
);
await fs.mkdir(path.join(payloads, 'qinglong-master/sample'), {
recursive: true,
});
await fs.writeFile(
path.join(payloads, 'qinglong-master/sample/config.sample.sh'),
'new sample',
);
execFileSync(
'/usr/bin/zip',
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
{ cwd: payloads },
);
const staged = await stageUpgrade(ctx, 'github');
assert.equal(
await fs.readFile(path.join(staged.source, 'package.json'), 'utf8'),
manifest,
);
assert.deepEqual(
JSON.parse(
await fs.readFile(
path.join(path.dirname(staged.source), 'ready.json'),
'utf8',
),
),
staged,
);
await fs.symlink(
'../../outside',
path.join(payloads, 'qinglong-master/escape'),
);
execFileSync(
'/usr/bin/zip',
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
{ cwd: payloads },
);
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
assert.deepEqual(
(await fs.readdir(ctx.paths.dir_tmp)).sort(),
[
path.basename(path.dirname(staged.source)),
'upgrade-ready-master.json',
].sort(),
);
await repairConfiguration(ctx);
await stub(ctx, 'pm2', '');
await reloadPanel(ctx, 'system');
assert.equal(
await fs.readFile(
path.join(ctx.paths.dir_config, 'config.sample.sh'),
'utf8',
),
'new sample',
);
assert.equal(
await fs.readFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'utf8'),
'console.log("fixture")',
);
});
test('check repairs dependencies and notifications, probes loopback and reloads with clean diagnostics', async (t) => {
const http = require('node:http');
const {
checkAndRepair,
diagnosticLog,
inspectPanel,
} = require('../../dist/internal/maintenance/check');
const ctx = await fixture(t);
const routes = [];
const server = http.createServer((req, res) => {
routes.push(req.url);
res.end(
req.url === '/'
? '<html><div id="root"></div></html>'
: '{"code":200,"data":{"status":"ok"}}',
);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
ctx.env.QlPort = String(server.address().port);
ctx.env.CALLS = path.join(ctx.root, 'operations.jsonl');
ctx.env.PM2_HOME = path.join(ctx.root, 'pm2');
ctx.env.HTTP_PROXY = 'http://127.0.0.1:1';
const record =
'require("node:fs").appendFileSync(process.env.CALLS,JSON.stringify({program:require("node:path").basename(process.argv[1]),args:process.argv.slice(2)})+"\\n");';
for (const executable of ['npm', 'pnpm', 'pm2'])
await stub(ctx, executable, record);
await repairConfiguration(ctx);
await fs.writeFile(ctx.paths.file_notify_py, 'custom old notify');
await fs.mkdir(path.join(ctx.env.PM2_HOME, 'logs'), { recursive: true });
const logfile = path.join(ctx.env.PM2_HOME, 'logs/qinglong-out.log');
await fs.writeFile(
logfile,
Array.from({ length: 350 }, (_, i) => `line ${i}`).join('\n') + '\n',
);
const result = await checkAndRepair(ctx);
assert.equal(
await fs.readFile(ctx.paths.file_notify_py, 'utf8'),
'sample:notify.py',
);
assert.equal(result.before.panel.healthy, true);
assert.equal(result.after.backend.healthy, true);
assert.equal(result.service.manager, 'pm2');
assert.equal(result.logs[0].text.split('\n').length, 300);
assert.equal(result.logs[0].text.split('\n')[0], 'line 50');
assert.equal(result.logs[1].error, 'Log is absent.');
const calls = (await fs.readFile(ctx.env.CALLS, 'utf8'))
.trim()
.split('\n')
.map(JSON.parse);
assert.deepEqual(calls, [
{
program: 'npm',
args: ['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
},
{
program: 'pnpm',
args: ['install', '--loglevel', 'error', '--production'],
},
{ program: 'pm2', args: ['flush'] },
{
program: 'pm2',
args: ['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
},
]);
assert.equal(routes.length, 4);
assert.equal(
routes.filter((route) => route.startsWith('/api/health?t=')).length,
2,
);
await fs.writeFile(logfile, 'x'.repeat(1024 * 1024) + '\nlast line\n');
assert.deepEqual(await diagnosticLog(logfile), {
file: logfile,
text: 'last line',
truncated: true,
});
ctx.env.QlPort = '5700/remote';
await assert.rejects(inspectPanel(ctx), /QlPort/);
assert.throws(() => parse(['check'], 'public'));
assert.equal(parse(['check'], 'local').name, 'local check');
});
test('diagnostics reject redirects and HTTP errors without following a remote target', async (t) => {
const http = require('node:http');
const { inspectPanel } = require('../../dist/internal/maintenance/check');
const ctx = await fixture(t);
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.writeHead(302, { Location: 'http://example.invalid/' });
res.end('<div id="root"></div>');
} else {
res.writeHead(503);
res.end('{"code":200}');
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
ctx.env.QlPort = String(server.address().port);
const result = await inspectPanel(ctx);
assert.deepEqual(result, {
panel: { healthy: false, status: 302 },
backend: { healthy: false, status: 503 },
});
});
test('repair stops after installer failure before replacing user files or reloading', async (t) => {
const { checkAndRepair } = require('../../dist/internal/maintenance/check');
const ctx = await fixture(t);
await repairConfiguration(ctx);
await fs.writeFile(ctx.paths.file_notify_js, 'keep existing notification');
await stub(ctx, 'npm', 'process.exit(17);');
await stub(
ctx,
'pm2',
'require("node:fs").writeFileSync(process.env.QL_DIR+"/unexpected-reload", "bad");',
);
await assert.rejects(checkAndRepair(ctx), /exit 17/);
assert.equal(
await fs.readFile(ctx.paths.file_notify_js, 'utf8'),
'keep existing notification',
);
await assert.rejects(fs.stat(path.join(ctx.root, 'unexpected-reload')), {
code: 'ENOENT',
});
});
test('legacy check workflow and TypeScript repair agree on dependency and notification operations', async (t) => {
const { execFileSync } = require('node:child_process');
const ctx = await fixture(t);
await repairConfiguration(ctx);
const trace = path.join(ctx.root, 'legacy-trace');
const shell = `
t() { :; }
npm() { printf 'npm %s\\n' "$*" >> "$TRACE"; }
fix_config() { printf 'repair-config\\n' >> "$TRACE"; }
npm_install_2() { printf 'dependencies %s\\n' "$1" >> "$TRACE"; }
cp() { printf 'copy %s\\n' "$*" >> "$TRACE"; command cp "$@"; }
curl() {
case "$*" in
*'/api/health'*|*'/api/system'*) printf '{"code":200,"data":{},"status":"ok"}' ;;
*) printf '<div id="root"></div>' ;;
esac
}
tail() { :; }
reload_pm2() { printf 'reload\\n' >> "$TRACE"; }
. "$CHECK_SOURCE"
`;
execFileSync('/bin/bash', ['--noprofile', '--norc', '-c', shell], {
env: {
...ctx.env,
TRACE: trace,
CHECK_SOURCE: path.resolve(__dirname, '../../../shell/check.sh'),
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const operations = (await fs.readFile(trace, 'utf8')).trim().split('\n');
assert.deepEqual(operations, [
'npm i -g pnpm@8.3.1 pm2 ts-node typescript@5',
'repair-config',
`dependencies ${ctx.root}`,
`copy -fv ${ctx.paths.file_notify_py_sample} ${ctx.paths.file_notify_py}`,
`copy -fv ${ctx.paths.file_notify_js_sample} ${ctx.paths.file_notify_js}`,
'reload',
]);
assert.equal(
await fs.readFile(ctx.paths.file_notify_js, 'utf8'),
'sample:notify.js',
);
assert.equal(
await fs.readFile(ctx.paths.file_notify_py, 'utf8'),
'sample:notify.py',
);
});
test('interrupted reload restores old files and recovery subprocesses ignore the cancelled operation', async (t) => {
const {
cancellableOperation,
operationSignal,
} = require('../../dist/internal/runtime/cancellation');
const { checkedProcess } = require('../../dist/internal/runtime/process');
for (const phase of ['before', 'stop', 'start']) {
const ctx = await fixture(t);
const target = path.join(ctx.root, 'installed');
const source = path.join(ctx.root, 'staged');
await fs.mkdir(target);
await fs.mkdir(source);
await fs.writeFile(path.join(target, 'version'), 'old');
await fs.writeFile(path.join(source, 'version'), 'new');
const controller = new AbortController();
const events = [];
if (phase === 'before') controller.abort('SIGTERM');
await assert.rejects(
cancellableOperation(controller.signal, () =>
replaceAndReload(ctx, [{ source, target }], {
stop: async () => {
events.push('stop');
if (phase === 'stop' && events.length === 1)
controller.abort('SIGTERM');
if (events.length > 1) assert.equal(operationSignal(), undefined);
},
start: async () => {
const version = await fs.readFile(
path.join(target, 'version'),
'utf8',
);
events.push(`start:${version}`);
if (version === 'new') {
controller.abort('SIGTERM');
// Simulate a start that reports success despite interruption.
return;
}
assert.equal(operationSignal(), undefined);
const result = await checkedProcess(
process.execPath,
['-e', 'process.stdout.write("recovered")'],
{ capture: true },
);
assert.equal(result.stdout, 'recovered');
},
}),
),
);
assert.deepEqual(
events,
phase === 'before'
? []
: phase === 'stop'
? ['stop', 'start:old']
: ['stop', 'start:new', 'stop', 'start:old'],
);
assert.equal(
await fs.readFile(path.join(target, 'version'), 'utf8'),
'old',
);
assert.ok(
!(await fs.readdir(ctx.root)).some((name) => name.includes('ql-backup')),
);
}
});
test('reload retains backups when a partially started new service cannot stop', async (t) => {
const ctx = await fixture(t);
const target = path.join(ctx.root, 'installed');
const source = path.join(ctx.root, 'staged');
await fs.mkdir(target);
await fs.mkdir(source);
await fs.writeFile(path.join(target, 'version'), 'old');
await fs.writeFile(path.join(source, 'version'), 'new');
let stops = 0;
await assert.rejects(
replaceAndReload(ctx, [{ source, target }], {
stop: async () => {
if (++stops === 2) throw new Error('service still running');
},
start: async () => {
throw new Error('partial startup');
},
}),
/service still running/,
);
assert.equal(await fs.readFile(path.join(target, 'version'), 'utf8'), 'new');
const backup = (await fs.readdir(ctx.root)).find((name) =>
name.startsWith('installed.ql-backup-'),
);
assert.ok(backup);
assert.equal(
await fs.readFile(path.join(ctx.root, backup, 'version'), 'utf8'),
'old',
);
});
test('overlay directory backup supports replacement and rollback after EXDEV', async (t) => {
for (const failed of [false, true]) {
const ctx = await fixture(t);
const target = path.join(ctx.root, 'installed');
const source = path.join(ctx.root, 'staged');
await fs.mkdir(target);
await fs.mkdir(source);
await fs.writeFile(path.join(target, 'version'), 'old');
await fs.writeFile(path.join(source, 'version'), 'new');
const rename = fs.rename;
fs.rename = async (from, to) => {
if (from === target)
throw Object.assign(new Error('overlay directory'), { code: 'EXDEV' });
return rename(from, to);
};
let starts = 0;
try {
const operation = replaceAndReload(ctx, [{ source, target }], {
stop: async () => {},
start: async () => {
if (++starts === 1 && failed) throw Error('new service failed');
},
});
if (failed) await assert.rejects(operation, /new service failed/);
else await operation;
assert.equal(
await fs.readFile(path.join(target, 'version'), 'utf8'),
failed ? 'old' : 'new',
);
assert.ok(
!(await fs.readdir(ctx.root)).some((name) =>
name.includes('ql-backup'),
),
);
} finally {
fs.rename = rename;
}
}
});
test('partial overlay backup, removal and replacement failures restore the complete previous installation', async (t) => {
for (const phase of ['backup', 'remove', 'replacement']) {
await t.test(phase, async () => {
const ctx = await fixture(t);
const replacements = [];
for (const name of ['first', 'second']) {
const target = path.join(ctx.root, name);
const source = path.join(ctx.root, `${name}-staged`);
await fs.mkdir(target);
await fs.mkdir(source);
await fs.writeFile(path.join(target, 'version'), `old-${name}`);
await fs.writeFile(path.join(target, 'keep'), 'original metadata');
await fs.symlink('version', path.join(target, 'link'));
await fs.writeFile(path.join(source, 'version'), `new-${name}`);
replacements.push({ source, target });
}
const [first, second] = replacements;
const original = { rename: fs.rename, cp: fs.cp, rm: fs.rm };
let injected = false;
const failure = Object.assign(new Error(`partial ${phase}`), {
code: phase === 'remove' ? 'EACCES' : 'ENOSPC',
});
const events = [];
fs.rename = async (from, to) => {
if (replacements.some((item) => item.target === from))
throw Object.assign(new Error('overlay directory'), {
code: 'EXDEV',
});
return original.rename(from, to);
};
fs.cp = async (from, to, options) => {
if (
!injected &&
((phase === 'backup' && from === second.target) ||
(phase === 'replacement' && from === second.source))
) {
injected = true;
await fs.mkdir(to, { recursive: true });
await fs.writeFile(path.join(to, 'version'), 'incomplete copy');
throw failure;
}
return original.cp(from, to, options);
};
fs.rm = async (target, options) => {
if (!injected && phase === 'remove' && target === second.target) {
injected = true;
await original.rm(path.join(target, 'version'));
throw failure;
}
return original.rm(target, options);
};
try {
await assert.rejects(
replaceAndReload(ctx, replacements, {
stop: async () => {
events.push('stop');
},
start: async () => {
events.push('start');
// Recovery must restore every root entry before restarting services.
for (const [index, item] of replacements.entries())
assert.equal(
await fs.readFile(path.join(item.target, 'version'), 'utf8'),
`old-${index === 0 ? 'first' : 'second'}`,
);
},
}),
(error) => error === failure,
);
assert.equal(injected, true);
assert.deepEqual(events, ['stop', 'start']);
for (const item of replacements) {
assert.equal(
await fs.readFile(path.join(item.target, 'keep'), 'utf8'),
'original metadata',
);
assert.equal(
await fs.readlink(path.join(item.target, 'link')),
'version',
);
}
assert.equal(
await fs.readFile(path.join(first.source, 'version'), 'utf8'),
'new-first',
);
assert.ok(
!(await fs.readdir(ctx.root)).some((name) =>
name.includes('ql-backup'),
),
);
} finally {
Object.assign(fs, original);
}
});
}
});
test('invalid or incomplete staged pointers cannot stop the installed panel', async (t) => {
const ctx = await fixture(t);
await fs.mkdir(ctx.paths.dir_tmp, { recursive: true });
const calls = path.join(ctx.root, 'service-called');
ctx.env.CALLS = calls;
await stub(
ctx,
'pm2',
'require("node:fs").writeFileSync(process.env.CALLS,"called")',
);
const pointer = path.join(ctx.paths.dir_tmp, 'upgrade-ready-master.json');
for (const value of [
'invalid json',
JSON.stringify({ directory: '../outside' }),
JSON.stringify({ directory: 'upgrade-missing' }),
]) {
await fs.writeFile(pointer, value);
await assert.rejects(reloadPanel(ctx, 'system'));
await assert.rejects(fs.access(calls));
}
const stage = path.join(ctx.paths.dir_tmp, 'upgrade-fixture');
await fs.mkdir(path.join(stage, 'qinglong-master'), { recursive: true });
await fs.mkdir(path.join(stage, 'qinglong-static-master'));
await fs.writeFile(pointer, JSON.stringify({ directory: 'upgrade-fixture' }));
await fs.writeFile(path.join(stage, 'ready.json'), '{}');
await assert.rejects(reloadPanel(ctx, 'system'), /readiness/);
await assert.rejects(fs.access(calls));
await fs.rm(stage, { recursive: true });
await fs.symlink(ctx.root, stage);
await assert.rejects(reloadPanel(ctx, 'system'), /directory/);
await assert.rejects(fs.access(calls));
await fs.rm(pointer);
await assert.rejects(
reloadPanel(ctx, 'system'),
(error) =>
error.code === 'ENOENT' &&
error.path === path.join(ctx.paths.dir_tmp, 'qinglong-master'),
);
});
test('deleted entries are restored alongside replacements when service startup fails', async (t) => {
const ctx = await fixture(t);
const removed = path.join(ctx.root, 'obsolete');
const added = path.join(ctx.root, 'added');
const source = path.join(ctx.root, 'incoming');
await fs.mkdir(removed);
await fs.writeFile(path.join(removed, 'keep'), 'old contents');
await fs.writeFile(source, 'new contents');
let starts = 0;
await assert.rejects(
replaceAndReload(ctx, [{ target: removed }, { source, target: added }], {
stop: async () => {},
start: async () => {
if (++starts === 1) {
await assert.rejects(fs.access(removed));
assert.equal(await fs.readFile(added, 'utf8'), 'new contents');
throw new Error('failed new start');
}
assert.equal(
await fs.readFile(path.join(removed, 'keep'), 'utf8'),
'old contents',
);
await assert.rejects(fs.access(added));
},
}),
/failed new start/,
);
assert.equal(starts, 2);
assert.ok(
!(await fs.readdir(ctx.root)).some((name) => name.includes('ql-backup')),
);
});
test('system upgrade preserves installed dotenv bytes and permissions even when payload includes dotenv', async (t) => {
const ctx = await fixture(t);
await repairConfiguration(ctx);
await stub(ctx, 'pm2', '');
const source = path.join(ctx.root, 'payload-source');
const staticRoot = path.join(ctx.root, 'payload-static');
await fs.mkdir(path.join(source, 'sample'), { recursive: true });
await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true });
await fs.writeFile(
path.join(source, 'package.json'),
'{"name":"replacement"}',
);
await fs.writeFile(
path.join(source, 'sample/config.sample.sh'),
'new sample',
);
await fs.writeFile(path.join(staticRoot, 'build/app.js'), '');
await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n');
const target = path.join(ctx.root, '.env');
const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n';
await fs.writeFile(target, original, { mode: 0o600 });
const before = await fs.stat(target);
await reloadPanel(ctx, 'system', { source, static: staticRoot });
assert.equal(await fs.readFile(target, 'utf8'), original);
const after = await fs.stat(target);
assert.equal(after.ino, before.ino);
assert.equal(after.mode & 0o777, 0o600);
assert.equal(
await fs.readFile(path.join(ctx.root, 'package.json'), 'utf8'),
'{"name":"replacement"}',
);
});
@@ -0,0 +1,131 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload, reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const { installCronEntrypoint } = require('../../dist/local/cronEntrypoint');
for (const language of ['zh', 'en', 'unsupported']) {
test(`upgrade failures preserve files and localize using operation language: ${language}`, async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-upgrade-language-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext({ root }, { QL_LANG: language });
const expected = (zh, en) => (language === 'en' ? en : zh);
let stops = 0;
const lifecycle = {
stop: async () => {
stops++;
},
start: async () => {},
};
await assert.rejects(
replaceAndReload(context, [{ source: root, target: root }], lifecycle),
expected(/源与目标相同/, /equals destination/),
);
assert.equal(stops, 0);
await fs.mkdir(context.paths.dir_tmp);
await fs.writeFile(
path.join(context.paths.dir_tmp, 'upgrade-ready-master.json'),
JSON.stringify({ directory: '../outside' }),
);
await assert.rejects(
reloadPanel(context, 'system'),
expected(/指针无效/, /Invalid staged upgrade pointer/),
);
const source = path.join(root, 'source');
const target = path.join(root, 'target');
await fs.mkdir(source);
await fs.mkdir(target);
await fs.writeFile(path.join(source, 'version'), 'new');
await fs.writeFile(path.join(target, 'version'), 'old');
await fs.symlink(target, path.join(source, 'external'));
await assert.rejects(
reloadPanel(context, 'system', { source, static: source }),
expected(/外部的符号链接/, /external symlink/),
);
await fs.unlink(path.join(source, 'external'));
const cause = new Error('fixture startup failed');
await assert.rejects(
replaceAndReload(context, [{ source, target }], {
stop: async () => {},
start: async () => {
throw cause;
},
}),
(error) => {
assert.match(
error.message,
expected(
/文件已恢复.*原服务无法/,
/files restored.*previous service/,
),
);
assert.equal(error.cause, cause);
return true;
},
);
assert.equal(
await fs.readFile(path.join(target, 'version'), 'utf8'),
'old',
);
});
test(`installed cron bridge localizes failure without exposing values: ${language}`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-cron-language-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const native = path.join(root, 'native');
const bin = path.join(root, 'bin');
await fs.mkdir(native);
const touched = path.join(root, 'touched');
await fs.writeFile(
path.join(native, 'crontab'),
`#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(
touched,
)},'called');`,
{ mode: 0o755 },
);
const source = path.join(root, 'table');
await fs.writeFile(source, '* * * * * task job.js\n');
const environment = {
PATH: native,
QL_LANG: language,
QL_DIR: 'private-path\ninvalid',
};
await installCronEntrypoint(
bin,
path.resolve(__dirname, '../..'),
source,
environment,
);
const run = (env) =>
spawnSync(path.join(bin, 'crontab'), [source], { env, encoding: 'utf8' });
const result = run({});
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(
result.stderr,
language === 'en' ? /Cannot install or read/ : /无法安装或读取/,
);
assert.doesNotMatch(result.stderr, /private-path/);
await assert.rejects(fs.access(touched), { code: 'ENOENT' });
assert.match(run({ QL_LANG: 'en' }).stderr, /Cannot install or read/);
await assert.rejects(
installCronEntrypoint('relative', root, source, environment),
language === 'en' ? /must be absolute/ : /必须为绝对路径/,
);
await fs.writeFile(path.join(bin, 'crontab'), 'unrelated');
await assert.rejects(
installCronEntrypoint(bin, root, source, environment),
language === 'en' ? /Refusing to replace/ : /拒绝覆盖/,
);
assert.equal(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
'unrelated',
);
});
}
@@ -0,0 +1,83 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { within } = require('../../dist/internal/runtime/files');
const { createContext } = require('../../dist/internal/runtime/context');
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
const { syncRaw, repositoryName } = require('../../dist/internal/subscription/subscriptionRunner');
const { LocalApi } = require('../../dist/internal/runtime/api');
const subprocess = require('../../dist/internal/runtime/process');
for (const language of ['zh', 'en', 'unsupported']) {
test(`path and subscription failures use their own operation language: ${language}`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-path-language-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const env = { QL_LANG: language, real_log_path: '../../outside' };
const context = createContext({ root }, env);
const expected = (zh, en) => (language === 'en' ? en : zh);
const boundaryError = (error) => {
assert.equal(error.exitCode, 2);
assert.match(error.message, expected(/管理目录内部/, /descendant/));
return true;
};
for (const relative of ['', '..', '../outside', path.dirname(root)])
assert.throws(() => within(root, relative, env), boundaryError);
assert.equal(
within(root, 'safe/file.js', env),
path.join(root, 'safe/file.js'),
);
let called = false;
await assert.rejects(
loggedOperation(context, 'extra', async () => {
called = true;
}),
boundaryError,
);
assert.equal(called, false);
await assert.rejects(
executeTask(context, { argv: ['must-not-execute'] }),
boundaryError,
);
assert.throws(
() =>
repositoryName('https://example.invalid/invalid\nname', undefined, env),
expected(/仓库路径无效/, /Invalid repository path/),
);
await assert.rejects(
syncRaw(context, { url: 'file:///private/example.js' }),
expected(/需要 HTTP\(S\)/, /require HTTP\(S\)/),
);
assert.deepEqual(await fs.readdir(root), []);
t.mock.method(subprocess, 'checkedProcess', async (program, args) => {
assert.equal(program, 'curl');
await fs.writeFile(
args[args.indexOf('--output') + 1],
'console.log("fixture")',
);
return { code: 0 };
});
const calls = [];
t.mock.method(LocalApi.prototype, 'call', async (...args) => {
calls.push(args);
return { data: null };
});
await assert.rejects(
syncRaw(context, {
url: 'https://example.invalid/job.js',
autoAdd: true,
}),
(error) => {
assert.equal(error.exitCode, 1);
assert.match(
error.message,
expected(/无效任务列表/, /Invalid task list/),
);
return true;
},
);
assert.deepEqual(calls, [['crons']]);
});
}
+111
View File
@@ -0,0 +1,111 @@
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 } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('real language preloaders inject generated environment, hooks and designated accounts', async (t) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-preload-')),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{
PATH: `${path.resolve(__dirname, '../../../node_modules/.bin')}${
path.delimiter
}${process.env.PATH}`,
no_tee: 'true',
TS_NODE_COMPILER_OPTIONS: JSON.stringify({
module: 'CommonJS',
moduleResolution: 'Node',
}),
},
);
for (const dir of [
context.paths.dir_preload,
context.paths.dir_config,
context.paths.dir_scripts,
])
await fs.mkdir(dir, { recursive: true });
for (const file of ['sitecustomize.js', 'sitecustomize.py', 'esm-loader.mjs'])
await fs.copyFile(
path.resolve(__dirname, '../../../shell/preload', file),
path.join(context.paths.dir_preload, file),
);
// Transport adapters are inert; the actual preloader and generated env formats run.
const fixtures = {
'client.js': 'module.exports={};',
'__ql_notify__.js': 'exports.sendNotify=()=>{};',
'client.py': 'class Client:\n pass\n',
'__ql_notify__.py': 'def send(*args, **kwargs):\n pass\n',
'env.sh': 'export ACCOUNTS="one&two&three"\n',
'env.js': 'process.env.ACCOUNTS="one&two&three";',
'env.py': 'import os\nos.environ["ACCOUNTS"]="one&two&three"\n',
};
for (const [file, text] of Object.entries(fixtures))
await fs.writeFile(path.join(context.paths.dir_preload, file), text);
await fs.writeFile(
context.paths.file_task_before,
'export FROM_SHELL=before\n',
);
await fs.writeFile(
context.paths.file_task_before_js,
'process.env.FROM_LANGUAGE="javascript";',
);
await fs.writeFile(
context.paths.file_task_before_py,
'import os\nos.environ["FROM_LANGUAGE"]="python"\n',
);
await fs.writeFile(
context.paths.file_task_after,
'printf "after:%s\\n" "$_task_exit_code"',
);
for (const extension of ['js', 'mjs', 'py', 'pyc', 'ts']) {
await t.test(extension, async () => {
const script = extension.startsWith('py')
? 'import os,json\nprint("RESULT:"+json.dumps([os.getenv("ACCOUNTS"),os.getenv("FROM_SHELL"),os.getenv("FROM_LANGUAGE"),hasattr(__import__("builtins"),"QLAPI")]))\n'
: 'console.log("RESULT:"+JSON.stringify([process.env.ACCOUNTS,process.env.FROM_SHELL,process.env.FROM_LANGUAGE,!!globalThis.QLAPI]));';
await fs.writeFile(
path.join(
context.paths.dir_scripts,
`fixture.${extension === 'pyc' ? 'py' : extension}`,
),
extension === 'ts'
? `const fixtureTypedValue: number = 1;\n${script}`
: script,
);
if (extension === 'pyc')
execFileSync('python3', [
'-c',
'import py_compile,sys; py_compile.compile(sys.argv[1], cfile=sys.argv[2], doraise=True)',
path.join(context.paths.dir_scripts, 'fixture.py'),
path.join(context.paths.dir_scripts, 'fixture.pyc'),
]);
const result = await executeTask(context, {
argv: [`fixture.${extension}`],
mode: 'desi',
variable: 'ACCOUNTS',
selection: '3 1',
});
assert.equal(result.exitCode, 0);
const log = await fs.readFile(
path.join(context.paths.dir_log, result.logPath),
'utf8',
);
const line = log.split('\n').find((line) => line.startsWith('RESULT:'));
assert.ok(line, log);
assert.deepEqual(JSON.parse(line.slice(7)), [
'three&one',
'before',
extension.startsWith('py') ? 'python' : 'javascript',
true,
]);
assert.match(log, /after:0/);
assert.doesNotMatch(log, /run task before error|run builtin code error/);
});
}
});
@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { runProcess, checkedProcess } = require('../../dist/internal/runtime/process');
for (const language of ['zh', 'en', 'unsupported']) {
test(`subprocess failures localize and reap failed writers: ${language}`, async () => {
const env = { QL_LANG: language };
const expected = (zh, en) => (language === 'en' ? en : zh);
await assert.rejects(
runProcess('/nonexistent/ql-fixture-program', [], { env }),
expected(/无法启动必要的可执行程序/, /Cannot start required executable/),
);
await assert.rejects(
checkedProcess(
process.execPath,
['-e', 'process.exitCode=7', 'private-argument'],
{ env },
),
(error) => {
assert.equal(error.exitCode, 1);
assert.match(error.message, expected(/退出码 7/, /exit 7/));
assert.doesNotMatch(error.message, /private-argument/);
return true;
},
);
for (const overflow of [false, true]) {
let pid;
await assert.rejects(
runProcess(
process.execPath,
[
'-e',
'process.stderr.write(String(process.pid)); setTimeout(()=>{process.stdout.write("x".repeat(4096));setInterval(()=>{},1000)},50)',
],
{
env,
capture: overflow,
maxCaptureBytes: 16,
stderrOutput: (chunk) => {
pid = Number(chunk.toString());
},
output: () => {
throw undefined;
},
},
),
overflow
? expected(/捕获上限/, /capture limit/)
: expected(/输出写入失败/, /output sink failed/),
);
assert.ok(Number.isSafeInteger(pid) && pid > 1);
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
}
const original = new Error('caller sink error');
await assert.rejects(
runProcess(process.execPath, ['-e', 'console.log("output")'], {
env,
output: () => {
throw original;
},
}),
(error) => error === original,
);
});
}
+360
View File
@@ -0,0 +1,360 @@
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 { spawnSync, spawn } = require('node:child_process');
const { parseExecution } = require('../../dist/runner');
test('cancelling random delay returns final JSON without starting the Shell task', async (t) => {
for (const signal of [
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGQUIT',
'SIGALRM',
'SIGTSTP',
]) {
await t.test(signal, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-delay-signal-'));
fs.mkdirSync(path.join(root, 'data/scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'data/config'), { recursive: true });
fs.writeFileSync(
path.join(root, 'data/config/config.sh'),
'RandomDelay=600\nRandomDelayFileExtensions=sh\nRandomDelayIgnoredMinutes=99\n',
);
const marker = path.join(root, 'task-started');
fs.writeFileSync(
path.join(root, 'data/scripts/delay.sh'),
'touch "$MARKER"\n',
);
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../dist/runner.js'),
'--root',
root,
'--json',
'delay.sh',
],
{
env: { PATH: process.env.PATH, MARKER: marker },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '',
stderr = '',
sent = false;
const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
try {
const completion = new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, exitSignal) =>
resolve({ code, exitSignal }),
);
});
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
if (!sent && stderr.includes('任务随机延迟')) {
sent = true;
child.kill(signal);
}
});
const result = await completion;
assert.equal(sent, true, stderr);
const code = 128 + os.constants.signals[signal];
assert.deepEqual(result, { code, exitSignal: null }, stderr);
const payload = JSON.parse(stdout);
assert.equal(payload.data.exitCode, code);
assert.equal(payload.data.timedOut, false);
assert.equal(fs.existsSync(marker), false);
const log = fs.readFileSync(
path.join(root, 'data/log', payload.data.logPath),
'utf8',
);
assert.ok(log.includes(`退出码 ${code}`));
} finally {
clearTimeout(timer);
child.kill('SIGKILL');
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
test('runner preserves child flags, script separators and legacy account modes', () => {
assert.deepEqual(parseExecution(['example.js', '--timeout']).execution.argv, [
'example.js',
'--timeout',
]);
const parsed = parseExecution([
'--json',
'-m',
'2s',
'example.js',
'desi',
'ACCOUNTS',
'1-3',
'--',
'--json',
'a b',
]);
assert.equal(parsed.values.json, true);
assert.equal(parsed.execution.timeout, '2s');
assert.equal(parsed.execution.selection, '1-3');
assert.deepEqual(parsed.execution.scriptArgs, ['--json', 'a b']);
assert.throws(() => parseExecution(['--unknown', 'example.js']), {
exitCode: 2,
});
});
test('runner executable keeps JSON stdout clean and returns the script exit status', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runner-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.mkdirSync(path.join(root, 'data/scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'data/config'), { recursive: true });
fs.writeFileSync(
path.join(root, 'data/config/task_before.sh'),
'export FROM_HOOK=before\nprintf "hook output\\n"\n',
);
fs.writeFileSync(
path.join(root, 'data/config/task_after.sh'),
'printf "after output\\n"\n',
);
fs.writeFileSync(
path.join(root, 'data/scripts/example.js'),
'console.log(process.env.FROM_HOOK);console.log(process.argv.slice(2).join("|"));process.exit(7)',
);
const result = spawnSync(
process.execPath,
[
path.resolve(__dirname, '../../dist/runner.js'),
'--root',
root,
'--json',
'example.js',
'--',
'--timeout',
'a b',
],
{ encoding: 'utf8', env: { PATH: process.env.PATH } },
);
assert.equal(result.status, 7, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.data.exitCode, 7);
assert.match(result.stderr, /hook output/);
assert.match(result.stderr, /before/);
assert.match(result.stderr, /--timeout\|a b/);
assert.match(result.stderr, /after output/);
const log = fs.readFileSync(
path.join(root, 'data/log', payload.data.logPath),
'utf8',
);
assert.match(log, /hook output/);
assert.match(log, /after output/);
});
test('runner signals cancel a live Shell session and emit a final JSON result', async (t) => {
for (const signal of [
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGQUIT',
'SIGALRM',
'SIGTSTP',
]) {
await t.test(signal, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runner-signal-'));
fs.mkdirSync(path.join(root, 'data/scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'data/config'), { recursive: true });
fs.writeFileSync(
path.join(root, 'data/config/task_after.sh'),
'echo unexpected-after\n',
);
fs.writeFileSync(
path.join(root, 'data/scripts/wait.sh'),
"trap 'echo signal-marker:SIGTERM; exit 23' TERM\ntrap 'echo signal-marker:SIGINT; exit 23' INT\ntrap 'echo signal-marker:SIGHUP; exit 23' HUP\ntrap 'echo signal-marker:SIGQUIT; exit 23' QUIT\ntrap 'echo signal-marker:SIGALRM; exit 23' ALRM\ntrap 'echo signal-marker:SIGTSTP; exit 23' TSTP\ntrap 'echo exit-marker' EXIT\necho ready-pid:$$\nwhile :; do sleep 0.1; done\n",
);
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../dist/runner.js'),
'--root',
root,
'--json',
'wait.sh',
'now',
],
{ env: { PATH: process.env.PATH }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let stdout = '',
stderr = '',
sessionPid;
let closed = false;
const timeout = setTimeout(() => {
child.kill('SIGKILL');
if (sessionPid) {
try {
process.kill(-sessionPid, 'SIGKILL');
} catch {}
}
}, 10000);
try {
const completion = new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, exitSignal) => {
closed = true;
resolve({ code, exitSignal });
});
});
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
const match = /ready-pid:(\d+)\n/.exec(stderr);
if (match && !sessionPid) {
sessionPid = Number(match[1]);
child.kill(signal);
}
});
const result = await completion;
assert.ok(sessionPid, stderr);
const expectedCode = 128 + os.constants.signals[signal];
assert.deepEqual(
result,
{ code: expectedCode, exitSignal: null },
stderr,
);
const payload = JSON.parse(stdout);
assert.equal(payload.data.exitCode, expectedCode);
assert.equal(payload.data.timedOut, false);
assert.deepEqual(stderr.match(/signal-marker:SIG\w+/g), [
`signal-marker:${signal}`,
]);
assert.match(stderr, /exit-marker/);
assert.doesNotMatch(stderr, /unexpected-after/);
const log = fs.readFileSync(
path.join(root, 'data/log', payload.data.logPath),
'utf8',
);
assert.ok(log.includes(`signal-marker:${signal}`));
assert.ok(log.includes(`退出码 ${expectedCode}`));
} finally {
clearTimeout(timeout);
if (!closed) child.kill('SIGKILL');
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
test('configuration and independent before hooks cancel without starting the task', async (t) => {
for (const phase of ['config', 'before']) {
for (const signal of [
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGQUIT',
'SIGALRM',
'SIGTSTP',
]) {
await t.test(`${phase}: ${signal}`, async () => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql-config-cancel-'),
);
fs.mkdirSync(path.join(root, 'data/scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'data/config'), { recursive: true });
const marker = path.join(root, 'started');
fs.writeFileSync(
path.join(root, 'data/scripts/task.js'),
'require("node:fs").writeFileSync(process.env.MARKER,"started")',
);
const hook = phase === 'config' ? 'config.sh' : 'task_before.sh';
fs.writeFileSync(
path.join(root, 'data/config', hook),
"trap 'echo received:SIGINT; exit 23' INT\n" +
"trap 'echo received:SIGTERM; exit 23' TERM\n" +
"trap 'echo received:SIGHUP; exit 23' HUP\n" +
"trap 'echo received:SIGQUIT; exit 23' QUIT\n" +
"trap 'echo received:SIGALRM; exit 23' ALRM\n" +
"trap 'echo received:SIGTSTP; exit 23' TSTP\n" +
'echo ready-pid:$$\nwhile :; do sleep 0.1; done\n',
);
const child = spawn(
process.execPath,
[
path.resolve(__dirname, '../../dist/runner.js'),
'--root',
root,
'--json',
'task.js',
'now',
],
{
env: { PATH: process.env.PATH, MARKER: marker },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let stdout = '',
stderr = '',
sessionPid;
const timer = setTimeout(() => {
child.kill('SIGKILL');
if (sessionPid) {
try {
process.kill(-sessionPid, 'SIGKILL');
} catch {}
}
}, 10000);
try {
const completion = new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, exitSignal) =>
resolve({ code, exitSignal }),
);
});
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
const match = /ready-pid:(\d+)\n/.exec(stderr);
if (match && !sessionPid) {
sessionPid = Number(match[1]);
child.kill(signal);
}
});
const result = await completion;
const code = 128 + os.constants.signals[signal];
assert.ok(sessionPid, stderr);
assert.deepEqual(result, { code, exitSignal: null }, stderr);
assert.deepEqual(stderr.match(/received:SIG\w+/g), [
`received:${signal}`,
]);
assert.equal(fs.existsSync(marker), false);
if (phase === 'config') {
assert.equal(stdout, '');
const error = JSON.parse(stderr.trim().split('\n').at(-1));
assert.equal(error.code, code);
assert.match(
error.message,
/cancelled during configuration|加载配置时已取消/,
);
} else {
const payload = JSON.parse(stdout);
assert.equal(payload.data.exitCode, code);
assert.equal(payload.data.timedOut, false);
}
} finally {
clearTimeout(timer);
child.kill('SIGKILL');
fs.rmSync(root, { recursive: true, force: true });
}
});
}
}
});
@@ -0,0 +1,72 @@
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 } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { listTaskScripts } = require('../../dist/internal/execution/scriptInventory');
test('no-argument runner lists legacy JS candidates without evaluating scripts or config', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-inventory-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const context = createContext({ root }, { PATH: process.env.PATH });
const scripts = context.paths.dir_scripts;
await fs.mkdir(scripts, { recursive: true });
await fs.mkdir(context.paths.dir_config, { recursive: true });
await fs.writeFile(context.paths.file_config_user, 'exit 99\n');
await fs.writeFile(
path.join(scripts, 'job.js'),
'throw Error("must not execute");\nconst $ = new Env("Daily job");\n',
);
await fs.writeFile(
path.join(scripts, 'plain.js'),
'throw Error("must not execute")',
);
await fs.writeFile(path.join(scripts, 'sendNotify.js'), '');
await fs.writeFile(path.join(scripts, 'python.py'), '');
await fs.mkdir(path.join(scripts, 'directory.js'));
const old = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const definition = old.slice(
old.indexOf('gen_array_scripts() {'),
old.indexOf('## 使用说明'),
);
const legacy = execFileSync(
'bash',
[
'-c',
definition + '\ngen_array_scripts\nprintf "%s\\n" "${array_scripts[@]}"',
],
{
env: { PATH: process.env.PATH, dir_scripts: scripts },
encoding: 'utf8',
},
)
.trim()
.split('\n')
.sort();
const expected = [
{ file: 'job.js', name: 'Daily job' },
{ file: 'plain.js', name: null },
];
assert.deepEqual(await listTaskScripts(context), expected);
assert.deepEqual(
expected.map((x) => x.file),
legacy,
);
const entry = path.resolve(__dirname, '../../dist/runner.js');
const run = (args) =>
execFileSync(process.execPath, [entry, '--root', root, ...args], {
env: { PATH: process.env.PATH, QL_LANG: 'en' },
encoding: 'utf8',
});
assert.deepEqual(JSON.parse(run(['--json'])).data.scripts, expected);
assert.match(run([]), /Available scripts:/);
assert.equal(JSON.parse(run(['--help', '--json'])).data.scripts, undefined);
await fs.rm(scripts, { recursive: true });
assert.deepEqual(JSON.parse(run(['--json'])).data.scripts, []);
assert.match(run([]), /No scripts available/);
});
@@ -0,0 +1,97 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { matchSubscriptionPaths } = require('../../dist/internal/subscription/subscriptionFilter');
test('subscription filters preserve legacy POSIX classes, escaping, anchors and option-like patterns', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-filter-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const files = [
'job12.js',
'jobdd.js',
'Job3.py',
'lib/helper.js',
'name space.js',
'-leading.js',
'$(touch injected).js',
];
const env = { ...process.env, LC_ALL: 'C', QL_LANG: 'en' };
for (const pattern of [
'^job[[:digit:]]+\\.js$',
'[[:space:]]',
'\\d+',
'^lib/|^Job',
'-leading',
'no-match',
'[$]',
]) {
let expected;
try {
expected = execFileSync('grep', ['-E', '-e', pattern], {
input: files.join('\n') + '\n',
encoding: 'utf8',
env,
})
.trimEnd()
.split('\n')
.filter(Boolean);
} catch (error) {
if (error.status !== 1) throw error;
expected = [];
}
assert.deepEqual(
[...(await matchSubscriptionPaths(files, pattern, root, env))],
expected,
);
}
await assert.rejects(
matchSubscriptionPaths(files, '[', root, env),
/Invalid subscription POSIX/,
);
await assert.rejects(
matchSubscriptionPaths(['line\nbreak.js'], 'job', root, env),
/line breaks/,
);
assert.deepEqual(await fs.readdir(root), []);
});
for (const language of ['zh', 'en', 'unsupported']) {
test(`filter errors localize without leaking paths or leaving temporary files: ${language}`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-filter-errors-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const env = { PATH: process.env.PATH, QL_LANG: language };
const expectError = (code, chinese, english) => (error) => {
assert.equal(error.exitCode, code);
assert.match(error.message, language === 'en' ? english : chinese);
assert.doesNotMatch(error.message, /private-path/);
return true;
};
await assert.rejects(
matchSubscriptionPaths(['private-path\nbad'], '.', root, env),
expectError(2, /无法安全过滤/, /cannot be filtered safely/),
);
await assert.rejects(
matchSubscriptionPaths(['private-path'], '[', root, env),
expectError(2, /正则表达式无效/, /Invalid subscription POSIX/),
);
assert.deepEqual(await fs.readdir(root), []);
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.writeFile(
path.join(bin, 'grep'),
`#!${process.execPath}\nprocess.exitCode=7;`,
{ mode: 0o755 },
);
await assert.rejects(
matchSubscriptionPaths(['private-path'], '.', root, {
...env,
PATH: bin,
}),
expectError(1, /退出码 7/, /exit 7/),
);
assert.deepEqual(await fs.readdir(root), ['bin']);
});
}
@@ -0,0 +1,433 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const {
syncRepository,
repositoryName,
cronMetadata,
} = require('../../dist/internal/subscription/subscriptionRunner');
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
test('repository metadata matches legacy names and cron annotations', () => {
assert.equal(
repositoryName('https://github.com/owner/repo.git', 'main'),
'owner_repo_main',
);
assert.equal(repositoryName('git@github.com:owner/repo.git'), 'owner_repo');
assert.deepEqual(
cronMetadata(
'const $ = new Env("Example");\n// cron: 0 9 * * *',
'example.js',
'1 1 * * *',
),
{ name: 'Example', schedule: '0 9 * * *' },
);
});
test('local repository sync copies selected scripts/dependencies and preserves last good version on clone failure', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sync-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, 'owner/repository');
fs.mkdirSync(path.join(source, 'lib'), { recursive: true });
const execute = (args) =>
execFileSync(git, args, { cwd: source, stdio: 'ignore' });
execute(['init']);
fs.writeFileSync(path.join(source, 'main.js'), 'v1');
fs.writeFileSync(path.join(source, 'skip.js'), 'skip');
fs.writeFileSync(path.join(source, 'lib/helper.js'), 'helper');
execute(['add', '.']);
execute([
'-c',
'user.name=Test',
'-c',
'user.email=test@example.invalid',
'commit',
'-m',
'fixture',
]);
const context = createContext(
{ root },
{
PATH:
process.platform === 'darwin'
? `/usr/bin:/bin:${process.env.PATH}`
: process.env.PATH,
},
);
const input = {
url: source,
include: 'main',
dependencies: 'lib/',
autoAdd: false,
autoDelete: false,
};
const result = await syncRepository(context, input);
assert.deepEqual(result.files, ['owner_repository/main.js']);
const destination = path.join(context.paths.dir_scripts, 'owner_repository');
assert.equal(
fs.readFileSync(path.join(destination, 'main.js'), 'utf8'),
'v1',
);
assert.equal(
fs.readFileSync(path.join(destination, 'lib/helper.js'), 'utf8'),
'helper',
);
assert.equal(fs.existsSync(path.join(destination, 'skip.js')), false);
fs.renameSync(source, source + '.unavailable');
await assert.rejects(syncRepository(context, input));
assert.equal(
fs.readFileSync(path.join(destination, 'main.js'), 'utf8'),
'v1',
);
});
test('subscription reconciliation scopes removals to canonical paths and the current subscription', async (t) => {
const http = require('node:http');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-reconcile-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, 'owner/repository');
fs.mkdirSync(source, { recursive: true });
const run = (args) =>
execFileSync(git, args, { cwd: source, stdio: 'ignore' });
run(['init']);
fs.writeFileSync(
path.join(source, 'new.js'),
'// cron: 0 9 * * *\nnew Env("New task")',
);
run(['add', '.']);
run([
'-c',
'user.name=Fixture',
'-c',
'user.email=fixture@example.invalid',
'commit',
'-qm',
'fixture',
]);
const context = createContext(
{ root },
{ PATH: `/usr/bin:/bin:${process.env.PATH}` },
);
const requests = [];
const rows = [
{ id: 1, sub_id: 7, command: 'task owner_repository/old.js' },
{ id: 2, sub_id: 8, command: 'task owner_repository/other.js' },
{ id: 3, sub_id: 7, command: 'task owner_repository/../outside.js' },
{ id: 4, sub_id: 7, command: 'task owner_repository_other/old.js' },
];
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString();
requests.push({
method: req.method,
url: req.url,
body: body ? JSON.parse(body) : undefined,
});
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
code: 200,
data: req.method === 'GET' ? { data: rows, total: rows.length } : {},
}),
);
});
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.writeFileSync(
context.paths.file_auth_token,
JSON.stringify({
value: 'fixture-token',
expiration: Date.now() / 1000 + 1000,
}),
);
const dest = path.join(context.paths.dir_scripts, 'owner_repository');
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, 'old.js'), 'old');
fs.writeFileSync(path.join(dest, 'other.js'), 'other');
fs.writeFileSync(
path.join(context.paths.dir_scripts, 'outside.js'),
'outside',
);
const result = await syncRepository(context, {
url: source,
subscriptionId: 7,
autoAdd: true,
autoDelete: true,
});
assert.equal(result.added, 1);
assert.equal(result.removed, 1);
assert.deepEqual(requests.find((r) => r.method === 'DELETE').body, [1]);
assert.deepEqual(requests.find((r) => r.method === 'POST').body, {
name: 'New task',
schedule: '0 9 * * *',
command: 'task owner_repository/new.js',
sub_id: 7,
});
assert.equal(fs.existsSync(path.join(dest, 'old.js')), false);
assert.equal(fs.readFileSync(path.join(dest, 'other.js'), 'utf8'), 'other');
assert.equal(
fs.readFileSync(path.join(context.paths.dir_scripts, 'outside.js'), 'utf8'),
'outside',
);
});
test('raw download failure preserves the last script and does not reconcile tasks', async (t) => {
const { syncRaw } = require('../../dist/internal/subscription/subscriptionRunner');
const http = require('node:http');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-raw-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const context = createContext(
{ root },
{ PATH: `/usr/bin:/bin:${process.env.PATH}` },
);
let failed = false;
const server = http.createServer((req, res) => {
res.writeHead(failed ? 503 : 200);
res.end(failed ? 'failed' : '// version one');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const input = {
url: `http://127.0.0.1:${server.address().port}/owner/file.js`,
autoAdd: false,
autoDelete: false,
};
const result = await syncRaw(context, input);
const installed = path.join(context.paths.dir_scripts, result.file);
assert.equal(fs.readFileSync(installed, 'utf8'), '// version one');
failed = true;
await assert.rejects(syncRaw(context, input));
assert.equal(fs.readFileSync(installed, 'utf8'), '// version one');
assert.ok(
fs
.readdirSync(context.paths.dir_raw)
.every((name) => !name.endsWith('.tmp')),
);
});
test('directory replacement stages on the destination filesystem before switching old files', async (t) => {
const fsp = require('node:fs/promises');
const { replaceDirectory } = require('../../dist/internal/subscription/subscriptionRunner');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cross-device-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, 'staged'),
destination = path.join(root, 'data/scripts');
fs.mkdirSync(staged);
fs.mkdirSync(destination, { recursive: true });
fs.writeFileSync(path.join(staged, 'new'), 'new');
fs.writeFileSync(path.join(destination, 'old'), 'old');
const rename = fsp.rename.bind(fsp),
copy = fsp.cp.bind(fsp);
t.mock.method(fsp, 'rename', async (from, to) => {
if (from === staged)
throw Object.assign(new Error('cross-device fixture'), { code: 'EXDEV' });
return rename(from, to);
});
let copied = false;
t.mock.method(fsp, 'cp', async (from, to, options) => {
assert.equal(fs.readFileSync(path.join(destination, 'old'), 'utf8'), 'old');
await copy(from, to, options);
copied = true;
});
await replaceDirectory(staged, destination);
assert.equal(copied, true);
assert.deepEqual(fs.readdirSync(destination), ['new']);
assert.ok(
fs
.readdirSync(path.dirname(destination))
.every(
(name) => !name.endsWith('.incoming') && !name.endsWith('.previous'),
),
);
});
test('cross-filesystem preparation failure leaves the old subscription directory untouched', async (t) => {
const fsp = require('node:fs/promises');
const { replaceDirectory } = require('../../dist/internal/subscription/subscriptionRunner');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-copy-failure-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const staged = path.join(root, 'staged'),
destination = path.join(root, 'destination');
fs.mkdirSync(staged);
fs.mkdirSync(destination);
fs.writeFileSync(path.join(destination, 'old'), 'old');
const rename = fsp.rename.bind(fsp);
t.mock.method(fsp, 'rename', async (from, to) => {
if (from === staged)
throw Object.assign(new Error('cross device'), { code: 'EXDEV' });
return rename(from, to);
});
t.mock.method(fsp, 'cp', async (from, to) => {
await fsp.mkdir(to);
throw new Error('fixture disk full');
});
await assert.rejects(
replaceDirectory(staged, destination),
/fixture disk full/,
);
assert.equal(fs.readFileSync(path.join(destination, 'old'), 'utf8'), 'old');
assert.deepEqual(fs.readdirSync(root).sort(), ['destination', 'staged']);
});
test('subscription copy precedence preserves local dependencies while selected scripts win', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-copy-order-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, 'owner/repository');
fs.mkdirSync(path.join(source, 'lib'), { recursive: true });
fs.writeFileSync(path.join(source, 'main.js'), 'selected-task');
fs.writeFileSync(path.join(source, 'lib/helper.js'), 'repository-helper');
fs.writeFileSync(path.join(source, 'sendNotify.js'), 'repository-notify');
const run = (args) =>
execFileSync(git, args, { cwd: source, stdio: 'ignore' });
run(['init']);
run(['add', '.']);
run([
'-c',
'user.name=Test',
'-c',
'user.email=test@example.invalid',
'commit',
'-m',
'fixture',
]);
const context = createContext(
{ root },
{ PATH: `/usr/bin:/bin:${process.env.PATH}` },
);
fs.mkdirSync(path.join(context.paths.dir_dep, 'lib'), { recursive: true });
fs.mkdirSync(context.paths.dir_config, { recursive: true });
fs.mkdirSync(context.paths.dir_scripts, { recursive: true });
fs.writeFileSync(context.paths.file_notify_js, 'default-notify');
fs.writeFileSync(context.paths.file_notify_py, 'default-python-notify');
fs.writeFileSync(
path.join(context.paths.dir_dep, 'lib/helper.js'),
'local-helper',
);
fs.writeFileSync(path.join(context.paths.dir_dep, 'main.js'), 'local-main');
fs.writeFileSync(
path.join(context.paths.dir_dep, 'sendNotify.js'),
'local-notify',
);
const input = {
url: source,
include: '^main',
dependencies: 'lib/|sendNotify',
autoAdd: false,
autoDelete: false,
};
const result = await syncRepository(context, input);
const destination = path.join(context.paths.dir_scripts, 'owner_repository');
assert.deepEqual(result.files, ['owner_repository/main.js']);
assert.equal(
fs.readFileSync(path.join(destination, 'main.js'), 'utf8'),
'selected-task',
);
assert.equal(
fs.readFileSync(path.join(destination, 'lib/helper.js'), 'utf8'),
'local-helper',
);
assert.equal(
fs.readFileSync(path.join(destination, 'sendNotify.js'), 'utf8'),
'local-notify',
);
const legacyRoot = path.join(root, 'legacy');
fs.mkdirSync(path.join(legacyRoot, 'owner_repository'), { recursive: true });
fs.mkdirSync(context.paths.dir_list_tmp, { recursive: true });
fs.writeFileSync(context.paths.list_crontab_user, '');
const original = fs.readFileSync(
path.resolve(__dirname, '../../../shell/update.sh'),
'utf8',
);
const definition = original.match(/^gen_list_repo\(\) \{[\s\S]*?^\}/m)?.[0];
assert.ok(definition);
execFileSync(
'/bin/bash',
[
'-c',
definition +
'\nmake_dir() { mkdir -p "$@"; }\ngen_list_repo "$SOURCE" owner "^main" "" "lib/|sendNotify" js',
],
{
env: {
...context.env,
SOURCE: source,
dir_scripts: legacyRoot,
dir_list_tmp: context.paths.dir_list_tmp,
dir_dep: context.paths.dir_dep,
file_notify_js: context.paths.file_notify_js,
file_notify_py: context.paths.file_notify_py,
list_crontab_user: context.paths.list_crontab_user,
uniq_path: 'owner_repository',
cmd_task: 'task',
},
stdio: 'pipe',
timeout: 10000,
},
);
for (const file of ['main.js', 'lib/helper.js', 'sendNotify.js'])
assert.equal(
fs.readFileSync(path.join(destination, file), 'utf8'),
fs.readFileSync(path.join(legacyRoot, 'owner_repository', file), 'utf8'),
);
fs.unlinkSync(path.join(context.paths.dir_dep, 'sendNotify.js'));
await syncRepository(context, input);
assert.equal(
fs.readFileSync(path.join(destination, 'sendNotify.js'), 'utf8'),
'repository-notify',
);
});
test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cron-metadata-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const original = fs.readFileSync(
path.resolve(__dirname, '../../../shell/update.sh'),
'utf8',
);
const definition = original.match(/^add_cron\(\) \{[\s\S]*?^\}/m)?.[0];
assert.ok(definition);
fs.mkdirSync(path.join(root, 'repo'));
fs.writeFileSync(path.join(root, 'list'), 'repo/example.js\n');
for (const contents of [
'// cron: 0 9 * * *\n// 10 2 * * * example.js\nconst $ = new Env("Example");',
'// 50 8 * * * example.js\n// 10 2 * * * example.js\n// cron: 0 9 * * *\nconst $ = new Env("Example");',
'// cron: 0 9 * * *\nconst $ = new Env("Example");',
'console.log("no metadata");',
]) {
fs.writeFileSync(path.join(root, 'repo/example.js'), contents);
const output = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-c',
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s' "$1"; }\nadd_cron "$dir_scripts/list" repo`,
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `/usr/bin:/bin:${process.env.PATH}`,
LC_ALL: 'C',
dir_scripts: root,
cmd_task: 'task',
default_cron: '1 1 * * *',
SUB_ID: '7',
},
},
).trim();
const [schedule, , name] = output.split(':');
assert.deepEqual(cronMetadata(contents, 'example.js', '1 1 * * *'), {
schedule,
name,
});
}
});
+133
View File
@@ -0,0 +1,133 @@
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 { spawnSync } = require('node:child_process');
const { matchesDelayExtension } = require('../../dist/internal/execution/taskDelay');
const { createContext } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
test('delay extension ERE matching agrees with unchanged legacy function', async () => {
const source = await fs.readFile(
path.resolve(__dirname, '../../../shell/otask.sh'),
'utf8',
);
const fn = source.match(/random_delay\(\) \{[\s\S]*?\n\}/)[0];
for (const [filename, extensions] of [
['a.js', undefined],
['a.sh', undefined],
['a.sh', ''],
['a.sh', 'js sh'],
['a.sh', 's[h]'],
['a.sh', '(js|sh)'],
['a.sh', '[[:alpha:]]{2}'],
['a.sh', ' '],
['a.sh', 'js\tsh'],
['a.sh', '['],
['a.sh', '-sh'],
['a.sh', '$(printf sh)'],
]) {
const env = {
PATH: process.env.PATH,
RandomDelay: '1',
RandomDelayIgnoredMinutes: '99',
...(extensions === undefined
? {}
: { RandomDelayFileExtensions: extensions }),
};
const legacy = spawnSync(
'/bin/bash',
[
'-c',
`${fn}
date() { printf 0; }
gen_random_num() { printf 0; }
t() { :; }
sleep() { printf DELAYED; }
random_delay "$1"
`,
'fixture',
filename,
],
{ env, encoding: 'utf8' },
);
assert.equal(legacy.status, 0, legacy.stderr);
assert.equal(
await matchesDelayExtension(filename, env),
legacy.stdout.includes('DELAYED'),
JSON.stringify([filename, extensions]),
);
}
});
test('task runner applies ERE delay selection and still executes the script', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-ere-'));
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',
RandomDelayIgnoredMinutes: '99',
RandomDelayFileExtensions: 's[h]',
};
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, /任务随机延迟/);
assert.match(output, /TASK_RAN/);
});
test('cancelled extension filtering uses the runner abort contract', async () => {
const controller = new AbortController();
controller.abort('SIGTERM');
await assert.rejects(
matchesDelayExtension(
'a.js',
{ PATH: process.env.PATH },
controller.signal,
),
(error) => error.name === 'AbortError' && error.code === 'ABORT_ERR',
);
});
for (const language of ['zh', 'en', 'unsupported']) {
test(`delay filtering failure and cancellation retain locale and status: ${language}`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-language-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(
path.join(root, 'grep'),
`#!${process.execPath}\nprocess.exitCode=7;`,
{ mode: 0o755 },
);
const env = { PATH: root, QL_LANG: language };
await assert.rejects(matchesDelayExtension('job.js', env), (error) => {
assert.equal(error.exitCode, 1);
assert.match(error.message, language === 'en' ? /exit 7/ : /退出码 7/);
return true;
});
const controller = new AbortController();
controller.abort('SIGTERM');
await assert.rejects(
matchesDelayExtension('job.js', env, controller.signal),
(error) => {
assert.equal(error.name, 'AbortError');
assert.equal(error.code, 'ABORT_ERR');
assert.match(
error.message,
language === 'en' ? /filtering cancelled/ : /过滤已取消/,
);
return true;
},
);
});
}
@@ -0,0 +1,95 @@
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 { spawnSync } = require('node:child_process');
const {
executeTask,
selectedAccounts,
durationMs,
} = require('../../dist/internal/execution/taskRunner');
const { createContext } = require('../../dist/internal/runtime/context');
for (const language of ['zh', 'en', 'unsupported']) {
test(`task validation exposes localized JSON and never starts invalid tasks: ${language}`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-task-language-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
const marker = path.join(root, 'executed');
const env = {
PATH: process.env.PATH,
QL_LANG: language,
QL_DIR: root,
TEST_ACCOUNTS: 'one&two',
};
const context = createContext({ root }, env);
await assert.rejects(executeTask(context, { argv: [] }), (error) => {
assert.equal(error.exitCode, 2);
assert.match(
error.message,
language === 'en' ? /script or executable/ : /脚本或可执行程序/,
);
return true;
});
assert.deepEqual(selectedAccounts('2-1 max', 2, env), [2, 1]);
assert.equal(durationMs('1.5m', env), 90000);
for (const [before, body, chinese, english] of [
[
['--timeout', 'invalid'],
['/usr/bin/touch'],
/超时必须为正时长/,
/positive duration/,
],
[
['--timeout', '0'],
['/usr/bin/touch'],
/超时时长超出支持范围/,
/outside the supported range/,
],
[
[],
['/usr/bin/touch', 'desi', 'INVALID-NAME', '1'],
/有效的账号环境变量名/,
/valid account environment variable/,
],
[
[],
['/usr/bin/touch', 'desi', 'TEST_ACCOUNTS', 'bad'],
/账号选择格式无效/,
/Invalid account selection/,
],
[
[],
['/usr/bin/touch', 'desi', 'TEST_ACCOUNTS', '3'],
/账号选择超出/,
/outside the environment variable range/,
],
]) {
const result = spawnSync(
process.execPath,
[
path.resolve(__dirname, '../../dist/runner.js'),
'--json',
'--root',
root,
...before,
...body,
'--',
marker,
],
{
env,
encoding: 'utf8',
timeout: 10000,
},
);
assert.equal(result.status, 2, result.stderr);
assert.equal(result.stdout, '');
const error = JSON.parse(result.stderr.trim().split('\n').at(-1));
assert.equal(error.code, 2);
assert.match(error.message, language === 'en' ? english : chinese);
await assert.rejects(fs.access(marker), { code: 'ENOENT' });
}
});
}
+79
View File
@@ -0,0 +1,79 @@
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 { spawnSync, spawn } = require('node:child_process');
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-lifecycle-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
for (const dir of ['data/config', 'data/scripts', 'bin'])
fs.mkdirSync(path.join(root, dir), { recursive: true });
fs.writeFileSync(path.join(root, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const trace = path.join(root, 'trace');
return {
root, trace,
write(file, contents) { fs.writeFileSync(path.join(root, file), contents); },
args: [path.resolve(__dirname, '../../dist/runner.js'), '--root', root, '--json'],
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, TRACE: trace },
events() { return fs.existsSync(trace) ? fs.readFileSync(trace, 'utf8').trim().split('\n') : []; },
};
}
test('shared Shell lifecycle scopes timeouts to the task and runs cleanup exactly once', async t => {
const cases = [
{ name: 'success', task: 'return 0', code: 0 },
{ name: 'failure', task: 'return 7', code: 7 },
{ name: 'function timeout preserves state', task: 'STATE=changed; sleep 10', code: 124, timeout: true, state: 'changed' },
{ name: 'external timeout', args: ['sleep', '10'], code: 124, timeout: true },
{ name: 'Shell script timeout preserves state', task: 'STATE=changed; sleep 10', shell: true, code: 124, timeout: true, state: 'changed' },
{ name: 'slow before is outside task timeout', before: 'sleep 0.2', task: 'return 0', code: 0 },
{ name: 'slow after is outside task timeout', after: 'sleep 0.2', task: 'return 0', code: 0 },
{ name: 'background child cannot hold pipes after timeout cleanup', task: '(trap "" INT; sleep 30) & sleep 30', after: 'sleep 0.3', code: 124, timeout: true },
{ name: 'script may redirect fd 3', shell: true, task: 'exec 3> "$TRACE.fd"; return 0', after: 'sleep 0.3', code: 0 },
{ name: 'before may close fd 3 and redirect fd 9', before: 'exec 3>&- 9> "$TRACE.lock"', task: 'return 0', after: 'sleep 0.3', code: 0 },
{ name: 'timeout cleanup survives script fd 3 redirection', shell: true, task: 'exec 3> "$TRACE.fd"; STATE=changed; sleep 30', after: 'sleep 0.3', code: 124, timeout: true, state: 'changed' },
{ name: 'explicit exit preserves user trap and skips after', task: 'trap \'printf "exit\\n" >> "$TRACE"\' EXIT; exit 9', code: 9, noAfter: true, userExit: true },
{ name: 'user EXIT trap survives timeout with parent cleanup', task: 'trap \'printf "exit\\n" >> "$TRACE"\' EXIT; sleep 10', code: 124, timeout: true, userExit: true },
{ name: 'forced termination falls back to cleanup', task: 'trap "" INT; sleep 30', code: 124, timeout: true },
];
for (const scenario of cases) await t.test(scenario.name, t => {
const f = fixture(t);
f.write('data/config/config.sh', `no_tee=true\nSTATE=original\nmy_task() { ${scenario.task || ':'}; }\n`);
f.write('data/config/task_before.sh', `printf 'before\\n' >> "$TRACE"\n${scenario.before || ':'}\n`);
f.write('data/config/task_after.sh', `${scenario.after || ':'}\nprintf 'after:%s:%s\\n' "$STATE" "$_task_exit_code" >> "$TRACE"\n`);
if (scenario.shell) f.write('data/scripts/task.sh', scenario.task + '\n');
const result = spawnSync(process.execPath, [...f.args, '-m', '0.1s', ...(scenario.args || [scenario.shell ? 'task.sh' : 'my_task'])], {
env: f.env, encoding: 'utf8', timeout: 15000,
});
assert.equal(result.status, scenario.code, result.stderr);
const data = JSON.parse(result.stdout).data;
assert.equal(data.exitCode, scenario.code);
assert.equal(data.timedOut, !!scenario.timeout);
assert.deepEqual(f.events(), [
'before', ...(scenario.userExit ? ['exit'] : []),
...(scenario.noAfter ? [] : [`after:${scenario.state || 'original'}:${scenario.code}`]),
]);
});
});
test('user cancellation remains cancellation, without timeout cleanup replay', async t => {
const f = fixture(t);
f.write('data/config/config.sh', 'my_task() { printf "READY\\n"; sleep 30; }\n');
f.write('data/config/task_after.sh', 'printf "after\\n" >> "$TRACE"\n');
const child = spawn(process.execPath, [...f.args, '-m', '20s', 'my_task'], { env: f.env });
let out = '', err = '', sent = false;
const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
t.after(() => { clearTimeout(timer); child.kill('SIGKILL'); });
child.stdout.on('data', chunk => out += chunk);
child.stderr.on('data', chunk => {
err += chunk;
if (!sent && err.includes('READY')) { sent = true; child.kill('SIGTERM'); }
});
const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve); });
assert.equal(sent, true, err);
assert.equal(code, 143, err);
assert.equal(JSON.parse(out).data.timedOut, false);
assert.deepEqual(f.events(), []);
});
+106
View File
@@ -0,0 +1,106 @@
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 { createContext } = require('../../dist/internal/runtime/context');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
const operator = require('../../dist/internal/maintenance/operator');
const selectedLoader =
'// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n';
async function fixture(t) {
const base = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-upgrade-selection-'),
);
t.after(() => fs.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'panel'),
source = path.join(base, 'source'),
staticRoot = path.join(base, 'static'),
cliRoot = path.join(base, 'cli');
for (const dir of [
root,
path.join(source, 'sample'),
path.join(staticRoot, 'build/loaders'),
path.join(cliRoot, 'dist/local'),
])
await fs.mkdir(dir, { recursive: true });
for (const file of ['entrypoints.js', 'cronEntrypoint.js'])
await fs.writeFile(path.join(cliRoot, 'dist/local', file), '');
const ctx = createContext({ root }, { QL_CLI_ROOT: cliRoot, QL_LANG: 'en' });
await fs.mkdir(path.join(ctx.paths.dir_static, 'build/loaders'), {
recursive: true,
});
await fs.writeFile(path.join(root, 'package.json'), '{"version":"2.19.0"}');
await fs.writeFile(path.join(source, 'package.json'), '{"version":"2.20.1"}');
await fs.writeFile(
path.join(source, 'sample/config.sample.sh'),
'new sample',
);
const loader = path.join(ctx.paths.dir_static, 'build/loaders/deps.js');
const incoming = path.join(staticRoot, 'build/loaders/deps.js');
await fs.writeFile(loader, selectedLoader);
await fs.writeFile(incoming, '// original released loader');
await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old');
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new');
return { ctx, source, staticRoot, loader, incoming };
}
for (const failure of [false, true])
test(`selected loader is preserved transactionally; startup failure=${failure}`, async (t) => {
const f = await fixture(t);
let starts = 0;
t.mock.method(operator, 'stopPanel', async () => {});
t.mock.method(operator, 'startPanel', async () => {
starts++;
assert.equal(await fs.readFile(f.loader, 'utf8'), selectedLoader);
assert.equal(
await fs.readFile(
path.join(f.ctx.paths.dir_static, 'build/app.js'),
'utf8',
),
starts === 1 ? 'new' : 'old',
);
if (failure && starts === 1) throw new Error('new startup failed');
return { manager: 'fixture' };
});
const operation = reloadPanel(f.ctx, 'system', {
source: f.source,
static: f.staticRoot,
});
if (failure) await assert.rejects(operation, /new startup failed/);
else assert.deepEqual((await operation).retainedBackups, []);
assert.equal(starts, failure ? 2 : 1);
assert.equal(
await fs.readFile(f.incoming, 'utf8'),
'// original released loader',
);
assert.deepEqual(await fs.readdir(f.ctx.paths.dir_tmp), []);
});
test('unsupported target and unrecognized loader fail before service stop', async (t) => {
const f = await fixture(t);
let stopped = false;
t.mock.method(operator, 'stopPanel', async () => {
stopped = true;
});
await fs.writeFile(
path.join(f.source, 'package.json'),
'{"version":"3.0.0"}',
);
await assert.rejects(
reloadPanel(f.ctx, 'system', { source: f.source, static: f.staticRoot }),
/verified 2.x/,
);
await fs.writeFile(
path.join(f.source, 'package.json'),
'{"version":"2.20.1"}',
);
await fs.writeFile(f.loader, 'unrecognized');
await assert.rejects(
reloadPanel(f.ctx, 'system', { source: f.source, static: f.staticRoot }),
/Cannot verify/,
);
assert.equal(stopped, false);
assert.equal(await fs.readFile(f.loader, 'utf8'), 'unrecognized');
});