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,417 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { compatibilityRoute } = require('../../dist/compat');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
|
||||
test('legacy adapter maps positional switches and preserves subscription arguments', () => {
|
||||
for (const [input, expected] of [
|
||||
[['update'], ['update']],
|
||||
[['update', 'true'], ['update']],
|
||||
[
|
||||
['-l', 'update', 'false'],
|
||||
['update', '--download-only'],
|
||||
],
|
||||
[['reload'], ['reload', '--target', 'services']],
|
||||
[
|
||||
['reload', 'system'],
|
||||
['reload', '--target', 'system'],
|
||||
],
|
||||
[
|
||||
['reload', 'data'],
|
||||
['reload', '--target', 'data'],
|
||||
],
|
||||
]) {
|
||||
const route = compatibilityRoute(input);
|
||||
assert.deepEqual(route, { surface: 'local', args: expected });
|
||||
assert.doesNotThrow(() => parse(route.args, 'local'));
|
||||
}
|
||||
for (const action of ['repo', 'raw']) {
|
||||
const args = [
|
||||
action,
|
||||
'https://example.invalid/owner/repo.git',
|
||||
'',
|
||||
'a b',
|
||||
'',
|
||||
'feature/test',
|
||||
];
|
||||
assert.deepEqual(compatibilityRoute(args), {
|
||||
surface: 'subscription',
|
||||
args,
|
||||
});
|
||||
}
|
||||
const reset = compatibilityRoute(['resetpwd', '--literal-password']);
|
||||
assert.deepEqual(parse(reset.args, 'local').positionals, [
|
||||
'--literal-password',
|
||||
]);
|
||||
for (const input of [
|
||||
['update', 'maybe'],
|
||||
['reload', 'wrong'],
|
||||
['login'],
|
||||
['task', 'run', '1'],
|
||||
])
|
||||
assert.throws(() => compatibilityRoute(input));
|
||||
});
|
||||
|
||||
test('legacy adapter help runs without panel setup and invalid commands fail before execution', () => {
|
||||
const entry = path.resolve(__dirname, '../../dist/compat.js');
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
QL_DIR: '/nonexistent-panel',
|
||||
QL_LANG: 'en',
|
||||
};
|
||||
const help = spawnSync(process.execPath, [entry, '--help'], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /Usage: ql-compat/);
|
||||
const invalid = spawnSync(process.execPath, [entry, 'update', 'maybe'], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(invalid.status, 2);
|
||||
assert.equal(invalid.stdout, '');
|
||||
assert.equal(JSON.parse(invalid.stderr).code, 2);
|
||||
});
|
||||
|
||||
test('legacy maintenance logs output and reports lifecycle without sourcing config twice', async (t) => {
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { promisify } = require('node:util');
|
||||
const exec = promisify(require('node:child_process').execFile);
|
||||
for (const flags of ['no_tee=true', 'real_time=true\nno_tee=true']) {
|
||||
for (const failed of [false, true]) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-compat-log-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
calls.push({ url: req.url, body: JSON.parse(body) });
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/config.sh'),
|
||||
`${flags}\nprintf x >> "$QL_DIR/config-loads"\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/token.json'),
|
||||
JSON.stringify({
|
||||
value: 'fixture',
|
||||
expiration: Date.now() / 1000 + 3600,
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/extra.sh'),
|
||||
`printf 'fixture-output\\n'\n${failed ? 'exit 7' : ':'}\n`,
|
||||
);
|
||||
const result = await exec(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/compat.js'), 'extra'],
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_DIR: root,
|
||||
QlPort: String(server.address().port),
|
||||
QL_CLI_LIFECYCLE: 'extended',
|
||||
ID: '9',
|
||||
QL_EXECUTION_ORIGIN: 'scheduled_system',
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
).then(
|
||||
(value) => ({ ...value, code: 0 }),
|
||||
(error) => error,
|
||||
);
|
||||
assert.equal(result.code, failed ? 1 : 0);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'config-loads'), 'utf8'),
|
||||
'x',
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.ok(calls.every((call) => call.url === '/open/crons/status'));
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.body.status),
|
||||
['0', '1'],
|
||||
);
|
||||
assert.equal(calls[1].body.exit_code, failed ? 1 : 0);
|
||||
assert.deepEqual(calls[0].body.ids, [9]);
|
||||
assert.match(
|
||||
calls[0].body.execution_id,
|
||||
/^legacy-system:\d+:[0-9a-f-]+$/,
|
||||
);
|
||||
assert.equal(calls[0].body.execution_id, calls[1].body.execution_id);
|
||||
const logPath = calls[0].body.log_path;
|
||||
assert.match(logPath, /^extra\/.*\.log$/);
|
||||
if (flags.startsWith('real_time')) {
|
||||
await assert.rejects(fs.stat(path.join(root, 'data/log', logPath)), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.match(result.stderr, /fixture-output/);
|
||||
} else {
|
||||
const log = await fs.readFile(
|
||||
path.join(root, 'data/log', logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(log, /fixture-output/);
|
||||
assert.match(log, /执行结束/);
|
||||
assert.doesNotMatch(result.stderr, /fixture-output/);
|
||||
}
|
||||
if (!failed) assert.equal(JSON.parse(result.stdout).logPath, logPath);
|
||||
else assert.equal(result.stdout, '');
|
||||
} finally {
|
||||
await new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('raw worker and compatibility route share subscription logs and lifecycle', async (t) => {
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const exec = require('node:util').promisify(
|
||||
require('node:child_process').execFile,
|
||||
);
|
||||
for (const entry of ['compat.js', 'subscription-worker.js']) {
|
||||
for (const failed of [false, true]) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-worker-log-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/fixture.js') {
|
||||
res.writeHead(failed ? 503 : 200);
|
||||
res.end('console.log("downloaded");');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
calls.push(JSON.parse(body));
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/config.sh'),
|
||||
'no_tee=true\nprintf x >> "$QL_DIR/config-loads"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/token.json'),
|
||||
JSON.stringify({
|
||||
value: 'fixture',
|
||||
expiration: Date.now() / 1000 + 3600,
|
||||
}),
|
||||
);
|
||||
const port = String(server.address().port);
|
||||
const result = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../dist', entry),
|
||||
'raw',
|
||||
`http://127.0.0.1:${port}/fixture.js`,
|
||||
'',
|
||||
'false',
|
||||
'false',
|
||||
],
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_DIR: root,
|
||||
QlPort: port,
|
||||
QL_CLI_LIFECYCLE: 'extended',
|
||||
ID: '12',
|
||||
SUB_ID: '3',
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
).then(
|
||||
(value) => ({ ...value, code: 0 }),
|
||||
(error) => error,
|
||||
);
|
||||
assert.equal(result.code, failed ? 1 : 0, result.stderr);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'config-loads'), 'utf8'),
|
||||
'x',
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.status),
|
||||
['0', '1'],
|
||||
);
|
||||
assert.deepEqual(calls[0].ids, [12]);
|
||||
assert.equal(calls[1].exit_code, failed ? 1 : 0);
|
||||
const logPath = calls[0].log_path;
|
||||
assert.match(logPath, /^raw\/.*\.log$/);
|
||||
const log = await fs.readFile(
|
||||
path.join(root, 'data/log', logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(log, /开始执行|执行结束/);
|
||||
if (failed) {
|
||||
assert.match(log, /503/);
|
||||
assert.doesNotMatch(result.stderr, /curl:.*503/);
|
||||
} else {
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.logPath, logPath);
|
||||
assert.match(
|
||||
await fs.readFile(
|
||||
path.join(root, 'data/scripts', payload.data.file),
|
||||
'utf8',
|
||||
),
|
||||
/downloaded/,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'compatibility commands finish lifecycle after signals and stop active children',
|
||||
{ timeout: 90000 },
|
||||
async (t) => {
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
for (const mode of ['config', 'extra', 'raw', 'worker']) {
|
||||
for (const [signal, code] of [
|
||||
['SIGINT', 130],
|
||||
['SIGTERM', 143],
|
||||
['SIGHUP', 129],
|
||||
['SIGQUIT', 128 + os.constants.signals.SIGQUIT],
|
||||
['SIGALRM', 128 + os.constants.signals.SIGALRM],
|
||||
['SIGTSTP', 128 + os.constants.signals.SIGTSTP],
|
||||
]) {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-compat-signal-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
let ready;
|
||||
const started = new Promise((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/waiting.js') {
|
||||
ready();
|
||||
return; // curl remains active until the command receives a signal.
|
||||
}
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
calls.push(JSON.parse(body));
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
let child;
|
||||
try {
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
const wait =
|
||||
'echo $$ > "$QL_DIR/child.pid"\nprintf "READY\\n" >&2\nexec sleep 60\n';
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/config.sh'),
|
||||
mode === 'config' ? wait : '',
|
||||
);
|
||||
await fs.writeFile(path.join(root, 'data/config/extra.sh'), wait);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/token.json'),
|
||||
JSON.stringify({
|
||||
value: 'fixture',
|
||||
expiration: Date.now() / 1000 + 3600,
|
||||
}),
|
||||
);
|
||||
const port = String(server.address().port);
|
||||
const args = [
|
||||
'raw',
|
||||
`http://127.0.0.1:${port}/waiting.js`,
|
||||
'',
|
||||
'false',
|
||||
'false',
|
||||
];
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../dist',
|
||||
mode === 'worker' ? 'subscription-worker.js' : 'compat.js',
|
||||
),
|
||||
...(mode === 'config' || mode === 'extra' ? ['extra'] : args),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_DIR: root,
|
||||
QlPort: port,
|
||||
QL_CLI_LIFECYCLE: 'extended',
|
||||
ID: '13',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let stdout = '',
|
||||
stderr = '';
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk));
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
if (stderr.includes('READY')) ready();
|
||||
});
|
||||
const closed = new Promise((resolve) =>
|
||||
child.on('close', (code, signal) => resolve({ code, signal })),
|
||||
);
|
||||
await Promise.race([
|
||||
started,
|
||||
closed.then(() => {
|
||||
throw new Error(`Exited before readiness: ${stderr}`);
|
||||
}),
|
||||
]);
|
||||
child.kill(signal);
|
||||
const result = await closed;
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ code, signal: null },
|
||||
`${mode} ${signal}: ${stderr}`,
|
||||
);
|
||||
assert.equal(stdout, '');
|
||||
assert.equal(JSON.parse(stderr.trim().split('\n').at(-1)).code, code);
|
||||
if (mode === 'config') assert.equal(calls.length, 0);
|
||||
else {
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.status),
|
||||
['0', '1'],
|
||||
);
|
||||
assert.equal(calls[1].exit_code, code);
|
||||
}
|
||||
if (mode === 'extra' || mode === 'config') {
|
||||
const pid = Number(
|
||||
await fs.readFile(path.join(root, 'child.pid'), 'utf8'),
|
||||
);
|
||||
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
|
||||
}
|
||||
} finally {
|
||||
if (child?.exitCode === null && child?.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
await new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { startupMain } = require('../../dist/startup');
|
||||
|
||||
test('startup compatibility uses the shared host-start handler with exact mode and directory arguments', async (t) => {
|
||||
const main = require('../../dist/main');
|
||||
const calls = [];
|
||||
t.mock.method(main, 'main', async (args, surface, signal) => {
|
||||
calls.push({ args, surface, signal });
|
||||
return 7;
|
||||
});
|
||||
assert.equal(await startupMain([]), 7);
|
||||
assert.equal(
|
||||
await startupMain([
|
||||
'reload',
|
||||
'--root',
|
||||
'/a b',
|
||||
'--data-dir',
|
||||
'/storage/data',
|
||||
'--json',
|
||||
]),
|
||||
7,
|
||||
);
|
||||
assert.equal(await startupMain(['--root', '/a b', '--no-startup']), 7);
|
||||
assert.deepEqual(
|
||||
calls.map(({ args }) => args),
|
||||
[
|
||||
['start'],
|
||||
[
|
||||
'start',
|
||||
'--reload',
|
||||
'--root',
|
||||
'/a b',
|
||||
'--data-dir',
|
||||
'/storage/data',
|
||||
'--json',
|
||||
],
|
||||
['start', '--root', '/a b', '--no-startup'],
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
calls.every(
|
||||
({ surface, signal }) =>
|
||||
surface === 'local' && signal instanceof AbortSignal,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('installed startup entry has bilingual JSON help and validates before executing host operations', () => {
|
||||
const entry = path.resolve(__dirname, '../../dist/startup.js');
|
||||
const run = (args, language = 'zh') =>
|
||||
spawnSync(process.execPath, [entry, ...args], {
|
||||
env: { ...process.env, QL_DIR: '', QL_DATA_DIR: '', QL_LANG: language },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
for (const [language, pattern] of [
|
||||
['zh', /用法/],
|
||||
['en', /Usage:/],
|
||||
]) {
|
||||
const result = run(['--help', '--json'], language);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const help = JSON.parse(result.stdout).data.help;
|
||||
assert.match(help, pattern);
|
||||
assert.match(help, /qinglong-cli/);
|
||||
assert.match(help, /reload/);
|
||||
}
|
||||
for (const args of [[], ['reload'], ['unexpected'], ['reload', 'extra']]) {
|
||||
const result = run([...args, '--json']);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(JSON.parse(result.stderr).code, 2);
|
||||
}
|
||||
});
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Fixed harness for comparing the original Shell implementation with the CLI.
|
||||
date() {
|
||||
if [[ $# == 1 && $1 == +%s ]]; then
|
||||
printf '%s' "$FIXED_NOW"
|
||||
else
|
||||
command date "$@"
|
||||
fi
|
||||
}
|
||||
t() { :; }
|
||||
find_cron_api() { [[ $1 == *active* ]] && printf referenced; }
|
||||
. "$1" 7
|
||||
@@ -0,0 +1,12 @@
|
||||
const parser = require('../../dist/shared/cli/arguments');
|
||||
const shared = require('../../dist/shared/cli/registry');
|
||||
const remote = require('../../dist/remote/commands/registry').registry;
|
||||
const local = require('../../dist/internal/commands/registry').registry;
|
||||
module.exports = {
|
||||
...shared,
|
||||
...parser,
|
||||
commands: [...remote.commands, ...local.commands],
|
||||
localOptions: local.options,
|
||||
parse: (args, surface = 'public') =>
|
||||
parser.parse(args, surface === 'local' ? local : remote),
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const source = path.resolve(__dirname, '../../src');
|
||||
function files(root) {
|
||||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
const file = path.join(root, entry.name);
|
||||
return entry.isDirectory()
|
||||
? files(file)
|
||||
: file.endsWith('.ts')
|
||||
? [file]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
test('shared, remote and internal dependencies respect their source boundaries', () => {
|
||||
for (const file of files(source)) {
|
||||
const owner = path.relative(source, file).split(path.sep)[0];
|
||||
const allowed = {
|
||||
shared: ['shared'],
|
||||
remote: ['remote', 'shared'],
|
||||
internal: ['internal', 'shared'],
|
||||
}[owner];
|
||||
if (!allowed) continue;
|
||||
const imports = ts.preProcessFile(
|
||||
fs.readFileSync(file, 'utf8'),
|
||||
true,
|
||||
true,
|
||||
).importedFiles;
|
||||
for (const imported of imports) {
|
||||
if (!imported.fileName.startsWith('.')) continue;
|
||||
const target = path
|
||||
.relative(source, path.resolve(path.dirname(file), imported.fileName))
|
||||
.split(path.sep)[0];
|
||||
assert.ok(
|
||||
allowed.includes(target),
|
||||
`${path.relative(source, file)} imports ${imported.fileName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('stable build aliases point to real modules and preserve exported entry functions', () => {
|
||||
const entries = require('../../scripts/entrypoints.cjs');
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const alias = path.resolve(__dirname, '../../dist', name);
|
||||
const target = path.resolve(__dirname, '../../dist', entry.module);
|
||||
assert.ok(fs.statSync(alias).isFile(), name);
|
||||
assert.ok(fs.statSync(target).isFile(), entry.module);
|
||||
// Auto-starting entry modules are exercised through child-process integration tests.
|
||||
if (entry.method)
|
||||
assert.equal(
|
||||
require(alias)[entry.method],
|
||||
require(target)[entry.method],
|
||||
name,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { createContext, sourceEnvironment } = require('../../dist/internal/runtime/context');
|
||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||
|
||||
test('unmodified legacy Shell and TS agree on shell hook state and account modes', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-differential-')),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
await fs.mkdir(bin);
|
||||
await fs.symlink(process.execPath, path.join(bin, 'node'));
|
||||
await fs.writeFile(path.join(bin, 'pnpm'), '#!/bin/sh\nexit 0\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ PATH: `${bin}:/usr/local/bin:/usr/bin:/bin`, no_tee: 'true' },
|
||||
);
|
||||
for (const directory of [
|
||||
context.paths.dir_shell,
|
||||
context.paths.dir_preload,
|
||||
context.paths.dir_config,
|
||||
context.paths.dir_scripts,
|
||||
context.paths.dir_log,
|
||||
path.join(root, 'static/build'),
|
||||
])
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
await fs.copyFile(
|
||||
path.resolve(__dirname, '../../../shell', name),
|
||||
path.join(context.paths.dir_shell, name),
|
||||
);
|
||||
await fs.mkdir(path.join(context.paths.dir_shell, 'lang'));
|
||||
for (const name of ['zh.sh', 'en.sh'])
|
||||
await fs.copyFile(
|
||||
path.resolve(__dirname, '../../../shell/lang', name),
|
||||
path.join(context.paths.dir_shell, 'lang', name),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'static/build/token.js'),
|
||||
'process.stdout.write("fixture-local-token")',
|
||||
);
|
||||
await fs.writeFile(
|
||||
context.paths.file_config_user,
|
||||
'no_tee=true\nconfiguration_function() { printf "function"; }\n',
|
||||
);
|
||||
await fs.writeFile(context.paths.file_env, 'export ACCOUNTS="one&two"\n');
|
||||
await fs.writeFile(context.paths.list_crontab_user, '');
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_before,
|
||||
'state=before\nprintf "before\\n" >> "$TRACE"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_after,
|
||||
'printf "after:%s:%s\\n" "$state" "$_task_exit_code" >> "$TRACE"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'fixture.sh'),
|
||||
'printf "run:%s:%s:%s\\n" "$ACCOUNTS" "$state" "$(configuration_function)" >> "$TRACE"\nstate=script\n',
|
||||
);
|
||||
for (const mode of ['now', 'desi', 'conc'])
|
||||
await t.test(mode, async () => {
|
||||
const legacy = path.join(root, `${mode}-legacy`),
|
||||
modern = path.join(root, `${mode}-modern`);
|
||||
const args =
|
||||
mode === 'now'
|
||||
? ['fixture.sh', 'now']
|
||||
: ['fixture.sh', mode, 'ACCOUNTS', '2', '1'];
|
||||
execFileSync(
|
||||
'/bin/bash',
|
||||
[path.join(context.paths.dir_shell, 'task.sh'), ...args],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...context.env, TRACE: legacy },
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
const env = await sourceEnvironment({ ...context.env, TRACE: modern }, [
|
||||
context.paths.file_config_user,
|
||||
]);
|
||||
const result = await executeTask(
|
||||
{ ...context, env },
|
||||
{
|
||||
argv: ['fixture.sh'],
|
||||
mode,
|
||||
variable: mode === 'now' ? undefined : 'ACCOUNTS',
|
||||
selection: mode === 'now' ? undefined : '2 1',
|
||||
},
|
||||
);
|
||||
assert.equal(result.exitCode, 0);
|
||||
const oldLines = (await fs.readFile(legacy, 'utf8')).trim().split('\n');
|
||||
const newLines = (await fs.readFile(modern, 'utf8')).trim().split('\n');
|
||||
assert.equal(oldLines[0], 'before');
|
||||
assert.equal(newLines[0], 'before');
|
||||
assert.equal(newLines.at(-1), oldLines.at(-1));
|
||||
assert.deepEqual(
|
||||
newLines.slice(1, -1).sort(),
|
||||
oldLines.slice(1, -1).sort(),
|
||||
);
|
||||
});
|
||||
await t.test(
|
||||
'Shell failure, explicit exit and EXIT traps preserve hook behavior',
|
||||
async () => {
|
||||
const cases = [
|
||||
{ name: 'return', script: 'return 7', code: 7, legacy: 0, after: true },
|
||||
{ name: 'exit', script: 'exit 7', code: 7, legacy: 7, after: false },
|
||||
{
|
||||
name: 'errexit',
|
||||
script: 'set -e; false',
|
||||
code: 1,
|
||||
legacy: 1,
|
||||
after: false,
|
||||
},
|
||||
{
|
||||
name: 'trap-return',
|
||||
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; return 7',
|
||||
code: 7,
|
||||
legacy: 0,
|
||||
after: true,
|
||||
trap: true,
|
||||
},
|
||||
{
|
||||
name: 'trap-exit',
|
||||
script: 'trap \'printf "exit-trap\\n" >> "$TRACE"\' EXIT; exit 7',
|
||||
code: 7,
|
||||
legacy: 7,
|
||||
after: false,
|
||||
trap: true,
|
||||
},
|
||||
];
|
||||
await fs.writeFile(context.paths.file_config_user, 'no_tee=true\n');
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_before,
|
||||
'printf "before\\n" >> "$TRACE"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_after,
|
||||
'printf "after:%s\\n" "$_task_exit_code" >> "$TRACE"\n',
|
||||
);
|
||||
for (const item of cases) {
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'failure.sh'),
|
||||
item.script + '\n',
|
||||
);
|
||||
const legacyTrace = path.join(root, `${item.name}-old`);
|
||||
const modernTrace = path.join(root, `${item.name}-new`);
|
||||
const legacy = spawnSync(
|
||||
'/bin/bash',
|
||||
[path.join(context.paths.dir_shell, 'task.sh'), 'failure.sh', 'now'],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...context.env, TRACE: legacyTrace },
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
assert.ifError(legacy.error);
|
||||
assert.equal(legacy.status, item.legacy, item.name);
|
||||
const env = await sourceEnvironment(
|
||||
{ ...context.env, TRACE: modernTrace },
|
||||
[context.paths.file_config_user],
|
||||
);
|
||||
const result = await executeTask(
|
||||
{ ...context, env },
|
||||
{ argv: ['failure.sh'], mode: 'now' },
|
||||
);
|
||||
assert.equal(result.exitCode, item.code, item.name);
|
||||
const expected = [
|
||||
'before',
|
||||
...(item.after ? [`after:${item.code}`] : []),
|
||||
...(item.trap ? ['exit-trap'] : []),
|
||||
];
|
||||
for (const file of [legacyTrace, modernTrace])
|
||||
assert.deepEqual(
|
||||
(await fs.readFile(file, 'utf8')).trim().split('\n'),
|
||||
expected,
|
||||
`${item.name}: ${file}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
await t.test('/dev/null preserves legacy stream routing', async () => {
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_before,
|
||||
'printf "before-out-marker\\n"; printf "before-err-marker\\n" >&2\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_after,
|
||||
'printf "after-out-marker\\n"; printf "after-err-marker\\n" >&2\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'streams.sh'),
|
||||
'printf "child-out-marker\\n"; printf "child-err-marker\\n" >&2\n',
|
||||
);
|
||||
for (const mode of ['now', 'desi', 'conc']) {
|
||||
for (const realtime of [false, true]) {
|
||||
await fs.writeFile(
|
||||
context.paths.file_config_user,
|
||||
`log_name=/dev/null\nno_tee=true\nreal_time=${realtime}\n`,
|
||||
);
|
||||
const legacy = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
path.join(context.paths.dir_shell, 'task.sh'),
|
||||
'streams.sh',
|
||||
mode,
|
||||
...(mode === 'now' ? [] : ['ACCOUNTS', '2', '1']),
|
||||
],
|
||||
{ cwd: root, env: context.env, encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
assert.ifError(legacy.error);
|
||||
assert.equal(legacy.status, 0, legacy.stderr);
|
||||
const env = await sourceEnvironment(context.env, [
|
||||
context.paths.file_config_user,
|
||||
]);
|
||||
let output = '';
|
||||
const result = await executeTask(
|
||||
{ ...context, env },
|
||||
{
|
||||
argv: ['streams.sh'],
|
||||
mode,
|
||||
variable: mode === 'now' ? undefined : 'ACCOUNTS',
|
||||
selection: mode === 'now' ? undefined : '2 1',
|
||||
output: (chunk) => {
|
||||
output += chunk;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.logPath, '/dev/null');
|
||||
const markers = (text) =>
|
||||
text.match(/(?:before|after|child)-(?:out|err)-marker/g) ?? [];
|
||||
assert.deepEqual(markers(legacy.stdout), []);
|
||||
assert.deepEqual(
|
||||
markers(output),
|
||||
markers(legacy.stderr),
|
||||
`${mode}, realtime=${realtime}`,
|
||||
);
|
||||
assert.ok(markers(output).includes('before-err-marker'));
|
||||
assert.equal(
|
||||
markers(output).includes('child-err-marker'),
|
||||
mode !== 'conc',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('empty ignored-minute settings match legacy random delay at minute zero', async (t) => {
|
||||
const source = await fs.readFile(
|
||||
path.resolve(__dirname, '../../../shell/otask.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const legacyFunction = source.match(/random_delay\(\) \{[\s\S]*?\n\}/)[0];
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-parity-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/scripts/fixture.sh'),
|
||||
'printf TASK_RAN',
|
||||
);
|
||||
const OriginalDate = global.Date;
|
||||
class MinuteZeroDate extends OriginalDate {
|
||||
getMinutes() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
global.Date = MinuteZeroDate;
|
||||
t.after(() => {
|
||||
global.Date = OriginalDate;
|
||||
});
|
||||
for (const ignored of [undefined, '', ' ', '0', ' 30 ']) {
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
RandomDelay: '1',
|
||||
RandomDelayFileExtensions: 'sh',
|
||||
...(ignored === undefined ? {} : { RandomDelayIgnoredMinutes: ignored }),
|
||||
};
|
||||
const legacy = execFileSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
`${legacyFunction}
|
||||
date() { printf 0; }
|
||||
gen_random_num() { printf 0; }
|
||||
t() { :; }
|
||||
sleep() { printf DELAYED; }
|
||||
random_delay fixture.sh
|
||||
`,
|
||||
],
|
||||
{ env, encoding: 'utf8' },
|
||||
);
|
||||
let output = '';
|
||||
const result = await executeTask(createContext({ root }, env), {
|
||||
argv: ['fixture.sh'],
|
||||
output: (chunk) => {
|
||||
output += chunk.toString();
|
||||
},
|
||||
});
|
||||
assert.equal(result.exitCode, 0, output);
|
||||
assert.match(output, /TASK_RAN/);
|
||||
assert.equal(
|
||||
output.includes('任务随机延迟'),
|
||||
legacy.includes('DELAYED'),
|
||||
`ignored=${JSON.stringify(ignored)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('random delay follows legacy dispatch for script arguments and executable commands', async (t) => {
|
||||
const source = await fs.readFile(
|
||||
path.resolve(__dirname, '../../../shell/otask.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const functions = ['random_delay', 'run_normal', 'main']
|
||||
.map(
|
||||
(name) =>
|
||||
source.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\}`))[0],
|
||||
)
|
||||
.join('\n');
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-dispatch-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/scripts/fixture.sh'),
|
||||
'printf TASK_RAN',
|
||||
);
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
RandomDelay: '1',
|
||||
RandomDelayFileExtensions: '',
|
||||
RandomDelayIgnoredMinutes: '99',
|
||||
};
|
||||
for (const args of [
|
||||
['fixture.sh'],
|
||||
['fixture.sh', 'argument'],
|
||||
['fixture.sh', 'now'],
|
||||
['true'],
|
||||
]) {
|
||||
const legacy = execFileSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
`${functions}
|
||||
date() { printf 0; }
|
||||
gen_random_num() { printf 0; }
|
||||
t() { :; }
|
||||
sleep() { printf DELAYED; }
|
||||
enter_script_workdir() { :; }
|
||||
run_else() { :; }
|
||||
which_program=true
|
||||
main "$@"
|
||||
`,
|
||||
'fixture',
|
||||
...args,
|
||||
],
|
||||
{ env, encoding: 'utf8' },
|
||||
);
|
||||
let output = '';
|
||||
const parsed = require('../../dist/runner').parseExecution(args);
|
||||
const result = await executeTask(createContext({ root }, env), {
|
||||
...parsed.execution,
|
||||
output: (chunk) => {
|
||||
output += chunk.toString();
|
||||
},
|
||||
});
|
||||
assert.equal(result.exitCode, 0, output);
|
||||
assert.equal(
|
||||
output.includes('任务随机延迟'),
|
||||
legacy.includes('DELAYED'),
|
||||
JSON.stringify(args),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('configuration shell options survive the environment bridge for pipeline and glob behavior', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-options-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = path.join(root, 'config.sh');
|
||||
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
|
||||
const probe =
|
||||
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
|
||||
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
|
||||
const legacy = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'. "$1"; eval "$2"',
|
||||
'fixture',
|
||||
config,
|
||||
probe,
|
||||
],
|
||||
{ env: base, encoding: 'utf8' },
|
||||
);
|
||||
const env = await sourceEnvironment(base, [config]);
|
||||
assert.ok(env.SHELLOPTS.split(':').includes('pipefail'));
|
||||
assert.ok(!env.SHELLOPTS.split(':').includes('allexport'));
|
||||
// Bash 3.x cannot import BASHOPTS itself; exercise the actual CLI bridge.
|
||||
const execution = await require('../../dist/internal/runtime/process').runProcess(
|
||||
'bash', ['--noprofile', '--norc', '-c', probe], { env, capture: true },
|
||||
);
|
||||
assert.equal(execution.code, 0);
|
||||
const modern = execution.stdout;
|
||||
assert.equal(modern, legacy);
|
||||
assert.match(modern, /pipeline=1/);
|
||||
const context = createContext({ root }, { ...env, no_tee: 'true' });
|
||||
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'pipeline.sh'),
|
||||
'false | true\n',
|
||||
);
|
||||
const result = await executeTask(context, {
|
||||
argv: ['pipeline.sh'],
|
||||
mode: 'now',
|
||||
});
|
||||
assert.equal(result.exitCode, 1);
|
||||
});
|
||||
|
||||
test('configuration errexit and nounset retain task and after-hook semantics', async (t) => {
|
||||
for (const option of ['errexit', 'nounset']) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-task-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = path.join(root, 'config.sh');
|
||||
await fs.writeFile(config, `set -o ${option}\n`);
|
||||
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
|
||||
assert.ok(env.SHELLOPTS.split(':').includes(option));
|
||||
const context = createContext({ root }, { ...env, no_tee: 'true' });
|
||||
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
|
||||
await fs.mkdir(context.paths.dir_config, { recursive: true });
|
||||
const marker = path.join(root, 'after');
|
||||
context.env.AFTER_MARKER = marker;
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_after,
|
||||
'printf "%s" "$-" > "$AFTER_MARKER"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'options.sh'),
|
||||
option === 'errexit'
|
||||
? 'false\nprintf SHOULD_NOT_RUN\n'
|
||||
: 'printf "%s" "$QL_UNSET_FIXTURE"\n',
|
||||
);
|
||||
const result = await executeTask(context, {
|
||||
argv: ['options.sh'],
|
||||
mode: 'now',
|
||||
});
|
||||
if (option === 'errexit') {
|
||||
assert.equal(result.exitCode, 1);
|
||||
await assert.rejects(fs.access(marker));
|
||||
assert.doesNotMatch(
|
||||
await fs.readFile(
|
||||
path.join(context.paths.dir_log, result.logPath),
|
||||
'utf8',
|
||||
),
|
||||
/SHOULD_NOT_RUN/,
|
||||
);
|
||||
} else {
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.match(await fs.readFile(marker, 'utf8'), /u/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('later hooks can disable previously enabled shell options without mutating the prior snapshot', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-option-reset-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = path.join(root, 'config.sh');
|
||||
const hook = path.join(root, 'before.sh');
|
||||
await fs.writeFile(config, 'set -o pipefail\nshopt -s nullglob\n');
|
||||
await fs.writeFile(hook, 'set +o pipefail\nshopt -u nullglob\n');
|
||||
const base = { PATH: process.env.PATH, EMPTY_DIR: root };
|
||||
const enabled = await sourceEnvironment(base, [config]);
|
||||
const disabled = await sourceEnvironment(enabled, [hook]);
|
||||
assert.ok(enabled.SHELLOPTS.split(':').includes('pipefail'));
|
||||
assert.ok(!disabled.SHELLOPTS.split(':').includes('pipefail'));
|
||||
assert.ok(!disabled.BASHOPTS.split(':').includes('nullglob'));
|
||||
const probe =
|
||||
'false | true; printf "pipeline=%s\\n" "$?"; files=("$EMPTY_DIR"/*.missing); printf "matches=%s\\n" "${#files[@]}"';
|
||||
const legacy = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'. "$1"; . "$2"; eval "$3"',
|
||||
'fixture',
|
||||
config,
|
||||
hook,
|
||||
probe,
|
||||
],
|
||||
{ env: base, encoding: 'utf8' },
|
||||
);
|
||||
const modern = execFileSync('bash', ['--noprofile', '--norc', '-c', probe], {
|
||||
env: disabled,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(modern, legacy);
|
||||
assert.equal(modern, 'pipeline=0\nmatches=1\n');
|
||||
const context = createContext({ root }, { ...enabled, no_tee: 'true' });
|
||||
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
|
||||
await fs.mkdir(context.paths.dir_config, { recursive: true });
|
||||
await fs.copyFile(hook, context.paths.file_task_before);
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'pipeline.sh'),
|
||||
'false | true\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
context.paths.file_task_after,
|
||||
'false | true; printf "%s" "$?" > "$AFTER_RESULT"\n',
|
||||
);
|
||||
context.env.AFTER_RESULT = path.join(root, 'after-result');
|
||||
const result = await executeTask(context, {
|
||||
argv: ['pipeline.sh'],
|
||||
mode: 'now',
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(await fs.readFile(context.env.AFTER_RESULT, 'utf8'), '0');
|
||||
});
|
||||
|
||||
test('disabled default shell options stay disabled across configuration and task bridges', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-default-options-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = path.join(root, 'config.sh');
|
||||
await fs.writeFile(config, 'set +o braceexpand\nshopt -u extquote\n');
|
||||
const env = await sourceEnvironment({ PATH: process.env.PATH }, [config]);
|
||||
const refreshed = await sourceEnvironment(env, []);
|
||||
assert.ok(!refreshed.SHELLOPTS.split(':').includes('braceexpand'));
|
||||
assert.ok(!refreshed.BASHOPTS.split(':').includes('extquote'));
|
||||
const context = createContext({ root }, { ...refreshed, no_tee: 'true' });
|
||||
await fs.mkdir(context.paths.dir_scripts, { recursive: true });
|
||||
const script =
|
||||
'printf "%s\\n" {a,b}; shopt -q extquote && printf WRONG; true\n';
|
||||
await fs.writeFile(
|
||||
path.join(context.paths.dir_scripts, 'options.sh'),
|
||||
script,
|
||||
);
|
||||
const legacy = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'. "$1"; eval "$2"',
|
||||
'fixture',
|
||||
config,
|
||||
script,
|
||||
],
|
||||
{ env: { PATH: process.env.PATH }, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(legacy, '{a,b}\n');
|
||||
const result = await executeTask(context, {
|
||||
argv: ['options.sh'],
|
||||
mode: 'now',
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
const log = await fs.readFile(
|
||||
path.join(context.paths.dir_log, result.logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(log.includes(legacy));
|
||||
assert.doesNotMatch(log, /WRONG/);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
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 { installCliEntrypoints } = require('../../dist/local/entrypoints');
|
||||
|
||||
test('generated entries treat quotes, newlines and code-like paths as data', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entry-paths-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const cliRoot = path.join(root, "CLI '中文\n\");throw new Error('injected');//");
|
||||
const bin = path.join(root, 'bin');
|
||||
await fs.mkdir(path.join(cliRoot, 'dist'), { recursive: true });
|
||||
await fs.writeFile(path.join(cliRoot, 'dist/ql.js'), 'exports.qlMain=async()=>{console.log(JSON.stringify(process.argv.slice(2)));return 7;};');
|
||||
await fs.writeFile(path.join(cliRoot, 'dist/task.js'), 'exports.taskMain=async()=>{throw new Error("secret-value");};');
|
||||
await installCliEntrypoints(bin, cliRoot);
|
||||
const ql = spawnSync(process.execPath, [path.join(bin, 'ql'), '--literal', 'value'], { encoding: 'utf8' });
|
||||
assert.equal(ql.status, 7, ql.stderr);
|
||||
assert.deepEqual(JSON.parse(ql.stdout), ['--literal', 'value']);
|
||||
for (const language of ['en', 'zh']) {
|
||||
const result = spawnSync(process.execPath, [path.join(bin, 'task')], { encoding: 'utf8', env: { QL_LANG: language } });
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stderr, language === 'en' ? 'CLI invocation failed.\n' : 'CLI 调用失败。\n');
|
||||
assert.doesNotMatch(result.stderr, /secret-value/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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 } = require('node:child_process');
|
||||
|
||||
test('legacy Shell and TS resolve executable paths after selecting the working directory', t => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ql-executable-')));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts/bin', 'data/scripts/custom', 'data/log', 'static/build', 'bin'])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
|
||||
for (const name of ['zh.sh', 'en.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
|
||||
fs.symlinkSync(process.execPath, path.join(root, 'bin/node'));
|
||||
fs.writeFileSync(path.join(root, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(root, 'static/build/token.js'), 'process.stdout.write("fixture")');
|
||||
fs.writeFileSync(path.join(root, 'data/config/config.sh'),
|
||||
'no_tee=true\nmy_task() { printf "%s\\n" "$PWD" "$@" > "$TRACE"; }\n');
|
||||
fs.writeFileSync(path.join(root, 'data/config/crontab.list'), '');
|
||||
const script = '#!/bin/sh\nprintf "%s\\n" "$PWD" "$@" > "$TRACE"\n';
|
||||
for (const file of ['data/scripts/bin/tool', 'data/scripts/bin/tool.exe', 'data/scripts/custom/tool', 'bin/path-tool'])
|
||||
fs.writeFileSync(path.join(root, file), script, { mode: 0o755 });
|
||||
const cases = [
|
||||
['bin/tool', '', 'bin'],
|
||||
['./bin/tool', '', 'bin'],
|
||||
['bin/tool.exe', '', 'bin'],
|
||||
[path.join(root, 'data/scripts/bin/tool'), '', 'bin'],
|
||||
['bin/tool', 'custom', 'custom'],
|
||||
['path-tool', '', ''],
|
||||
['my_task', '', ''],
|
||||
['my_task', 'custom', 'custom'],
|
||||
];
|
||||
for (const [program, workDir, directory] of cases) {
|
||||
for (const [label, command, prefix] of [
|
||||
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
|
||||
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
|
||||
]) {
|
||||
const trace = path.join(root, `${label}.trace`);
|
||||
fs.rmSync(trace, { force: true });
|
||||
const result = spawnSync(command, [...prefix, program, 'first', 'two words'], {
|
||||
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, QL_DIR: root, work_dir: workDir, TRACE: trace },
|
||||
encoding: 'utf8', timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, `${label}/${program}: ${result.stderr}`);
|
||||
assert.equal(fs.readFileSync(trace, 'utf8'),
|
||||
`${path.join(root, 'data/scripts', directory)}\nfirst\ntwo words\n`, `${label}/${program}`);
|
||||
}
|
||||
}
|
||||
// A builtin has no executable file. Function arguments must remain literal.
|
||||
for (const args of [[':', 'unused'], ['my_task', '$(touch INJECTED)', '; exit 9']]) {
|
||||
const result = spawnSync(process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/runner.js'), '--root', root, '--json', ...args], {
|
||||
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, TRACE: path.join(root, 'literal.trace') },
|
||||
encoding: 'utf8', timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
assert.equal(fs.readFileSync(path.join(root, 'literal.trace'), 'utf8'),
|
||||
`${path.join(root, 'data/scripts')}\n$(touch INJECTED)\n; exit 9\n`);
|
||||
assert.equal(fs.existsSync(path.join(root, 'data/scripts/INJECTED')), false);
|
||||
fs.writeFileSync(path.join(root, 'data/config/config.sh'),
|
||||
'no_tee=true\nSTATE=before\nmy_task() { export STATE=after; return 7; }\n');
|
||||
fs.writeFileSync(path.join(root, 'data/config/task_after.sh'),
|
||||
'printf "%s:%s" "$STATE" "$_task_exit_code" > "$TRACE.after"\n');
|
||||
for (const dir of ['', 'custom'])
|
||||
fs.writeFileSync(path.join(root, 'data/scripts', dir, 'show.sh'),
|
||||
'printf "%s" "$PWD" > "$TRACE"\n');
|
||||
for (const scenario of ['function-state', 'hook-directory']) {
|
||||
fs.writeFileSync(path.join(root, 'data/config/task_before.sh'),
|
||||
scenario === 'hook-directory' ? 'work_dir=custom\n' : '');
|
||||
const args = scenario === 'function-state' ? ['my_task'] : ['show.sh', 'now'];
|
||||
for (const [label, command, prefix] of [
|
||||
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
|
||||
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
|
||||
]) {
|
||||
const trace = path.join(root, `${scenario}-${label}`);
|
||||
const result = spawnSync(command, [...prefix, ...args], {
|
||||
env: { PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`, QL_DIR: root, TRACE: trace },
|
||||
encoding: 'utf8', timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, scenario === 'function-state' && label === 'ts' ? 7 : 0, result.stderr);
|
||||
if (scenario === 'function-state')
|
||||
assert.equal(fs.readFileSync(`${trace}.after`, 'utf8'), 'after:7');
|
||||
else
|
||||
assert.equal(fs.readFileSync(trace, 'utf8'), path.join(root, 'data/scripts/custom'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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');
|
||||
|
||||
test('standalone public commands load only public modules and never local operators or backend dependencies', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-loading-')),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const dist = path.join(root, 'dist');
|
||||
await fs.cp(path.resolve(__dirname, '../../dist'), dist, { recursive: true });
|
||||
const script = `
|
||||
const path = require('node:path');
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = () => true;
|
||||
process.stderr.write = () => true;
|
||||
const entry = process.argv[1];
|
||||
require(entry).main(JSON.parse(process.argv[2])).then(code => {
|
||||
originalWrite(JSON.stringify({ code, modules: Object.keys(require.cache).map(file => path.relative(path.dirname(entry), file)) }));
|
||||
});
|
||||
`;
|
||||
for (const [args, expected] of [
|
||||
[['--help'], 0],
|
||||
[['task', 'list', '--help'], 0],
|
||||
[['auth', 'logout'], 0],
|
||||
[['auth', 'status', '--json'], 3],
|
||||
[['task', 'list', '--json'], 3],
|
||||
[['subscription', 'list', '--json'], 3],
|
||||
]) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
['-e', script, path.join(dist, 'main.js'), JSON.stringify(args)],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_CLI_CONFIG: path.join(root, 'missing/config.json'),
|
||||
QL_DIR: '/nonexistent-panel',
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
const result = JSON.parse(child.stdout);
|
||||
assert.equal(result.code, expected, JSON.stringify(args));
|
||||
assert.ok(result.modules.length > 0);
|
||||
for (const module of result.modules) {
|
||||
assert.ok(!module.startsWith('..'), `External module loaded: ${module}`);
|
||||
assert.doesNotMatch(
|
||||
module,
|
||||
/^(local|internal|back|preload)[/\\]|(^|[/\\])node_modules[/\\]/,
|
||||
JSON.stringify(args),
|
||||
);
|
||||
}
|
||||
if (args.includes('--help'))
|
||||
assert.ok(
|
||||
!result.modules.some((module) =>
|
||||
/^remote[/\\](commands[/\\](auth|open|task|subscription)\.js|api[/\\](client|download)\.js|auth[/\\])/.test(module),
|
||||
),
|
||||
'Help eagerly loaded command implementation',
|
||||
);
|
||||
if (args[0] === 'task' && !args.includes('--help'))
|
||||
assert.ok(result.modules.includes(path.join('remote', 'commands', 'task.js')));
|
||||
if (args[0] === 'subscription')
|
||||
assert.ok(
|
||||
result.modules.includes(path.join('remote', 'commands', 'subscription.js')),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
// Exercise the real dispatcher/parser without performing an actual upgrade,
|
||||
// service restart or account mutation.
|
||||
function invoke(args) {
|
||||
const script = `
|
||||
require('./cli/dist/internal/commands/dispatch').dispatch = async command => ({code:200,data:command});
|
||||
require('./cli/dist/ql').qlMain(JSON.parse(process.argv[1])).then(code=>{process.exitCode=code});`;
|
||||
return spawnSync(process.execPath, ['-e', script, JSON.stringify(args)], {
|
||||
cwd: path.resolve(__dirname, '../../..'), encoding: 'utf8',
|
||||
env: { PATH: process.env.PATH, QL_LANG: 'en' }, timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
test('direct maintenance commands and local aliases accept modern and legacy options identically', () => {
|
||||
for (const [args, name, values, positionals] of [
|
||||
[['update', '--mirror', 'gitee', '--download-only', '--root', '/isolated'], 'update', {mirror:'gitee', 'download-only':true, root:'/isolated'}, []],
|
||||
[['update', 'false', '--root', '/isolated'], 'update', {'download-only':true, root:'/isolated'}, []],
|
||||
[['update', 'true'], 'update', {mirror:'github'}, []],
|
||||
[['reload', 'system', '--root', '/isolated'], 'reload', {target:'system', root:'/isolated'}, []],
|
||||
[['reload', '--target', 'data'], 'reload', {target:'data'}, []],
|
||||
[['check'], 'check', {}, []],
|
||||
[['rmlog', '7'], 'rmlog', {}, ['7']],
|
||||
[['start', '--no-startup'], 'start', {'no-startup':true}, []],
|
||||
[['repair-config'], 'repair-config', {}, []],
|
||||
[['resetpwd', '--', '-literal-value'], 'resetpwd', {}, ['-literal-value']],
|
||||
[['update', '--root', 'false'], 'update', {root:'false'}, []],
|
||||
]) {
|
||||
const direct = invoke(['--json', ...args]);
|
||||
const alias = invoke(['--json', 'local', ...args]);
|
||||
assert.equal(direct.status, 0, direct.stderr);
|
||||
assert.equal(alias.status, 0, alias.stderr);
|
||||
assert.deepEqual(JSON.parse(direct.stdout), JSON.parse(alias.stdout));
|
||||
const command = JSON.parse(direct.stdout).data;
|
||||
assert.equal(command.name, `local ${name}`);
|
||||
assert.deepEqual(command.positionals, positionals);
|
||||
for (const [key, value] of Object.entries(values)) assert.equal(command.values[key], value);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid maintenance options fail before dispatch; help recommends direct commands', () => {
|
||||
for (const args of [
|
||||
['update', 'invalid'], ['reload', 'invalid'], ['update', 'false', 'extra'],
|
||||
['reload', 'system', '--target', 'data'], ['update', '--unknown'],
|
||||
['update', '--', 'false'], ['rmlog', '-1'],
|
||||
]) {
|
||||
const result = invoke(['--json', ...args]);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(JSON.parse(result.stderr).code, 2);
|
||||
}
|
||||
for (const group of [[], ['local']]) {
|
||||
const result = invoke([...group, 'update', '--help', '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const help = JSON.parse(result.stdout).data.help;
|
||||
assert.match(help, /Usage: ql update/);
|
||||
assert.match(help, /--mirror/);
|
||||
assert.match(help, /--download-only/);
|
||||
}
|
||||
});
|
||||
|
||||
test('direct and aliased maintenance preserve logs, lifecycle and single configuration evaluation', async t => {
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const exec = require('node:util').promisify(require('node:child_process').execFile);
|
||||
for (const prefix of [[], ['local']]) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-direct-extra-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const reports = [];
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
reports.push({ url: req.url, data: JSON.parse(body) });
|
||||
res.end(JSON.stringify({ code: 200 }));
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'data/config/config.sh'), 'no_tee=true\nprintf x >> "$QL_DIR/loads"\n');
|
||||
await fs.writeFile(path.join(root, 'data/config/token.json'), JSON.stringify({value:'fixture', expiration:Date.now()/1000+3600}));
|
||||
await fs.writeFile(path.join(root, 'data/config/extra.sh'), 'printf "logged-extra\\n"\n');
|
||||
const result = await exec(process.execPath, [path.resolve(__dirname, '../../dist/ql.js'), ...prefix, 'extra', '--root', root, '--json'], {
|
||||
env: {PATH:process.env.PATH, QlPort:String(server.address().port), ID:'9'}, timeout:10000,
|
||||
});
|
||||
assert.equal(await fs.readFile(path.join(root, 'loads'), 'utf8'), 'x');
|
||||
assert.equal(result.stderr, '');
|
||||
const response = JSON.parse(result.stdout);
|
||||
assert.match(response.logPath, /^extra\//);
|
||||
assert.match(await fs.readFile(path.join(root, 'data/log', response.logPath), 'utf8'), /logged-extra/);
|
||||
assert.deepEqual(reports.map(report => report.data.status), ['0', '1']);
|
||||
assert.ok(reports.every(report => report.url === '/open/crons/status'));
|
||||
} finally {
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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 } = require('node:child_process');
|
||||
|
||||
test('legacy Shell and TS preserve task stdin in each execution mode', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stdin-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of ['shell/preload', 'shell/lang', 'data/config', 'data/scripts', 'data/log', 'static/build', 'bin'])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
for (const name of ['task.sh', 'otask.sh', 'share.sh', 'api.sh', 'env.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell', name), path.join(root, 'shell', name));
|
||||
for (const name of ['zh.sh', 'en.sh'])
|
||||
fs.copyFileSync(path.resolve(__dirname, '../../../shell/lang', name), path.join(root, 'shell/lang', name));
|
||||
fs.symlinkSync(process.execPath, path.join(root, 'bin/node'));
|
||||
fs.writeFileSync(path.join(root, 'bin/pnpm'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(root, 'static/build/token.js'), 'process.stdout.write("fixture")');
|
||||
fs.writeFileSync(path.join(root, 'data/config/config.sh'), 'no_tee=true\n');
|
||||
fs.writeFileSync(path.join(root, 'data/config/crontab.list'), '');
|
||||
fs.writeFileSync(path.join(root, 'shell/preload/env.sh'), 'export ACCOUNTS=one\n');
|
||||
fs.writeFileSync(path.join(root, 'data/scripts/read.sh'),
|
||||
'IFS= read -r value\nprintf "value=%s\\n" "$value" > "$TRACE"\n');
|
||||
const env = {
|
||||
PATH: `${path.join(root, 'bin')}:/usr/bin:/bin`,
|
||||
QL_DIR: root,
|
||||
QL_DATA_DIR: path.join(root, 'data'),
|
||||
no_delay: 'true',
|
||||
};
|
||||
for (const mode of ['normal', 'now', 'desi', 'conc']) {
|
||||
const args = ['read.sh', ...(mode === 'normal' ? [] : [mode]),
|
||||
...(['desi', 'conc'].includes(mode) ? ['ACCOUNTS', '1'] : [])];
|
||||
for (const [label, command, prefix] of [
|
||||
['shell', '/bin/bash', [path.join(root, 'shell/task.sh')]],
|
||||
['ts', process.execPath, [path.resolve(__dirname, '../../dist/runner.js'), '--root', root]],
|
||||
]) {
|
||||
const trace = path.join(root, `${mode}-${label}`);
|
||||
const result = spawnSync(command, [...prefix, ...args], {
|
||||
env: { ...env, TRACE: trace }, input: 'hello\n', encoding: 'utf8', timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, `${mode}/${label}: ${result.stderr}`);
|
||||
assert.equal(fs.readFileSync(trace, 'utf8'), 'value=hello\n', `${mode}/${label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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 { installCliEntrypoints } = require('../../dist/local/entrypoints');
|
||||
|
||||
test('unified ql groups and task shortcut preserve help, errors and local execution boundaries', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-unified-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
QL_DIR: root,
|
||||
QL_CLI_CONFIG: path.join(root, 'credentials'),
|
||||
QL_LANG: 'en',
|
||||
};
|
||||
const call = (entry, args) =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args],
|
||||
{ env, encoding: 'utf8', timeout: 10000 },
|
||||
);
|
||||
for (const group of ['', 'task', 'local']) {
|
||||
const result = call('ql', [...(group ? [group] : []), '--help', '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(JSON.parse(result.stdout).data.help, /ql/);
|
||||
}
|
||||
for (const kind of ['repo', 'raw']) {
|
||||
const help = call('ql', ['local', kind, '--help', '--json']);
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
assert.match(JSON.parse(help.stdout).data.help, /ql repo/);
|
||||
}
|
||||
for (const args of [
|
||||
['task', 'list'],
|
||||
['task', 'run', '12'],
|
||||
['subscription', 'list'],
|
||||
['app', 'list'],
|
||||
['system', 'info'],
|
||||
['auth', 'status'],
|
||||
['api', 'routes'],
|
||||
]) {
|
||||
const result = call('ql', [...args, '--json']);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
}
|
||||
const invalid = call('ql', ['task', 'run', 'demo.sh', '--json']);
|
||||
assert.equal(invalid.status, 2);
|
||||
assert.equal(invalid.stdout, '');
|
||||
const local = call('ql', [
|
||||
'local',
|
||||
'repair-config',
|
||||
'--root',
|
||||
'relative',
|
||||
'--json',
|
||||
]);
|
||||
assert.equal(local.status, 2);
|
||||
assert.match(JSON.parse(local.stderr).message, /absolute/);
|
||||
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/config.sh'),
|
||||
'no_tee=false\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/scripts/demo.sh'),
|
||||
'printf "script:%s\\n" "$1"; return 7\n',
|
||||
);
|
||||
for (const [entry, args] of [
|
||||
['ql', ['task', 'demo.sh', 'now', '--', '--json']],
|
||||
['ql', ['task', 'exec', 'demo.sh', 'now', '--', '--json']],
|
||||
['task', ['demo.sh', 'now', '--', '--json']],
|
||||
['task', ['exec', 'demo.sh', 'now', '--', '--json']],
|
||||
]) {
|
||||
const result = call(entry, args);
|
||||
assert.equal(result.status, 7, result.stderr);
|
||||
assert.match(result.stdout, /script:--json/);
|
||||
}
|
||||
const shortcut = call('task', ['list', '--json']);
|
||||
assert.equal(shortcut.status, 2, shortcut.stderr);
|
||||
const legacy = call('ql', ['update', 'invalid']);
|
||||
assert.equal(legacy.status, 2, legacy.stderr);
|
||||
assert.ok(legacy.stderr.length > 0);
|
||||
for (const args of [['raw'], ['local', 'raw'], ['repo'], ['local', 'repo']]) {
|
||||
const result = call('ql', args);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
}
|
||||
const bin = path.join(root, 'bin');
|
||||
await installCliEntrypoints(bin, path.resolve(__dirname, '../..'));
|
||||
for (const name of ['ql', 'task']) {
|
||||
const result = spawnSync(path.join(bin, name), ['--help'], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /task/);
|
||||
}
|
||||
});
|
||||
|
||||
test('internal rejection never loads remote credentials or local operators', () => {
|
||||
const code = `process.stdout.write=()=>true;process.stderr.write=()=>true;
|
||||
require('./cli/dist/ql').qlMain(['task','list','--json']).then(code=>{
|
||||
if(code!==2) process.exit(1);
|
||||
if(Object.keys(require.cache).some(p=>p.includes('/dist/internal/runtime/') || p.includes('/dist/remote/') || p.includes('/dist/remote/commands/auth') || p.includes('/dist/remote/auth/store'))) process.exit(2);
|
||||
});`;
|
||||
const result = spawnSync(process.execPath, ['-e', code], {
|
||||
cwd: path.resolve(__dirname, '../../..'),
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_CLI_CONFIG: '/nonexistent-ql-unified-config',
|
||||
QL_URL: 'https://must-not-be-contacted.invalid',
|
||||
QL_ACCESS_TOKEN: 'test-only',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
# Test runtime only: never used as the CLI's distribution or panel image.
|
||||
FROM node:22.12.0-bookworm-slim@sha256:35531c52ce27b6575d69755c73e65d4468dba93a25644eed56dc12879cae9213
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Match the repository's current TS execution fixture; no host node_modules mount.
|
||||
RUN npm install -g --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2 \
|
||||
&& npm cache clean --force
|
||||
ENV NODE_PATH=/usr/local/lib/node_modules
|
||||
WORKDIR /workspace
|
||||
CMD ["sh", "-c", "node cli/scripts/test.cjs && node cli/scripts/verify-package.cjs"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# Test runtime only; retains the production Alpine coreutils/Bash tool choices.
|
||||
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
||||
RUN apk add --no-cache bash ca-certificates coreutils curl git git-daemon jq openssl perl procps python3 unzip zip
|
||||
RUN npm install -g --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2 \
|
||||
&& npm cache clean --force
|
||||
ENV NODE_PATH=/usr/local/lib/node_modules
|
||||
WORKDIR /workspace
|
||||
CMD ["sh", "-c", "node cli/scripts/test.cjs && node cli/scripts/verify-package.cjs"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM qinglong-cli-test:node22-debian
|
||||
RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d \
|
||||
&& chmod +x /usr/sbin/policy-rc.d \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends nginx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install -g --ignore-scripts --no-audit --no-fund pm2@5.4.3 \
|
||||
&& npm cache clean --force
|
||||
CMD ["node", "--test", "cli/test/linux/services.test.cjs"]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Disposable SSH transport fixture only; never a panel distribution image.
|
||||
FROM qinglong-cli-test:node24-alpine
|
||||
RUN apk add --no-cache openssh
|
||||
@@ -0,0 +1,164 @@
|
||||
# Linux CLI regression runtime
|
||||
|
||||
Build the CLI on the host first (`npm run build:cli`), then build this test-only image:
|
||||
|
||||
```sh
|
||||
docker build -t qinglong-cli-test:node22-debian cli/test/linux
|
||||
docker run --rm --network none \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
--mount type=bind,src="$(pwd)/shell",dst=/workspace/shell,readonly \
|
||||
--mount type=bind,src="$(pwd)/back/api",dst=/workspace/back/api,readonly \
|
||||
--workdir /workspace qinglong-cli-test:node22-debian
|
||||
```
|
||||
|
||||
The image supplies Linux interpreters, Git and archive tools. It does not mount host node_modules, credentials, the Docker socket or production data. Tests use temporary installations and loopback API fixtures; the runtime has no external network. Package installation verification runs offline after the test suite. No installed ql/task command is replaced. The original Shell is mounted read-only for differential tests.
|
||||
|
||||
Image construction needs network access. If Docker injects a stale proxy, override it for this build only with empty HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and their lowercase build arguments; do not change global proxy settings as part of the test.
|
||||
|
||||
The Node base image digest and TypeScript interpreter versions are fixed. Debian package revisions follow the Bookworm repositories at build time. Passing this suite does not prove real panel authentication/database persistence, nginx/PM2 deployment, or compatibility of all user scripts.
|
||||
|
||||
For Alpine/musl, use the second pinned base and the same read-only runtime mounts:
|
||||
|
||||
```sh
|
||||
docker build -f cli/test/linux/Dockerfile.alpine -t qinglong-cli-test:node24-alpine cli/test/linux
|
||||
docker run --rm --network none \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
--mount type=bind,src="$(pwd)/shell",dst=/workspace/shell,readonly \
|
||||
--mount type=bind,src="$(pwd)/back/api",dst=/workspace/back/api,readonly \
|
||||
--workdir /workspace qinglong-cli-test:node24-alpine
|
||||
```
|
||||
|
||||
This includes Bash/coreutils, as does the panel's Alpine Dockerfile; it is not a bare BusyBox compatibility claim. Python comes from the pinned base's Alpine repository, and package patch revisions remain build-time dependent.
|
||||
|
||||
## Real service manager integration
|
||||
|
||||
After building the Debian test image:
|
||||
|
||||
```sh
|
||||
docker build -f cli/test/linux/Dockerfile.services -t qinglong-cli-test:services cli/test/linux
|
||||
docker run --rm --network none \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
--workdir /workspace qinglong-cli-test:services
|
||||
```
|
||||
|
||||
This separate test installs nginx and PM2 5.4.3 in the image. It invokes bootstrap reload twice with an isolated PM2_HOME and nginx config, checks proxy responses/config replacement/backend PID change, and verifies stopPanel removes the PM2 application. Cleanup quits nginx and kills the test PM2 daemon before the disposable container is removed. Ports 5801/5802 remain container-local. The backend is a minimal HTTP fixture, not a real QingLong application/database. No OS boot integration (`pm2 startup`) or first-time online dependency provisioning is claimed by this test.
|
||||
|
||||
## Released 2.x panel integration
|
||||
|
||||
`panel.cjs` is for a fresh disposable official panel only. It refuses initialized instances, generates random temporary owner/application credentials without printing them, exercises API login/status/subscription reads and a scheduled task run, then invokes the new local task engine against the real token generator and verifies persisted log_path/execution time. It does not replace installed Shell entrypoints. The fixture script exits 7 in the local-runner step.
|
||||
|
||||
Run the official `whyour/qinglong:2.20.1@sha256:4e96d821494cfbeddd29f5a46dfb006a5a64f5639e0b8665816fcf55f39a85ea` image with `--network none`, no published ports, and this CLI directory mounted read-only at `/candidate/cli`. Clear inherited proxy variables for the test container. After its startup log reports readiness, execute:
|
||||
|
||||
```sh
|
||||
docker exec -e QL_PANEL_INTEGRATION=1 <temporary-container-id> node /candidate/cli/test/linux/panel.cjs
|
||||
```
|
||||
|
||||
Always remove that temporary container and its anonymous volume with `docker rm -fv <temporary-container-id>` after the run, including failures. Never point this test at an existing initialized panel. The version is explicit because the current workspace's backend build also contains independent 3.0 changes; this gate tests the public 2.x compatibility contract without pulling 3.0 work into the migration.
|
||||
|
||||
## Interrupted replacement with real services
|
||||
|
||||
Using the services image above, run:
|
||||
|
||||
```sh
|
||||
docker run --rm --network none \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
--workdir /workspace qinglong-cli-test:services \
|
||||
node --test cli/test/linux/upgrade.test.cjs
|
||||
```
|
||||
|
||||
This test covers direct Node and PM2 separately. It starts the old HTTP fixture, invokes replaceAndReload in a child process, waits until the new HTTP fixture responds, sends SIGTERM to the upgrade child, then checks exit 143, old file/HTTP restoration, disappearance of the new PID, and backup cleanup. The child fixture inserts a deterministic wait after real startup so the signal cannot race past the rollback boundary. Test services and PM2_HOME are temporary; port 5811 is container-local. It exercises the real service functions and filesystem replacement but not QingLong database migrations, archive downloads, or interruption during a package install.
|
||||
|
||||
The optional `QL_PANEL_ENTRYPOINTS=1` environment flag on the same fresh-panel command enables a test-only reversible switch of the official release's `~/bin/task` and `~/bin/ql` symlinks. Original Shell targets remain on disk, and the links are restored in finally. It additionally schedules `ql extra` and checks persisted log metadata. The corrected gate passed on official 2.20.1 after legacy status-field selection and scheduler log-path reuse were added; retain the run output as evaluation evidence. It covers task and ql extra scheduling, not every legacy command.
|
||||
|
||||
With entry switching enabled, panel.cjs also runs panel-subscription.cjs: a loopback file subscription downloads through the new worker, creates one task with its subscription ID, then executes that task through the panel and checks its output. The official 2.20.1 gate passed on 2026-09-25. Notification channels are intentionally unconfigured; their rejection must produce a diagnostic without failing an otherwise completed synchronization. Repository subscriptions are outside this particular gate.
|
||||
|
||||
The switched-entrypoint fixture also includes panel-repository.cjs. It creates a local file:// Git repository with a main and selected branch, include/exclude patterns, a shared dependency, and two revisions. Assertions check the selected branch, exclusion, dependency execution, stable ID for an unchanged task, addition/removal after the second pull, and removal of the obsolete script. The source is local because the official image lacks git-daemon. This gate does not establish network Git transport, proxies or private repository authentication.
|
||||
|
||||
The switched-entrypoint gate also runs panel-account.cjs at the end. It changes only the freshly generated test owner's username/password through ql, verifies new login credentials, activates login retry/second-factor requirements via the test owner's API, and verifies resetlet/resettfa restore access. It never prints credentials and must only run inside the disposable panel guarded by panel.cjs. The account and its data volume are removed with the container.
|
||||
|
||||
The expanded switched-entrypoint gate now includes panel-operator.cjs for real log retention and full-panel service reload. Without the candidate loader, the original 2.20.1 startup loader overwrites temporary Node wrappers with Shell links. Use the candidate-loader gate below to verify persistent entry selection.
|
||||
|
||||
### Persistent entrypoint selection gate
|
||||
|
||||
The loader integration regression can be run on the development host with `node --test cli/test/linux/entrypoints.test.cjs`; it needs the repository TypeScript dev dependency and reads the actual back/loaders/deps.ts. It verifies both commands, exact args/exit codes, paths with spaces, repeated startup selection, invalid/incomplete installations and return to original Shell.
|
||||
|
||||
For the real-panel gate, transpile only back/loaders/deps.ts to a temporary CommonJS file using the repository TypeScript transpileModule (CommonJS, esModuleInterop, ES2022). Mount that file read-only as /candidate/deps.js alongside the CLI mount and start the disposable official image with QL_CLI_ROOT=/candidate/cli. Add QL_PANEL_LOADER=1 to the panel.cjs docker exec environment. The guarded fixture backs up the installed compiled loader, copies this single candidate loader, exercises reload, and restores the compiled loader and command links in finally. No full workspace backend/3.0 build is used. The updated gate passed with operatorMaintenance=true on 2026-09-25; without the candidate loader the expected old-entry overwrite remains reproducible.
|
||||
|
||||
## Online check/repair gate
|
||||
|
||||
Use a fresh disposable official 2.20.1 container with the CLI read-only mount, no published ports/user data, and normal container networking (unlike the offline gates). Clear inherited proxy environment variables. Execute `node /candidate/cli/dist/admin.js check --root /ql --json` with npm_config_registry=https://registry.npmjs.org, npm_config_fetch_retries=0 and npm_config_fetch_timeout=30000, redirecting stdout to /tmp/check-result.json. This performs real package downloads and container-local installs.
|
||||
|
||||
For the repair scenario, append a fixture comment to /ql/data/config/config.sh, copy it to /tmp/expected-config.sh, delete /ql/data/config/task_before.sh, and overwrite /ql/data/scripts/sendNotify.js with a fixture string. Run the same check again, redirecting stdout to /tmp/check-repair-result.json. Then execute `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/verify-check.cjs` inside that container. Always remove the container and anonymous volumes afterward. The official image and registry dependencies can change; retain the exact image digest and resulting tool versions with the evidence. Never run this damage fixture on an existing panel.
|
||||
|
||||
## First-start container gate
|
||||
|
||||
Start a fresh official image with `--entrypoint sleep`, arguments `infinity`, explicit QL_DIR=/ql and QL_DATA_DIR=/ql/data, and the read-only CLI mount. Allow package network access and clear inherited proxy variables as in the online check gate. This bypasses the legacy container entrypoint. Run `node /candidate/cli/dist/admin.js start --root /ql --data-dir /ql/data --no-startup --json > /tmp/start-result.json` inside the container, then `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/verify-start.cjs`. Use the same bounded npm registry environment as online check. Remove the container and anonymous volumes afterward.
|
||||
|
||||
--no-startup explicitly skips OS boot registration for containers lacking init, while PM2 save still runs. This does not validate host reboot behavior. The official image already provides Node/Python/panel dependencies; the gate verifies migration of startup orchestration, not provisioning an empty OS.
|
||||
|
||||
## Online Bot installation/process gate
|
||||
|
||||
Use a fresh official 2.20.1 container with `--entrypoint sleep ... infinity`, normal container networking, cleared proxy variables and the read-only CLI mount. Run `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/bot-install.cjs` inside it. The fixture creates a local Git repository, performs real apk/pip installation, starts two Python test bots in separate data directories, replaces one and verifies configuration/process isolation, then stops/cleans them. It sends no Telegram traffic and uses no bot credentials. Remove the disposable container and anonymous volumes afterward. The dependency fixture pins colorama 0.4.6; OS packages follow the image's configured repositories.
|
||||
|
||||
The network repository regression uses a loopback smart-HTTP Git service, random test credentials and a loopback HTTP proxy. It requires `git-http-backend`; Alpine packages it in `git-daemon`, included only in this test image. No external repository or credential is used, and the runtime still works with `--network none`.
|
||||
|
||||
The same regression also runs a TLS smart-HTTP service and an actual HTTP CONNECT tunnel. OpenSSL generates a temporary self-signed certificate with an IP SAN; Git receives it through GIT_SSL_CAINFO only for the trusted case. Removing trust must fail, as must HTTP 401/503, while preserving installed scripts and checkout HEAD. The Alpine test image includes the openssl executable for this fixture; no runtime CLI dependency or certificate-verification bypass is introduced.
|
||||
|
||||
## Isolated SSH subscription gate
|
||||
|
||||
Build `cli/test/linux/Dockerfile.ssh` as `qinglong-cli-test:ssh` after the Alpine test image. Run only in a fresh disposable container:
|
||||
|
||||
```sh
|
||||
docker run --rm --init --network none -e QL_SSH_INTEGRATION=1 \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
qinglong-cli-test:ssh node --test cli/test/linux/ssh-repository.test.cjs
|
||||
```
|
||||
|
||||
This root-only fixture creates a container-local account, host/client keys and repository. It starts sshd on loopback port 22022 with password authentication and forwarding disabled, then verifies both SSH URL and scp-style Git addresses. StrictHostKeyChecking and IdentitiesOnly remain enabled. Wrong identities and mismatched known_hosts must fail without changing scripts or checkout HEAD. The fixture does not mount host SSH configuration or keys. Its temporary authorization files live below /var/lib because sshd StrictModes rejects the world-writable /tmp ancestor. Never run this opt-in fixture directly on a host: account removal is provided by disposable-container deletion.
|
||||
|
||||
## Published upgrade archive gate
|
||||
|
||||
Run `QL_ARCHIVE_INTEGRATION=1 node --test cli/test/linux/published-archive.test.cjs` with network access and current dist. Set QL_ARCHIVE_MIRROR=gitee to exercise that explicit mirror instead of GitHub. The gate calls stageUpgrade against real master source/static ZIP URLs and validates paths, expected app entry, readiness marker and selected-stage pointer. It records archive hashes and version for reproducibility because master can change.
|
||||
|
||||
Dependency installation is intercepted: downloaded source is never executed, and no installed panel is reloaded. The existing package-install and retained-data upgrade gates provide separate evidence for those operations. This gate uses temporary directories, bounds download work with cancellation at 150 seconds and removes staging afterward. External archive availability is a prerequisite; connection failures must not be reported as successful transport validation.
|
||||
|
||||
Data reload with a real mount root and a live Node backend has a separate disposable gate:
|
||||
|
||||
```sh
|
||||
docker run --rm --network none --tmpfs /mounted-data -e QL_MOUNT_TEST=1 \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
qinglong-cli-test:node24-alpine node --test cli/test/linux/mounted-data.test.cjs
|
||||
```
|
||||
|
||||
The fixture requires an empty separate filesystem at /mounted-data, never a production data mount. It verifies the mount inode/device remain unchanged, obsolete data is removed, staged data is installed and the restarted backend serves the new data.
|
||||
|
||||
## Container startup-hook ownership
|
||||
|
||||
```sh
|
||||
docker run --rm --init --network none --user 65534:65534 \
|
||||
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
|
||||
qinglong-cli-test:node24-alpine node --test \
|
||||
cli/test/containerRuntime.test.cjs cli/test/linux/container-bot.test.cjs
|
||||
```
|
||||
|
||||
The bot fixture runs the actual detached admin installer and Python launcher against a local module with no Telegram code. OS/pip provisioning commands are fixture stubs; the separate online gate above validates dependency installation. It waits for the installer to exit while Python remains alive, stops the container runtime, and verifies that only its bot exits while another installation's bot stays alive. All fixture processes are cleaned, including on assertion failures. --init reaps adopted orphans.
|
||||
|
||||
## Real container crond gate
|
||||
|
||||
Start a fresh opt-in 2.20.1 image using the TS container entry with --init, --stop-timeout 30 and QL_SCHEDULER=system. Rebuild the evaluation image with the current CLI and mount only cli/test read-only at /opt/qinglong-cli/test. Do not overlay cli or dist: the source tree lacks image-generated bin links and local tsc output lacks the executable modes assigned during image packaging. No network or published port is required. Run `QL_PANEL_INTEGRATION=1 node /opt/qinglong-cli/test/linux/container-cron.cjs` with docker exec. It creates one minute-scheduled task through the local API, checks the installed system crontab and waits up to ninety seconds for a real minute tick. It never calls the task run API. The task records its process ancestry; the gate requires real crond, the selected Node task entry (verified wrapper or symlink target) and the TS container entry, and reads output through the panel log API. Task and script are removed in a finalizer. Stop the container, inspect its exit code and remove it with its anonymous volume afterward.
|
||||
|
||||
## Packaged language preload and service reload gate
|
||||
|
||||
Mount only cli/test at /opt/qinglong-cli/test in a fresh packaged 2.20.1 evaluation container. Start it through dist/container.js with QL_PANEL_INTEGRATION=1, --init and a 30-second stop timeout. Run panel.cjs first on the uninitialized panel for application authentication and API task/log checks. Then run `node /opt/qinglong-cli/test/linux/panel-preload.cjs` inside that container; set QL_PANEL_RELOAD_INTEGRATION=1 to additionally invoke the packaged local service reload after creating the first task and verify it survives. The gate schedules JS/MJS/Python through the real API, checks Shell before-hook exports and actual QLAPI, and imports a temporary global ESM exported subpath. It restores the hook and removes tasks/scripts/package in cleanup.
|
||||
|
||||
Use the image's ordinary seeded data volume. An empty tmpfs or host bind mount over /ql/data masks image-preinstalled requests under dep_cache/python3; that is not a complete interpreter environment. When explicitly evaluating tmpfs, seed the fixed image's preinstalled dependencies into the live mount and verify `python3 -c 'import requests'` before this gate. The recorded run used an offline copy from the same image, not pip installation or stub notification modules. Docker cp did not materialize writes in the active tmpfs in this environment; extraction by tar inside the running container did. Keep this prerequisite distinct from the separate empty-OS provisioning requirement.
|
||||
|
||||
## Real host reboot gate
|
||||
|
||||
`host-boot.cjs` runs only as root with QL_HOST_INTEGRATION=1 inside a disposable Alpine/OpenRC or Debian/systemd VM. Prepare a 2.x panel distribution plus the CLI, with Bash/Node/npm/Python/pip prerequisite runtimes. Run the actual `ql-local-cli start --root /ql` and require a successful registration result. Copy the test directory alongside the CLI dist directory, then run `node test/linux/host-boot.cjs before`, reboot the guest, and run the same script with `after` once SSH returns. Never run this on a production host.
|
||||
|
||||
The before phase verifies live nginx/crond, creates a minute-scheduled task and saves the kernel boot ID in a private fixture state file. The after phase checks OpenRC or systemd service state, requires a different boot ID, checks the stored task command and waits up to ninety seconds for a task log containing the new boot ID. It does not call the task run API. Success removes the task, script and state. On failure, retain the isolated guest for diagnosis or destroy it; do not cite PM2-only recovery as proof of nginx/cron recovery. The task fixture and script paths are under /ql, and the guest state file is /var/lib/ql-host-fixture.json.
|
||||
|
||||
## Remote OpenAPI CRUD gate
|
||||
|
||||
`openapi.cjs` requires a separate fresh official panel and `QL_PANEL_INTEGRATION=1`; it refuses initialized panels. Mount cli read-only at /candidate/cli, run the official image without published ports or production data, then execute `docker exec -e QL_PANEL_INTEGRATION=1 <container> node /candidate/cli/test/linux/openapi.cjs`. It creates temporary owner/application credentials without printing them and verifies task/subscription/app/env CRUD, app secret rotation, script/config writes and reads, and log/dependency reads through the npm bundle. It does not execute system updates, data import, real dependency installation or every dashboard/user mutation. Always remove the disposable container and anonymous volumes with `docker rm -fv <container>`.
|
||||
@@ -0,0 +1,111 @@
|
||||
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 {
|
||||
installAndStartBot,
|
||||
launchBot,
|
||||
stopBot,
|
||||
} = require('../../dist/internal/maintenance/bot');
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
(async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-online-'));
|
||||
const foreignRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-other-'));
|
||||
const source = path.join(root, 'source');
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PIP_DEFAULT_TIMEOUT: '30',
|
||||
PIP_RETRIES: '0',
|
||||
};
|
||||
const ctx = createContext({ root }, { ...env, BotRepoUrl: source });
|
||||
const other = createContext({ root: foreignRoot }, env);
|
||||
const program = (version) =>
|
||||
`import time, colorama\nprint('${version}:'+colorama.__version__,flush=True)\nwhile True: time.sleep(0.1)\n`;
|
||||
try {
|
||||
await fs.mkdir(path.join(source, 'jbot'), { recursive: true });
|
||||
await fs.mkdir(path.join(source, 'config'));
|
||||
await fs.writeFile(
|
||||
path.join(source, 'config/bot.json'),
|
||||
'{"fixture":"default"}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(source, 'jbot/requirements.txt'),
|
||||
'colorama==0.4.6\n',
|
||||
);
|
||||
await fs.writeFile(path.join(source, 'jbot/__main__.py'), program('FIRST'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', ['-C', source, ...args], { stdio: 'ignore' });
|
||||
git('init', '-b', 'main');
|
||||
git('add', '.');
|
||||
git(
|
||||
'-c',
|
||||
'user.name=fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
'commit',
|
||||
'-m',
|
||||
'fixture',
|
||||
);
|
||||
const first = await installAndStartBot(ctx);
|
||||
const oldPid = first.result.service.pid;
|
||||
assert.match(
|
||||
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
|
||||
/FIRST:0.4.6/,
|
||||
);
|
||||
await fs.mkdir(path.join(other.data, 'jbot'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(other.data, 'jbot/__main__.py'),
|
||||
program('OTHER'),
|
||||
);
|
||||
const otherPid = (await launchBot(other)).pid;
|
||||
await fs.writeFile(
|
||||
path.join(ctx.paths.dir_config, 'bot.json'),
|
||||
'{"fixture":"preserve"}',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(first.repository, 'jbot/__main__.py'),
|
||||
program('SECOND'),
|
||||
);
|
||||
const second = await installAndStartBot(ctx);
|
||||
assert.notEqual(second.result.service.pid, oldPid);
|
||||
assert.throws(() => process.kill(oldPid, 0), { code: 'ESRCH' });
|
||||
process.kill(otherPid, 0);
|
||||
assert.match(
|
||||
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
|
||||
/SECOND:0.4.6/,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(ctx.paths.dir_config, 'bot.json'), 'utf8'),
|
||||
'{"fixture":"preserve"}',
|
||||
);
|
||||
await stopBot(ctx);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.throws(() => process.kill(second.result.service.pid, 0), {
|
||||
code: 'ESRCH',
|
||||
});
|
||||
process.kill(otherPid, 0);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
dependencyInstalled: true,
|
||||
started: true,
|
||||
replaced: true,
|
||||
configurationPreserved: true,
|
||||
otherInstallationSurvived: true,
|
||||
stopped: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
for (const context of [ctx, other]) {
|
||||
if (await fs.stat(context.data).catch(() => undefined))
|
||||
await stopBot(context);
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(foreignRoot, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(() => {
|
||||
console.error('Bot installation integration test failed; raw subprocess errors are omitted to protect environment credentials.');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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');
|
||||
const { launchStartupHook } = require('../../dist/internal/maintenance/bootstrap');
|
||||
const { launchBot } = require('../../dist/internal/maintenance/bot');
|
||||
|
||||
const live = (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
async function until(check) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (await check()) return;
|
||||
await delay(25);
|
||||
}
|
||||
throw new Error('Timed out waiting for fixture process');
|
||||
}
|
||||
|
||||
test(
|
||||
'container stops its daemonized bot after installer exits and preserves another installation',
|
||||
{ skip: process.platform !== 'linux', timeout: 20000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-bot-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
for (const dir of [
|
||||
'data',
|
||||
'sample',
|
||||
'etc',
|
||||
'bin',
|
||||
'data/repo/dockerbot/.git',
|
||||
'data/repo/dockerbot/jbot',
|
||||
'data/repo/dockerbot/config',
|
||||
'other/data/jbot',
|
||||
'other/data/log',
|
||||
])
|
||||
await fs.mkdir(path.join(root, dir), { recursive: true });
|
||||
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');
|
||||
await fs.writeFile(
|
||||
path.join(root, 'sample/config.sample.sh'),
|
||||
'AutoStartBot=true\n',
|
||||
);
|
||||
// Isolate package provisioning; execute the real installer, Python launcher
|
||||
// and detached admin entry against a local module that never uses Telegram.
|
||||
for (const name of ['apk', 'sudo', 'pip3'])
|
||||
await fs.writeFile(path.join(root, 'bin', name), '#!/bin/sh\nexit 0\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
const python =
|
||||
'import os,time\nfrom pathlib import Path\nPath(os.environ["FIXTURE_PID"]).write_text(str(os.getpid()))\nwhile True: time.sleep(1)\n';
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/repo/dockerbot/jbot/__main__.py'),
|
||||
python,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/repo/dockerbot/jbot/requirements.txt'),
|
||||
'',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/repo/dockerbot/config/bot.json'),
|
||||
'{}',
|
||||
);
|
||||
await fs.writeFile(path.join(root, 'other/data/jbot/__main__.py'), python);
|
||||
const env = {
|
||||
PATH: `${root}/bin:${process.env.PATH}`,
|
||||
HOME: root,
|
||||
QL_OS_TYPE: 'alpine',
|
||||
QL_SCHEDULER: 'node',
|
||||
FIXTURE_PID: path.join(root, 'bot.pid'),
|
||||
};
|
||||
const context = createContext({ root }, env);
|
||||
const foreign = createContext(
|
||||
{ root: path.join(root, 'other') },
|
||||
{ ...env, FIXTURE_PID: path.join(root, 'foreign.pid') },
|
||||
);
|
||||
const foreignBot = await launchBot(foreign);
|
||||
let ownPid, installerPid;
|
||||
const controller = new AbortController();
|
||||
let running;
|
||||
t.after(async () => {
|
||||
controller.abort('SIGTERM');
|
||||
for (const pid of [ownPid, installerPid, foreignBot.pid])
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
await running?.catch(() => {});
|
||||
});
|
||||
running = runContainer(context, controller.signal, {
|
||||
systemDirectory: path.join(root, 'etc'),
|
||||
services: {
|
||||
start: async () => ({ manager: 'node' }),
|
||||
stop: async () => {},
|
||||
hook: async (runtime, action) => {
|
||||
installerPid = await launchStartupHook(runtime, action);
|
||||
return installerPid;
|
||||
},
|
||||
},
|
||||
});
|
||||
await until(
|
||||
async () => !!(await fs.stat(env.FIXTURE_PID).catch(() => false)),
|
||||
);
|
||||
ownPid = Number(await fs.readFile(env.FIXTURE_PID, 'utf8'));
|
||||
assert.ok(ownPid > 1);
|
||||
await until(() => installerPid && !live(installerPid));
|
||||
assert.equal(
|
||||
live(ownPid),
|
||||
true,
|
||||
'bot must outlive its completed installer',
|
||||
);
|
||||
controller.abort('SIGTERM');
|
||||
assert.equal(await running, 143);
|
||||
await until(() => !live(ownPid));
|
||||
assert.equal(
|
||||
live(foreignBot.pid),
|
||||
true,
|
||||
'other installation must remain running',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Run only in a fresh disposable panel started by dist/container.js, system mode.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { LocalApi } = require('../../dist/internal/runtime/api');
|
||||
|
||||
(async () => {
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
assert.equal(process.env.QL_SCHEDULER, 'system');
|
||||
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
|
||||
const filename = `container-cron-${randomUUID()}.js`;
|
||||
const script = path.join('/ql/data/scripts', filename);
|
||||
await fs.writeFile(
|
||||
script,
|
||||
`const fs=require('node:fs');const chain=[];let pid=process.pid;while(pid>0&&chain.length<12){try{const argv=fs.readFileSync('/proc/'+pid+'/cmdline','utf8').split('\\0').filter(Boolean);chain.push({pid,argv});const stat=fs.readFileSync('/proc/'+pid+'/stat','utf8');pid=Number(stat.slice(stat.lastIndexOf(')')+2).split(' ')[1]);}catch{break}}console.log('CRON_ANCESTRY='+JSON.stringify(chain));\n`,
|
||||
);
|
||||
let task;
|
||||
try {
|
||||
task = (
|
||||
await api.call('crons', 'POST', {
|
||||
name: 'Disposable real crond fixture',
|
||||
command: `task ${filename}`,
|
||||
schedule: '* * * * *',
|
||||
})
|
||||
).data;
|
||||
assert.ok(task.id);
|
||||
const table = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
|
||||
assert.equal(table.status, 0, table.stderr);
|
||||
assert.ok(
|
||||
table.stdout
|
||||
.split('\n')
|
||||
.some((line) => !line.startsWith('#') && line.includes(filename)),
|
||||
'panel must install task in real crontab',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ event: 'waiting-for-crond', taskId: task.id }),
|
||||
);
|
||||
let chain;
|
||||
const deadline = Date.now() + 90000;
|
||||
while (Date.now() < deadline) {
|
||||
const log = (await api.call(`crons/${task.id}/log`)).data;
|
||||
const match = typeof log === 'string' && log.match(/CRON_ANCESTRY=(.*)/);
|
||||
if (match) {
|
||||
chain = JSON.parse(match[1]);
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
assert.ok(
|
||||
chain,
|
||||
'real minute tick must produce task output without a run API call',
|
||||
);
|
||||
assert.ok(
|
||||
chain.some(({ argv }) => path.basename(argv[0] || '') === 'crond'),
|
||||
'ancestry must include actual crond',
|
||||
);
|
||||
const runner = chain.find(
|
||||
({ argv }) =>
|
||||
path.basename(argv[0] || '') === 'node' &&
|
||||
['/root/bin/task', '/opt/qinglong-cli/bin/task'].includes(argv[1]),
|
||||
);
|
||||
assert.ok(runner, 'scheduled task must execute the selected Node wrapper');
|
||||
if (runner.argv[1] === '/root/bin/task') {
|
||||
assert.match(
|
||||
await fs.readFile(runner.argv[1], 'utf8'),
|
||||
/\/opt\/qinglong-cli\/dist\/task.js/,
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
await fs.realpath(runner.argv[1]),
|
||||
'/opt/qinglong-cli/dist/task.js',
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
chain.some(({ argv }) =>
|
||||
argv.includes('/opt/qinglong-cli/dist/container.js'),
|
||||
),
|
||||
'scheduler must descend from the TS container entry',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
realCrondMinuteTick: true,
|
||||
tsRunner: true,
|
||||
persistedLog: true,
|
||||
tsContainerAncestor: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
if (task) await api.call('crons', 'DELETE', [task.id]);
|
||||
await fs.rm(script, { force: true });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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 vm = require('node:vm');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const ts = require('typescript');
|
||||
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
|
||||
|
||||
test('2.x startup selection survives repeated linking and can return to original Shell', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entrypoints-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const home = path.join(root, 'home');
|
||||
const cliRoot = path.join(root, 'CLI with spaces');
|
||||
const bin = path.join(home, 'bin');
|
||||
await fs.mkdir(path.join(root, 'shell'), { recursive: true });
|
||||
for (const name of ['update.sh', 'task.sh'])
|
||||
await fs.writeFile(path.join(root, 'shell', name), 'original');
|
||||
await fs.mkdir(path.join(cliRoot, 'dist/local'), { recursive: true });
|
||||
for (const [file, method] of [
|
||||
['ql', 'qlMain'],
|
||||
['task', 'taskMain'],
|
||||
])
|
||||
await fs.writeFile(
|
||||
path.join(cliRoot, 'dist', `${file}.js`),
|
||||
`exports.${method}=async()=>{console.log(JSON.stringify(process.argv.slice(2)));return 7;};`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cliRoot, 'dist/local/entrypoints.js'),
|
||||
`exports.installCliEntrypoints=require(${JSON.stringify(
|
||||
path.resolve(__dirname, '../../dist/local/entrypoints'),
|
||||
)}).installCliEntrypoints;`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cliRoot, 'dist/local/cronEntrypoint.js'),
|
||||
`module.exports=require(${JSON.stringify(
|
||||
path.resolve(__dirname, '../../dist/local/cronEntrypoint'),
|
||||
)});`,
|
||||
);
|
||||
const code = ts.transpileModule(
|
||||
await fs.readFile(
|
||||
path.resolve(__dirname, '../../../back/loaders/deps.ts'),
|
||||
'utf8',
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const errors = [];
|
||||
const legacyBin = path.join(root, 'legacy-bin');
|
||||
await fs.mkdir(legacyBin);
|
||||
for (const name of ['ql', 'task'])
|
||||
await fs.writeFile(path.join(legacyBin, name), '#!/bin/sh\nexit 99\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
await fs.writeFile(path.join(legacyBin, 'crontab'), '#!/bin/sh\nexit 0\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
const originalPath = [legacyBin, bin, '/usr/bin', '/bin'].join(
|
||||
path.delimiter,
|
||||
);
|
||||
const environment = { PATH: originalPath };
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(code, {
|
||||
module,
|
||||
exports: module.exports,
|
||||
process: { env: environment },
|
||||
require: (id) => {
|
||||
if (id === 'os') return { homedir: () => home };
|
||||
if (id === '../config/index')
|
||||
return {
|
||||
rootPath: root,
|
||||
dataPath: path.join(root, 'data'),
|
||||
crontabFile: path.join(root, 'data/config/crontab.list'),
|
||||
};
|
||||
if (id === './logger') return { error: (...args) => errors.push(args) };
|
||||
return require(id);
|
||||
},
|
||||
});
|
||||
await module.exports.default();
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(bin, 'ql')),
|
||||
path.join(root, 'shell/update.sh'),
|
||||
);
|
||||
assert.equal(environment.PATH, originalPath);
|
||||
assert.equal(spawnSync('ql', [], { env: environment }).status, 99);
|
||||
environment.QL_CLI_ROOT = cliRoot;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await module.exports.default();
|
||||
assert.equal(environment.PATH.split(path.delimiter)[0], bin);
|
||||
assert.equal(
|
||||
environment.PATH.split(path.delimiter).filter((value) => value === bin)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
for (const name of ['ql', 'task']) {
|
||||
const result = spawnSync(name, ['--literal', 'a b', '$(nothing)'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...environment },
|
||||
});
|
||||
assert.equal(result.status, 7, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), [
|
||||
'--literal',
|
||||
'a b',
|
||||
'$(nothing)',
|
||||
]);
|
||||
}
|
||||
}
|
||||
assert.match(
|
||||
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
|
||||
/QingLong CLI crontab bridge/,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
const before = await fs.readFile(path.join(bin, 'ql'), 'utf8');
|
||||
environment.QL_CLI_ROOT = 'relative';
|
||||
await module.exports.default();
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(await fs.readFile(path.join(bin, 'ql'), 'utf8'), before);
|
||||
delete environment.QL_CLI_ROOT;
|
||||
await module.exports.default();
|
||||
await assert.rejects(fs.lstat(path.join(bin, 'crontab')), { code: 'ENOENT' });
|
||||
await fs.writeFile(path.join(bin, 'crontab'), '# unrelated user command');
|
||||
await module.exports.default();
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
|
||||
'# unrelated user command',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(bin, 'task')),
|
||||
path.join(root, 'shell/task.sh'),
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'shell/task.sh'), 'utf8'),
|
||||
'original',
|
||||
);
|
||||
});
|
||||
|
||||
test('incomplete CLI installation leaves both existing entrypoints untouched', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entry-incomplete-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
await fs.mkdir(bin);
|
||||
await fs.mkdir(path.join(root, 'dist'));
|
||||
await fs.writeFile(path.join(root, 'dist/ql.js'), '');
|
||||
for (const name of ['ql', 'task'])
|
||||
await fs.writeFile(path.join(bin, name), 'keep');
|
||||
await assert.rejects(installCliEntrypoints(bin, root));
|
||||
for (const name of ['ql', 'task'])
|
||||
assert.equal(await fs.readFile(path.join(bin, name), 'utf8'), 'keep');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
|
||||
// Test-only reversible switch; callers must first reject initialized panels.
|
||||
exports.installEvaluationEntrypoints =
|
||||
async function installEvaluationEntrypoints() {
|
||||
if (process.env.QL_PANEL_INTEGRATION !== '1')
|
||||
throw new Error('Disposable panel required');
|
||||
const changed = [];
|
||||
const restore = async () => {
|
||||
for (const entry of changed.reverse()) {
|
||||
await fs.rm(entry.target, { force: true });
|
||||
await fs.rename(entry.backup, entry.target);
|
||||
}
|
||||
};
|
||||
try {
|
||||
for (const [name, moduleName, entry] of [
|
||||
['task', 'task', 'taskMain'],
|
||||
['ql', 'ql', 'qlMain'],
|
||||
]) {
|
||||
const target = path.join(require('node:os').homedir(), 'bin', name);
|
||||
const backup = `${target}.shell-evaluation-backup`;
|
||||
const stat = await fs.lstat(target);
|
||||
if (!stat.isSymbolicLink())
|
||||
throw new Error(`Expected original symlink: ${name}`);
|
||||
await fs.access(backup).then(
|
||||
() => {
|
||||
throw new Error('Backup already exists');
|
||||
},
|
||||
(error) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
},
|
||||
);
|
||||
await fs.rename(target, backup);
|
||||
changed.push({ target, backup });
|
||||
const modulePath = path.resolve(__dirname, '../../dist', moduleName);
|
||||
await fs.writeFile(
|
||||
target,
|
||||
`#!${process.execPath}\nrequire(${JSON.stringify(
|
||||
modulePath,
|
||||
)}).${entry}().then(code => {process.exitCode = code;});\n`,
|
||||
{ mode: 0o755, flag: 'wx' },
|
||||
);
|
||||
}
|
||||
if (process.env.QL_PANEL_LOADER === '1') {
|
||||
const target = '/ql/static/build/loaders/deps.js';
|
||||
const backup = `${target}.shell-evaluation-backup`;
|
||||
await fs.rename(target, backup);
|
||||
changed.push({ target, backup });
|
||||
await fs.copyFile('/candidate/deps.js', target);
|
||||
}
|
||||
return restore;
|
||||
} catch (error) {
|
||||
await restore();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
// Run as root only inside the disposable QEMU host-startup fixture.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { LocalApi } = require('../../dist/internal/runtime/api');
|
||||
|
||||
(async () => {
|
||||
assert.equal(process.env.QL_HOST_INTEGRATION, '1');
|
||||
const mode = process.argv[2];
|
||||
assert.ok(['before', 'after'].includes(mode));
|
||||
const stateFile = '/var/lib/ql-host-fixture.json';
|
||||
const boot = (
|
||||
await fs.readFile('/proc/sys/kernel/random/boot_id', 'utf8')
|
||||
).trim();
|
||||
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
|
||||
const deadline = Date.now() + 45000;
|
||||
let system;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
system = (await api.call('system')).data;
|
||||
break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
assert.ok(system, 'panel must become available without manual service start');
|
||||
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
|
||||
const systemd = /^ID=(?:"?)(?:debian|ubuntu)(?:"?)$/m.test(osRelease);
|
||||
for (const service of mode === 'after' ? ['nginx', 'crond'] : ['crond'])
|
||||
execFileSync(
|
||||
systemd ? 'systemctl' : 'rc-service',
|
||||
systemd
|
||||
? ['is-active', service === 'crond' ? 'cron' : service]
|
||||
: [service, 'status'],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
const nginxPid = Number(
|
||||
(await fs.readFile(systemd ? '/run/nginx.pid' : '/run/nginx/nginx.pid', 'utf8')).trim(),
|
||||
);
|
||||
assert.ok(Number.isSafeInteger(nginxPid) && nginxPid > 1);
|
||||
process.kill(nginxPid, 0);
|
||||
assert.match(await fs.readFile(`/proc/${nginxPid}/comm`, 'utf8'), /^nginx/);
|
||||
if (mode === 'before') {
|
||||
const filename = `host-reboot-${randomUUID()}.js`;
|
||||
await fs.writeFile(
|
||||
`/ql/data/scripts/${filename}`,
|
||||
'console.log("HOST_REBOOT_TASK_OK:"+require("node:fs").readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim());',
|
||||
);
|
||||
const task = (
|
||||
await api.call('crons', 'POST', {
|
||||
name: 'Disposable host reboot task',
|
||||
command: `task ${filename}`,
|
||||
schedule: '* * * * *',
|
||||
})
|
||||
).data;
|
||||
await fs.writeFile(stateFile, JSON.stringify({ boot, task, filename }), {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
beforeBootId: boot,
|
||||
taskCreated: true,
|
||||
version: system.version,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
|
||||
assert.notEqual(boot, state.boot, 'kernel boot ID must change');
|
||||
const task = (await api.call(`crons/${state.task.id}`)).data;
|
||||
assert.equal(task.command, state.task.command);
|
||||
let log = '';
|
||||
const deadline = Date.now() + 90000;
|
||||
while (Date.now() < deadline) {
|
||||
log = (await api.call(`crons/${task.id}/log`)).data || '';
|
||||
if (
|
||||
log.includes(`HOST_REBOOT_TASK_OK:${boot}`) &&
|
||||
/完成|执行结束/.test(log)
|
||||
)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
assert.ok(
|
||||
log.includes(`HOST_REBOOT_TASK_OK:${boot}`),
|
||||
'crond must run the retained task during this boot without a run API call',
|
||||
);
|
||||
assert.match(log, /完成|执行结束/);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
afterBootId: boot,
|
||||
automaticPanelStartup: true,
|
||||
initSystem: systemd ? 'systemd' : 'openrc',
|
||||
taskRetained: true,
|
||||
taskExecuted: true,
|
||||
nginxStarted: true,
|
||||
crondStarted: true,
|
||||
automaticMinuteTick: true,
|
||||
version: system.version,
|
||||
}),
|
||||
);
|
||||
await api.call('crons', 'DELETE', [task.id]);
|
||||
await fs.rm(`/ql/data/scripts/${state.filename}`);
|
||||
await fs.rm(stateFile);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createContext } = require('/opt/qinglong-cli/dist/internal/runtime/context');
|
||||
const { LocalApi } = require('/opt/qinglong-cli/dist/internal/runtime/api');
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
(async () => {
|
||||
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
|
||||
await fs.writeFile(
|
||||
'/ql/data/scripts/image-selection.js',
|
||||
'console.log("IMAGE_PARENT="+JSON.stringify(require("node:fs").readFileSync("/proc/"+process.ppid+"/cmdline","utf8").split("\\0")));',
|
||||
);
|
||||
const task = (
|
||||
await api.call('crons', 'POST', {
|
||||
name: 'image selection fixture',
|
||||
command: 'task image-selection.js',
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
try {
|
||||
await api.call('crons/run', 'PUT', [task.id]);
|
||||
let log = '';
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
log = (await api.call(`crons/${task.id}/log`)).data;
|
||||
if (log.includes('IMAGE_PARENT=')) break;
|
||||
}
|
||||
const parent = JSON.parse(log.match(/IMAGE_PARENT=(.*)/)[1]);
|
||||
assert.match(parent[0], /(?:^|\/)node$/);
|
||||
assert.equal(parent[1], '/root/bin/task');
|
||||
assert.match(
|
||||
await fs.readFile('/root/bin/task', 'utf8'),
|
||||
/\/opt\/qinglong-cli\/dist\/task.js/,
|
||||
);
|
||||
const reload = spawnSync('ql', ['reload'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
assert.equal(reload.status, 0, reload.stderr);
|
||||
assert.equal(JSON.parse(reload.stdout).code, 200);
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
healthy =
|
||||
(
|
||||
await (
|
||||
await fetch('http://127.0.0.1:5700/api/system', {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
})
|
||||
).json()
|
||||
).code === 200;
|
||||
} catch {}
|
||||
if (healthy) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
assert.ok(healthy);
|
||||
const help = spawnSync('ql', ['--help'], { encoding: 'utf8' });
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /ql-compat/);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
packagedTaskScheduled: true,
|
||||
packagedReload: true,
|
||||
healthyAfterReload: true,
|
||||
selectionRetained: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await api.call('crons', 'DELETE', [task.id]);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
process.channel.ref();
|
||||
// Child fixture: real service startup pauses at a deterministic cancellation boundary.
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
|
||||
const { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const {
|
||||
withCommandCancellation,
|
||||
cancellableOperation,
|
||||
interruptedCode,
|
||||
} = require('../../dist/internal/runtime/cancellation');
|
||||
const path = require('node:path');
|
||||
const context = createContext({ root: process.env.TEST_ROOT }, process.env);
|
||||
let starts = 0;
|
||||
withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
await cancellableOperation(signal, () =>
|
||||
replaceAndReload(
|
||||
context,
|
||||
[
|
||||
{
|
||||
source: path.join(context.root, 'staged'),
|
||||
target: context.paths.dir_static,
|
||||
},
|
||||
],
|
||||
{
|
||||
stop: stopPanel,
|
||||
start: async (ctx) => {
|
||||
const result = await startPanel(ctx);
|
||||
if (++starts === 1) {
|
||||
const body = await require('./service-response.cjs')('new');
|
||||
process.send({ ready: true, pid: body.pid });
|
||||
if (!signal.aborted)
|
||||
await new Promise((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true }),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) console.error(error);
|
||||
return interruptedCode(signal) ?? 1;
|
||||
}
|
||||
}).then((code) => {
|
||||
process.exitCode = code;
|
||||
process.disconnect();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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 { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
|
||||
test(
|
||||
'data reload preserves a real mount root while replacing contents and restarting the backend',
|
||||
{ timeout: 30000 },
|
||||
async (t) => {
|
||||
assert.equal(
|
||||
process.env.QL_MOUNT_TEST,
|
||||
'1',
|
||||
'Run only in the documented disposable mounted-data container',
|
||||
);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-mounted-data-'));
|
||||
const data = '/mounted-data';
|
||||
assert.deepEqual(await fs.readdir(data), []);
|
||||
const before = await fs.stat(data);
|
||||
assert.notEqual(before.dev, (await fs.stat(root)).dev);
|
||||
const context = createContext(
|
||||
{ root, 'data-dir': data },
|
||||
{ PATH: '/nonexistent' },
|
||||
);
|
||||
t.after(async () => {
|
||||
await stopPanel(context);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
await fs.writeFile(path.join(data, 'version'), 'old');
|
||||
await fs.writeFile(path.join(data, 'obsolete'), 'remove');
|
||||
await fs.mkdir(path.join(root, '.tmp/data'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, '.tmp/data/version'), 'new');
|
||||
await fs.writeFile(path.join(root, '.tmp/data/.hidden'), 'included');
|
||||
await fs.mkdir(path.join(root, 'static/build'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'static/build/app.js'),
|
||||
`const fs=require('node:fs');if(fs.readFileSync(process.env.QL_DATA_DIR+'/version','utf8')==='broken')process.exit(7);const s=require('node:http').createServer((q,r)=>r.end(fs.readFileSync(process.env.QL_DATA_DIR+'/version')));s.listen(0,'127.0.0.1',()=>fs.writeFileSync(process.env.QL_DIR+'/port',String(s.address().port)));`,
|
||||
);
|
||||
await startPanel(context);
|
||||
const response = async () => {
|
||||
const port = await fs.readFile(path.join(root, 'port'), 'utf8');
|
||||
return (
|
||||
await fetch('http://127.0.0.1:' + port, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
).text();
|
||||
};
|
||||
assert.equal(await response(), 'old');
|
||||
const result = await reloadPanel(context, 'data');
|
||||
assert.deepEqual(result.retainedBackups, []);
|
||||
assert.equal(await response(), 'new');
|
||||
assert.equal((await fs.stat(data)).ino, before.ino);
|
||||
assert.equal((await fs.stat(data)).dev, before.dev);
|
||||
await assert.rejects(fs.access(path.join(data, 'obsolete')));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(data, '.hidden'), 'utf8'),
|
||||
'included',
|
||||
);
|
||||
assert.ok(
|
||||
!(await fs.readdir(data)).some((name) => name.includes('ql-backup')),
|
||||
);
|
||||
await fs.writeFile(path.join(root, '.tmp/data/version'), 'broken');
|
||||
await fs.writeFile(path.join(root, '.tmp/data/new-only'), 'must roll back');
|
||||
await assert.rejects(reloadPanel(context, 'data'), /startup/);
|
||||
assert.equal(await response(), 'new');
|
||||
assert.equal((await fs.stat(data)).ino, before.ino);
|
||||
await assert.rejects(fs.access(path.join(data, 'new-only')));
|
||||
assert.ok(
|
||||
!(await fs.readdir(data)).some((name) => name.includes('ql-backup')),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
// Fresh disposable official panel only; never run against an initialized panel.
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
|
||||
(async () => {
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
const url = 'http://127.0.0.1:5700';
|
||||
const api = async (route, method = 'GET', body) => {
|
||||
const response = await fetch(url + '/api/' + route, { method,
|
||||
headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const result = await response.json(); assert.equal(result.code, 200); return result.data;
|
||||
};
|
||||
const info = await api('system'); assert.equal(info.isInitialized, false, 'Refuse initialized panels');
|
||||
const credentials = { username: 'fixture-' + randomUUID(), password: randomUUID() };
|
||||
await api('user/init', 'PUT', credentials);
|
||||
const owner = (await api('user/login', 'POST', credentials)).token;
|
||||
const config = '/tmp/ql-openapi-auth.json';
|
||||
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
||||
const cli = (args, ownerMode = false, extra = {}) => {
|
||||
const result = spawnSync(process.execPath, [entry, ...args, '--json'], {
|
||||
encoding: 'utf8', timeout: 30000,
|
||||
env: { ...process.env, QL_CLI_CONFIG: config, QL_URL: ownerMode ? url : '', QL_ACCESS_TOKEN: ownerMode ? owner : '', ...extra },
|
||||
});
|
||||
// Report only the command name, never request bodies or application credentials.
|
||||
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
|
||||
return JSON.parse(result.stdout).data;
|
||||
};
|
||||
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
|
||||
assert.ok(app.client_id && app.client_secret);
|
||||
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
|
||||
cli(['auth', 'status', '--scope', 'apps']);
|
||||
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
|
||||
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
|
||||
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
|
||||
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
|
||||
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
|
||||
assert.equal(cli(['task', 'get', String(task.id)]).name, 'cli-task-updated');
|
||||
const sub = cli(['subscription', 'create', '--type', 'file', '--url', url + '/fixture.js', '--alias', 'cli-sub', '--schedule-type', 'crontab', '--schedule', '0 0 * * *']);
|
||||
cli(['subscription', 'update', String(sub.id), '--data', JSON.stringify({ type: 'file', url: url + '/fixture.js', alias: 'cli-sub', name: 'cli-sub-updated', schedule_type: 'crontab', schedule: '0 1 * * *' })]);
|
||||
cli(['subscription', 'disable', String(sub.id)]); cli(['subscription', 'enable', String(sub.id)]);
|
||||
assert.equal(cli(['subscription', 'get', String(sub.id)]).name, 'cli-sub-updated');
|
||||
const env = cli(['env', 'create', '--data', '[{"name":"CLI_FIXTURE","value":"one"}]'])[0];
|
||||
cli(['env', 'update', String(env.id), '--data', '{"name":"CLI_FIXTURE","value":"two"}']);
|
||||
assert.equal(cli(['env', 'get', String(env.id)]).value, 'two');
|
||||
cli(['env', 'delete', String(env.id)]);
|
||||
cli(['script', 'create', '--data', '{"filename":"cli-openapi.js","content":"console.log(1)","path":""}']);
|
||||
assert.equal(cli(['script', 'get', '--query', '{"file":"cli-openapi.js"}']), 'console.log(1)');
|
||||
cli(['config', 'save', '--data', '{"name":"cli-openapi.sh","content":"# fixture"}']);
|
||||
assert.equal(cli(['config', 'get', '--query', '{"path":"cli-openapi.sh"}']), '# fixture');
|
||||
cli(['log', 'list']); cli(['dependency', 'list']);
|
||||
cli(['task', 'delete', String(task.id)]); cli(['subscription', 'delete', String(sub.id)]);
|
||||
const reset = cli(['app', 'reset-secret', String(app.id), '--show-secrets'], true);
|
||||
assert.ok(reset.client_secret !== app.client_secret);
|
||||
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: reset.client_secret });
|
||||
cli(['auth', 'status']); cli(['app', 'delete', String(app.id)], true);
|
||||
await fs.rm(config, { force: true });
|
||||
console.log(JSON.stringify({ panelVersion: info.version, taskCrud: true, subscriptionCrud: true, appCrudAndSecretRotation: true, envCrud: true, scriptCreateRead: true, configSaveRead: true, logAndDependencyRead: true }));
|
||||
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,56 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
exports.verifyAccountMaintenance = async function verifyAccountMaintenance(
|
||||
api,
|
||||
) {
|
||||
const credentials = {
|
||||
username: `renamed-${randomUUID()}`,
|
||||
password: `--${randomUUID()}`,
|
||||
};
|
||||
const local = (action, value) => {
|
||||
const result = spawnSync(
|
||||
path.join(os.homedir(), 'bin/ql'),
|
||||
[action, ...(value ? ['--', value] : [])],
|
||||
{
|
||||
env: { ...process.env, QL_DIR: '/ql' },
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, `${action} failed: ${result.stderr}`);
|
||||
assert.equal(JSON.parse(result.stdout).data.completed, true);
|
||||
};
|
||||
const login = async () => {
|
||||
const response = await fetch('http://127.0.0.1:5700/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
local('resetname', credentials.username);
|
||||
local('resetpwd', credentials.password);
|
||||
assert.equal((await login()).code, 200, 'New credentials were not saved');
|
||||
await api('system/auth/reset', 'PUT', { retries: 4 });
|
||||
assert.notEqual(
|
||||
(await login()).code,
|
||||
200,
|
||||
'Fixture failed to activate login limit',
|
||||
);
|
||||
local('resetlet');
|
||||
assert.equal((await login()).code, 200, 'Login limit was not cleared');
|
||||
await api('system/auth/reset', 'PUT', { twoFactorActivated: true });
|
||||
assert.notEqual(
|
||||
(await login()).code,
|
||||
200,
|
||||
'Fixture failed to activate second factor',
|
||||
);
|
||||
local('resettfa');
|
||||
assert.equal((await login()).code, 200, 'Second factor was not cleared');
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
(async () => {
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
|
||||
const file = `inventory-${randomUUID()}.js`;
|
||||
const target = path.join('/ql/data/scripts', file);
|
||||
await fs.writeFile(
|
||||
target,
|
||||
'throw Error("inventory must not execute");\nconst $ = new Env("Packaged inventory fixture");\n',
|
||||
{ flag: 'wx' },
|
||||
);
|
||||
try {
|
||||
const result = JSON.parse(
|
||||
execFileSync(path.join(os.homedir(), 'bin/task'), ['--json'], {
|
||||
env: { ...process.env, QL_DIR: '/ql' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
}),
|
||||
);
|
||||
assert.equal(result.code, 200);
|
||||
assert.deepEqual(
|
||||
result.data.scripts.find((row) => row.file === file),
|
||||
{ file, name: 'Packaged inventory fixture' },
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ packagedTaskInventory: true, scriptExecuted: false }),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(target);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
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');
|
||||
|
||||
exports.verifyOperator = async function verifyOperator(api) {
|
||||
const ql = path.join(os.homedir(), 'bin/ql');
|
||||
const invoke = (args) => {
|
||||
const result = spawnSync(ql, args, {
|
||||
env: { ...process.env, QL_DIR: '/ql' },
|
||||
encoding: 'utf8',
|
||||
timeout: 45000,
|
||||
});
|
||||
assert.equal(result.status, 0, `${args[0]} failed: ${result.stderr}`);
|
||||
return JSON.parse(result.stdout).data;
|
||||
};
|
||||
const dir = '/ql/data/log/cli-retention';
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(`${dir}/2000-01-01-unused.log`, 'unused');
|
||||
await fs.writeFile(`${dir}/2000-01-01-kept.log`, 'referenced');
|
||||
const task = (
|
||||
await api('crons', 'POST', {
|
||||
name: 'CLI retention reference',
|
||||
command: 'echo retention',
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
try {
|
||||
await api('crons/status', 'PUT', {
|
||||
ids: [task.id],
|
||||
status: '0',
|
||||
log_path: 'cli-retention/2000-01-01-kept.log',
|
||||
});
|
||||
const result = invoke(['rmlog', '7']);
|
||||
assert.ok(result.removed.includes('cli-retention/2000-01-01-unused.log'));
|
||||
assert.ok(result.retained.includes('cli-retention/2000-01-01-kept.log'));
|
||||
await assert.rejects(fs.access(`${dir}/2000-01-01-unused.log`));
|
||||
assert.equal(
|
||||
await fs.readFile(`${dir}/2000-01-01-kept.log`, 'utf8'),
|
||||
'referenced',
|
||||
);
|
||||
} finally {
|
||||
await api('crons', 'DELETE', [task.id]);
|
||||
}
|
||||
const service = invoke(['reload']);
|
||||
assert.ok(['pm2', 'node'].includes(service.manager));
|
||||
let ready = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:5700/api/system', {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
if ((await response.json()).code === 200) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
assert.equal(ready, true, 'Panel failed to recover after reload');
|
||||
const retained = !(await fs.lstat(ql)).isSymbolicLink();
|
||||
assert.equal(
|
||||
retained,
|
||||
true,
|
||||
'Panel startup overwrote the TypeScript ql entrypoint',
|
||||
);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
// Explicit opt-in: execute only in the disposable packaged evaluation panel.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { LocalApi } = require('../../dist/internal/runtime/api');
|
||||
|
||||
(async () => {
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
|
||||
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
|
||||
const marker = randomUUID();
|
||||
const name = `ql-preload-${marker}`;
|
||||
const global = execFileSync('pnpm', ['root', '-g'], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
assert.ok(path.isAbsolute(global));
|
||||
const packagePath = path.join(global, name);
|
||||
const beforePath = '/ql/data/config/task_before.sh';
|
||||
const before = await fs.readFile(beforePath);
|
||||
const ids = [],
|
||||
scripts = [];
|
||||
await fs.mkdir(packagePath, { recursive: true });
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(packagePath, 'package.json'),
|
||||
JSON.stringify({
|
||||
name,
|
||||
type: 'module',
|
||||
exports: { '.': './index.mjs', './feature': './feature.mjs' },
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(packagePath, 'index.mjs'),
|
||||
`export default ${JSON.stringify(marker)};`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(packagePath, 'feature.mjs'),
|
||||
`export default ${JSON.stringify(marker)};`,
|
||||
);
|
||||
await fs.appendFile(beforePath, `\nexport QL_PRELOAD_FIXTURE=${marker}\n`);
|
||||
for (const extension of ['js', 'mjs', 'py']) {
|
||||
const filename = `${name}.${extension}`;
|
||||
const file = path.join('/ql/data/scripts', filename);
|
||||
scripts.push(file);
|
||||
const source =
|
||||
extension === 'py'
|
||||
? 'import os,json,builtins\nprint("PANEL_PRELOAD:"+json.dumps([os.getenv("QL_PRELOAD_FIXTURE"),hasattr(builtins,"QLAPI")]))\n'
|
||||
: extension === 'mjs'
|
||||
? `import value from '${name}/feature'; console.log('PANEL_PRELOAD:'+JSON.stringify([process.env.QL_PRELOAD_FIXTURE,!!globalThis.QLAPI,value]));`
|
||||
: "console.log('PANEL_PRELOAD:'+JSON.stringify([process.env.QL_PRELOAD_FIXTURE,!!globalThis.QLAPI]));";
|
||||
await fs.writeFile(file, source);
|
||||
const task = (
|
||||
await api.call('crons', 'POST', {
|
||||
name: `Packaged preload ${extension}`,
|
||||
command: `task ${filename}`,
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
ids.push(task.id);
|
||||
if (
|
||||
extension === 'js' &&
|
||||
process.env.QL_PANEL_RELOAD_INTEGRATION === '1'
|
||||
) {
|
||||
const reload = JSON.parse(
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../dist/admin.js'),
|
||||
'reload',
|
||||
'--root',
|
||||
'/ql',
|
||||
'--json',
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 60000 },
|
||||
),
|
||||
);
|
||||
assert.equal(reload.code, 200);
|
||||
const deadline = Date.now() + 30000;
|
||||
let saved;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
saved = (await api.call(`crons/${task.id}`)).data;
|
||||
break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
assert.equal(
|
||||
saved?.command,
|
||||
task.command,
|
||||
'task must survive services reload',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ servicesReloaded: true, taskRetained: true }),
|
||||
);
|
||||
}
|
||||
await api.call('crons/run', 'PUT', [task.id]);
|
||||
let log = '';
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
log = (await api.call(`crons/${task.id}/log`)).data || '';
|
||||
if (log.includes('PANEL_PRELOAD:') && /完成|执行结束/.test(log)) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
const found = log.match(/PANEL_PRELOAD:(.*)/);
|
||||
assert.ok(found, `${extension}: ${log}`);
|
||||
assert.deepEqual(
|
||||
JSON.parse(found[1]),
|
||||
extension === 'mjs' ? [marker, true, marker] : [marker, true],
|
||||
);
|
||||
assert.doesNotMatch(log, /run task before error|run builtin code error/);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
language: extension,
|
||||
panelScheduled: true,
|
||||
shellHook: true,
|
||||
QLAPI: true,
|
||||
...(extension === 'mjs' ? { globalExportedSubpath: true } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
const cleaned = await Promise.allSettled([
|
||||
...(ids.length ? [api.call('crons', 'DELETE', ids)] : []),
|
||||
fs.writeFile(beforePath, before),
|
||||
...scripts.map((file) => fs.rm(file, { force: true })),
|
||||
fs.rm(packagePath, { recursive: true, force: true }),
|
||||
]);
|
||||
const failed = cleaned.filter((result) => result.status === 'rejected');
|
||||
if (failed.length)
|
||||
throw new AggregateError(
|
||||
failed.map((result) => result.reason),
|
||||
'Disposable preload cleanup failed',
|
||||
);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
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 { pathToFileURL } = require('node:url');
|
||||
|
||||
exports.verifyRepository = async function verifyRepository(api, cli) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repository-gate-'));
|
||||
const repository = path.join(root, 'fixture');
|
||||
await fs.mkdir(repository);
|
||||
const git = (...args) =>
|
||||
execFileSync('git', ['-C', repository, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.name', 'CLI fixture');
|
||||
git('config', 'user.email', 'fixture@example.invalid');
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'job-wrong.js'),
|
||||
'console.log("WRONG_BRANCH")',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'main');
|
||||
git('checkout', '-b', 'selected');
|
||||
await fs.rm(path.join(repository, 'job-wrong.js'));
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'shared.js'),
|
||||
'module.exports="DEPENDENCY_OK";',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'job-keep.js'),
|
||||
'// cron: 0 0 1 1 *\nconsole.log(require("./shared"));',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'job-old.js'),
|
||||
'// cron: 0 0 1 1 *\nconsole.log("old");',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'job-excluded.js'),
|
||||
'throw Error("excluded");',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'selected');
|
||||
let subscription;
|
||||
const tasks = () =>
|
||||
cli(['task', 'list']).data.data.filter(
|
||||
(row) => row.sub_id === subscription.id,
|
||||
);
|
||||
try {
|
||||
subscription = (
|
||||
await api('subscriptions', 'POST', {
|
||||
name: 'CLI repository integration',
|
||||
alias: 'cli-repository-integration',
|
||||
type: 'public-repo',
|
||||
url: pathToFileURL(repository).href,
|
||||
branch: 'selected',
|
||||
whitelist: 'job-',
|
||||
blacklist: 'excluded',
|
||||
dependences: 'shared',
|
||||
extensions: 'js',
|
||||
schedule_type: 'crontab',
|
||||
schedule: '0 0 1 1 *',
|
||||
autoAddCron: true,
|
||||
autoDelCron: true,
|
||||
})
|
||||
).data;
|
||||
const run = async (added, removed) => {
|
||||
cli(['subscription', 'run', String(subscription.id)]);
|
||||
let log = '';
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
log = cli(['subscription', 'logs', String(subscription.id)]).data;
|
||||
if (log.includes(`"added":${added},"removed":${removed}`)) return;
|
||||
}
|
||||
assert.fail(`Repository sync did not finish: ${log}`);
|
||||
};
|
||||
await run(2, 0);
|
||||
let rows = tasks();
|
||||
assert.equal(rows.length, 2);
|
||||
assert.ok(rows.some((row) => row.command.endsWith('/job-old.js')));
|
||||
const keep = rows.find((row) => row.command.endsWith('/job-keep.js'));
|
||||
assert.ok(keep);
|
||||
const directory = path.dirname(`/ql/data/scripts/${keep.command.slice(5)}`);
|
||||
assert.match(
|
||||
await fs.readFile(path.join(directory, 'shared.js'), 'utf8'),
|
||||
/DEPENDENCY_OK/,
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(directory, 'job-wrong.js')));
|
||||
await assert.rejects(fs.access(path.join(directory, 'job-excluded.js')));
|
||||
cli(['task', 'run', String(keep.id)]);
|
||||
let taskLog = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
taskLog = cli(['task', 'logs', String(keep.id)]).data;
|
||||
if (taskLog.includes('DEPENDENCY_OK') && /完成|执行结束/.test(taskLog))
|
||||
break;
|
||||
}
|
||||
assert.match(taskLog, /DEPENDENCY_OK/);
|
||||
await fs.rm(path.join(repository, 'job-old.js'));
|
||||
await fs.writeFile(
|
||||
path.join(repository, 'job-new.js'),
|
||||
'// cron: 0 0 1 1 *\nconsole.log("new");',
|
||||
);
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'replace task');
|
||||
await run(1, 1);
|
||||
rows = tasks();
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(
|
||||
rows.find((row) => row.command.endsWith('/job-keep.js')).id,
|
||||
keep.id,
|
||||
);
|
||||
assert.ok(rows.some((row) => row.command.endsWith('/job-new.js')));
|
||||
assert.ok(!rows.some((row) => row.command.endsWith('/job-old.js')));
|
||||
await assert.rejects(fs.access(path.join(directory, 'job-old.js')));
|
||||
return true;
|
||||
} finally {
|
||||
if (subscription) {
|
||||
const rows = tasks();
|
||||
if (rows.length)
|
||||
await api(
|
||||
'crons',
|
||||
'DELETE',
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
await api('subscriptions', 'DELETE', [subscription.id]);
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const http = require('node:http');
|
||||
|
||||
exports.verifySubscription = async function verifySubscription(api, cli) {
|
||||
const marker = 'CLI_SUBSCRIPTION_PANEL_MARKER';
|
||||
const server = http.createServer((req, res) =>
|
||||
res.end(`// cron: 0 0 1 1 *\nconsole.log('${marker}');\n`),
|
||||
);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
let subscription;
|
||||
let generated = [];
|
||||
try {
|
||||
subscription = (
|
||||
await api('subscriptions', 'POST', {
|
||||
name: 'CLI raw integration',
|
||||
alias: 'cli-raw-integration',
|
||||
type: 'file',
|
||||
url: `http://127.0.0.1:${server.address().port}/fixture.js`,
|
||||
schedule_type: 'crontab',
|
||||
schedule: '0 0 1 1 *',
|
||||
autoAddCron: true,
|
||||
autoDelCron: false,
|
||||
})
|
||||
).data;
|
||||
const id = String(subscription.id);
|
||||
assert.equal(cli(['subscription', 'run', id]).data.accepted, true);
|
||||
let log = '';
|
||||
for (let attempt = 0; attempt < 80; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
log = cli(['subscription', 'logs', id]).data;
|
||||
if (log.includes('"added":1')) break;
|
||||
}
|
||||
assert.match(log, /"added":1/, log);
|
||||
assert.doesNotMatch(log, /reporting failed/);
|
||||
generated = cli(['task', 'list']).data.data.filter(
|
||||
(row) => row.sub_id === subscription.id,
|
||||
);
|
||||
assert.equal(generated.length, 1);
|
||||
const task = generated[0];
|
||||
assert.match(task.command, /^task raw_/);
|
||||
assert.match(
|
||||
await fs.readFile(`/ql/data/scripts/${task.command.slice(5)}`, 'utf8'),
|
||||
new RegExp(marker),
|
||||
);
|
||||
cli(['task', 'run', String(task.id)]);
|
||||
let taskLog = '';
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
taskLog = cli(['task', 'logs', String(task.id)]).data;
|
||||
if (taskLog.includes(marker) && /完成|执行结束/.test(taskLog)) break;
|
||||
}
|
||||
assert.match(taskLog, new RegExp(marker));
|
||||
assert.match(taskLog, /完成|执行结束/);
|
||||
return true;
|
||||
} finally {
|
||||
if (generated.length)
|
||||
await api(
|
||||
'crons',
|
||||
'DELETE',
|
||||
generated.map((row) => row.id),
|
||||
);
|
||||
if (subscription) await api('subscriptions', 'DELETE', [subscription.id]);
|
||||
await new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { installPanelDependencies } = require('../../dist/internal/maintenance/operator');
|
||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
(async () => {
|
||||
let token;
|
||||
const api = async (endpoint, method = 'GET', body) => {
|
||||
const response = await fetch(`http://127.0.0.1:5700/api/${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const result = await response.json();
|
||||
assert.equal(result.code, 200, `${method} ${endpoint}: ${result.code}`);
|
||||
return result;
|
||||
};
|
||||
const before = (await api('system')).data;
|
||||
assert.equal(before.version, '2.19.0');
|
||||
assert.equal(before.isInitialized, false);
|
||||
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
|
||||
const verifySelection = async () => {
|
||||
for (const [name, module] of [
|
||||
['task', 'runner'],
|
||||
['ql', 'compat'],
|
||||
]) {
|
||||
const entry = `/root/bin/${name}`;
|
||||
assert.equal((await fs.lstat(entry)).isSymbolicLink(), false);
|
||||
assert.ok(
|
||||
(await fs.readFile(entry, 'utf8')).includes(
|
||||
`/opt/qinglong-cli/dist/${module}.js`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
await verifySelection();
|
||||
|
||||
const credentials = {
|
||||
username: `upgrade-${randomUUID()}`,
|
||||
password: randomUUID(),
|
||||
};
|
||||
await api('user/init', 'PUT', credentials);
|
||||
token = (await api('user/login', 'POST', credentials)).data.token;
|
||||
await fs.writeFile(
|
||||
'/ql/data/scripts/upgrade-fixture.js',
|
||||
'console.log("UPGRADE_PRESERVED_TASK");',
|
||||
);
|
||||
const task = (
|
||||
await api('crons', 'POST', {
|
||||
name: 'upgrade retained task',
|
||||
command: 'task upgrade-fixture.js',
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
await fs.appendFile(
|
||||
'/ql/data/config/config.sh',
|
||||
'\n# UPGRADE_PRESERVED_CONFIG\n',
|
||||
);
|
||||
const config = await fs.readFile('/ql/data/config/config.sh');
|
||||
const dotenv = await fs.readFile('/ql/.env');
|
||||
const context = createContext({ root: '/ql' }, process.env);
|
||||
await installPanelDependencies(context, '/stage/source');
|
||||
const replacement = await reloadPanel(context, 'system', {
|
||||
source: '/stage/source',
|
||||
static: '/stage/static',
|
||||
});
|
||||
assert.deepEqual(replacement.retainedBackups, []);
|
||||
let after;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
after = (await api('system')).data;
|
||||
if (after.version === '2.20.1') break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
assert.equal(after?.version, '2.20.1');
|
||||
await verifySelection();
|
||||
token = (await api('user/login', 'POST', credentials)).data.token;
|
||||
const persisted = (await api(`crons/${task.id}`)).data;
|
||||
assert.equal(persisted.name, task.name);
|
||||
assert.equal(persisted.command, task.command);
|
||||
assert.deepEqual(await fs.readFile('/ql/data/config/config.sh'), config);
|
||||
assert.deepEqual(await fs.readFile('/ql/.env'), dotenv);
|
||||
await api('crons/run', 'PUT', [task.id]);
|
||||
let log = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
log = (await api(`crons/${task.id}/log`)).data;
|
||||
if (log.includes('UPGRADE_PRESERVED_TASK') && /完成|执行结束/.test(log))
|
||||
break;
|
||||
}
|
||||
assert.match(log, /UPGRADE_PRESERVED_TASK/);
|
||||
assert.match(log, /完成|执行结束/);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
from: before.version,
|
||||
to: after.version,
|
||||
tsCommandSelectionPreserved: true,
|
||||
accountPreserved: true,
|
||||
taskPreserved: true,
|
||||
scriptExecuted: true,
|
||||
configurationPreserved: true,
|
||||
dotenvPreserved: true,
|
||||
backupsCleaned: true,
|
||||
}),
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error(error.stack);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// Run only inside a fresh, disposable official 2.x panel container.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||
|
||||
let restoreEntrypoints;
|
||||
(async () => {
|
||||
assert.equal(
|
||||
process.env.QL_PANEL_INTEGRATION,
|
||||
'1',
|
||||
'Disposable-panel opt-in required',
|
||||
);
|
||||
const base = 'http://127.0.0.1:5700';
|
||||
let token;
|
||||
async function api(endpoint, method = 'GET', body) {
|
||||
const response = await fetch(`${base}/api/${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const result = await response.json();
|
||||
assert.equal(
|
||||
result.code,
|
||||
200,
|
||||
`${method} ${endpoint} failed: ${response.status}/${result.code}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
const system = await api('system');
|
||||
assert.equal(
|
||||
system.data.isInitialized,
|
||||
false,
|
||||
'Refuse to alter an initialized panel',
|
||||
);
|
||||
const switched = process.env.QL_PANEL_ENTRYPOINTS === '1';
|
||||
if (switched && process.env.QL_PANEL_PACKAGED === '1') {
|
||||
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
|
||||
for (const [name, module] of [['task', 'task'], ['ql', 'ql']]) {
|
||||
const target = require('node:path').join(require('node:os').homedir(), 'bin', name);
|
||||
assert.equal((await fs.lstat(target)).isSymbolicLink(), false);
|
||||
assert.ok((await fs.readFile(target, 'utf8')).includes(Buffer.from(`/opt/qinglong-cli/dist/${module}.js`).toString('base64')));
|
||||
}
|
||||
} else if (switched)
|
||||
restoreEntrypoints =
|
||||
await require('./evaluation-entrypoints.cjs').installEvaluationEntrypoints();
|
||||
const credentials = {
|
||||
username: `fixture-${randomUUID()}`,
|
||||
password: randomUUID(),
|
||||
};
|
||||
await api('user/init', 'PUT', credentials);
|
||||
token = (await api('user/login', 'POST', credentials)).data.token;
|
||||
assert.equal(typeof token, 'string');
|
||||
const app = (
|
||||
await api('apps', 'POST', {
|
||||
name: 'CLI integration',
|
||||
scopes: ['crons', 'subscriptions'],
|
||||
})
|
||||
).data;
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_CLI_CONFIG: '/tmp/ql-integration-auth.json',
|
||||
QL_CLIENT_ID: app.client_id,
|
||||
QL_CLIENT_SECRET: app.client_secret,
|
||||
};
|
||||
const cli = (args) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/npm/ql.js'), ...args, '--json'],
|
||||
{
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 20000,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`CLI ${args.slice(0, 2).join(' ')} failed: ${result.stderr}`,
|
||||
);
|
||||
return JSON.parse(result.stdout);
|
||||
};
|
||||
assert.equal(cli(['login', '--url', base]).data.authenticated, true);
|
||||
assert.equal(cli(['auth', 'status']).data.authenticated, true);
|
||||
assert.equal(
|
||||
cli(['auth', 'status', '--scope', 'subscriptions']).data.authenticated,
|
||||
true,
|
||||
);
|
||||
assert.ok(Array.isArray(cli(['subscription', 'list']).data));
|
||||
const filename = 'cli-integration.js';
|
||||
await fs.writeFile(
|
||||
`/ql/data/scripts/${filename}`,
|
||||
'console.log("CLI_REAL_PANEL_MARKER");',
|
||||
);
|
||||
const task = (
|
||||
await api('crons', 'POST', {
|
||||
name: 'CLI integration',
|
||||
command: `task ${filename}`,
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
const id = String(task.id);
|
||||
assert.equal(cli(['task', 'get', id]).data.id, task.id);
|
||||
assert.equal(cli(['task', 'run', id]).data.accepted, true);
|
||||
let observed = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const logs = cli(['task', 'logs', id]);
|
||||
if (
|
||||
logs.data.includes('CLI_REAL_PANEL_MARKER') &&
|
||||
/完成|执行结束/.test(logs.data)
|
||||
) {
|
||||
observed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
observed,
|
||||
true,
|
||||
'Legacy panel task did not finish with expected logs',
|
||||
);
|
||||
if (switched) {
|
||||
await fs.writeFile(
|
||||
'/ql/data/config/extra.sh',
|
||||
'printf "CLI_COMPAT_PANEL_MARKER\\n"\n',
|
||||
);
|
||||
const maintenance = (
|
||||
await api('crons', 'POST', {
|
||||
name: 'CLI compat maintenance',
|
||||
command: 'ql extra',
|
||||
schedule: '0 0 1 1 *',
|
||||
})
|
||||
).data;
|
||||
const maintenanceId = String(maintenance.id);
|
||||
cli(['task', 'run', maintenanceId]);
|
||||
let done = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const logs = cli(['task', 'logs', maintenanceId]).data;
|
||||
if (
|
||||
logs.includes('CLI_COMPAT_PANEL_MARKER') &&
|
||||
logs.includes('执行结束')
|
||||
) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
done,
|
||||
true,
|
||||
'Switched ql did not complete through panel scheduling',
|
||||
);
|
||||
const saved = cli(['task', 'get', maintenanceId]).data;
|
||||
assert.match(saved.log_path, /^ql\//);
|
||||
assert.ok(saved.last_execution_time > 0);
|
||||
await api('crons', 'DELETE', [maintenance.id]);
|
||||
}
|
||||
const subscriptionExecution = switched
|
||||
? await require('./panel-subscription.cjs').verifySubscription(api, cli)
|
||||
: false;
|
||||
const repositoryExecution = switched
|
||||
? await require('./panel-repository.cjs').verifyRepository(api, cli)
|
||||
: false;
|
||||
// Exercise the migrated runner against the real local-token generator/status API.
|
||||
await fs.writeFile(
|
||||
`/ql/data/scripts/${filename}`,
|
||||
'console.log("CLI_NATIVE_PANEL_MARKER");process.exit(7);',
|
||||
);
|
||||
const context = createContext(
|
||||
{ root: '/ql' },
|
||||
{ ...process.env, ID: id, no_tee: 'true', QlPort: '5700' },
|
||||
);
|
||||
const result = await executeTask(context, {
|
||||
argv: [filename],
|
||||
mode: 'now',
|
||||
output: () => {},
|
||||
});
|
||||
assert.equal(result.exitCode, 7);
|
||||
const persisted = cli(['task', 'get', id]).data;
|
||||
assert.equal(persisted.log_path, result.logPath);
|
||||
assert.match(cli(['task', 'logs', id]).data, /CLI_NATIVE_PANEL_MARKER/);
|
||||
assert.ok(persisted.last_execution_time > 0);
|
||||
await api('crons', 'DELETE', [task.id]);
|
||||
const operatorMaintenance = switched
|
||||
? await require('./panel-operator.cjs').verifyOperator(api)
|
||||
: false;
|
||||
const accountMaintenance = switched
|
||||
? await require('./panel-account.cjs').verifyAccountMaintenance(api)
|
||||
: false;
|
||||
cli(['auth', 'logout']);
|
||||
await fs.rm(`/ql/data/scripts/${filename}`);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
panelVersion: system.data.version,
|
||||
applicationAuth: true,
|
||||
subscriptionRead: true,
|
||||
subscriptionExecution,
|
||||
repositoryExecution,
|
||||
accountMaintenance,
|
||||
operatorMaintenance,
|
||||
remoteRunAndLogs: true,
|
||||
switchedEntrypoints: switched,
|
||||
localRunnerExitCode: result.exitCode,
|
||||
persistedLogPath: true,
|
||||
}),
|
||||
);
|
||||
})()
|
||||
.catch(() => {
|
||||
// Assertions can embed environment values or subprocess output in their message.
|
||||
console.error('Disposable panel integration failed; inspect the failing scenario locally.');
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
if (restoreEntrypoints) await restoreEntrypoints();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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 { spawn } = require('node:child_process');
|
||||
|
||||
// Separate acceptance gate until the reused language preloaders handle these
|
||||
// paths. This runs the same real-language assertions as the normal preload suite.
|
||||
test(
|
||||
'language preloaders preserve hooks when the installation path contains spaces',
|
||||
{
|
||||
skip: process.env.QL_PRELOAD_PATH_INTEGRATION !== '1',
|
||||
timeout: 120000,
|
||||
},
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql preload paths '));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--test',
|
||||
'--test-reporter=tap',
|
||||
path.resolve(__dirname, '../preload.test.cjs'),
|
||||
],
|
||||
{
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
assert.deepEqual(status, { code: 0, signal: null }, output);
|
||||
assert.match(
|
||||
output,
|
||||
/# tests 6\b/,
|
||||
'the child must actually run all five languages',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
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 { createHash } = require('node:crypto');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const operator = require('../../dist/internal/maintenance/operator');
|
||||
const { stageUpgrade } = require('../../dist/internal/maintenance/upgrade');
|
||||
const { cancellableOperation } = require('../../dist/internal/runtime/cancellation');
|
||||
|
||||
test(
|
||||
'published master archives pass actual HTTPS download and staging validation',
|
||||
{
|
||||
skip: process.env.QL_ARCHIVE_INTEGRATION !== '1',
|
||||
timeout: 180000,
|
||||
},
|
||||
async (t) => {
|
||||
const mirror = process.env.QL_ARCHIVE_MIRROR || 'github';
|
||||
assert.ok(['github', 'gitee'].includes(mirror));
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-published-archive-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.writeFile(
|
||||
path.join(root, 'package.json'),
|
||||
'{"name":"isolated-archive-gate"}',
|
||||
);
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ PATH: process.env.PATH, HOME: root, QL_BRANCH: 'master' },
|
||||
);
|
||||
const installations = [];
|
||||
// This gate must not execute code from the downloaded source. Real dependency
|
||||
// installation and retained-data upgrade are separate existing Linux gates.
|
||||
t.mock.method(
|
||||
operator,
|
||||
'installPanelDependencies',
|
||||
async (ctx, directory) => {
|
||||
assert.equal(ctx.root, root);
|
||||
assert.equal(
|
||||
path.dirname(path.dirname(directory)),
|
||||
context.paths.dir_tmp,
|
||||
);
|
||||
installations.push(directory);
|
||||
},
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort('SIGTERM'), 150000);
|
||||
t.after(() => clearTimeout(timer));
|
||||
const staged = await cancellableOperation(controller.signal, () =>
|
||||
stageUpgrade(context, mirror),
|
||||
);
|
||||
assert.deepEqual(installations, [staged.source]);
|
||||
const release = await fs.readFile(
|
||||
path.join(staged.source, 'version.yaml'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
release,
|
||||
/^version:\s*2\./m,
|
||||
'Published branch must still be 2.x',
|
||||
);
|
||||
await fs.access(path.join(staged.static, 'build/app.js'));
|
||||
const workspace = path.dirname(staged.source);
|
||||
const pointer = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(context.paths.dir_tmp, 'upgrade-ready-master.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(pointer.directory, path.basename(workspace));
|
||||
assert.deepEqual(
|
||||
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
|
||||
staged,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
|
||||
'{"name":"isolated-archive-gate"}',
|
||||
);
|
||||
const archives = {};
|
||||
for (const name of ['qinglong', 'qinglong-static']) {
|
||||
const content = await fs.readFile(path.join(workspace, `${name}.zip`));
|
||||
archives[name] = {
|
||||
bytes: content.length,
|
||||
sha256: createHash('sha256').update(content).digest('hex'),
|
||||
};
|
||||
}
|
||||
t.diagnostic(
|
||||
JSON.stringify({
|
||||
mirror,
|
||||
release: release.match(/^version:\s*(\S+)/m)?.[1],
|
||||
archives,
|
||||
downloadedCodeExecuted: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = async function serviceResponse(version) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:5811', {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (response.ok && body.version === version) return body;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`HTTP service did not reach version ${version}`);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
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 { bootstrapPanel } = require('../../dist/internal/maintenance/bootstrap');
|
||||
const { stopPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const { runProcess } = require('../../dist/internal/runtime/process');
|
||||
|
||||
test(
|
||||
'real PM2 and nginx bootstrap reload serves requests and replaces configuration',
|
||||
{ timeout: 45000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-services-'));
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{
|
||||
PATH: process.env.PATH,
|
||||
PM2_HOME: path.join(root, 'pm2'),
|
||||
HOME: root,
|
||||
},
|
||||
);
|
||||
const nginxConfig = path.join(root, 'nginx.conf');
|
||||
const options = {
|
||||
cwd: root,
|
||||
env: context.env,
|
||||
output: () => {},
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
t.after(async () => {
|
||||
await runProcess('nginx', ['-c', nginxConfig, '-s', 'quit'], options);
|
||||
await runProcess('pm2', ['kill'], options);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.join(root, 'sample'));
|
||||
for (const name 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', name), '');
|
||||
await fs.writeFile(path.join(root, '.env.example'), 'FIXTURE=1\n');
|
||||
await fs.mkdir(path.join(root, 'static/build'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'static/build/app.js'),
|
||||
'require("http").createServer((q,s)=>s.end(JSON.stringify({pid:process.pid}))).listen(5801,"127.0.0.1")',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'ecosystem.config.js'),
|
||||
'module.exports={apps:[{name:"fixture-panel",script:"static/build/app.js",instances:1,exec_mode:"fork"}]}',
|
||||
);
|
||||
const writeNginx = (generation) =>
|
||||
fs.writeFile(
|
||||
nginxConfig,
|
||||
`pid ${root}/nginx.pid;\nerror_log ${root}/nginx-error.log;\nevents {}\nhttp { access_log off; server { listen 127.0.0.1:5802; location / { add_header X-Fixture ${generation}; proxy_pass http://127.0.0.1:5801; } } }\n`,
|
||||
);
|
||||
await writeNginx('one');
|
||||
const system = {
|
||||
nginxConfig,
|
||||
nginxIncludes: path.join(root, 'nginx-includes'),
|
||||
nginxRun: path.join(root, 'nginx-run'),
|
||||
background: async () => {
|
||||
throw new Error('reload must not dispatch hooks');
|
||||
},
|
||||
};
|
||||
async function response(generation) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const result = await fetch('http://127.0.0.1:5802', {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
const body = await result.json();
|
||||
if (result.ok && result.headers.get('x-fixture') === generation)
|
||||
return body;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`nginx did not serve generation ${generation}`);
|
||||
}
|
||||
const first = await bootstrapPanel(context, true, system);
|
||||
assert.equal(first.service.manager, 'pm2');
|
||||
const before = await response('one');
|
||||
await writeNginx('two');
|
||||
const second = await bootstrapPanel(context, true, system);
|
||||
assert.equal(second.service.manager, 'pm2');
|
||||
const after = await response('two');
|
||||
assert.notEqual(after.pid, before.pid);
|
||||
await stopPanel(context);
|
||||
const list = await runProcess('pm2', ['jlist'], {
|
||||
...options,
|
||||
capture: true,
|
||||
});
|
||||
assert.deepEqual(JSON.parse(list.stdout), []);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
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 net = require('node:net');
|
||||
const { execFileSync, spawn } = require('node:child_process');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
|
||||
|
||||
test(
|
||||
'real SSH subscriptions require the configured identity and pinned host key',
|
||||
{ skip: process.env.QL_SSH_INTEGRATION !== '1', timeout: 30000 },
|
||||
async (t) => {
|
||||
assert.equal(process.platform, 'linux');
|
||||
assert.equal(
|
||||
process.getuid(),
|
||||
0,
|
||||
'Use only the disposable root SSH fixture container',
|
||||
);
|
||||
// sshd StrictModes rejects authorized_keys below world-writable /tmp.
|
||||
const root = await fs.mkdtemp('/var/lib/ql-ssh-');
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.chmod(root, 0o755);
|
||||
const run = (program, args) =>
|
||||
execFileSync(program, args, { stdio: 'pipe' }).toString();
|
||||
run('adduser', ['-D', '-H', '-s', '/bin/sh', 'qlfixture']);
|
||||
run('passwd', ['-d', 'qlfixture']);
|
||||
for (const name of ['host', 'identity', 'wrong'])
|
||||
run('ssh-keygen', [
|
||||
'-t',
|
||||
'ed25519',
|
||||
'-N',
|
||||
'',
|
||||
'-f',
|
||||
path.join(root, name),
|
||||
]);
|
||||
const authorized = path.join(root, 'authorized_keys');
|
||||
await fs.copyFile(path.join(root, 'identity.pub'), authorized);
|
||||
await fs.chmod(authorized, 0o644);
|
||||
const source = path.join(root, 'owner/repo');
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
run('git', ['-C', source, 'init', '-b', 'selected']);
|
||||
run('git', ['-C', source, 'config', 'user.name', 'Fixture']);
|
||||
run('git', [
|
||||
'-C',
|
||||
source,
|
||||
'config',
|
||||
'user.email',
|
||||
'fixture@example.invalid',
|
||||
]);
|
||||
await fs.writeFile(path.join(source, 'job.js'), 'selected SSH script');
|
||||
run('git', ['-C', source, 'add', '.']);
|
||||
run('git', ['-C', source, 'commit', '-m', 'fixture']);
|
||||
run('chown', ['-R', 'qlfixture:qlfixture', path.join(root, 'owner')]);
|
||||
const port = 22022;
|
||||
const serverConfig = path.join(root, 'sshd_config');
|
||||
await fs.writeFile(
|
||||
serverConfig,
|
||||
`Port ${port}\nListenAddress 127.0.0.1\nHostKey ${root}/host\nPidFile ${root}/sshd.pid\nAuthorizedKeysFile ${authorized}\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers qlfixture\nAllowTcpForwarding no\nX11Forwarding no\nUsePAM no\n`,
|
||||
);
|
||||
const server = spawn('/usr/sbin/sshd', ['-D', '-e', '-f', serverConfig], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let diagnostic = '';
|
||||
server.stderr.on('data', (chunk) => {
|
||||
diagnostic = (diagnostic + chunk).slice(-2000);
|
||||
});
|
||||
t.after(async () => {
|
||||
t.diagnostic(diagnostic);
|
||||
if (server.exitCode === null && server.signalCode === null) {
|
||||
const exited = new Promise((resolve) => server.once('exit', resolve));
|
||||
server.kill('SIGTERM');
|
||||
await exited;
|
||||
}
|
||||
});
|
||||
let ready = false;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ready = await new Promise((resolve) => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
socket.once('connect', () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once('error', () => resolve(false));
|
||||
});
|
||||
if (ready || server.exitCode !== null) break;
|
||||
await delay(20);
|
||||
}
|
||||
assert.ok(ready, diagnostic);
|
||||
const knownHosts = path.join(root, 'known_hosts');
|
||||
const trusted = `[127.0.0.1]:${port} ${await fs.readFile(
|
||||
path.join(root, 'host.pub'),
|
||||
'utf8',
|
||||
)}`;
|
||||
const config = path.join(root, 'ssh_config');
|
||||
const configure = async (key = 'identity') =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
`Host fixture\n HostName 127.0.0.1\n Port ${port}\n User qlfixture\n IdentityFile ${root}/${key}\n IdentityAgent none\n IdentitiesOnly yes\n BatchMode yes\n StrictHostKeyChecking yes\n UserKnownHostsFile ${knownHosts}\n GlobalKnownHostsFile /dev/null\n ConnectTimeout 3\n`,
|
||||
);
|
||||
for (const url of [`ssh://fixture${source}`, `fixture:${source}`]) {
|
||||
await configure();
|
||||
await fs.writeFile(knownHosts, trusted);
|
||||
const panel = await fs.mkdtemp(path.join(root, 'panel-'));
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
GIT_SSH_COMMAND: `ssh -F ${config}`,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
const context = createContext({ root: panel }, env);
|
||||
const input = {
|
||||
url,
|
||||
branch: 'selected',
|
||||
autoAdd: false,
|
||||
autoDelete: false,
|
||||
};
|
||||
const result = await syncRepository(context, input);
|
||||
const job = path.join(
|
||||
context.paths.dir_scripts,
|
||||
result.repository,
|
||||
'job.js',
|
||||
);
|
||||
const checkout = path.join(context.paths.dir_repo, result.repository);
|
||||
const head = run('git', ['-C', checkout, 'rev-parse', 'HEAD']);
|
||||
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
|
||||
for (const failure of ['identity', 'host-key']) {
|
||||
await configure(failure === 'identity' ? 'wrong' : 'identity');
|
||||
await fs.writeFile(
|
||||
knownHosts,
|
||||
failure === 'host-key'
|
||||
? `[127.0.0.1]:${port} ${await fs.readFile(
|
||||
path.join(root, 'wrong.pub'),
|
||||
'utf8',
|
||||
)}`
|
||||
: trusted,
|
||||
);
|
||||
await assert.rejects(syncRepository(context, input));
|
||||
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
|
||||
assert.equal(run('git', ['-C', checkout, 'rev-parse', 'HEAD']), head);
|
||||
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,101 @@
|
||||
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 { fork } = require('node:child_process');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const { runProcess } = require('../../dist/internal/runtime/process');
|
||||
|
||||
test(
|
||||
'interrupted replacement stops the real new service and restores the old HTTP backend',
|
||||
{ timeout: 60000 },
|
||||
async (t) => {
|
||||
for (const manager of ['node', 'pm2']) {
|
||||
await t.test(manager, async (t) => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-real-'),
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
TEST_ROOT: root,
|
||||
QL_DIR: root,
|
||||
HOME: root,
|
||||
PM2_HOME: path.join(root, 'pm2'),
|
||||
PATH: manager === 'node' ? '/nonexistent' : process.env.PATH,
|
||||
};
|
||||
const context = createContext({ root }, env);
|
||||
let child;
|
||||
t.after(async () => {
|
||||
if (child && child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
await stopPanel(context);
|
||||
if (manager === 'pm2')
|
||||
await runProcess('pm2', ['kill'], {
|
||||
env,
|
||||
output: () => {},
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
for (const [directory, version] of [
|
||||
['static', 'old'],
|
||||
['staged', 'new'],
|
||||
]) {
|
||||
await fs.mkdir(path.join(root, directory, 'build'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(root, directory, 'build/app.js'),
|
||||
`require('http').createServer((q,s)=>s.end(JSON.stringify({version:'${version}',pid:process.pid}))).listen(5811,'127.0.0.1')`,
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(root, 'ecosystem.config.js'),
|
||||
'module.exports={apps:[{name:"upgrade-fixture",script:"static/build/app.js",exec_mode:"fork",instances:1}]}',
|
||||
);
|
||||
assert.equal((await startPanel(context)).manager, manager);
|
||||
const old = await require('./service-response.cjs')('old');
|
||||
assert.equal(old.version, 'old');
|
||||
child = fork(path.join(__dirname, 'interrupted-reload.cjs'), [], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
let diagnostics = '';
|
||||
child.stdout.on('data', (chunk) => (diagnostics += chunk));
|
||||
child.stderr.on('data', (chunk) => (diagnostics += chunk));
|
||||
const closed = new Promise((resolve) =>
|
||||
child.on('close', (code, signal) => resolve({ code, signal })),
|
||||
);
|
||||
const ready = await Promise.race([
|
||||
new Promise((resolve) => child.once('message', resolve)),
|
||||
closed.then((result) => {
|
||||
throw new Error(
|
||||
`Premature exit ${JSON.stringify(result)}: ${diagnostics}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
assert.equal(ready.ready, true);
|
||||
assert.notEqual(ready.pid, old.pid);
|
||||
child.kill('SIGTERM');
|
||||
assert.deepEqual(
|
||||
await closed,
|
||||
{ code: 143, signal: null },
|
||||
diagnostics,
|
||||
);
|
||||
const restored = await require('./service-response.cjs')('old');
|
||||
assert.equal(restored.version, 'old');
|
||||
assert.notEqual(restored.pid, ready.pid);
|
||||
assert.throws(() => process.kill(ready.pid, 0), { code: 'ESRCH' });
|
||||
assert.match(
|
||||
await fs.readFile(path.join(root, 'static/build/app.js'), 'utf8'),
|
||||
/version:'old'/,
|
||||
);
|
||||
assert.ok(
|
||||
!(await fs.readdir(root)).some((name) => name.includes('ql-backup')),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Assertions for the disposable online check/repair scenario in README.md.
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
const result = JSON.parse(
|
||||
fs.readFileSync('/tmp/check-repair-result.json', 'utf8'),
|
||||
);
|
||||
assert.equal(result.code, 200);
|
||||
assert.equal(result.data.after.panel.healthy, true);
|
||||
assert.equal(result.data.after.backend.healthy, true);
|
||||
assert.equal(result.data.service.manager, 'pm2');
|
||||
assert.ok(result.data.restored.includes('/ql/data/config/task_before.sh'));
|
||||
assert.deepEqual(
|
||||
fs.readFileSync('/ql/data/config/task_before.sh'),
|
||||
fs.readFileSync('/ql/sample/task.sample.sh'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync('/ql/data/config/config.sh'),
|
||||
fs.readFileSync('/tmp/expected-config.sh'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync('/ql/data/scripts/sendNotify.js'),
|
||||
fs.readFileSync('/ql/sample/notify.js'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readFileSync('/ql/data/scripts/notify.py'),
|
||||
fs.readFileSync('/ql/sample/notify.py'),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
healthy: true,
|
||||
restoredHook: true,
|
||||
preservedConfiguration: true,
|
||||
refreshedNotifications: true,
|
||||
manager: result.data.service.manager,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Used only after start --no-startup in a disposable container without its old entrypoint.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
|
||||
(async () => {
|
||||
const result = JSON.parse(fs.readFileSync('/tmp/start-result.json', 'utf8'));
|
||||
assert.equal(result.code, 200);
|
||||
assert.equal(result.data.mode, 'install');
|
||||
assert.equal(result.data.startup, 'skipped');
|
||||
assert.equal(result.data.service.manager, 'pm2');
|
||||
const response = await fetch('http://127.0.0.1:5700/api/system', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const system = await response.json();
|
||||
assert.equal(system.code, 200);
|
||||
assert.equal(system.data.isInitialized, false);
|
||||
const html = await (
|
||||
await fetch('http://127.0.0.1:5700/', { signal: AbortSignal.timeout(5000) })
|
||||
).text();
|
||||
assert.match(html, /<div\s+id=["']root["']/);
|
||||
const saved = JSON.parse(fs.readFileSync('/root/.pm2/dump.pm2', 'utf8'));
|
||||
assert.ok(saved.some((app) => app.name === 'qinglong'));
|
||||
for (const file of ['config.sh', 'task_before.sh', 'task_after.sh'])
|
||||
assert.ok(fs.existsSync(`/ql/data/config/${file}`));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
install: true,
|
||||
startupRegistration: result.data.startup,
|
||||
healthy: true,
|
||||
initialized: false,
|
||||
pm2StateSaved: true,
|
||||
configurationPrepared: true,
|
||||
}),
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { request, authenticate } = require('../../dist/remote/api/client');
|
||||
|
||||
test('API diagnostics preserve resource scope, uncertainty, codes and secret suppression in both languages', async (t) => {
|
||||
const previous = process.env.QL_LANG;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.QL_LANG;
|
||||
else process.env.QL_LANG = previous;
|
||||
});
|
||||
let response;
|
||||
let calls = 0;
|
||||
t.mock.method(global, 'fetch', async () => {
|
||||
calls++;
|
||||
if (response instanceof Error) throw response;
|
||||
return response;
|
||||
});
|
||||
const config = {
|
||||
url: 'https://fixture.invalid',
|
||||
clientId: 'secret-id',
|
||||
clientSecret: 'secret-value',
|
||||
token: 'secret-token',
|
||||
};
|
||||
for (const language of ['zh', 'en']) {
|
||||
process.env.QL_LANG = language;
|
||||
for (const [endpoint, resource] of [
|
||||
['crons/run', language === 'en' ? 'task' : '任务'],
|
||||
['subscriptions/run', language === 'en' ? 'subscription' : '订阅'],
|
||||
]) {
|
||||
for (const failure of ['permission', 'unavailable', 'network', 'api']) {
|
||||
response =
|
||||
failure === 'permission'
|
||||
? new Response('secret-value', { status: 403 })
|
||||
: failure === 'unavailable'
|
||||
? new Response('secret-value', { status: 503 })
|
||||
: failure === 'network'
|
||||
? new Error('secret-value')
|
||||
: new Response(
|
||||
JSON.stringify({ code: 401, message: 'secret-value' }),
|
||||
);
|
||||
const before = calls;
|
||||
await assert.rejects(
|
||||
request(config, endpoint, { method: 'PUT', body: [1] }),
|
||||
(error) => {
|
||||
assert.equal(
|
||||
error.exitCode,
|
||||
['permission', 'api'].includes(failure) ? 3 : 1,
|
||||
);
|
||||
assert.doesNotMatch(error.message, /secret-|fixture\.invalid/);
|
||||
if (failure === 'unavailable' || failure === 'network') {
|
||||
assert.ok(error.message.includes(resource));
|
||||
assert.match(
|
||||
error.message,
|
||||
language === 'en' ? /outcome.*unknown/ : /结果.*未知/,
|
||||
);
|
||||
}
|
||||
if (failure !== 'network') {
|
||||
assert.ok(
|
||||
error.message.includes(
|
||||
endpoint.startsWith('subscriptions')
|
||||
? 'subscriptions'
|
||||
: 'crons',
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
error.message,
|
||||
language === 'en' ? /permission/ : /权限/,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
calls - before,
|
||||
1,
|
||||
'diagnostics must not replay the mutation',
|
||||
);
|
||||
}
|
||||
}
|
||||
response = new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
data: { token: 'secret-token', expiration: 0 },
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
authenticate(config),
|
||||
language === 'en' ? /Invalid authentication response/ : /认证响应无效/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
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 { spawn } = require('node:child_process');
|
||||
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
||||
|
||||
async function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-ts-cli-'));
|
||||
const file = path.join(dir, 'config.json');
|
||||
const requests = [];
|
||||
const state = { mode: 'ok', log: 'one\r\ntwo\r\nthree\r\n' };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
requests.push({
|
||||
url,
|
||||
method: req.method,
|
||||
body,
|
||||
authorization: req.headers.authorization,
|
||||
});
|
||||
if (state.mode === 'disconnect') {
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'redirect') {
|
||||
res.writeHead(302, { Location: '/elsewhere' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'html401') {
|
||||
res.writeHead(401);
|
||||
res.end('secret-body');
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'invalid') {
|
||||
res.end('invalid-secret-body');
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', 'application/json');
|
||||
const json = (data) => res.end(JSON.stringify(data));
|
||||
if (state.mode === 'denied') {
|
||||
res.writeHead(403);
|
||||
json({ code: 403, message: 'test-secret' });
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'apiError') {
|
||||
json({ code: 400, message: 'test-secret' });
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'badData') {
|
||||
json({ code: 200, data: null });
|
||||
return;
|
||||
}
|
||||
if (state.mode === 'expiredAuth') {
|
||||
json({ code: 200, data: { token: 'expired', expiration: 1 } });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/panel/open/auth/token') {
|
||||
assert.equal(url.searchParams.get('client_id'), 'test-id');
|
||||
assert.equal(url.searchParams.get('client_secret'), 'test-secret');
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
json({
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'test-token',
|
||||
expiration: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
assert.equal(req.headers.authorization, 'Bearer test-token');
|
||||
if (url.pathname === '/panel/open/crons/12/log')
|
||||
json({ code: 200, data: state.log, logStatus: 'completed' });
|
||||
else if (url.pathname === '/panel/open/crons/12')
|
||||
json({ code: 200, data: { id: 12, name: 'example', status: 1 } });
|
||||
else if (url.pathname === '/panel/open/crons')
|
||||
json({
|
||||
code: 200,
|
||||
data: { data: [{ id: 12, name: 'example' }], total: 1 },
|
||||
});
|
||||
else if (
|
||||
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
|
||||
url.pathname,
|
||||
)
|
||||
) {
|
||||
assert.equal(req.method, 'PUT');
|
||||
assert.equal(body, '[12]');
|
||||
json({ code: 200 });
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
json({ code: 404 });
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(() => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
||||
const invoke = (args, env = {}, executable = entry) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [executable, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
QL_DIR: '/nonexistent',
|
||||
QL_CLI_CONFIG: file,
|
||||
QL_CLIENT_ID: 'test-id',
|
||||
QL_CLIENT_SECRET: 'test-secret',
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '',
|
||||
err = '';
|
||||
child.stdout.on('data', (data) => (out += data));
|
||||
child.stderr.on('data', (data) => (err += data));
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => resolve({ code, out, err }));
|
||||
});
|
||||
const login = async (prefix = ['login']) => {
|
||||
const result = await invoke([...prefix, '--url', url, '--json']);
|
||||
assert.equal(result.code, 0, result.err);
|
||||
assert.deepEqual(JSON.parse(result.out), {
|
||||
code: 200,
|
||||
data: { authenticated: true, url },
|
||||
});
|
||||
assert.doesNotMatch(result.out + result.err, /test-secret|test-token/);
|
||||
return result;
|
||||
};
|
||||
return { dir, file, requests, state, url, invoke, login };
|
||||
}
|
||||
|
||||
function failure(result, code) {
|
||||
assert.equal(result.code, code, result.err);
|
||||
assert.equal(result.out, '');
|
||||
assert.equal(JSON.parse(result.err).code, code);
|
||||
assert.doesNotMatch(result.err, /test-secret|test-token|secret-body/);
|
||||
}
|
||||
|
||||
test('compiled entry: login aliases, private storage, status, refresh and local logout', async (t) => {
|
||||
const f = await fixture(t);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 3);
|
||||
await f.login();
|
||||
assert.equal(fs.statSync(f.file).mode & 0o777, 0o600);
|
||||
const status = await f.invoke(['auth', 'status', '--json']);
|
||||
assert.equal(status.code, 0, status.err);
|
||||
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
||||
assert.doesNotMatch(status.out, /test-secret|test-token/);
|
||||
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
|
||||
const config = JSON.parse(fs.readFileSync(f.file));
|
||||
config.expiration = 1;
|
||||
fs.writeFileSync(f.file, JSON.stringify(config));
|
||||
const get = await f.invoke(['task', 'get', '12', '--json']);
|
||||
assert.equal(get.code, 0, get.err);
|
||||
assert.equal(JSON.parse(get.out).data.id, 12);
|
||||
assert.equal(f.requests.at(-2).url.pathname, '/panel/open/auth/token');
|
||||
await f.login(['auth', 'login']);
|
||||
const count = f.requests.length;
|
||||
const logout = await f.invoke(['auth', 'logout', '--json']);
|
||||
assert.equal(logout.code, 0, logout.err);
|
||||
assert.equal(JSON.parse(logout.out).data.localOnly, true);
|
||||
assert.equal(f.requests.length, count);
|
||||
assert.equal(fs.existsSync(f.file), false);
|
||||
assert.equal((await f.invoke(['auth', 'logout', '--json'])).code, 0);
|
||||
});
|
||||
|
||||
test('list pagination, exact task operations and log tail preserve the 2.x API contract', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
const list = await f.invoke([
|
||||
'task',
|
||||
'list',
|
||||
'--search',
|
||||
'任务 & a',
|
||||
'--page',
|
||||
'2',
|
||||
'--size',
|
||||
'10',
|
||||
'--json',
|
||||
]);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.deepEqual(JSON.parse(list.out).data, {
|
||||
data: [{ id: 12, name: 'example' }],
|
||||
total: 1,
|
||||
});
|
||||
assert.equal(
|
||||
f.requests.at(-1).url.searchParams.get('searchValue'),
|
||||
'任务 & a',
|
||||
);
|
||||
assert.equal(f.requests.at(-1).url.searchParams.get('page'), '2');
|
||||
assert.equal(f.requests.at(-1).url.searchParams.get('size'), '10');
|
||||
const logs = await f.invoke(['task', 'logs', '12', '--tail', '2', '--json']);
|
||||
assert.deepEqual(JSON.parse(logs.out), {
|
||||
code: 200,
|
||||
data: 'two\nthree',
|
||||
logStatus: 'completed',
|
||||
truncated: true,
|
||||
});
|
||||
f.state.log = '';
|
||||
const empty = await f.invoke(['task', 'logs', '12', '--json']);
|
||||
assert.deepEqual(JSON.parse(empty.out), {
|
||||
code: 200,
|
||||
data: '',
|
||||
logStatus: 'completed',
|
||||
truncated: false,
|
||||
});
|
||||
for (const action of ['run', 'stop']) {
|
||||
const result = await f.invoke(['task', action, '12', '--json']);
|
||||
assert.equal(result.code, 0, result.err);
|
||||
assert.deepEqual(JSON.parse(result.out).data, {
|
||||
taskId: 12,
|
||||
action,
|
||||
accepted: true,
|
||||
});
|
||||
assert.equal(f.requests.at(-1).url.pathname, `/panel/open/crons/${action}`);
|
||||
assert.equal(f.requests.at(-1).body, '[12]');
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid input is rejected before authentication or HTTP requests', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const args of [
|
||||
['task', 'run', '12;echo'],
|
||||
['task', 'get', '-1'],
|
||||
['task', 'run', '9007199254740992'],
|
||||
['task', 'list', '--size', '201'],
|
||||
['task', 'logs', '12', '--tail', '0'],
|
||||
['task', 'list', '--page'],
|
||||
['task', 'list', '--page', '1', '--page', '2'],
|
||||
['task', 'stop', '12', '--unknown'],
|
||||
['task', 'run'],
|
||||
['task', 'run', '12', '13'],
|
||||
['unknown'],
|
||||
['login'],
|
||||
['login', '--url', 'http://example.com'],
|
||||
['login', '--url', 'https://user:password@example.com'],
|
||||
['login', '--url', 'https://example.com?secret=value'],
|
||||
['login', '--url', 'file:///tmp/config'],
|
||||
])
|
||||
failure(await f.invoke([...args, '--json']), 2);
|
||||
failure(
|
||||
await f.invoke(['login', '--url', f.url, '--json'], {
|
||||
QL_CLIENT_ID: '',
|
||||
QL_CLIENT_SECRET: '',
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.equal(f.requests.length, 0);
|
||||
});
|
||||
|
||||
test('authentication failures, proxy errors and uncertain mutations never replay requests or leak secrets', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
for (const [mode, code] of [
|
||||
['denied', 3],
|
||||
['html401', 3],
|
||||
['apiError', 1],
|
||||
['invalid', 1],
|
||||
['disconnect', 1],
|
||||
['redirect', 1],
|
||||
]) {
|
||||
f.state.mode = mode;
|
||||
const count = f.requests.length;
|
||||
const result = await f.invoke(['task', 'run', '12', '--json']);
|
||||
failure(result, code);
|
||||
assert.equal(f.requests.length, count + 1, mode);
|
||||
if (['invalid', 'disconnect', 'redirect'].includes(mode))
|
||||
assert.match(result.err, /outcome is unknown|执行结果未知/);
|
||||
}
|
||||
const prior = fs.readFileSync(f.file, 'utf8');
|
||||
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
||||
assert.equal(fs.readFileSync(f.file, 'utf8'), prior);
|
||||
});
|
||||
|
||||
test('malformed successful responses are rejected, including expired authentication', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
f.state.mode = 'badData';
|
||||
for (const args of [
|
||||
['task', 'list'],
|
||||
['task', 'get', '12'],
|
||||
['task', 'logs', '12'],
|
||||
['login', '--url', f.url],
|
||||
]) {
|
||||
failure(await f.invoke([...args, '--json']), 1);
|
||||
}
|
||||
f.state.mode = 'expiredAuth';
|
||||
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
||||
});
|
||||
|
||||
test('config permissions, malformed content and symlinks fail closed', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
fs.chmodSync(f.file, 0o644);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
fs.chmodSync(f.file, 0o600);
|
||||
fs.writeFileSync(f.file, '{broken');
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
fs.writeFileSync(
|
||||
f.file,
|
||||
JSON.stringify({ url: f.url, clientId: 123, clientSecret: 'test-secret' }),
|
||||
);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
const target = path.join(f.dir, 'target');
|
||||
fs.renameSync(f.file, target);
|
||||
fs.symlinkSync(target, f.file);
|
||||
failure(await f.invoke(['task', 'list', '--json']), 1);
|
||||
await f.login();
|
||||
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
|
||||
});
|
||||
|
||||
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const dist = path.join(f.dir, 'standalone');
|
||||
fs.cpSync(path.dirname(entry), dist, { recursive: true });
|
||||
const link = path.join(f.dir, 'ql');
|
||||
fs.symlinkSync(path.join(dist, 'ql.js'), link);
|
||||
const help = await f.invoke(['--help', '--json'], {}, link);
|
||||
assert.equal(help.code, 0, help.err);
|
||||
assert.match(JSON.parse(help.out).data.help, /QingLong 2.x/);
|
||||
const login = await f.invoke(['login', '--url', f.url, '--json'], {}, link);
|
||||
assert.equal(login.code, 0, login.err);
|
||||
const list = await f.invoke(['task', 'list', '--json'], {}, link);
|
||||
assert.equal(list.code, 0, list.err);
|
||||
assert.equal(JSON.parse(list.out).data.total, 1);
|
||||
});
|
||||
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
test(`resource response errors are localized through the compiled public CLI: ${language}`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.login();
|
||||
f.state.mode = 'badData';
|
||||
for (const [args, chinese, english] of [
|
||||
[['task', 'list'], /任务列表响应无效/, /Invalid task list response/],
|
||||
[['task', 'get', '12'], /任务响应无效/, /Invalid task response/],
|
||||
[['task', 'logs', '12'], /日志响应无效/, /Invalid log response/],
|
||||
[
|
||||
['subscription', 'list'],
|
||||
/订阅列表响应无效/,
|
||||
/Invalid subscription list response/,
|
||||
],
|
||||
[
|
||||
['subscription', 'get', '12'],
|
||||
/订阅响应无效/,
|
||||
/Invalid subscription response/,
|
||||
],
|
||||
[
|
||||
['subscription', 'logs', '12'],
|
||||
/订阅日志响应无效/,
|
||||
/Invalid subscription log response/,
|
||||
],
|
||||
]) {
|
||||
const result = await f.invoke([...args, '--json'], { QL_LANG: language });
|
||||
failure(result, 1);
|
||||
assert.match(
|
||||
JSON.parse(result.err).message,
|
||||
language === 'en' ? english : chinese,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { task } = require('../../dist/remote/commands/task');
|
||||
const { subscription } = require('../../dist/remote/commands/subscription');
|
||||
|
||||
test('task and subscription logs preserve server truncation as well as local tail limits', async (t) => {
|
||||
const previous = { url: process.env.QL_URL, token: process.env.QL_ACCESS_TOKEN };
|
||||
process.env.QL_URL = 'http://127.0.0.1';
|
||||
process.env.QL_ACCESS_TOKEN = 'fixture';
|
||||
t.after(() => {
|
||||
for (const [key, value] of [['QL_URL', previous.url], ['QL_ACCESS_TOKEN', previous.token]]) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
let response;
|
||||
t.mock.method(global, 'fetch', async () => new Response(JSON.stringify(response), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
const run = async (tail) => [
|
||||
await task({ kind: 'logs', id: 1, tail }),
|
||||
await subscription({ name: 'subscription logs', positionals: ['1'], values: { tail: String(tail) } }),
|
||||
];
|
||||
|
||||
// A byte-limited backend response can contain fewer lines than --tail.
|
||||
response = { code: 200, data: 'partial long line\nlast line\n', offset: 262144, total: 524288, truncated: true };
|
||||
for (const result of await run(200)) {
|
||||
assert.equal(result.data, 'partial long line\nlast line');
|
||||
assert.equal(result.truncated, true);
|
||||
}
|
||||
|
||||
// Older backends omit truncated; complete responses may explicitly set false.
|
||||
for (const metadata of [{}, { truncated: false }]) {
|
||||
response = { code: 200, data: 'one\r\ntwo\r\nthree\r\n', ...metadata };
|
||||
for (const result of await run(2)) {
|
||||
assert.equal(result.data, 'two\nthree');
|
||||
assert.equal(result.truncated, true);
|
||||
}
|
||||
for (const result of await run(3)) {
|
||||
assert.equal(result.data, 'one\ntwo\nthree');
|
||||
assert.equal(result.truncated, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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 http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { openOperations } = require('../../dist/remote/api/openOperations');
|
||||
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-openapi-'));
|
||||
const requests = [];
|
||||
let reply;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const record = { method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() };
|
||||
requests.push(record);
|
||||
if (reply) return reply(req, res);
|
||||
if (/\/(download|data\/export|command-run)$/.test(req.url)) {
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'content-disposition': 'attachment; filename="fixture.json"' });
|
||||
res.end('{"fixture":true}');
|
||||
} else if (req.url === '/panel/open/system/log') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('log content');
|
||||
} else {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ code: 200, data: { id: 7, name: 'fixture', client_secret: 'SECRET-MARKER', tokens: [{ value: 'TOKEN-MARKER' }] } }));
|
||||
}
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
||||
const run = (args, stdin = '', extra = {}) => new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [entry, ...args, '--json'], {
|
||||
env: { PATH: process.env.PATH, QL_URL: url, QL_ACCESS_TOKEN: 'fixture-token', QL_LANG: 'en', ...extra },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '', err = '';
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error('CLI timeout')); }, 12000);
|
||||
child.stdout.on('data', data => { out += data; }); child.stderr.on('data', data => { err += data; });
|
||||
child.on('error', reject); child.on('close', code => { clearTimeout(timer); resolve({ code, out, err }); });
|
||||
child.stdin.end(stdin);
|
||||
});
|
||||
return { root, requests, run, reply: value => { reply = value; } };
|
||||
}
|
||||
function success(result) { assert.equal(result.code, 0, result.err); return JSON.parse(result.out); }
|
||||
|
||||
test('dashboard record sends execution statistics and rejects a missing body before HTTP', async t => {
|
||||
const f = await fixture(t);
|
||||
const payload = { ref_id: 7, code: 0, elapsed: 1.5 };
|
||||
success(await f.run(['dashboard', 'record', '--data', '-'], JSON.stringify(payload)));
|
||||
assert.equal(f.requests.length, 1);
|
||||
assert.equal(f.requests[0].method, 'POST');
|
||||
assert.equal(f.requests[0].url, '/panel/open/dashboard/record');
|
||||
assert.deepEqual(JSON.parse(f.requests[0].body), payload);
|
||||
assert.equal((await f.run(['dashboard', 'record'])).code, 2);
|
||||
assert.equal(f.requests.length, 1);
|
||||
});
|
||||
|
||||
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
||||
const root = path.resolve(__dirname, '../../../back/api');
|
||||
const expected = [];
|
||||
const retired = new Set(['GET configs/:file', 'GET scripts/:file', 'GET logs/:file']);
|
||||
const index = await fs.readFile(path.join(root, 'index.ts'), 'utf8');
|
||||
for (const filename of await fs.readdir(root)) {
|
||||
if (!filename.endsWith('.ts') || filename === 'index.ts') continue;
|
||||
const source = await fs.readFile(path.join(root, filename), 'utf8');
|
||||
assert.ok(index.includes(`'./${filename.slice(0, -3)}'`), `Unregistered API module ${filename}`);
|
||||
const mount = /app\.use\(['"]([^'"]+)['"]/.exec(source);
|
||||
assert.ok(mount, filename);
|
||||
for (const m of source.matchAll(/route\.(get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g)) {
|
||||
const endpoint = [mount[1], m[2]].join('/').split('/').filter(Boolean).join('/');
|
||||
const key = `${m[1].toUpperCase()} ${endpoint}`;
|
||||
if (!retired.has(key)) expected.push(key);
|
||||
else assert.match(source.slice(m.index, m.index + 200), /code: 410/);
|
||||
}
|
||||
}
|
||||
const actual = openOperations.map(op => `${op.method} ${op.path}`);
|
||||
assert.equal(new Set(actual).size, actual.length);
|
||||
assert.equal(new Set(openOperations.map(op => op.name)).size, openOperations.length);
|
||||
assert.deepEqual(actual.sort(), expected.sort());
|
||||
});
|
||||
|
||||
test('task, subscription and app CRUD produce exact 2.x requests and protect app credentials', async t => {
|
||||
const f = await fixture(t);
|
||||
success(await f.run(['task', 'create', '--name', 'demo', '--command', 'task demo.js', '--schedule', '0 0 * * *']));
|
||||
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { name: 'demo', command: 'task demo.js', schedule: '0 0 * * *' });
|
||||
assert.equal(f.requests.at(-1).method, 'POST'); assert.equal(f.requests.at(-1).url, '/panel/open/crons');
|
||||
success(await f.run(['task', 'update', '7', '--data', '-', '--name', 'updated'], '{"command":"task demo.js","schedule":"0 1 * * *"}'));
|
||||
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { id: 7, name: 'updated', command: 'task demo.js', schedule: '0 1 * * *' });
|
||||
success(await f.run(['task', 'delete', '7', '8'])); assert.deepEqual(JSON.parse(f.requests.at(-1).body), [7, 8]);
|
||||
assert.equal(f.requests.at(-1).method, 'DELETE');
|
||||
success(await f.run(['subscription', 'create', '--type', 'public-repo', '--url', 'https://example.com/repo.git', '--alias', 'demo', '--schedule-type', 'crontab', '--schedule', '0 0 * * *']));
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/subscriptions');
|
||||
assert.equal(JSON.parse(f.requests.at(-1).body).schedule_type, 'crontab');
|
||||
const app = success(await f.run(['app', 'create', '--name', 'worker', '--scopes', 'crons,subscriptions']));
|
||||
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { name: 'worker', scopes: ['crons', 'subscriptions'] });
|
||||
assert.equal(app.data.client_secret, undefined); assert.equal(app.data.tokens, undefined);
|
||||
assert.equal(success(await f.run(['app', 'reset-secret', '7', '--show-secrets'])).data.client_secret, 'SECRET-MARKER');
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/apps/7/reset-secret');
|
||||
for (const req of f.requests) assert.equal(req.headers.authorization, 'Bearer fixture-token');
|
||||
});
|
||||
|
||||
test('all added named operations reach their registered method/path, including upload/download and anonymous routes', async t => {
|
||||
const f = await fixture(t);
|
||||
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
|
||||
for (const op of openOperations.filter(op => !op.existing)) {
|
||||
const args = op.name.split(' ');
|
||||
for (const _ of op.params ?? []) args.push('7');
|
||||
if (op.body === 'ids') args.push('7', '8');
|
||||
else if (op.body) {
|
||||
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
|
||||
: op.name.startsWith('subscription ') ? { type: 'file', url: 'https://example.com/job.js', alias: 'job', schedule_type: 'crontab' } : {};
|
||||
args.push('--data', JSON.stringify(data));
|
||||
}
|
||||
if (op.upload) args.push('--file', file);
|
||||
if (op.download) args.push('--output', path.join(f.root, op.name.replace(' ', '-') + '.out'));
|
||||
success(await f.run(args));
|
||||
const last = f.requests.at(-1);
|
||||
assert.equal(last.method, op.method, op.name);
|
||||
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
|
||||
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
|
||||
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw route access retains query/body capabilities and rejects unsupported paths before authentication', async t => {
|
||||
const f = await fixture(t);
|
||||
success(await f.run(['api', 'request', 'GET', '/open/crons', '--query', '{"searchValue":"a & b","page":2}']));
|
||||
assert.equal(f.requests.at(-1).url, '/panel/open/crons?searchValue=a+%26+b&page=2');
|
||||
const bodyFile = path.join(f.root, 'env.json'); await fs.writeFile(bodyFile, '[{"name":"A","value":"B"}]');
|
||||
success(await f.run(['env', 'create', '--data', '@' + bodyFile]));
|
||||
assert.equal(f.requests.at(-1).body, '[{"name":"A","value":"B"}]');
|
||||
const count = f.requests.length;
|
||||
for (const args of [
|
||||
['api', 'request', 'GET', 'https://example.com'], ['api', 'request', 'DELETE', 'auth/token'],
|
||||
['api', 'request', 'GET', '../api/user'], ['api', 'request', 'GET', 'crons/0'], ['api', 'request', 'GET', 'crons/9007199254740993'],
|
||||
['task', 'create', '--data', '{'], ['task', 'create', '--command', 'echo fixture'],
|
||||
['task', 'update', '7', '--data', '{"id":8}'], ['task', 'delete', '-1'],
|
||||
['env', 'list', '--query', '{"secret":{"value":1}}'],
|
||||
['app', 'create', '--data', '{"name":"A"}', '--name', 'B'],
|
||||
['log', 'download', '--data', '{}', '--output', bodyFile],
|
||||
]) { const result = await f.run(args); assert.equal(result.code, 2, JSON.stringify(args) + result.err); assert.equal(result.out, ''); }
|
||||
assert.equal(f.requests.length, count);
|
||||
const output = path.join(f.root, 'download.json');
|
||||
success(await f.run(['log', 'download', '--data', '{"filename":"fixture.json"}', '--output', output]));
|
||||
assert.equal(await fs.readFile(output, 'utf8'), '{"fixture":true}');
|
||||
assert.equal((await fs.stat(output)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('POST/DELETE uncertainty and permission failures never retry or print server secrets', async t => {
|
||||
const f = await fixture(t);
|
||||
f.reply((_req, res) => { res.writeHead(503); res.end('SERVER-SECRET'); });
|
||||
for (const args of [['task', 'create', '--command', 'echo fixture', '--schedule', '0 0 * * *'], ['task', 'delete', '7']]) {
|
||||
const count = f.requests.length; const result = await f.run(args);
|
||||
assert.equal(result.code, 1); assert.match(result.err, /unknown|retry/i);
|
||||
assert.doesNotMatch(result.err, /SERVER-SECRET/); assert.equal(f.requests.length, count + 1);
|
||||
}
|
||||
f.reply((_req, res) => { res.writeHead(403); res.end('SERVER-SECRET'); });
|
||||
const result = await f.run(['app', 'list']); assert.equal(result.code, 3); assert.match(result.err, /apps/);
|
||||
assert.doesNotMatch(result.err, /SERVER-SECRET/);
|
||||
});
|
||||
|
||||
test('failed downloads leave no partial files and timed-out mutations are not replayed', async t => {
|
||||
const f = await fixture(t);
|
||||
const output = path.join(f.root, 'partial.log');
|
||||
f.reply((_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.write('partial');
|
||||
setTimeout(() => res.destroy(), 50);
|
||||
});
|
||||
const failed = await f.run(['log', 'download', '--data', '{"filename":"fixture.log"}', '--output', output]);
|
||||
assert.equal(failed.code, 1); assert.equal(failed.out, '');
|
||||
await assert.rejects(fs.stat(output), { code: 'ENOENT' });
|
||||
f.reply(() => {});
|
||||
const count = f.requests.length;
|
||||
const timeout = await f.run(['app', 'create', '--name', 'fixture', '--scopes', 'crons', '--timeout', '1']);
|
||||
assert.equal(timeout.code, 1); assert.match(timeout.err, /unknown/);
|
||||
assert.equal(f.requests.length, count + 1);
|
||||
});
|
||||
|
||||
test('owner two-factor challenge remains actionable without exposing server messages', async t => {
|
||||
const f = await fixture(t);
|
||||
f.reply((_req, res) => { res.setHeader('content-type', 'application/json'); res.end('{"code":420,"message":"SERVER-SECRET"}'); });
|
||||
const result = await f.run(['user', 'login', '--data', '{"username":"fixture","password":"fixture"}']);
|
||||
assert.equal(result.code, 3); assert.match(result.err, /two-factor-login/); assert.doesNotMatch(result.err, /SERVER-SECRET/);
|
||||
assert.equal(f.requests.length, 1); assert.equal(f.requests[0].headers.authorization, undefined);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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 { randomUUID } = require('node:crypto');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { syncRaw } = require('../../dist/internal/subscription/subscriptionRunner');
|
||||
const { runProcess } = require('../../dist/internal/runtime/process');
|
||||
|
||||
test('raw subscriptions retain legacy netrc authentication and preserve files on denied access', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-raw-netrc-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const password = randomUUID();
|
||||
const authorization =
|
||||
'Basic ' + Buffer.from('fixture:' + password).toString('base64');
|
||||
let denied = false;
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/public.js') return res.end('public script');
|
||||
if (denied || req.headers.authorization !== authorization)
|
||||
return res
|
||||
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
|
||||
.end();
|
||||
res.end('private script');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const env = { PATH: process.env.PATH, HOME: root, QL_LANG: 'en' };
|
||||
await fs.writeFile(
|
||||
path.join(root, '.netrc'),
|
||||
`machine 127.0.0.1 login fixture password ${password}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const endpoint = `http://127.0.0.1:${server.address().port}`;
|
||||
if (process.env.QL_WGET_COMPARE === '1') {
|
||||
const original = await runProcess(
|
||||
'wget',
|
||||
['-q', '-O', path.join(root, 'original.js'), endpoint + '/private.js'],
|
||||
{ env },
|
||||
);
|
||||
assert.equal(
|
||||
original.code,
|
||||
0,
|
||||
'retained Shell wget authentication must work',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'original.js'), 'utf8'),
|
||||
'private script',
|
||||
);
|
||||
}
|
||||
const context = createContext({ root }, env);
|
||||
const input = {
|
||||
url: endpoint + '/private.js',
|
||||
autoAdd: false,
|
||||
autoDelete: false,
|
||||
};
|
||||
const result = await syncRaw(context, input);
|
||||
const destination = path.join(context.paths.dir_scripts, result.file);
|
||||
assert.equal(await fs.readFile(destination, 'utf8'), 'private script');
|
||||
denied = true;
|
||||
await assert.rejects(syncRaw(context, input));
|
||||
assert.equal(await fs.readFile(destination, 'utf8'), 'private script');
|
||||
assert.equal(
|
||||
(await fs.readdir(context.paths.dir_raw)).filter((x) => x.endsWith('.tmp'))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
await fs.rm(path.join(root, '.netrc'));
|
||||
await assert.rejects(syncRaw(context, input));
|
||||
const publicResult = await syncRaw(context, {
|
||||
...input,
|
||||
url: endpoint + '/public.js',
|
||||
});
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(context.paths.dir_scripts, publicResult.file),
|
||||
'utf8',
|
||||
),
|
||||
'public script',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const { openOperations } = require('../../dist/remote/api/openOperations');
|
||||
|
||||
test('body-consuming backend routes expose a CLI request body or upload', () => {
|
||||
const root = path.resolve(__dirname, '../../../back/api');
|
||||
let checked = 0;
|
||||
for (const filename of fs.readdirSync(root).filter(name => name.endsWith('.ts'))) {
|
||||
const text = fs.readFileSync(path.join(root, filename), 'utf8');
|
||||
const mount = text.match(/app\.use\(['"]([^'"]+)/)?.[1];
|
||||
if (!mount) continue;
|
||||
const source = ts.createSourceFile(filename, text, ts.ScriptTarget.Latest, true);
|
||||
const visit = node => {
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
||||
&& node.expression.expression.getText(source) === 'route'
|
||||
&& ['post', 'put', 'delete'].includes(node.expression.name.text)
|
||||
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0])
|
||||
&& /\breq\.body\b/.test(node.getText(source))) {
|
||||
const endpoint = [mount, node.arguments[0].text].join('/').split('/').filter(Boolean).join('/');
|
||||
const method = node.expression.name.text.toUpperCase();
|
||||
const operation = openOperations.find(item => item.method === method && item.path === endpoint);
|
||||
assert.ok(operation, `${method} ${endpoint} is missing`);
|
||||
assert.ok(operation.body || operation.upload, `${operation.name} cannot provide req.body`);
|
||||
checked++;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
}
|
||||
assert.ok(checked > 0, 'No body-consuming routes were inspected');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
const { inspectPanel } = require('../../dist/internal/maintenance/check');
|
||||
|
||||
test('public task entries retain no-argument script discovery and help stays read-only', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-public-inventory-'));
|
||||
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/job.js'), 'throw Error("do not execute"); const $ = new Env("Job");');
|
||||
for (const [entry, args] of [['task', []], ['ql', ['task']]]) {
|
||||
const run = (...extra) => spawnSync(process.execPath, [path.resolve(__dirname, `../../dist/${entry}.js`), ...args, ...extra, '--json'], { env: { PATH: process.env.PATH, QL_DIR: root }, encoding: 'utf8' });
|
||||
const listing = run();
|
||||
assert.equal(listing.status, 0, listing.stderr);
|
||||
assert.deepEqual(JSON.parse(listing.stdout).data.scripts, [{ file: 'job.js', name: 'Job' }]);
|
||||
assert.equal(JSON.parse(run('--help').stdout).data.scripts, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('removed development publication is rejected before doing any work', () => {
|
||||
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../dist/ql.js'), 'dev', 'release', '--root', '/absent', '--json'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(JSON.parse(result.stderr).code, 2);
|
||||
});
|
||||
|
||||
test('health probes use the base path and reject failed or malformed scheduler health', async (t) => {
|
||||
let health = { status: 503, body: { code: 503, data: { status: 'error' } } };
|
||||
const seen = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
seen.push(req.url);
|
||||
if (req.url.startsWith('/ql/api/health?')) {
|
||||
res.statusCode = health.status;
|
||||
res.end(JSON.stringify(health.body));
|
||||
} else if (req.url === '/ql/') res.end('<div id="root"></div>');
|
||||
else res.end('{"code":200,"data":{"version":"2.21.0"}}');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
const context = { env: { QlPort: String(server.address().port), QlBaseUrl: '/ql/' } };
|
||||
assert.equal((await inspectPanel(context)).backend.healthy, false);
|
||||
health = { status: 200, body: { code: 200, data: {} } };
|
||||
assert.equal((await inspectPanel(context)).backend.healthy, false);
|
||||
health = { status: 200, body: { code: 200, data: { status: 'ok' } } };
|
||||
assert.equal((await inspectPanel(context)).backend.healthy, true);
|
||||
assert.equal(seen.some(route => route.includes('/api/system')), false);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
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 http = require('node:http');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
|
||||
test('public CLI excludes local maintenance while the admin entry retains compatibility names', () => {
|
||||
const help = parse(['--help']).help;
|
||||
assert.match(help, /subscription list/);
|
||||
assert.doesNotMatch(help, /resetpwd|local extra|rmlog/);
|
||||
for (const name of ['rmlog', 'resetpwd', 'extra'])
|
||||
assert.throws(() => parse([name, '1']), { exitCode: 2 });
|
||||
assert.equal(parse(['resetpwd', 'example'], 'local').name, 'local resetpwd');
|
||||
assert.doesNotMatch(
|
||||
parse(['--help'], 'local').help,
|
||||
/subscription list|auth login/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parse(['auth', 'status', '--scope', 'all']),
|
||||
{ exitCode: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription management uses panel API, projects credentials out and never invokes local repo/raw', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-subscription-'));
|
||||
const records = [];
|
||||
const row = {
|
||||
id: 7,
|
||||
alias: 'sample',
|
||||
name: 'sample',
|
||||
status: 1,
|
||||
pull_option: { password: 'private-secret' },
|
||||
url: 'https://user:private-secret@example.com/repo.git',
|
||||
command: 'private-secret',
|
||||
sub_before: 'private-secret',
|
||||
};
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
records.push({
|
||||
path: url.pathname,
|
||||
query: url.searchParams,
|
||||
body,
|
||||
method: req.method,
|
||||
});
|
||||
assert.equal(req.headers.authorization, 'Bearer sub-token');
|
||||
const data = url.pathname.endsWith('/log')
|
||||
? 'one\ntwo\nthree\n'
|
||||
: url.pathname.endsWith('/7')
|
||||
? row
|
||||
: [row];
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ code: 200, data }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const config = path.join(root, 'config.json');
|
||||
fs.writeFileSync(
|
||||
config,
|
||||
JSON.stringify({
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
token: 'sub-token',
|
||||
expiration: Date.now() / 1000 + 10000,
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const run = (args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/npm/ql.js'), ...args, '--json'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
QL_CLI_CONFIG: config,
|
||||
QL_DIR: '/not-a-panel',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
let out = '',
|
||||
err = '';
|
||||
child.stdout.on('data', (chunk) => (out += chunk));
|
||||
child.stderr.on('data', (chunk) => (err += chunk));
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
assert.equal(code, 0, err);
|
||||
assert.doesNotMatch(out + err, /private-secret/);
|
||||
resolve(JSON.parse(out));
|
||||
});
|
||||
});
|
||||
const status = await run(['auth', 'status', '--scope', 'subscriptions']);
|
||||
assert.equal(status.data.scopeChecked, 'subscriptions');
|
||||
const list = await run(['subscription', 'list', '--search', 'a & b']);
|
||||
assert.deepEqual(list.data, [
|
||||
{ id: 7, name: 'sample', alias: 'sample', status: 1 },
|
||||
]);
|
||||
assert.equal(records.at(-1).query.get('searchValue'), 'a & b');
|
||||
assert.equal((await run(['subscription', 'get', '7'])).data.id, 7);
|
||||
assert.deepEqual(await run(['subscription', 'logs', '7', '--tail', '2']), {
|
||||
code: 200,
|
||||
data: 'two\nthree',
|
||||
truncated: true,
|
||||
});
|
||||
for (const action of ['run', 'stop', 'enable', 'disable']) {
|
||||
const response = await run(['subscription', action, '7']);
|
||||
assert.deepEqual(response.data, {
|
||||
subscriptionId: 7,
|
||||
action,
|
||||
accepted: true,
|
||||
});
|
||||
assert.equal(records.at(-1).method, 'PUT');
|
||||
assert.equal(records.at(-1).body, '[7]');
|
||||
assert.equal(records.at(-1).path, `/open/subscriptions/${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared API errors identify subscription permission and uncertain subscription outcomes', async (t) => {
|
||||
const { request } = require('../../dist/remote/api/client');
|
||||
const http = require('node:http');
|
||||
let mode = 'denied',
|
||||
count = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
count++;
|
||||
if (mode === 'denied') {
|
||||
res.writeHead(403);
|
||||
res.end('forbidden');
|
||||
} else if (mode === 'api-denied') {
|
||||
res.end(JSON.stringify({ code: 403 }));
|
||||
} else {
|
||||
res.writeHead(503);
|
||||
res.end('unavailable');
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const config = {
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
token: 'fixture',
|
||||
clientId: 'fixture',
|
||||
clientSecret: 'fixture',
|
||||
expiration: Date.now() / 1000 + 1000,
|
||||
};
|
||||
for (const failure of ['denied', 'api-denied']) {
|
||||
mode = failure;
|
||||
await assert.rejects(
|
||||
request(config, 'subscriptions'),
|
||||
(error) =>
|
||||
error.exitCode === 3 &&
|
||||
/subscriptions (permission|权限)/.test(error.message) &&
|
||||
!/crons/.test(error.message),
|
||||
);
|
||||
}
|
||||
mode = 'unavailable';
|
||||
await assert.rejects(
|
||||
request(config, 'subscriptions/run', { method: 'PUT', body: [1] }),
|
||||
/check subscription status before retrying|先检查订阅状态再考虑重试/,
|
||||
);
|
||||
assert.equal(count, 3);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
const { parseExecution } = require('../../dist/runner');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('Commander adapter preserves literal values, option polarity, duplicate errors and IDs', () => {
|
||||
assert.equal(parse(['task','list','--search','00123']).values.search, '00123');
|
||||
assert.equal(parse(['local','start','--no-startup'], 'local').values['no-startup'], true);
|
||||
assert.equal(parse(['local','start'], 'local').values['no-startup'], undefined);
|
||||
assert.equal(parse(['auth','login','--url=--json']).values.url, '--json');
|
||||
assert.deepEqual(parse(['local','resetpwd','--','-literal'], 'local').positionals, ['-literal']);
|
||||
for (const args of [
|
||||
['task','list','--search','--json'], ['auth','login','--url','--json'],
|
||||
['task','list','-p','2','--page','3'], ['task','list','--json','--json'],
|
||||
['--json','task','list','--json'], ['task','list','unexpected'],
|
||||
['task','run','0'], ['task','run','script.js'], ['task','list','--size','201'],
|
||||
['task','list','--json=false'],
|
||||
]) assert.throws(() => parse(args), { exitCode:2 }, JSON.stringify(args));
|
||||
assert.ok(parse(['auth','login','--help']).help);
|
||||
assert.ok(parse(['--help']).help);
|
||||
});
|
||||
|
||||
test('Commander task parsing stops at the script and preserves the execution separator', () => {
|
||||
const args = ['--root','/isolated','--json','script.sh','now','--','--json','--root','-literal'];
|
||||
const parsed = parseExecution(args);
|
||||
assert.equal(parsed.values.root, '/isolated');
|
||||
assert.equal(parsed.values.json, true);
|
||||
assert.deepEqual(parsed.execution.scriptArgs, ['--json','--root','-literal']);
|
||||
assert.deepEqual(parseExecution(['script.sh','--json']).execution.argv, ['script.sh','--json']);
|
||||
assert.deepEqual(parseExecution(['--','-script','--','-x']).execution.scriptArgs, ['-x']);
|
||||
assert.throws(() => parseExecution(['--root','--json','script.sh']), { exitCode:2 });
|
||||
});
|
||||
|
||||
test('Commander is bundled once with its license and has no external runtime resolution', () => {
|
||||
const manifest = require('../../package.json');
|
||||
assert.equal(manifest.devDependencies.commander, '15.0.0');
|
||||
assert.equal(Object.keys(manifest.dependencies || {}).length, 0);
|
||||
const bundled = fs.readFileSync(path.join(__dirname,'../../dist/shared/cli/commander.js'), 'utf8');
|
||||
assert.doesNotMatch(bundled, /require\(["']commander["']\)/);
|
||||
assert.match(fs.readFileSync(path.join(__dirname,'../../dist/licenses/commander-LICENSE'), 'utf8'), /MIT/);
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
|
||||
test('legacy and worker entrypoints localize invalid input before accessing an installation', () => {
|
||||
const cases = [
|
||||
['compat', ['absent'], '', /未知旧命令/, /Unknown legacy command/],
|
||||
[
|
||||
'compat',
|
||||
['update', 'invalid'],
|
||||
'',
|
||||
/用法:ql-compat update/,
|
||||
/Usage: ql-compat update/,
|
||||
],
|
||||
[
|
||||
'compat',
|
||||
['reload', 'invalid'],
|
||||
'',
|
||||
/用法:ql-compat reload/,
|
||||
/Usage: ql-compat reload/,
|
||||
],
|
||||
[
|
||||
'subscription-worker',
|
||||
['absent'],
|
||||
'',
|
||||
/用法:ql-subscription-worker/,
|
||||
/Usage: ql-subscription-worker/,
|
||||
],
|
||||
[
|
||||
'subscription-worker',
|
||||
['raw', 'https://example.invalid/job.js', '', 'yes'],
|
||||
'',
|
||||
/订阅布尔参数无效/,
|
||||
/Invalid subscription boolean/,
|
||||
],
|
||||
[
|
||||
'compat',
|
||||
['raw', 'https://example.invalid/job.js', '', 'yes'],
|
||||
'',
|
||||
/订阅布尔参数无效/,
|
||||
/Invalid subscription boolean/,
|
||||
],
|
||||
[
|
||||
'subscription-worker',
|
||||
['raw', 'https://example.invalid/job.js', '', '', '', 'extra'],
|
||||
'',
|
||||
/订阅参数过多/,
|
||||
/Too many subscription arguments/,
|
||||
],
|
||||
[
|
||||
'subscription-worker',
|
||||
['raw', 'https://example.invalid/job.js'],
|
||||
'-1',
|
||||
/SUB_ID 无效/,
|
||||
/Invalid SUB_ID/,
|
||||
],
|
||||
];
|
||||
for (const [entry, args, subscriptionId, chinese, english] of cases) {
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
QL_LANG: language,
|
||||
QL_DIR: '',
|
||||
QL_DATA_DIR: '',
|
||||
SUB_ID: subscriptionId,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
const error = JSON.parse(result.stderr);
|
||||
assert.equal(error.code, 2);
|
||||
assert.match(error.message, language === 'en' ? english : chinese);
|
||||
assert.doesNotMatch(error.message, /example\.invalid/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function invoke(entry, args, language) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args, '--json'],
|
||||
{
|
||||
env: { ...process.env, QL_LANG: language, QL_DIR: '', QL_DATA_DIR: '' },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('runner JSON error handling respects the script and option-terminator boundaries', () => {
|
||||
const entry = path.resolve(__dirname, '../../dist/runner.js');
|
||||
for (const args of [
|
||||
['sample.js', '--json'],
|
||||
['--unknown-option', '--', '--json'],
|
||||
]) {
|
||||
const result = spawnSync(process.execPath, [entry, ...args], {
|
||||
env: { ...process.env, QL_DIR: '', QL_DATA_DIR: '' },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.throws(
|
||||
() => JSON.parse(result.stderr),
|
||||
SyntaxError,
|
||||
'child --json must not select CLI JSON output',
|
||||
);
|
||||
}
|
||||
const result = invoke('runner', ['--json', '--unknown-option'], 'en');
|
||||
assert.equal(result.status, 2);
|
||||
assert.equal(JSON.parse(result.stderr).code, 2);
|
||||
});
|
||||
|
||||
test('actual argument failures honor locale, preserve usage status, and keep stdout empty', () => {
|
||||
const cases = [
|
||||
[
|
||||
'runner',
|
||||
['--unknown-option'],
|
||||
/任务执行器选项无效/,
|
||||
/Invalid runner options/,
|
||||
],
|
||||
['index', ['absent'], /未知命令/, /Unknown command/],
|
||||
[
|
||||
'index',
|
||||
['task', 'list', '--absent'],
|
||||
/选项未知/,
|
||||
/Unknown or missing option/,
|
||||
],
|
||||
[
|
||||
'index',
|
||||
['task', 'list', '--page', '1', '-p', '2'],
|
||||
/选项重复/,
|
||||
/Duplicate option/,
|
||||
],
|
||||
['index', ['task', 'get'], /缺少必要参数/, /missing required argument/],
|
||||
['index', ['login'], /缺少 --url/, /Missing --url/],
|
||||
[
|
||||
'index',
|
||||
['auth', 'status', '--scope', 'all'],
|
||||
/--scope 无效/,
|
||||
/Invalid --scope/,
|
||||
],
|
||||
['index', ['task', 'get', '0'], /支持范围内的整数/, /supported range/],
|
||||
['admin', ['absent'], /ql --help/, /ql --help/],
|
||||
];
|
||||
for (const [entry, args, chinese, english] of cases) {
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
const result = invoke(entry, args, language);
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
const error = JSON.parse(result.stderr);
|
||||
assert.equal(error.code, 2);
|
||||
assert.match(error.message, language === 'en' ? english : chinese);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unexpected local configuration failures use localized generic diagnostics without exposing config output', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-diagnostic-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'data/config/config.sh'), 'exit 7\n');
|
||||
for (const language of ['zh', 'en']) {
|
||||
const result = invoke('admin', ['extra', '--root', root], language);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
const error = JSON.parse(result.stderr);
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(
|
||||
error.message,
|
||||
language === 'en' ? /Check local configuration/ : /检查本机配置和权限/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy and worker failures preserve stderr diagnostics and localize the final JSON', async (t) => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-worker-diagnostic-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
await fs.mkdir(path.join(root, 'data/config'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'data/config/config.sh'),
|
||||
'echo private-config-marker >&2\nexit 7\n',
|
||||
);
|
||||
for (const [entry, args, chinese, english] of [
|
||||
[
|
||||
'compat',
|
||||
['extra'],
|
||||
/旧命令适配器执行失败/,
|
||||
/Legacy command adapter failed/,
|
||||
],
|
||||
[
|
||||
'subscription-worker',
|
||||
['raw', 'https://example.invalid/job.js'],
|
||||
/订阅执行器失败/,
|
||||
/Subscription worker failed/,
|
||||
],
|
||||
]) {
|
||||
for (const language of ['zh', 'en']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), ...args],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
QL_LANG: language,
|
||||
QL_DIR: root,
|
||||
QL_DATA_DIR: path.join(root, 'data'),
|
||||
SUB_ID: '',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
const lines = result.stderr.trim().split('\n');
|
||||
assert.equal(lines[0], 'private-config-marker');
|
||||
const error = JSON.parse(lines.at(-1));
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.message, language === 'en' ? english : chinese);
|
||||
assert.doesNotMatch(
|
||||
error.message,
|
||||
/private-config-marker|example\.invalid/,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('container entry rejects positional configuration in the selected language before startup', () => {
|
||||
for (const language of ['zh', 'en', 'unsupported']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/container.js'), 'invalid'],
|
||||
{ env: { QL_LANG: language }, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
const error = JSON.parse(result.stderr);
|
||||
assert.equal(error.event, 'error');
|
||||
assert.match(
|
||||
error.message,
|
||||
language === 'en'
|
||||
? /environment variables only/
|
||||
: /仅接受通过环境变量配置/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parse } = require('../helpers/commands.cjs');
|
||||
const {
|
||||
commands,
|
||||
globalOptions,
|
||||
localOptions,
|
||||
} = require('../helpers/commands.cjs');
|
||||
const { runnerOptions } = require('../../dist/internal/execution/definition');
|
||||
const { standaloneHelp } = require('../../dist/internal/help/standalone');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
test('every registered command and option is discoverable in both help languages', () => {
|
||||
const previous = process.env.QL_LANG;
|
||||
try {
|
||||
for (const language of ['zh', 'en']) {
|
||||
process.env.QL_LANG = language;
|
||||
for (const surface of ['public', 'local']) {
|
||||
const root = parse(['--help'], surface).help;
|
||||
for (const spec of commands.filter((spec) =>
|
||||
surface === 'local' ? spec.local : !spec.local,
|
||||
)) {
|
||||
assert.ok(
|
||||
root.includes(surface === 'local' ? spec.name.slice(6) : spec.name),
|
||||
spec.name,
|
||||
);
|
||||
const help = parse([...spec.name.split(' '), '--help'], surface).help;
|
||||
for (const name of Object.keys({
|
||||
...globalOptions,
|
||||
...(spec.local ? localOptions : {}),
|
||||
...spec.options,
|
||||
}))
|
||||
assert.ok(help.includes(`--${name}`), `${spec.name}: ${name}`);
|
||||
}
|
||||
}
|
||||
const runner = standaloneHelp('runner', { QL_LANG: language });
|
||||
for (const name of Object.keys(runnerOptions))
|
||||
assert.ok(runner.includes(`--${name}`), name);
|
||||
const internal = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, '../../dist/ql.js'), '--help', '--json'],
|
||||
{
|
||||
env: { PATH: process.env.PATH, QL_LANG: language },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
assert.equal(internal.status, 0, internal.stderr);
|
||||
const help = JSON.parse(internal.stdout).data.help;
|
||||
for (const spec of commands.filter((spec) => spec.local))
|
||||
assert.ok(help.includes(spec.name.slice(6)), spec.name);
|
||||
assert.doesNotMatch(
|
||||
help,
|
||||
/auth login|task run|subscription list|app list/,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.QL_LANG;
|
||||
else process.env.QL_LANG = previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const {
|
||||
commands,
|
||||
globalOptions,
|
||||
localOptions,
|
||||
} = require('../helpers/commands.cjs');
|
||||
const { helpText } = require('../../dist/shared/i18n/help');
|
||||
const { loggedOperation } = require('../../dist/internal/runtime/commandLog');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
|
||||
test('every registered help description has a Chinese translation and preserves English', () => {
|
||||
const text = [
|
||||
...commands.map((c) => c.summary),
|
||||
...Object.values({ ...globalOptions, ...localOptions }).map(
|
||||
(o) => o.description,
|
||||
),
|
||||
...commands.flatMap((c) =>
|
||||
Object.values(c.options || {}).map((o) => o.description),
|
||||
),
|
||||
];
|
||||
for (const value of text) {
|
||||
assert.notEqual(
|
||||
helpText(value, {}),
|
||||
value,
|
||||
`Missing translation: ${value}`,
|
||||
);
|
||||
assert.equal(helpText(value, { QL_LANG: 'en' }), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('public and local JSON help honors QL_LANG without reading panel configuration', () => {
|
||||
for (const entry of ['index', 'admin'])
|
||||
for (const language of ['zh', 'en']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), '--help', '--json'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_LANG: language,
|
||||
QL_DIR: '/nonexistent',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const help = JSON.parse(result.stdout).data.help;
|
||||
assert.match(help, language === 'en' ? /Usage:/ : /用法:/);
|
||||
assert.match(help, language === 'en' ? /Options:/ : /选项:/);
|
||||
assert.match(help, /--json/);
|
||||
}
|
||||
});
|
||||
|
||||
test('maintenance log banners honor Chinese fallback and English selection', async (t) => {
|
||||
for (const language of ['zh', 'en']) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-i18n-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ QL_LANG: language, no_tee: 'true' },
|
||||
);
|
||||
const { logPath } = await loggedOperation(
|
||||
context,
|
||||
'extra',
|
||||
async () => true,
|
||||
);
|
||||
const log = await fs.readFile(
|
||||
path.join(context.paths.dir_log, logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(log, language === 'en' ? /Starting/ : /开始执行/);
|
||||
assert.match(
|
||||
log,
|
||||
language === 'en' ? /Finished.*exit code 0/ : /执行结束.*退出码 0/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone entry help supports both languages without panel or repository setup', () => {
|
||||
for (const entry of [
|
||||
'runner',
|
||||
'compat',
|
||||
'subscription-worker',
|
||||
]) {
|
||||
for (const language of ['en', 'zh']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve(__dirname, `../../dist/${entry}.js`), '--help'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
cwd: os.tmpdir(),
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
QL_LANG: language,
|
||||
QL_DIR: '/missing-panel',
|
||||
QL_CLI_CONFIG: '/missing-credentials',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.match(result.stdout, language === 'en' ? /Usage:/ : /用法:/);
|
||||
assert.match(result.stdout, /ql(?: |-)/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('task, maintenance and subscription failure warnings honor locale without failing completed work', async (t) => {
|
||||
const { LocalApi } = require('../../dist/internal/runtime/api');
|
||||
const { executeTask } = require('../../dist/internal/execution/taskRunner');
|
||||
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
|
||||
const { withOperationOutput } = require('../../dist/internal/runtime/output');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-warning-locale-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'owner/source');
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'job.js'), '// cron: 0 9 * * *\n');
|
||||
for (const args of [
|
||||
['init'],
|
||||
['add', '.'],
|
||||
[
|
||||
'-c',
|
||||
'user.name=Fixture',
|
||||
'-c',
|
||||
'user.email=fixture@example.invalid',
|
||||
'commit',
|
||||
'-m',
|
||||
'fixture',
|
||||
],
|
||||
])
|
||||
execFileSync(git, args, { cwd: source, stdio: 'ignore' });
|
||||
let created = 0,
|
||||
notifications = 0;
|
||||
t.mock.method(LocalApi.prototype, 'call', async (endpoint, method) => {
|
||||
if (endpoint === 'crons' && method === 'POST') {
|
||||
created++;
|
||||
return { data: { id: created } };
|
||||
}
|
||||
if (endpoint === 'crons') return { data: { data: [] } };
|
||||
if (endpoint === 'system/notify') notifications++;
|
||||
throw new Error('Fixture unavailable');
|
||||
});
|
||||
for (const language of ['zh', 'en']) {
|
||||
const panel = path.join(root, language);
|
||||
await fs.mkdir(path.join(panel, 'data/scripts'), { recursive: true });
|
||||
await fs.writeFile(path.join(panel, 'data/scripts/job.sh'), 'exit 0');
|
||||
const context = createContext(
|
||||
{ root: panel },
|
||||
{
|
||||
PATH: `/usr/bin:/bin:${process.env.PATH}`,
|
||||
ID: '12',
|
||||
QL_LANG: language,
|
||||
QL_CLI_LIFECYCLE: 'extended',
|
||||
no_tee: 'true',
|
||||
},
|
||||
);
|
||||
const task = await executeTask(context, { argv: ['job.sh'], mode: 'now' });
|
||||
assert.equal(task.exitCode, 0);
|
||||
const taskLog = await fs.readFile(
|
||||
path.join(context.paths.dir_log, task.logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
taskLog,
|
||||
language === 'en'
|
||||
? /Task lifecycle reporting failed/
|
||||
: /任务状态上报失败/,
|
||||
);
|
||||
assert.match(
|
||||
taskLog,
|
||||
language === 'en'
|
||||
? /Task statistics reporting failed/
|
||||
: /任务统计上报失败/,
|
||||
);
|
||||
const command = await loggedOperation(context, 'extra', async () => true);
|
||||
const commandLog = await fs.readFile(
|
||||
path.join(context.paths.dir_log, command.logPath),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
commandLog,
|
||||
language === 'en'
|
||||
? /Command lifecycle reporting failed/
|
||||
: /命令状态上报失败/,
|
||||
);
|
||||
let warning = '';
|
||||
const subscription = await withOperationOutput(
|
||||
(chunk) => {
|
||||
warning += chunk.toString();
|
||||
},
|
||||
() =>
|
||||
syncRepository(context, {
|
||||
url: source,
|
||||
autoAdd: true,
|
||||
autoDelete: false,
|
||||
}),
|
||||
);
|
||||
assert.equal(subscription.added, 1);
|
||||
assert.match(
|
||||
warning,
|
||||
language === 'en'
|
||||
? /Subscription synchronized, but notification delivery failed/
|
||||
: /订阅同步已完成,但通知发送失败/,
|
||||
);
|
||||
}
|
||||
assert.equal(created, 2);
|
||||
assert.equal(notifications, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user