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