Files
qinglong/cli/test/internal/operator.test.cjs
T
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
2026-09-25 23:24:41 +08:00

800 lines
28 KiB
JavaScript

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"}',
);
});