mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants
This commit is contained in:
@@ -0,0 +1,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)/,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
}
|
||||
@@ -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\)/),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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), []);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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']]);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user