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
132 lines
4.7 KiB
JavaScript
132 lines
4.7 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { createContext } = require('../../dist/internal/runtime/context');
|
|
const { replaceAndReload, reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
|
const { installCronEntrypoint } = require('../../dist/local/cronEntrypoint');
|
|
|
|
for (const language of ['zh', 'en', 'unsupported']) {
|
|
test(`upgrade failures preserve files and localize using operation language: ${language}`, async (t) => {
|
|
const root = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'ql-upgrade-language-'),
|
|
);
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const context = createContext({ root }, { QL_LANG: language });
|
|
const expected = (zh, en) => (language === 'en' ? en : zh);
|
|
let stops = 0;
|
|
const lifecycle = {
|
|
stop: async () => {
|
|
stops++;
|
|
},
|
|
start: async () => {},
|
|
};
|
|
await assert.rejects(
|
|
replaceAndReload(context, [{ source: root, target: root }], lifecycle),
|
|
expected(/源与目标相同/, /equals destination/),
|
|
);
|
|
assert.equal(stops, 0);
|
|
await fs.mkdir(context.paths.dir_tmp);
|
|
await fs.writeFile(
|
|
path.join(context.paths.dir_tmp, 'upgrade-ready-master.json'),
|
|
JSON.stringify({ directory: '../outside' }),
|
|
);
|
|
await assert.rejects(
|
|
reloadPanel(context, 'system'),
|
|
expected(/指针无效/, /Invalid staged upgrade pointer/),
|
|
);
|
|
const source = path.join(root, 'source');
|
|
const target = path.join(root, 'target');
|
|
await fs.mkdir(source);
|
|
await fs.mkdir(target);
|
|
await fs.writeFile(path.join(source, 'version'), 'new');
|
|
await fs.writeFile(path.join(target, 'version'), 'old');
|
|
await fs.symlink(target, path.join(source, 'external'));
|
|
await assert.rejects(
|
|
reloadPanel(context, 'system', { source, static: source }),
|
|
expected(/外部的符号链接/, /external symlink/),
|
|
);
|
|
await fs.unlink(path.join(source, 'external'));
|
|
const cause = new Error('fixture startup failed');
|
|
await assert.rejects(
|
|
replaceAndReload(context, [{ source, target }], {
|
|
stop: async () => {},
|
|
start: async () => {
|
|
throw cause;
|
|
},
|
|
}),
|
|
(error) => {
|
|
assert.match(
|
|
error.message,
|
|
expected(
|
|
/文件已恢复.*原服务无法/,
|
|
/files restored.*previous service/,
|
|
),
|
|
);
|
|
assert.equal(error.cause, cause);
|
|
return true;
|
|
},
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(target, 'version'), 'utf8'),
|
|
'old',
|
|
);
|
|
});
|
|
|
|
test(`installed cron bridge localizes failure without exposing values: ${language}`, async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-cron-language-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const native = path.join(root, 'native');
|
|
const bin = path.join(root, 'bin');
|
|
await fs.mkdir(native);
|
|
const touched = path.join(root, 'touched');
|
|
await fs.writeFile(
|
|
path.join(native, 'crontab'),
|
|
`#!${process.execPath}\nrequire('node:fs').writeFileSync(${JSON.stringify(
|
|
touched,
|
|
)},'called');`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const source = path.join(root, 'table');
|
|
await fs.writeFile(source, '* * * * * task job.js\n');
|
|
const environment = {
|
|
PATH: native,
|
|
QL_LANG: language,
|
|
QL_DIR: 'private-path\ninvalid',
|
|
};
|
|
await installCronEntrypoint(
|
|
bin,
|
|
path.resolve(__dirname, '../..'),
|
|
source,
|
|
environment,
|
|
);
|
|
const run = (env) =>
|
|
spawnSync(path.join(bin, 'crontab'), [source], { env, encoding: 'utf8' });
|
|
const result = run({});
|
|
assert.equal(result.status, 1);
|
|
assert.equal(result.stdout, '');
|
|
assert.match(
|
|
result.stderr,
|
|
language === 'en' ? /Cannot install or read/ : /无法安装或读取/,
|
|
);
|
|
assert.doesNotMatch(result.stderr, /private-path/);
|
|
await assert.rejects(fs.access(touched), { code: 'ENOENT' });
|
|
assert.match(run({ QL_LANG: 'en' }).stderr, /Cannot install or read/);
|
|
await assert.rejects(
|
|
installCronEntrypoint('relative', root, source, environment),
|
|
language === 'en' ? /must be absolute/ : /必须为绝对路径/,
|
|
);
|
|
await fs.writeFile(path.join(bin, 'crontab'), 'unrelated');
|
|
await assert.rejects(
|
|
installCronEntrypoint(bin, root, source, environment),
|
|
language === 'en' ? /Refusing to replace/ : /拒绝覆盖/,
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
|
|
'unrelated',
|
|
);
|
|
});
|
|
}
|