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

272 lines
8.6 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 { 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\)/),
);
});
}