Files
qinglong/test/back/upgrade-prestart.test.cjs
T
whyour bbb3dbf32b feat: ship preflight-verified online and offline platform upgrades
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.
2026-10-18 16:17:54 +08:00

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']);
});