204 lines
9.5 KiB
JavaScript
204 lines
9.5 KiB
JavaScript
'use strict';
|
|||
|
|
|
||
|
|
/**
|
||
|
|
* A packaged ProdDash carries no modules. This drives a packaged copy of the
|
||
|
|
* shell — server.js and its client in a folder with no .git — through the
|
||
|
|
* three things that make that true:
|
||
|
|
*
|
||
|
|
* 1. an install made before 1.10.0 still has modules in its app folder:
|
||
|
|
* at boot they move into the data directory once, executables keeping
|
||
|
|
* their mode, an uninstalled one is dropped, and the app folder's
|
||
|
|
* modules/ is gone;
|
||
|
|
* 2. a shell update from an archive that carries modules/ never writes
|
||
|
|
* them into the app folder — they show up in the catalog instead;
|
||
|
|
* 3. a fresh install lists everything the repo offers, and installing
|
||
|
|
* several at once lands them in the data directory and mounts them.
|
||
|
|
*
|
||
|
|
* node tools/unbundled.test.js
|
||
|
|
*
|
||
|
|
* Plain Node, no dependencies: a mock forge, a temp app folder, a temp data
|
||
|
|
* directory. Exits non-zero on the first failure and cleans up after itself.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const { spawn, execFileSync } = require('node:child_process');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const http = require('node:http');
|
||
|
|
const net = require('node:net');
|
||
|
|
const os = require('node:os');
|
||
|
|
const path = require('node:path');
|
||
|
|
|
||
|
|
const REPO = path.join(__dirname, '..');
|
||
|
|
|
||
|
|
function freePort() {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const probe = net.createServer();
|
||
|
|
probe.listen(0, '127.0.0.1', () => {
|
||
|
|
const { port } = probe.address();
|
||
|
|
probe.close(() => resolve(port));
|
||
|
|
});
|
||
|
|
probe.on('error', reject);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitFor(url, ms = 15000) {
|
||
|
|
const deadline = Date.now() + ms;
|
||
|
|
let last = '';
|
||
|
|
while (Date.now() < deadline) {
|
||
|
|
try {
|
||
|
|
const r = await fetch(url);
|
||
|
|
if (r.ok) return;
|
||
|
|
last = 'HTTP ' + r.status;
|
||
|
|
} catch (err) {
|
||
|
|
last = err.message;
|
||
|
|
}
|
||
|
|
await new Promise((r) => setTimeout(r, 200));
|
||
|
|
}
|
||
|
|
throw new Error('server did not come up: ' + last);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeModule(dir, id, version, extra = {}) {
|
||
|
|
fs.mkdirSync(dir, { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({ id, name: id[0].toUpperCase() + id.slice(1), version, client: 'client.js', ...extra }));
|
||
|
|
fs.writeFileSync(path.join(dir, 'client.js'), 'export default function () {}\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The repo as a forge would archive it: one top-level folder, the shell files, and modules/. */
|
||
|
|
function buildArchive(dir) {
|
||
|
|
const top = path.join(dir, 'ProdDash-main');
|
||
|
|
fs.mkdirSync(top, { recursive: true });
|
||
|
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(top, 'server.js'));
|
||
|
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||
|
|
writeModule(path.join(top, 'modules', 'demo'), 'demo', '2.0.0');
|
||
|
|
writeModule(path.join(top, 'modules', 'other'), 'other', '1.0.0');
|
||
|
|
writeModule(path.join(top, 'modules', 'third'), 'third', '1.0.0');
|
||
|
|
const out = path.join(dir, 'main.tar.gz');
|
||
|
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||
|
|
return fs.readFileSync(out);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A packaged install as it was before 1.10.0: the shell plus bundled modules, no .git. */
|
||
|
|
function buildPackagedApp(app) {
|
||
|
|
fs.mkdirSync(path.join(app, 'config'), { recursive: true });
|
||
|
|
fs.copyFileSync(path.join(REPO, 'server.js'), path.join(app, 'server.js'));
|
||
|
|
fs.copyFileSync(path.join(REPO, 'package.json'), path.join(app, 'package.json'));
|
||
|
|
fs.cpSync(path.join(REPO, 'public'), path.join(app, 'public'), { recursive: true });
|
||
|
|
fs.writeFileSync(path.join(app, 'config', 'proddash.json'), JSON.stringify({ port: 24500 }));
|
||
|
|
writeModule(path.join(app, 'modules', 'demo'), 'demo', '1.0.0');
|
||
|
|
fs.mkdirSync(path.join(app, 'modules', 'demo', 'bin'));
|
||
|
|
fs.writeFileSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), '#!/bin/sh\necho helper\n');
|
||
|
|
fs.chmodSync(path.join(app, 'modules', 'demo', 'bin', 'demo-helper'), 0o755);
|
||
|
|
writeModule(path.join(app, 'modules', 'gone'), 'gone', '1.0.0');
|
||
|
|
}
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-unbundled-'));
|
||
|
|
const app = path.join(work, 'app');
|
||
|
|
const dataDir = path.join(work, 'data');
|
||
|
|
fs.mkdirSync(dataDir);
|
||
|
|
buildPackagedApp(app);
|
||
|
|
// The operator had uninstalled "gone" before this update.
|
||
|
|
fs.writeFileSync(path.join(dataDir, 'modules.json'), JSON.stringify({ gone: { uninstalled: true } }));
|
||
|
|
const archive = buildArchive(path.join(work, 'forge'));
|
||
|
|
|
||
|
|
const forge = http.createServer((req, res) => {
|
||
|
|
if (req.url.startsWith('/michael/ProdDash/archive/main.tar.gz')) {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||
|
|
return res.end(archive);
|
||
|
|
}
|
||
|
|
res.writeHead(404);
|
||
|
|
res.end();
|
||
|
|
});
|
||
|
|
const forgePort = await freePort();
|
||
|
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||
|
|
|
||
|
|
const port = await freePort();
|
||
|
|
const base = `http://127.0.0.1:${port}`;
|
||
|
|
const env = {
|
||
|
|
...process.env,
|
||
|
|
PRODDASH_DATA_DIR: dataDir,
|
||
|
|
PORT: String(port),
|
||
|
|
PRODDASH_FORGE: `http://127.0.0.1:${forgePort}`,
|
||
|
|
PRODDASH_REPO: 'michael/ProdDash',
|
||
|
|
PRODDASH_NO_REMOTE_CHECK: '1',
|
||
|
|
PRODDASH_LAUNCHER: '1', // a supervisor is watching: an update exits 75 instead of respawning
|
||
|
|
};
|
||
|
|
let log = '';
|
||
|
|
let child = null;
|
||
|
|
const start = () => {
|
||
|
|
child = spawn(process.execPath, ['server.js'], { cwd: app, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
|
child.stdout.on('data', (d) => { log += d; });
|
||
|
|
child.stderr.on('data', (d) => { log += d; });
|
||
|
|
return child;
|
||
|
|
};
|
||
|
|
const exited = (proc) => new Promise((resolve) => proc.on('exit', resolve));
|
||
|
|
const api = async (p, init = {}) => {
|
||
|
|
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||
|
|
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||
|
|
};
|
||
|
|
const moduleIds = async () => (await api('/api/modules')).body.modules.map((m) => m.id).sort();
|
||
|
|
|
||
|
|
let failed = false;
|
||
|
|
try {
|
||
|
|
// ── 1. the move ──
|
||
|
|
start();
|
||
|
|
await waitFor(base + '/api/theme');
|
||
|
|
const moved = path.join(dataDir, 'modules', 'demo');
|
||
|
|
assert.ok(fs.existsSync(path.join(moved, 'module.json')), 'demo moved into the data directory');
|
||
|
|
const helper = path.join(moved, 'bin', 'demo-helper');
|
||
|
|
assert.ok(fs.existsSync(helper), 'its compiled helper came along');
|
||
|
|
assert.ok(fs.statSync(helper).mode & 0o111, 'and is still executable');
|
||
|
|
assert.equal(fs.existsSync(path.join(dataDir, 'modules', 'gone')), false, 'an uninstalled module is dropped, not moved');
|
||
|
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, "the app folder's modules/ is gone");
|
||
|
|
assert.deepEqual(await moduleIds(), ['demo'], 'demo runs, gone does not');
|
||
|
|
assert.match(log, /moved bundled module "demo" v1\.0\.0 into the data directory/);
|
||
|
|
assert.match(log, /the app folder carries no modules now/);
|
||
|
|
const state = (await api('/api/admin/state')).body;
|
||
|
|
assert.equal(state.modules.find((m) => m.id === 'demo')?.source, 'installed', 'demo is an installed copy now');
|
||
|
|
|
||
|
|
// The catalog sees the repo's modules/ — as offers, not as a bundle.
|
||
|
|
let cat = (await api('/api/admin/catalog?refresh=1')).body;
|
||
|
|
assert.equal(cat.shell.remote.error, '', 'the mock forge answers: ' + cat.shell.remote.error);
|
||
|
|
assert.deepEqual(cat.updates.map((u) => `${u.id} ${u.installedVersion}->${u.version}`), ['demo 1.0.0->2.0.0']);
|
||
|
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['gone', 'other', 'third'].filter((id) => id !== 'gone'), 'other and third are on offer');
|
||
|
|
|
||
|
|
// ── 2. a shell update carries no modules ──
|
||
|
|
const running = child;
|
||
|
|
const up = await api('/api/admin/update', { method: 'POST', body: '{}' });
|
||
|
|
assert.equal(up.status, 200, 'update: ' + JSON.stringify(up.body));
|
||
|
|
assert.equal(await exited(running), 75, 'the server asks its supervisor for a restart');
|
||
|
|
assert.equal(fs.existsSync(path.join(app, 'modules')), false, 'the archive\'s modules/ was not written into the app folder');
|
||
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(app, 'package.json'), 'utf8')).version, '9.9.9', 'but the shell itself was updated');
|
||
|
|
|
||
|
|
log = '';
|
||
|
|
start();
|
||
|
|
await waitFor(base + '/api/theme');
|
||
|
|
assert.doesNotMatch(log, /moved bundled module/, 'nothing left to move on the second start');
|
||
|
|
assert.deepEqual(await moduleIds(), ['demo'], 'still just demo, from the data directory');
|
||
|
|
cat = (await api('/api/admin/catalog')).body;
|
||
|
|
assert.deepEqual(cat.available.map((a) => a.id).sort(), ['other', 'third'], 'the new modules are offers, not installs');
|
||
|
|
|
||
|
|
// ── 3. several at once ──
|
||
|
|
const many = await api('/api/admin/modules/install', { method: 'POST', body: JSON.stringify({ ids: ['other', 'third', 'nope'] }) });
|
||
|
|
assert.equal(many.status, 200);
|
||
|
|
assert.equal(many.body.ok, false, 'one of them does not exist');
|
||
|
|
assert.deepEqual(many.body.results.map((r) => `${r.id}:${r.ok}`), ['other:true', 'third:true', 'nope:false']);
|
||
|
|
assert.ok(fs.existsSync(path.join(dataDir, 'modules', 'other', 'module.json')), 'other landed in the data directory');
|
||
|
|
assert.deepEqual(await moduleIds(), ['demo', 'other', 'third'], 'and both are mounted');
|
||
|
|
const empty = await api('/api/admin/modules/install', { method: 'POST', body: '{}' });
|
||
|
|
assert.equal(empty.status, 400, 'nothing to install is said');
|
||
|
|
|
||
|
|
console.log('unbundled shell: every check passed');
|
||
|
|
} catch (err) {
|
||
|
|
failed = true;
|
||
|
|
console.error('FAIL:', err.message);
|
||
|
|
console.error('--- last of the server log ---\n' + log.slice(-2500));
|
||
|
|
} finally {
|
||
|
|
try { child?.kill('SIGTERM'); } catch { /* gone */ }
|
||
|
|
forge.close();
|
||
|
|
await new Promise((r) => setTimeout(r, 400));
|
||
|
|
fs.rmSync(work, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
process.exit(failed ? 1 : 0);
|
||
|
|
})();
|