import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { once } from 'node:events'; import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { fixture } from '../../cli/tests/helpers.mjs'; import { consoleReads } from '../src/reads.mjs'; // Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and // serve from the copy it writes. The copy comes from a fixture repository // holding this tree's packages, scripts and roles, so a path the Console // loads that the script's list leaves out fails the serve test here. // Nothing here reaches systemd: copy makes no systemctl call. const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, ''); const NODE_DIR = dirname(process.execPath); const GIT = ['-c', 'user.name=fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null']; const git = (cwd, ...args) => execFileSync('git', [...GIT, '-C', cwd, ...args], { encoding: 'utf8' }).trim(); // A copy's directories are read-only, so removal gives write back first, // as the docs' removal command does. function tmp(t, prefix) { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => { execFileSync('chmod', ['-R', 'u+w', root]); rmSync(root, { recursive: true, force: true }); }); return root; } // A canonical checkout (a .git directory) on branch refactor, with this // tree's files, the uncommitted ones included, and a RELEASE of its own. function fixtureRepo(t) { const dir = tmp(t, 'webui-copy-'); const repo = join(dir, 'repo'); const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean); for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f)); writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n'); git(dir, 'init', '-q', '-b', 'refactor', repo); // The commit leaves thousands of loose objects; a detached gc --auto // would still be deleting them when the cleanup's chmod walks .git. git(repo, 'config', 'gc.auto', '0'); git(repo, 'add', '-A'); git(repo, 'commit', '-q', '-m', 'fixture'); return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') }; } async function run(dir, script, args) { const env = { PATH: `${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; const child = spawn(script, args, { env }); let stdout = '', stderr = ''; child.stdout.on('data', (d) => { stdout += d; }); child.stderr.on('data', (d) => { stderr += d; }); const [status] = await once(child, 'close'); return { status, stdout, stderr }; } function filesUnder(root) { const out = []; for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) if (entry.isFile()) out.push(join(entry.parentPath, entry.name)); return out; } test('copy writes the Console paths at the full id, read-only, and leaves an existing copy alone', async (t) => { const { dir, repo, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); const target = join(copies, head); const r = await run(dir, script, ['copy', head.slice(0, 12), '--copies', copies]); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm')); assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind'); assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']); assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']); assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation="); assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only'); assert.ok(existsSync(join(target, 'packages', 'tasks', 'fields.json')), 'the task field table'); assert.ok(!existsSync(join(target, 'packages', 'queue')), 'the queue read runs from the checkout'); assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n'); const files = filesUnder(target); assert.ok(files.length > 50, String(files.length)); for (const f of files) assert.equal(statSync(f).mode & 0o222, 0, `${f} is writable`); // Directories too, the top one included, so a writer that replaces by // rename can neither change a file nor add one. const dirs = [target, ...readdirSync(target, { withFileTypes: true, recursive: true }).filter((e) => e.isDirectory()).map((e) => join(e.parentPath, e.name))]; assert.ok(dirs.length > 10, String(dirs.length)); for (const d of dirs) assert.equal(statSync(d).mode & 0o777, 0o555, `${d} mode`); const config = join(target, 'scripts', 'mosaic-config.mjs'); const before = readFileSync(config, 'utf8'); const sed = await run(dir, '/usr/bin/sed', ['-i', 's/^/x/', config]); assert.notEqual(sed.status, 0, 'sed -i replaced a file in the copy'); assert.equal(readFileSync(config, 'utf8'), before); assert.throws(() => writeFileSync(join(target, 'scripts', 'added.mjs'), ''), { code: 'EACCES' }); // Already there: left as it is, by id or by branch name. for (const commit of [head, 'refactor']) { const again = await run(dir, script, ['copy', commit, '--copies', copies]); assert.equal(again.status, 0, again.stderr); assert.equal(again.stdout, `exists: ${target}\n`); } // The default directory is under XDG_DATA_HOME, else ~/.local/share. const byDefault = await run(dir, script, ['copy', 'refactor']); assert.equal(byDefault.status, 0, byDefault.stderr); assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-console', head, 'packages', 'webui', 'src', 'cli.mjs'))); assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched'); }); test('copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory', async (t) => { const { dir, repo, script } = fixtureRepo(t); const copies = join(dir, 'copies'); git(repo, 'checkout', '-q', '-b', 'side'); writeFileSync(join(repo, 'side.txt'), 'side\n'); git(repo, 'add', 'side.txt'); git(repo, 'commit', '-q', '-m', 'side'); const side = git(repo, 'rev-parse', 'HEAD'); git(repo, 'checkout', '-q', 'refactor'); for (const [commit, why] of [[side, new RegExp(`^webui-service: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^webui-service: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^webui-service: commit must be a commit id or branch name: \.refactor$/m]]) { const r = await run(dir, script, ['copy', commit, '--copies', copies]); assert.equal(r.status, 1, commit); assert.match(r.stderr, why, commit); assert.equal(r.stdout, '', commit); } for (const args of [['copy'], ['copy', '-x'], ['copy', 'refactor', '--copy', side], ['copy', 'refactor', '--port', '7332'], ['copy', 'refactor', '--dir', dir]]) { assert.equal((await run(dir, script, args)).status, 4, args.join(' ')); } for (const bad of ['copies', `${dir}/a b`, `${dir}/a%b`]) { const r = await run(dir, script, ['copy', 'refactor', '--copies', bad]); assert.equal(r.status, 1, bad); assert.match(r.stderr, /copies directory (must be an absolute path|contains a character the unit cannot carry)/, bad); } assert.ok(!existsSync(copies), 'no refusal writes anything'); // N2: a linked worktree has a .git file; --allow-worktree is for tests. const tree = join(dir, 'tree'); git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor'); const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]); assert.equal(fromTree.status, 1); assert.match(fromTree.stderr, /^webui-service: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m); assert.ok(!existsSync(copies)); const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']); assert.equal(allowed.status, 0, allowed.stderr); assert.match(allowed.stdout, /^copied: /m); assert.match((await run(dir, script, ['--help'])).stdout, /--allow-worktree is for the tests only/); }); test('copy writes nothing when an archived path is missing at the commit', async (t) => { const { dir, repo, script } = fixtureRepo(t); const copies = join(dir, 'copies'); git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs'); git(repo, 'commit', '-q', '-m', 'drop the config check'); const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]); assert.equal(r.status, 1); assert.match(r.stderr, /^webui-service: git archive of [0-9a-f]{40} failed; no copy written$/m); assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed'); }); test('a copy that loses the rename race leaves no read-only temp directory behind', async (t) => { const { dir, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); // A stand-in mv that makes the target appear first, as a concurrent copy // would, then fails: copy says exists: and its trap removes the temp copy. const bin = join(dir, 'bin'); mkdirSync(bin); writeFileSync(join(bin, 'mv'), `#!/bin/sh\nfor a; do t="$a"; done\nmkdir "$t"\nexit 1\n`, { mode: 0o755 }); const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') }; const child = spawn(script, ['copy', 'refactor', '--copies', copies], { env }); let stdout = ''; child.stdout.on('data', (d) => { stdout += d; }); const [status] = await once(child, 'close'); assert.equal(status, 0); assert.equal(stdout, `exists: ${join(copies, head)}\n`); assert.deepEqual(readdirSync(copies), [head], 'the temp directory is removed'); }); // serve from a copy: /healthz, the config check, roles and RELEASE from the // copy, and the queue read from --checkout or, without it, from the copy. async function serve(t, cli, args, env) { const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(() => child.kill()); let out = '', err = ''; child.stderr.on('data', (c) => { err += c; }); const port = await new Promise((resolve, reject) => { child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`))); }); const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; }; return { out, get }; } test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => { const { dir, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs'); // A checkout whose queue read says where it ran. const checkout = join(dir, 'checkout'); mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true }); writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`); const { env } = fixture(join(dir, 'home')); delete env.MOSAIC_ROLES_DIR; const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env); assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy'); assert.match(out, /^Queue: read from the --checkout directory$/m); assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } }); const settings = await get('/api/settings'); assert.equal(settings.status, 200); assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy'); const business = await get('/api/business'); assert.equal(business.status, 200, JSON.stringify(business.body)); assert.equal(business.body.business.id, 'acme', 'roles from the copy'); const queue = await get('/api/queue'); assert.equal(queue.status, 200, JSON.stringify(queue.body)); assert.deepEqual(queue.body.notes, ['read in the checkout']); // Row 61's field table loads from the copy's packages/tasks. const fields = await get('/api/task-fields'); assert.equal(fields.status, 200, JSON.stringify(fields.body)); assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8'))); // Without --checkout the copy's own queue read runs, and the copy holds // no packages/queue, so it fails closed. const plain = await serve(t, cli, ['--business', 'acme'], env); assert.doesNotMatch(plain.out, /^Queue:/m); assert.equal((await plain.get('/healthz')).status, 200); const none = await plain.get('/api/queue'); assert.equal(none.status, 503); assert.equal(none.body.error, 'read-failed'); }); test('serve refuses a --checkout that is not an absolute directory path', async (t) => { const dir = tmp(t, 'webui-checkout-'); writeFileSync(join(dir, 'file'), ''); // 'sub' is a directory under the child's cwd, so only its being relative refuses it. mkdirSync(join(dir, 'sub')); for (const value of ['sub', join(dir, 'missing'), join(dir, 'file')]) { const child = spawn(process.execPath, [join(REPO, 'packages', 'webui', 'src', 'cli.mjs'), 'serve', '--port', '0', '--checkout', value], { cwd: dir, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { err += c; }); const [code] = await once(child, 'close'); assert.equal(code, 2, value); assert.equal(err, 'refused: --checkout must be the absolute path of a directory\n', value); assert.equal(out, ''); } }); test('consoleReads runs the queue read under queueRoot and reads RELEASE under root', async (t) => { const dir = tmp(t, 'webui-roots-'); const [root, queueRoot] = [join(dir, 'copy'), join(dir, 'checkout')]; for (const [base, said] of [[root, 'copy'], [queueRoot, 'checkout']]) { mkdirSync(join(base, 'packages', 'webui', 'src'), { recursive: true }); writeFileSync(join(base, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: ['${said}', process.cwd() === ${JSON.stringify(base)} ? 'cwd there' : 'cwd elsewhere'] }));\n`); writeFileSync(join(base, 'RELEASE'), `${said}-release\n`); } const apart = consoleReads({ root, queueRoot, rolesDir: join(dir, 'roles') }); assert.deepEqual((await apart.queue()).notes, ['checkout', 'cwd there'], 'the checkout ran it, with its cwd there'); assert.equal((await apart.settings()).release, 'copy-release'); const together = consoleReads({ root, rolesDir: join(dir, 'roles') }); assert.deepEqual((await together.queue()).notes, ['copy', 'cwd there'], 'queueRoot defaults to root'); assert.equal((await together.settings()).release, 'copy-release'); });