Files
qinglong/cli/test/linux/container-bot.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

142 lines
4.5 KiB
JavaScript

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