fix(build,docs): dependency preflight before the build wipes dist, docs drift for 1.40.0

- scripts/build.mjs resolves exceljs/dist/exceljs.min.js and fflate first,
  before tsc and before rm -rf dist/web/public. A tree whose node_modules
  predate those devDependencies (pulled but never ran npm install) used to
  fail in prepare-spreadsheet-assets.mjs with the live dist assets already
  deleted, so the running server served an index.html whose hashed files
  were gone. It now exits 1 with "run `npm install` first", nothing touched.
  test/spreadsheet-assets.test.ts pins the order, that the list covers every
  require.resolve in the prepare script, and runs a relocated copy of the
  build to prove the exit and message.
- CLAUDE.md: the header visibility rule's stock desktop default now lists
  Tiles (1180px and wider), which ships ON on desktop.
- docs/wiki/Agent-CLIs.md: "Before 1.36.0" becomes "Before 1.40.0" (four
  places); 1.36.0 never ships.
- docs/wiki/Home.md: the "Everything in the manual" index lists Tile Grid
  and Custom Model Endpoints, matching the sidebar. test/wiki-home-index
  fails when a sidebar page is missing from that index.
- docs/wiki/Tile-Grid.md: the Tiles default is off on tablets too since the
  touch-primary default landed, not only on phones.
- docs/browser-testing-guide.md: the fixed port table and new WebServer(PORT)
  snippet give way to the port-0 pattern (new WebServer(0, false, true),
  server.boundPort) that test/test-ports-guard.test.ts enforces; the
  examples that opened localhost:3000, the live instance, use BASE_URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:47:22 +02:00
parent ecd577157b
commit 9d38cbf51a
8 changed files with 169 additions and 23 deletions
+1 -1
View File
@@ -353,7 +353,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case) **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron) **Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`, and the desktop-gated `btn-split--hidden` / `btn-tile-grid--hidden`, which also need a `@media (max-width: 1179px)` backstop) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + Tiles (1180px and wider) + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
+22 -15
View File
@@ -71,17 +71,21 @@ We tested three browser automation frameworks against the Codeman web UI:
## Test File Structure ## Test File Structure
### Port Allocation ### Ports
| Port Range | Test File | A test that starts a server binds an ephemeral port, never a fixed one:
|------------|-----------|
| 3150-3153 | browser-e2e.test.ts (existing) | - `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
| 3154 | file-link-click.test.ts | `server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
| 3155 | browser-playwright.test.ts | any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
| 3156 | browser-puppeteer.test.ts | excepted).
| 3157 | browser-agent.test.ts | - A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
| 3158-3160 | browser-comparison.test.ts | - The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
| 3180-3182 | scripts/browser-comparison.mjs | convention in `test/mobile/README.md` for now.
- Never port 3000: that is the live instance.
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
fixed ports 3180-3182.
### File Purposes ### File Purposes
@@ -106,7 +110,7 @@ const browser = await chromium.launch({
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('http://localhost:3000'); await page.goto(BASE_URL);
// Auto-waiting selectors // Auto-waiting selectors
await page.click('.btn-claude'); await page.click('.btn-claude');
@@ -140,7 +144,7 @@ const browser = await puppeteer.launch({
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto('http://localhost:3000'); await page.goto(BASE_URL);
// Manual waiting often needed // Manual waiting often needed
await page.click('.btn-claude'); await page.click('.btn-claude');
@@ -186,7 +190,7 @@ function agentBrowserJson<T>(cmd: string): T {
} }
// Usage // Usage
agentBrowser('open http://localhost:3000'); agentBrowser(`open ${BASE_URL}`);
agentBrowser('click ".btn-claude"'); agentBrowser('click ".btn-claude"');
const title = agentBrowserJson<{title: string}>('get title'); const title = agentBrowserJson<{title: string}>('get title');
@@ -246,11 +250,14 @@ npx playwright install chromium
### 4. Wait for Server Startup ### 4. Wait for Server Startup
```typescript ```typescript
const server = new WebServer(PORT); const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
await server.start(); await server.start();
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize const BASE_URL = `http://localhost:${server.boundPort}`;
``` ```
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
other snippets on this page is this one.
### 5. Clean Up Sessions ### 5. Clean Up Sessions
Track created sessions for cleanup: Track created sessions for cleanup:
+4 -4
View File
@@ -124,7 +124,7 @@ environment is injected through socket-scoped `tmux setenv` rather than the comm
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
session from an idle one. A pending permission prompt shows as idle, since it is waiting on session from an idle one. A pending permission prompt shows as idle, since it is waiting on
you. Before 1.36.0 an OpenCode session that had run a tool showed as working for good. you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md). Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
@@ -164,7 +164,7 @@ only the CLI you spawned yourself.
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool (`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
confirmation that waits for you shows as idle. Before 1.36.0 a Gemini session showed as confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
working for good after its first turn. working for good after its first turn.
### Antigravity ### Antigravity
@@ -190,7 +190,7 @@ Pi needs the opposite instincts from every other CLI here.
once. They stay out. once. They stay out.
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it - **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
that to tell working from idle. Before 1.36.0 a Pi session that had started a turn showed that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
as working for good. as working for good.
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md). Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
@@ -246,7 +246,7 @@ flag from Codeman; change that in OMP's own config, not here.
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.36.0 bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
an OMP session that had started a turn showed as working for good. an OMP session that had started a turn showed as working for good.
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md). Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
+2
View File
@@ -62,7 +62,9 @@ codeman web # then open http://localhost:3000
| Page | What it answers | | Page | What it answers |
| ------------------------------------------ | ---------------------------------------------------------- | | ------------------------------------------ | ---------------------------------------------------------- |
| [The Dashboard](The-Dashboard) | What is the UI telling me? | | [The Dashboard](The-Dashboard) | What is the UI telling me? |
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? | | [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? | | [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? | | [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? | | [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
+2 -1
View File
@@ -10,7 +10,8 @@ never offered in a popped-out session window.
## Turning it on ## Turning it on
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default **App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
everywhere except phones, and the button only appears in a window at least 1180px wide. on desktops and laptops and off on phones and tablets, and the button only appears in a
window at least 1180px wide.
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`. It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
## Opening a grid ## Opening a grid
+27
View File
@@ -4,6 +4,7 @@
* Extracted from the package.json one-liner for readability and debuggability. * Extracted from the package.json one-liner for readability and debuggability.
* *
* Steps: * Steps:
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
* 1. TypeScript compilation * 1. TypeScript compilation
* 2. Copy static assets (web/public, templates) * 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles * 3. Build vendor xterm bundles
@@ -13,6 +14,7 @@
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { createRequire } from 'module';
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs'; import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -25,6 +27,31 @@ function run(label, cmd) {
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true }); execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
} }
// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
// with dist/web/public already wiped, so the running server kept serving an index.html
// whose hashed assets were gone. Keep the list in step with every require.resolve in
// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
// Only specifiers that resolve without an exports map in the way: a subpath of a package
// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
const requireFromBuild = createRequire(import.meta.url);
const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
try {
requireFromBuild.resolve(specifier);
return false;
} catch {
return true;
}
});
if (missingModules.length > 0) {
console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
process.exit(1);
}
// 1. TypeScript compilation // 1. TypeScript compilation
run('tsc', 'tsc'); run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js'); run('chmod dist/index.js', 'chmod +x dist/index.js');
+74 -2
View File
@@ -7,8 +7,11 @@
* that shape plus the pinned versions and the dev/prod vendoring steps. * that shape plus the pinned versions and the dev/prod vendoring steps.
*/ */
import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js'; import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
@@ -94,3 +97,72 @@ describe('spreadsheet preview assets', () => {
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')"); expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
}); });
}); });
/**
* The build deletes dist/web/public and only then copies the vendor bundles out of
* node_modules. A tree whose node_modules predate exceljs/fflate (a deploy that pulled
* but never ran `npm install`) failed there, with the live assets already gone. The
* preflight at the top of build.mjs resolves them before anything is touched.
*/
describe('build preflight for the spreadsheet vendor packages', () => {
const preflightModules = (build: string): string[] => {
const list = /const BUILD_TIME_MODULES = \[([^\]]*)\]/.exec(build)?.[1] ?? '';
return [...list.matchAll(/'([^']+)'/g)].map((m) => m[1]);
};
it('covers every package prepare-spreadsheet-assets.mjs resolves, and runs before tsc and the clean', () => {
const build = read('scripts/build.mjs');
const preflight = preflightModules(build);
const resolvedByPrepare = [
...read('scripts/prepare-spreadsheet-assets.mjs').matchAll(/require\.resolve\('([^']+)'\)/g),
].map((m) => m[1]);
// Parse sanity; the loop below is the actual coverage check.
expect(resolvedByPrepare).toContain('exceljs/dist/exceljs.min.js');
expect(resolvedByPrepare).toContain('fflate');
for (const specifier of resolvedByPrepare) expect(preflight, specifier).toContain(specifier);
const bail = build.indexOf('[build] run `npm install` first');
expect(bail).toBeGreaterThan(-1);
expect(bail).toBeLessThan(build.indexOf("run('tsc', 'tsc')"));
expect(bail).toBeLessThan(build.indexOf("'rm -rf dist/web/public'"));
});
it('resolves every preflight package in this installed tree', () => {
const requireFromBuild = createRequire(resolve(root, 'scripts/build.mjs'));
const preflight = preflightModules(read('scripts/build.mjs'));
expect(preflight.length).toBeGreaterThan(0);
for (const specifier of preflight) expect(() => requireFromBuild.resolve(specifier), specifier).not.toThrow();
});
it('exits with an npm install hint, and touches nothing, where the packages do not resolve', () => {
// A copy of build.mjs outside the repo: nothing resolves from there, and its ROOT
// (derived from its own location) is the temp dir, so a missing preflight could
// only ever act on that throwaway tree.
const tree = mkdtempSync(join(tmpdir(), 'codeman-build-preflight-'));
try {
mkdirSync(join(tree, 'scripts'));
const copy = join(tree, 'scripts', 'build.mjs');
copyFileSync(resolve(root, 'scripts/build.mjs'), copy);
const requireFromCopy = createRequire(copy);
const unresolvable = ['exceljs/dist/exceljs.min.js', 'fflate'].filter((specifier) => {
try {
requireFromCopy.resolve(specifier);
return false;
} catch {
return true;
}
});
// Precondition: this host has no stray node_modules above the temp dir.
expect(unresolvable.length).toBeGreaterThan(0);
const result = spawnSync(process.execPath, [copy], { cwd: tree, encoding: 'utf8', timeout: 20_000 });
expect(result.status).toBe(1);
expect(result.stderr).toContain(`[build] missing build dependency: ${unresolvable.join(', ')}`);
expect(result.stderr).toContain('run `npm install` first');
expect(result.stdout).not.toContain('[build] tsc');
expect(existsSync(join(tree, 'dist'))).toBe(false);
} finally {
rmSync(tree, { recursive: true, force: true });
}
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Static guard: the wiki Home page's "Everything in the manual" index
* links every page the sidebar lists.
*
* docs/wiki/ is mirrored to the GitHub wiki, where _Sidebar.md shows on every page and
* Home.md calls itself the whole manual. A new page added to the sidebar alone (Tile
* Grid, Custom Model Endpoints) silently dropped out of that index. Compared by link
* target, since the two files label some pages differently.
*
* Port: N/A (pure static analysis).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const wiki = resolve(import.meta.dirname, '..', 'docs', 'wiki');
const read = (name: string) => readFileSync(resolve(wiki, name), 'utf8');
/** Internal wiki page targets of `[label](Target)` links, external URLs excluded. */
const pageTargets = (markdown: string): Set<string> =>
new Set(
[...markdown.matchAll(/\]\(([^)\s#]+)(?:#[^)]*)?\)/g)]
.map((m) => m[1])
.filter((target) => !/^[a-z]+:/i.test(target) && target !== 'Home')
);
describe('wiki Home index', () => {
it('links every page the sidebar lists', () => {
const sidebar = pageTargets(read('_Sidebar.md'));
expect(sidebar.size).toBeGreaterThan(20);
const index = read('Home.md').split('## Everything in the manual')[1] ?? '';
expect(index, 'Home.md has an "Everything in the manual" section').not.toBe('');
const indexed = pageTargets(index);
const missing = [...sidebar].filter((target) => !indexed.has(target));
expect(missing, 'add these to the "Everything in the manual" tables in docs/wiki/Home.md').toEqual([]);
});
});