mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-05 21:01:11 +08:00
Build complete platform tar.gz bundles with production dependencies for 13 Linux architecture/libc targets, including ARMv6 glibc and musl. Share archive, payload, runtime and dependency verification across online preparation and manual offline installation while retaining the existing Node runtime. Publish readiness only after an isolated backend starts successfully. Repair verified dependency failures once with the frozen lockfile; offline repair requires explicit opt-in. Preserve configured proxies and bound memory, concurrency, process lifetimes and cleanup on small devices. Verify the running build after explicit restart, restore prior application files and dependencies on failure, and retain backups when the failed service cannot stop. Complete cancellation recovery outside the aborted signal, reap descendants as their supervisor exits, and keep operation-only upgrade variables out of both Bash and CLI service startup environments. Keep dialog/sidebar states consistent, hide completed notices, and retain menu/user controls in narrow desktop viewports with long-name truncation. Validation: 791 backend/frontend tests passed (8 platform skips), 294 CLI tests passed (5 platform skips), backend/CLI type checks and standalone offline installer build. On dev, 29 additional Linux fault regressions passed without external networking, including real Node/PM2 SIGTERM rollback. Earlier full bundle application and ARM1176/glibc prestart acceptance remain recorded. Make the mirror notification timing regression respect Bash SECONDS integer resolution: require two clock ticks and assert the logged retry threshold. Validated with Node 20 full regression and 12 repeated boundary runs.
351 lines
13 KiB
JavaScript
351 lines
13 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 {
|
|
prestartBackend,
|
|
dependencyFailure,
|
|
probeLimits,
|
|
repairDependencies,
|
|
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
|
|
|
|
for (const exitCode of [0, 1])
|
|
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source'),
|
|
statics = path.join(root, 'static'),
|
|
survivor = path.join(root, 'survivor');
|
|
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
|
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
|
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
|
|
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
|
await fs.writeFile(
|
|
path.join(statics, 'build-info.json'),
|
|
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
|
|
`
|
|
const fs=require('node:fs'),crypto=require('node:crypto');
|
|
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
|
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
|
survivor,
|
|
)},'escaped'),600);`,
|
|
)}],{stdio:'inherit'});
|
|
process.send({type:'upgrade-probe',ok:${
|
|
exitCode === 0
|
|
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
|
|
`,
|
|
);
|
|
const operation = prestartBackend(
|
|
{ source, static: statics },
|
|
{ target: { arch: 'armv6' }, limits: { heapMb: 128, timeoutMs: 3000 } },
|
|
);
|
|
if (exitCode) await assert.rejects(operation, /simulated prestart failure/);
|
|
else await operation;
|
|
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
|
|
await new Promise((r) => setTimeout(r, 700));
|
|
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
|
});
|
|
|
|
for (const phase of ['install', 'rebuild'])
|
|
test(`dependency repair ${phase} failure preserves original dependencies, cleans workspace and uses configured proxy`, async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-failure-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source'),
|
|
bin = path.join(root, 'bin');
|
|
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
|
await fs.mkdir(bin);
|
|
await fs.writeFile(
|
|
path.join(source, 'package.json'),
|
|
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
|
);
|
|
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
|
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
|
|
const proxy = path.join(root, 'network.json');
|
|
await fs.writeFile(
|
|
path.join(bin, 'pnpm'),
|
|
`#!${process.execPath}
|
|
const fs=require('node:fs');
|
|
if(process.argv[2]==='--version') {console.log('8.3.1');process.exit(0);}
|
|
fs.writeFileSync(${JSON.stringify(
|
|
proxy,
|
|
)},JSON.stringify({https:process.env.HTTPS_PROXY,no:process.env.no_proxy,options:process.env.NODE_OPTIONS}));
|
|
fs.mkdirSync('node_modules',{recursive:true});fs.writeFileSync('node_modules/partial','partial');
|
|
if(process.argv[2]===${JSON.stringify(
|
|
phase,
|
|
)}) {console.error('simulated repair failure');process.exit(12);}
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
await assert.rejects(
|
|
repairDependencies(source, {
|
|
target: { arch: 'armv6' },
|
|
env: {
|
|
PATH: bin,
|
|
HTTPS_PROXY: 'http://127.0.0.1:7890',
|
|
no_proxy: 'localhost,127.0.0.1',
|
|
NODE_OPTIONS: '--require untrusted',
|
|
},
|
|
}),
|
|
/simulated repair failure/,
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
|
|
'preserved',
|
|
);
|
|
assert.deepEqual(await fs.readdir(path.join(source, 'node_modules')), [
|
|
'old',
|
|
]);
|
|
assert.deepEqual((await fs.readdir(root)).sort(), [
|
|
'bin',
|
|
'network.json',
|
|
'source',
|
|
]);
|
|
const network = JSON.parse(await fs.readFile(proxy));
|
|
assert.equal(network.https, 'http://127.0.0.1:7890');
|
|
assert.equal(network.no, 'localhost,127.0.0.1');
|
|
assert.doesNotMatch(network.options, /untrusted/);
|
|
});
|
|
|
|
test('cancelled dependency repair kills descendants and retains the original tree', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-cancel-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source'),
|
|
bin = path.join(root, 'bin'),
|
|
started = path.join(root, 'started'),
|
|
survivor = path.join(root, 'survivor');
|
|
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
|
await fs.mkdir(bin);
|
|
await fs.writeFile(
|
|
path.join(source, 'package.json'),
|
|
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
|
);
|
|
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
|
await fs.writeFile(path.join(source, 'node_modules/old'), 'preserved');
|
|
await fs.writeFile(
|
|
path.join(bin, 'pnpm'),
|
|
`#!${process.execPath}
|
|
if(process.argv[2]==='--version'){console.log('8.3.1');process.exit(0);}
|
|
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
|
`process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(
|
|
started,
|
|
)},'started');setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
|
survivor,
|
|
)},'escaped'),1400);`,
|
|
)}],{stdio:'inherit'});
|
|
setInterval(()=>{},1000);
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const controller = new AbortController();
|
|
const operation = repairDependencies(source, {
|
|
target: { arch: 'armv6' },
|
|
env: { PATH: bin },
|
|
signal: controller.signal,
|
|
});
|
|
const rejection = assert.rejects(operation, /cancel repair/);
|
|
let seen = false;
|
|
for (let i = 0; i < 150; i++) {
|
|
if (await fs.stat(started).catch(() => null)) {
|
|
seen = true;
|
|
break;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
}
|
|
controller.abort(new Error('cancel repair'));
|
|
await rejection;
|
|
assert.ok(seen);
|
|
assert.equal(
|
|
await fs.readFile(path.join(source, 'node_modules/old'), 'utf8'),
|
|
'preserved',
|
|
);
|
|
assert.deepEqual((await fs.readdir(root)).sort(), [
|
|
'bin',
|
|
'source',
|
|
'started',
|
|
]);
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
|
});
|
|
|
|
test('real Node resolution failures distinguish broken dependency entries and internal imports from backend source', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-missing-entry-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const directory = path.join(root, 'node_modules/broken-entry');
|
|
await fs.mkdir(directory, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(directory, 'package.json'),
|
|
JSON.stringify({ main: 'missing.js' }),
|
|
);
|
|
const load = require('node:module').createRequire(
|
|
path.join(root, 'package.json'),
|
|
);
|
|
assert.throws(
|
|
() => load('broken-entry'),
|
|
(error) => dependencyFailure(error),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(directory, 'missing.js'),
|
|
"require('./missing-internal-file')",
|
|
);
|
|
assert.throws(
|
|
() => load('broken-entry'),
|
|
(error) => dependencyFailure(error),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(root, 'api.js'),
|
|
"require('./missing-api-route')",
|
|
);
|
|
assert.throws(
|
|
() => load('./api.js'),
|
|
(error) => !dependencyFailure(error),
|
|
);
|
|
});
|
|
|
|
test('ARMv6 repair isolates configuration, uses frozen install, one compiler job and interpreted Node', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-armv6-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source'),
|
|
bin = path.join(root, 'bin');
|
|
await fs.mkdir(source);
|
|
await fs.mkdir(bin);
|
|
await fs.writeFile(
|
|
path.join(source, 'package.json'),
|
|
JSON.stringify({ packageManager: 'pnpm@8.3.1' }),
|
|
);
|
|
await fs.writeFile(path.join(source, 'pnpm-lock.yaml'), 'frozen lock');
|
|
await fs.writeFile(
|
|
path.join(bin, 'pnpm'),
|
|
`#!${process.execPath}
|
|
const fs=require('node:fs');
|
|
if(process.argv[2]==='--version') {console.error('harmless runtime warning'); console.log('8.3.1');}
|
|
else if(process.argv[2]==='install') {
|
|
if(!process.argv.includes('--frozen-lockfile')||!process.argv.includes('--prod')) throw Error('not frozen production install');
|
|
fs.mkdirSync('node_modules');fs.writeFileSync('node_modules/flags.json',JSON.stringify({
|
|
args:process.argv,options:process.env.NODE_OPTIONS,source:process.env.npm_config_build_from_source,
|
|
processConfig:process.env.npm_config_force_process_config,jobs:process.env.npm_config_jobs,make:process.env.MAKEFLAGS
|
|
}));
|
|
} else if(process.argv.slice(2).join(' ')!=='rebuild sqlite3 @whyour/sqlite3') throw Error('unexpected command');
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
await repairDependencies(source, {
|
|
target: { arch: 'armv6' },
|
|
env: {
|
|
PATH: bin,
|
|
NODE_OPTIONS: '--require untrusted',
|
|
npm_config_nodedir: '/wrong-node',
|
|
},
|
|
});
|
|
const flags = JSON.parse(
|
|
await fs.readFile(path.join(source, 'node_modules/flags.json')),
|
|
);
|
|
assert.match(flags.options, /--jitless/);
|
|
assert.match(flags.options, /max-old-space-size=256/);
|
|
assert.equal(flags.source, 'true');
|
|
assert.equal(flags.processConfig, 'true');
|
|
assert.equal(flags.jobs, '1');
|
|
assert.equal(flags.make, '-j1');
|
|
assert.deepEqual((await fs.readdir(root)).sort(), ['bin', 'source']);
|
|
});
|
|
|
|
test('repair classification excludes application, platform, config, database, OOM and timeouts', () => {
|
|
for (const error of [
|
|
{ code: 'MODULE_NOT_FOUND', message: "Cannot find module './api/missing'" },
|
|
{ message: 'Bundle platform mismatch' },
|
|
{ message: 'SQLITE_CANTOPEN' },
|
|
{ message: 'Upgrade prestart timed out' },
|
|
{
|
|
code: 'ERR_DLOPEN_FAILED',
|
|
message: 'node_modules/sqlite3/node.node',
|
|
signal: 'SIGKILL',
|
|
},
|
|
{ message: 'JavaScript heap out of memory' },
|
|
])
|
|
assert.equal(dependencyFailure(error), false, JSON.stringify(error));
|
|
assert.equal(
|
|
dependencyFailure({
|
|
code: 'MODULE_NOT_FOUND',
|
|
message: "Cannot find module 'decimal.js'",
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
dependencyFailure({
|
|
code: 'ERR_DLOPEN_FAILED',
|
|
message: 'dlopen /tmp/node_modules/sqlite3/node.node: invalid ELF header',
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
dependencyFailure({
|
|
message:
|
|
'Could not locate the bindings file /tmp/node_modules/sqlite3/bindings',
|
|
}),
|
|
true,
|
|
);
|
|
assert.deepEqual(probeLimits({ arch: 'armv6' }), {
|
|
heapMb: 128,
|
|
timeoutMs: 300000,
|
|
});
|
|
});
|
|
|
|
test('prestart deadline kills its process group and removes temporary data', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-prestart-timeout-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source'),
|
|
statics = path.join(root, 'static');
|
|
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
|
|
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
|
|
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
|
|
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
|
|
await fs.writeFile(
|
|
path.join(statics, 'build-info.json'),
|
|
JSON.stringify({ sourceCommit: 'a'.repeat(40) }),
|
|
);
|
|
const survivor = path.join(root, 'survivor');
|
|
await fs.writeFile(
|
|
path.join(statics, 'build/bootstrap/upgradeProbe.js'),
|
|
`
|
|
require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(
|
|
`setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(
|
|
survivor,
|
|
)},'escaped'),2200)`,
|
|
)}],{stdio:'ignore'});
|
|
setInterval(()=>{},1000);
|
|
`,
|
|
);
|
|
await assert.rejects(
|
|
prestartBackend(
|
|
{ source, static: statics },
|
|
{
|
|
target: { arch: 'armv6' },
|
|
limits: { heapMb: 128, timeoutMs: 1000 },
|
|
},
|
|
),
|
|
/timed out/,
|
|
);
|
|
assert.deepEqual((await fs.readdir(root)).sort(), ['source', 'static']);
|
|
await new Promise((resolve) => setTimeout(resolve, 1600));
|
|
await assert.rejects(fs.access(survivor), { code: 'ENOENT' });
|
|
});
|
|
|
|
test('repair refuses an unsupported package manager before invoking a network command', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repair-lock-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'source');
|
|
await fs.mkdir(source);
|
|
await fs.writeFile(path.join(source, 'package.json'), '{}');
|
|
await assert.rejects(
|
|
repairDependencies(source, {
|
|
target: { arch: 'armv6' },
|
|
env: { PATH: '' },
|
|
}),
|
|
/Unsupported frozen-lockfile/,
|
|
);
|
|
assert.deepEqual(await fs.readdir(root), ['source']);
|
|
});
|