mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* 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
238 lines
9.1 KiB
JavaScript
238 lines
9.1 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 { 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)/,
|
||
);
|
||
},
|
||
);
|
||
}
|