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

238 lines
9.1 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 { 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)/,
);
},
);
}