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

163 lines
6.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 {
prepareContainerEnvironment,
} = require('../../dist/internal/runtime/containerEnvironment');
async function fixture(t) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-env-'));
t.after(() => fs.rm(base, { recursive: true, force: true }));
const root = path.join(base, 'panel');
const data = path.join(base, 'external/data');
const systemDirectory = path.join(base, 'etc');
for (const dir of [root, data, systemDirectory])
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(systemDirectory, 'alpine-release'), '3.20');
return {
root,
data,
systemDirectory,
env: { PATH: process.env.PATH, HOME: path.join(base, 'absent') },
};
}
test('container preparation retains data and existing temporary files, and does not mutate caller environment', async (t) => {
const options = await fixture(t);
const original = { ...options.env };
await fs.mkdir(path.join(options.root, '.tmp'));
await fs.writeFile(path.join(options.root, '.tmp/keep'), 'pending upgrade');
await fs.writeFile(path.join(options.data, 'keep'), 'user data');
const result = await prepareContainerEnvironment(options);
assert.equal(result.env.HOME, path.join(options.root, '.tmp'));
assert.deepEqual(options.env, original);
assert.deepEqual(await fs.readdir(options.data), ['keep']);
assert.equal(
await fs.readFile(path.join(options.root, '.tmp/keep'), 'utf8'),
'pending upgrade',
);
assert.deepEqual(result.warnings, []);
});
test('network initialization appends complete lines once and matches localhost as a hostname token', async (t) => {
const options = await fixture(t);
const hosts = path.join(options.systemDirectory, 'hosts');
const resolv = path.join(options.systemDirectory, 'resolv.conf');
await fs.writeFile(
hosts,
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost',
);
await fs.writeFile(resolv, 'nameserver 127.0.0.11');
await prepareContainerEnvironment(options);
const expectedHosts =
'# 127.0.0.1 localhost\n127.0.0.1 localhost.example\n::1 alias localhost\n127.0.0.1 localhost\n';
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'nameserver 127.0.0.11\noptions ndots:0\n',
);
await prepareContainerEnvironment(options);
assert.equal(await fs.readFile(hosts, 'utf8'), expectedHosts);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'nameserver 127.0.0.11\noptions ndots:0\n',
);
});
test('existing HOME, DNS options and non-Alpine DNS are preserved', async (t) => {
const options = await fixture(t);
options.env.HOME = options.root;
const resolv = path.join(options.systemDirectory, 'resolv.conf');
await fs.writeFile(resolv, 'options timeout:1 ndots:0 # configured\n');
assert.equal(
(await prepareContainerEnvironment(options)).env.HOME,
options.root,
);
assert.equal(
await fs.readFile(resolv, 'utf8'),
'options timeout:1 ndots:0 # configured\n',
);
await fs.rm(path.join(options.systemDirectory, 'alpine-release'));
await fs.writeFile(resolv, 'nameserver 1.2.3.4');
await prepareContainerEnvironment(options);
assert.equal(await fs.readFile(resolv, 'utf8'), 'nameserver 1.2.3.4');
});
test('network failures are reported without hiding permission preflight errors', async (t) => {
const options = await fixture(t);
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
const result = await prepareContainerEnvironment(options);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /hosts[::] ?EISDIR/);
const absent = path.join(options.root, 'absent');
await assert.rejects(
prepareContainerEnvironment({ ...options, data: absent }),
/not writable\/searchable|不可写或不可访问/,
);
await assert.rejects(fs.stat(absent), { code: 'ENOENT' });
await assert.rejects(
prepareContainerEnvironment({ ...options, root: 'relative' }),
/absolute paths|必须为绝对路径/,
);
});
test(
'non-root preflight rejects an inaccessible data directory without creating volume files',
{ skip: process.getuid?.() === 0 },
async (t) => {
const options = await fixture(t);
await fs.chmod(options.data, 0o400);
try {
await assert.rejects(
prepareContainerEnvironment(options),
/not writable\/searchable|不可写或不可访问/,
);
} finally {
await fs.chmod(options.data, 0o700);
}
assert.deepEqual(await fs.readdir(options.data), []);
assert.deepEqual(await fs.readdir(options.systemDirectory), [
'alpine-release',
]);
},
);
for (const language of ['zh', 'en', 'unsupported']) {
test(`container preflight and network warnings preserve language and data: ${language}`, async (t) => {
const options = await fixture(t);
options.env.QL_LANG = language;
const expected = (zh, en) => (language === 'en' ? en : zh);
await assert.rejects(
prepareContainerEnvironment({ ...options, root: 'relative' }),
(error) => {
assert.equal(error.exitCode, 2);
assert.match(
error.message,
expected(/必须为绝对路径/, /absolute paths/),
);
return true;
},
);
const absent = path.join(options.root, 'missing-data');
await assert.rejects(
prepareContainerEnvironment({ ...options, data: absent }),
expected(/不可写或不可访问/, /not writable\/searchable/),
);
await assert.rejects(fs.access(absent), { code: 'ENOENT' });
await fs.writeFile(path.join(options.data, 'retained'), 'keep');
await fs.mkdir(path.join(options.systemDirectory, 'hosts'));
const result = await prepareContainerEnvironment(options);
assert.equal(result.warnings.length, 1);
assert.match(
result.warnings[0],
expected(/无法初始化.*EISDIR/, /Cannot initialize.*EISDIR/),
);
assert.equal(
await fs.readFile(path.join(options.data, 'retained'), 'utf8'),
'keep',
);
});
}