@celilo/cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The two assertions that matter here are both about STAYING QUIET.
3
+ *
4
+ * A host with no browser provisioned is the NORMAL case — installation is
5
+ * opt-in — so a check that fired there would put a finding on every default
6
+ * host in the fleet forever. And a module that bundles no browser client is
7
+ * almost every module. Getting either wrong turns a guardrail into noise
8
+ * that trains an operator to ignore the audit.
9
+ *
10
+ * The revision is read from the bundle's own `browsers.json`, never from a
11
+ * version→revision table, so the reader tests use real files.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { auditBrowserPin, readBundledBrowserClient } from './browser-pin';
19
+
20
+ const PROVISIONED = { revision: '1223', playwrightVersion: '1.60.0' };
21
+
22
+ describe('auditBrowserPin', () => {
23
+ test('says nothing when no browser is provisioned', async () => {
24
+ // The opt-in default. `system doctor` reports the absence; this must not,
25
+ // or every host that never wanted a browser carries a permanent finding.
26
+ const findings = await auditBrowserPin({
27
+ consumers: [{ moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' }],
28
+ provisioned: null,
29
+ });
30
+
31
+ expect(findings).toEqual([]);
32
+ });
33
+
34
+ test('says nothing when no module bundles a browser client', async () => {
35
+ expect(await auditBrowserPin({ consumers: [], provisioned: PROVISIONED })).toEqual([]);
36
+ });
37
+
38
+ test('says nothing when the bundled client expects the installed revision', async () => {
39
+ const findings = await auditBrowserPin({
40
+ consumers: [{ moduleId: 'lunacycle', clientVersion: '1.60.0', expectedRevision: '1223' }],
41
+ provisioned: PROVISIONED,
42
+ });
43
+
44
+ expect(findings).toEqual([]);
45
+ });
46
+
47
+ test('reports drift — never blocked — when the revisions differ', async () => {
48
+ const findings = await auditBrowserPin({
49
+ consumers: [{ moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' }],
50
+ provisioned: PROVISIONED,
51
+ });
52
+
53
+ expect(findings).toHaveLength(1);
54
+ const finding = findings[0];
55
+ // Task 4.2: it warns, it never blocks. Under D2 the consumer passes an
56
+ // explicit executablePath, so this is a soft protocol risk, not a
57
+ // failure — blocking a deploy on it would be wrong.
58
+ expect(finding?.severity).toBe('drift');
59
+ expect(finding?.category).toBe('browser_pin');
60
+ expect(finding?.subject).toBe('lunacycle');
61
+ expect(finding?.actionable).toBe(false);
62
+ // Both revisions named, so the operator can tell which end to move.
63
+ expect(finding?.message).toContain('1193');
64
+ expect(finding?.message).toContain('1223');
65
+ expect(finding?.remediation).toContain('1.60.0');
66
+ });
67
+
68
+ test('reports one finding per drifting module', async () => {
69
+ const findings = await auditBrowserPin({
70
+ consumers: [
71
+ { moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' },
72
+ { moduleId: 'aligned', clientVersion: '1.60.0', expectedRevision: '1223' },
73
+ { moduleId: 'other', clientVersion: '1.50.0', expectedRevision: '1150' },
74
+ ],
75
+ provisioned: PROVISIONED,
76
+ });
77
+
78
+ expect(findings.map((f) => f.subject)).toEqual(['lunacycle', 'other']);
79
+ });
80
+ });
81
+
82
+ describe('readBundledBrowserClient', () => {
83
+ let root: string;
84
+
85
+ beforeEach(() => {
86
+ root = mkdtempSync(join(tmpdir(), 'celilo-browser-pin-'));
87
+ });
88
+
89
+ afterEach(() => {
90
+ rmSync(root, { recursive: true, force: true });
91
+ });
92
+
93
+ /** Write a bundled playwright-core under `<root>/<bundleDir>/node_modules`. */
94
+ function bundle(bundleDir: string, version: string, revision: number | string): void {
95
+ const pkg = join(root, bundleDir, 'node_modules', 'playwright-core');
96
+ mkdirSync(pkg, { recursive: true });
97
+ writeFileSync(join(pkg, 'package.json'), JSON.stringify({ version }));
98
+ writeFileSync(
99
+ join(pkg, 'browsers.json'),
100
+ JSON.stringify({
101
+ browsers: [
102
+ { name: 'chromium', revision: 9999 },
103
+ { name: 'chromium-headless-shell', revision },
104
+ ],
105
+ }),
106
+ );
107
+ }
108
+
109
+ test('finds a bundle under the conventional scripts/ directory', () => {
110
+ bundle('scripts', '1.60.0', 1223);
111
+
112
+ expect(readBundledBrowserClient('m', root, ['./scripts/health-check.ts'])).toEqual({
113
+ moduleId: 'm',
114
+ clientVersion: '1.60.0',
115
+ expectedRevision: '1223',
116
+ });
117
+ });
118
+
119
+ test('finds a bundle beside a hook script in a non-standard layout', () => {
120
+ // The one real browser consumer keeps its hooks in celilo/scripts/,
121
+ // so a hardcoded scripts/ would have missed exactly the module this
122
+ // check exists for.
123
+ bundle('celilo/scripts', '1.55.1', 1193);
124
+
125
+ const found = readBundledBrowserClient('lunacycle', root, ['./celilo/scripts/health-check.ts']);
126
+
127
+ expect(found?.expectedRevision).toBe('1193');
128
+ expect(found?.clientVersion).toBe('1.55.1');
129
+ });
130
+
131
+ test('reads the revision as a STRING even though the file holds a number', () => {
132
+ // browsers.json stores it unquoted; comparing a number to the
133
+ // descriptor's string would never match and the check would be silent.
134
+ bundle('scripts', '1.60.0', 1223);
135
+
136
+ expect(readBundledBrowserClient('m', root, [])?.expectedRevision).toBe('1223');
137
+ });
138
+
139
+ test('returns null when the module bundles no browser client', () => {
140
+ expect(readBundledBrowserClient('m', root, ['./scripts/on_install.ts'])).toBeNull();
141
+ });
142
+
143
+ test('returns null when browsers.json is unreadable rather than throwing', () => {
144
+ const pkg = join(root, 'scripts', 'node_modules', 'playwright-core');
145
+ mkdirSync(pkg, { recursive: true });
146
+ writeFileSync(join(pkg, 'browsers.json'), 'not json {');
147
+
148
+ expect(readBundledBrowserClient('m', root, [])).toBeNull();
149
+ });
150
+
151
+ test('a bundle without a readable package.json still reports its revision', () => {
152
+ // The revision is the load-bearing half; a missing version should not
153
+ // suppress a real drift finding.
154
+ const pkg = join(root, 'scripts', 'node_modules', 'playwright-core');
155
+ mkdirSync(pkg, { recursive: true });
156
+ writeFileSync(
157
+ join(pkg, 'browsers.json'),
158
+ JSON.stringify({ browsers: [{ name: 'chromium-headless-shell', revision: 1223 }] }),
159
+ );
160
+
161
+ expect(readBundledBrowserClient('m', root, [])).toEqual({
162
+ moduleId: 'm',
163
+ clientVersion: 'unknown',
164
+ expectedRevision: '1223',
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Browser pin-drift check (managed-browser-runtime D9).
3
+ *
4
+ * A module bundles its own `playwright-core`, and celilo provisions the
5
+ * browser binary. Under D2 the consumer passes an explicit
6
+ * `executablePath`, so a client bump can no longer move the browser out
7
+ * from under it — the hard failure that started that change cannot recur.
8
+ * What remains is softer: a client several minor versions from the binary's
9
+ * build drives it over a protocol the two were versioned together. Usually
10
+ * fine; not guaranteed.
11
+ *
12
+ * So this WARNS and never blocks (D9 / task 4.2).
13
+ *
14
+ * **It compares REVISIONS, not version strings.** The revision is what
15
+ * actually has to match a build, and each `playwright-core` states its own
16
+ * in the `browsers.json` inside the package — which is where 1193 and 1223
17
+ * came from. Reading it out of the bundle is what keeps this honest and
18
+ * means nobody maintains a version→revision table that will rot.
19
+ *
20
+ * Like `system doctor` (D4), it reads the bundle and the descriptor and
21
+ * never asks Playwright which executable it would use: that reports the
22
+ * full browser regardless of what a headless launch would open.
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+ import { resolveBrowser } from '@celilo/capabilities';
28
+ import type { ModuleManifest } from '../../manifest/schema';
29
+ import type { DriftFinding } from './types';
30
+
31
+ /** What a module bundles, already read off disk. */
32
+ export interface BundledBrowserClient {
33
+ moduleId: string;
34
+ /** The bundled `playwright-core` version, e.g. `1.60.0`. */
35
+ clientVersion: string;
36
+ /** The chromium-headless-shell revision that client expects, e.g. `1223`. */
37
+ expectedRevision: string;
38
+ }
39
+
40
+ export interface BrowserPinAuditDeps {
41
+ /**
42
+ * Modules that bundle a browser client. A module with none contributes
43
+ * nothing — most modules never launch a browser.
44
+ */
45
+ consumers: BundledBrowserClient[];
46
+ /**
47
+ * The provisioned browser, or `null` when this host has none.
48
+ *
49
+ * Null means silence, not a finding: installation is opt-in, so a host
50
+ * without a browser is the normal case and `system doctor` is already
51
+ * the place that reports its absence.
52
+ */
53
+ provisioned: { revision: string; playwrightVersion: string } | null;
54
+ }
55
+
56
+ export async function auditBrowserPin(deps: BrowserPinAuditDeps): Promise<DriftFinding[]> {
57
+ const { provisioned } = deps;
58
+ if (!provisioned) return [];
59
+
60
+ const findings: DriftFinding[] = [];
61
+ for (const consumer of deps.consumers) {
62
+ if (consumer.expectedRevision === provisioned.revision) continue;
63
+
64
+ // Any difference is reported. A "how far apart is too far" threshold
65
+ // would be a tuning knob with no evidence behind it — the revision is
66
+ // the thing that has to match a build, so it either does or it does not.
67
+ findings.push({
68
+ category: 'browser_pin',
69
+ severity: 'drift',
70
+ code: 'browser_client_revision_drift',
71
+ subject: consumer.moduleId,
72
+ message:
73
+ `${consumer.moduleId} bundles playwright-core ${consumer.clientVersion}, which expects ` +
74
+ `chromium revision ${consumer.expectedRevision}, but this host has revision ` +
75
+ `${provisioned.revision} (installed for playwright ${provisioned.playwrightVersion}).`,
76
+ details:
77
+ 'Not a failure: the module passes an explicit executablePath, so it launches the ' +
78
+ 'installed browser regardless of which revision its client expects. The risk is ' +
79
+ 'protocol skew — a client far from its browser build drives it over a protocol the ' +
80
+ 'two were versioned together.',
81
+ remediation:
82
+ `Align them: either republish ${consumer.moduleId} against playwright-core ` +
83
+ `${provisioned.playwrightVersion}, or set celilo-mgmt's playwright_version to ` +
84
+ `${consumer.clientVersion} and redeploy it.`,
85
+ actionable: false,
86
+ });
87
+ }
88
+ return findings;
89
+ }
90
+
91
+ /**
92
+ * Read the browser client a module bundles, or `null` when it bundles none.
93
+ *
94
+ * Searches beside each hook script rather than walking the module tree:
95
+ * `scripts/node_modules` is where a module's hook runtime is bundled, and
96
+ * the manifest's own hook list is the authoritative statement of where its
97
+ * hook code lives. A module laying its hooks out differently
98
+ * (`celilo/scripts/…`, as the one real browser consumer does) is therefore
99
+ * found without hardcoding either layout.
100
+ */
101
+ export function readBundledBrowserClient(
102
+ moduleId: string,
103
+ sourcePath: string,
104
+ hookScripts: string[],
105
+ ): BundledBrowserClient | null {
106
+ const searched = new Set<string>([join(sourcePath, 'scripts')]);
107
+ for (const script of hookScripts) {
108
+ searched.add(dirname(join(sourcePath, script)));
109
+ }
110
+
111
+ for (const dir of searched) {
112
+ const pkgRoot = join(dir, 'node_modules', 'playwright-core');
113
+ const browsers = join(pkgRoot, 'browsers.json');
114
+ if (!existsSync(browsers)) continue;
115
+
116
+ const expectedRevision = revisionFor(browsers, 'chromium-headless-shell');
117
+ if (!expectedRevision) continue;
118
+
119
+ return {
120
+ moduleId,
121
+ clientVersion: packageVersion(join(pkgRoot, 'package.json')) ?? 'unknown',
122
+ expectedRevision,
123
+ };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * The revision a `playwright-core` states for a browser, from the
130
+ * `browsers.json` inside the package. Never a lookup table — the package
131
+ * is the only thing that knows, and a table would rot silently.
132
+ */
133
+ function revisionFor(browsersJsonPath: string, browserName: string): string | null {
134
+ try {
135
+ const parsed = JSON.parse(readFileSync(browsersJsonPath, 'utf-8')) as {
136
+ browsers?: Array<{ name?: string; revision?: string | number }>;
137
+ };
138
+ const entry = parsed.browsers?.find((b) => b.name === browserName);
139
+ return entry?.revision === undefined ? null : String(entry.revision);
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function packageVersion(packageJsonPath: string): string | null {
146
+ try {
147
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string };
148
+ return parsed.version ?? null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Gather this check's inputs from the module store and the provisioned
156
+ * browser's descriptor.
157
+ *
158
+ * Lives here rather than in the CLI so the two `AuditDeps` builders stay
159
+ * one line each and cannot drift apart — they already each carry their own
160
+ * copy of every other category's wiring.
161
+ */
162
+ export function collectBrowserPinDeps(
163
+ modules: Array<{ id: string; sourcePath: string; manifestData: unknown }>,
164
+ ): BrowserPinAuditDeps {
165
+ const consumers: BundledBrowserClient[] = [];
166
+ for (const module of modules) {
167
+ const manifest = module.manifestData as ModuleManifest | undefined;
168
+ const hookScripts = Object.values(manifest?.hooks ?? {})
169
+ .map((hook) => (hook as { script?: string } | undefined)?.script)
170
+ .filter((script): script is string => typeof script === 'string');
171
+ const bundled = readBundledBrowserClient(module.id, module.sourcePath, hookScripts);
172
+ if (bundled) consumers.push(bundled);
173
+ }
174
+
175
+ let provisioned: BrowserPinAuditDeps['provisioned'] = null;
176
+ try {
177
+ const browser = resolveBrowser();
178
+ provisioned = { revision: browser.revision, playwrightVersion: browser.playwrightVersion };
179
+ } catch {
180
+ // No browser on this host — the check stays silent. `system doctor`
181
+ // is where an absent browser is reported, not here.
182
+ }
183
+
184
+ return { consumers, provisioned };
185
+ }
@@ -15,6 +15,7 @@ const emptyDeps = {
15
15
  applied: () => [],
16
16
  db: fakeDb,
17
17
  },
18
+ browserPin: { consumers: [], provisioned: null },
18
19
  capabilityAbi: { modules: [] },
19
20
  terraformPlan: {
20
21
  modules: [],
@@ -15,6 +15,7 @@ import {
15
15
  auditAbandonedOperations,
16
16
  } from './abandoned-operations';
17
17
  import { type BackupsAuditDeps, auditBackups } from './backups';
18
+ import { type BrowserPinAuditDeps, auditBrowserPin } from './browser-pin';
18
19
  import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
19
20
  import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
20
21
  import { type HealthAuditDeps, auditHealth } from './health';
@@ -48,6 +49,7 @@ export interface AuditDeps {
48
49
  cliVersion: CliVersionAuditDeps;
49
50
  schema: SchemaAuditDeps;
50
51
  capabilityAbi: CapabilityAbiAuditDeps;
52
+ browserPin: BrowserPinAuditDeps;
51
53
  terraformPlan: TerraformPlanAuditDeps;
52
54
  moduleVersions: ModuleVersionsAuditDeps;
53
55
  moduleConfigs: ModuleConfigsAuditDeps;
@@ -101,6 +103,7 @@ export async function runAudit(
101
103
  wrap('cli_version', auditCliVersion(deps.cliVersion)),
102
104
  wrap('schema', auditSchema(deps.schema)),
103
105
  wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
106
+ wrap('browser_pin', auditBrowserPin(deps.browserPin)),
104
107
  wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
105
108
  wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
106
109
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
@@ -25,6 +25,7 @@ export type DriftCategory =
25
25
  | 'cli_version'
26
26
  | 'schema'
27
27
  | 'capability_abi'
28
+ | 'browser_pin'
28
29
  | 'terraform_plan'
29
30
  | 'module_versions'
30
31
  | 'module_configs'
@@ -31,6 +31,16 @@ export interface HealthCheckResult {
31
31
  status: 'healthy' | 'degraded' | 'unhealthy' | 'no-checks' | 'error';
32
32
  checks: HealthCheckItem[];
33
33
  error?: string;
34
+ /**
35
+ * Files the hook wrote to its per-run artifact directory.
36
+ *
37
+ * This path is the one that runs UNATTENDED every fifteen minutes, and
38
+ * it used to drop `HookResult`'s artifacts on the floor entirely — the
39
+ * on-demand paths (`module run-hook`, deploy) surfaced them and this one
40
+ * did not, so the artifacts were discarded exactly when nobody was
41
+ * watching to re-run the check by hand.
42
+ */
43
+ artifactPaths?: string[];
34
44
  }
35
45
 
36
46
  export interface HealthCheckOptions {
@@ -191,11 +201,15 @@ export async function runModuleHealthCheck(
191
201
  presentation.finish(hookResult.success);
192
202
 
193
203
  if (!hookResult.success) {
204
+ // Artifacts matter MOST here: the hook died, so there are no named
205
+ // checks to explain why, and whatever it managed to write is all the
206
+ // operator gets.
194
207
  return {
195
208
  moduleId,
196
209
  status: 'error',
197
210
  checks: [],
198
211
  error: hookResult.error,
212
+ artifactPaths: hookResult.artifactPaths,
199
213
  };
200
214
  }
201
215
 
@@ -220,7 +234,7 @@ export async function runModuleHealthCheck(
220
234
  db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
221
235
  }
222
236
 
223
- return { moduleId, status, checks };
237
+ return { moduleId, status, checks, artifactPaths: hookResult.artifactPaths };
224
238
  }
225
239
 
226
240
  /**
@@ -15,6 +15,7 @@ import { capabilities, machines, modules } from '../db/schema';
15
15
  import { loadCapabilityFunctions } from '../hooks/capability-loader';
16
16
  import { invokeHook } from '../hooks/executor';
17
17
  import { createGaugeLogger } from '../hooks/logger';
18
+ import { describeArtifacts } from '../hooks/types';
18
19
  import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
19
20
  import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
20
21
  import { decryptSecret } from '../secrets/encryption';
@@ -624,10 +625,9 @@ async function deployModuleImpl(
624
625
 
625
626
  if (!hookResult.success) {
626
627
  gauge.stop(false);
627
- let errorMsg = hookResult.error || 'Configuration validation failed';
628
- if (hookResult.screenshotPath) {
629
- errorMsg += `\n\nScreenshot saved: ${hookResult.screenshotPath}`;
630
- }
628
+ const errorMsg =
629
+ (hookResult.error || 'Configuration validation failed') +
630
+ describeArtifacts(hookResult.artifactPaths);
631
631
  return {
632
632
  success: false,
633
633
  error: errorMsg,
@@ -72,6 +72,7 @@ const cleanAudit: AuditDeps = {
72
72
  cliVersion: { installedVersion: '0.1.5', fetcher: async () => '0.1.5' },
73
73
  schema: { journal: () => null, applied: () => [], db: fakeDb },
74
74
  capabilityAbi: { modules: [] },
75
+ browserPin: { consumers: [], provisioned: null },
75
76
  terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
76
77
  moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
77
78
  moduleConfigs: { modules: [] },
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The browser runtime is described in two languages: an Ansible role
3
+ * installs it, and TypeScript reads it back. Nothing in either language
4
+ * can see the other, so these are the assertions that keep them agreeing.
5
+ *
6
+ * Both facts here have already drifted in other pairs of files in this
7
+ * repo; neither drift is visible in a diff.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import {
14
+ BROWSER_DESCRIPTOR_PATH,
15
+ BROWSER_EXECUTABLE_PATH,
16
+ BROWSER_ROOT,
17
+ } from '@celilo/capabilities';
18
+
19
+ const MODULE_ROOT = join(import.meta.dir, '../../../../modules/celilo-mgmt');
20
+
21
+ function read(relativePath: string): string {
22
+ return readFileSync(join(MODULE_ROOT, relativePath), 'utf-8');
23
+ }
24
+
25
+ describe('celilo-mgmt browser provisioning', () => {
26
+ test('the playbook stamps the module version the manifest declares', () => {
27
+ // The descriptor records which celilo-mgmt wrote it. No template prefix
28
+ // exposes a module's own version ($self: resolves module CONFIG), so
29
+ // the playbook carries a literal — and this is what stops it rotting.
30
+ const manifestVersion = read('manifest.yml').match(/^version:\s*(\S+)$/m)?.[1];
31
+ const playbookVersion = read('ansible/playbook.yml.tpl').match(
32
+ /celilo_mgmt_version:\s*"([^"]+)"/,
33
+ )?.[1];
34
+
35
+ expect(manifestVersion).toBeTruthy();
36
+ expect(playbookVersion).toBe(manifestVersion as string);
37
+ });
38
+
39
+ test('the role installs into the tree the resolver reads', () => {
40
+ const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
41
+
42
+ expect(BROWSER_EXECUTABLE_PATH).toBe(`${BROWSER_ROOT}/current/chrome`);
43
+ expect(debian).toContain(`PLAYWRIGHT_BROWSERS_PATH: ${BROWSER_ROOT}`);
44
+ expect(debian).toContain(`dest: ${BROWSER_EXECUTABLE_PATH}`);
45
+ expect(debian).toContain(`dest: ${BROWSER_DESCRIPTOR_PATH}`);
46
+ });
47
+
48
+ test('the install is gated, so a default host downloads nothing', () => {
49
+ const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
50
+ const manifest = read('manifest.yml');
51
+
52
+ expect(debian).toContain('when: celilo_install_browser | bool');
53
+ // The gate is worthless if the flag defaults on: the e2e topology has
54
+ // no real internet, and the browser comes from an external CDN.
55
+ expect(manifest).toMatch(/name: install_browser[\s\S]*?default: false/);
56
+ });
57
+
58
+ test('the role provisions the headless shell, not full Chromium', () => {
59
+ // Reversed after measuring celilo-mgr: both builds there are headless
60
+ // shells and chromium.launch() has been driving one in production all
61
+ // along. Provisioning the full browser would change what works.
62
+ const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
63
+
64
+ expect(debian).toContain('install chromium-headless-shell');
65
+ expect(debian).toContain("'flavor': 'headless-shell'");
66
+ });
67
+ });
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { describe, expect, test } from 'bun:test';
17
+ import { unlinkSync, writeFileSync } from 'node:fs';
18
+ import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities';
17
19
  import {
18
20
  PREREQUISITES,
19
21
  checkPrerequisite,
@@ -52,6 +54,8 @@ describe('PREREQUISITES table', () => {
52
54
  expect(names).toContain('git');
53
55
  expect(names).toContain('curl');
54
56
  expect(names).toContain('unzip');
57
+ expect(names).toContain('browser');
58
+ expect(names).toContain('fonts');
55
59
  });
56
60
  });
57
61
 
@@ -313,6 +317,75 @@ describe('checkPrerequisite', () => {
313
317
  });
314
318
  });
315
319
 
320
+ // ── Absolute-path prerequisites (the browser) ─────────────────────────
321
+
322
+ describe('absolute-path prerequisites', () => {
323
+ test('a real executable at an absolute path is present, with its version', () => {
324
+ // `bun --version` prints a bare semver, and Bun.which finds a real path
325
+ // for it — so this exercises the absolute-path branch end to end.
326
+ const bunPath = Bun.which('bun');
327
+ if (!bunPath) throw new Error('bun is not on PATH');
328
+ const result = checkPrerequisite({
329
+ name: 'browser',
330
+ description: 'stand-in for the provisioned browser',
331
+ command: bunPath,
332
+ versionFlag: '--version',
333
+ versionRegex: /(\d+\.\d+\.\d+)/,
334
+ minVersion: null,
335
+ });
336
+ expect(result.present).toBe(true);
337
+ expect(result.binaryPath).toBe(bunPath);
338
+ expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
339
+ });
340
+
341
+ test('a DIRECTORY at the declared path is not present', () => {
342
+ // The case that motivates the whole check: a build directory with no
343
+ // binary in it satisfies a path test and then fails at launch.
344
+ const result = checkPrerequisite({
345
+ name: 'browser',
346
+ description: 'stand-in for the provisioned browser',
347
+ command: '/tmp',
348
+ versionFlag: '--version',
349
+ versionRegex: /(\d+\.\d+\.\d+)/,
350
+ minVersion: null,
351
+ });
352
+ expect(result.present).toBe(false);
353
+ expect(result.binaryPath).toBeNull();
354
+ });
355
+
356
+ test('a non-executable file at the declared path is not present', () => {
357
+ const path = `/tmp/celilo-prereq-not-executable-${process.pid}`;
358
+ writeFileSync(path, 'not a browser', { mode: 0o644 });
359
+ try {
360
+ const result = checkPrerequisite({
361
+ name: 'browser',
362
+ description: 'stand-in for the provisioned browser',
363
+ command: path,
364
+ versionFlag: '--version',
365
+ versionRegex: /(\d+\.\d+\.\d+)/,
366
+ minVersion: null,
367
+ });
368
+ expect(result.present).toBe(false);
369
+ } finally {
370
+ unlinkSync(path);
371
+ }
372
+ });
373
+
374
+ test('the browser row names install_browser as its remedy', () => {
375
+ const spec = PREREQUISITES.find((p) => p.name === 'browser');
376
+ if (!spec) throw new Error('browser spec missing');
377
+ expect(spec.command).toBe(BROWSER_EXECUTABLE_PATH);
378
+ expect(getInstallHint('browser', 'apt')).toContain('install_browser');
379
+ });
380
+
381
+ test('fc-match output yields the resolved font family', () => {
382
+ const spec = PREREQUISITES.find((p) => p.name === 'fonts');
383
+ if (!spec) throw new Error('fonts spec missing');
384
+ const sample = 'DejaVuSans.ttf: "DejaVu Sans" "Book"';
385
+ expect(sample.match(spec.versionRegex)?.[1]).toBe('DejaVu Sans');
386
+ });
387
+ });
388
+
316
389
  // ── failingPrerequisites ──────────────────────────────────────────────
317
390
 
318
391
  describe('failingPrerequisites', () => {