Files
qinglong/cli/test/internal/containerRuntime.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

281 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'],
);
});
}