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
+74 -2
View File
@@ -7,8 +7,11 @@
* that shape plus the pinned versions and the dev/prod vendoring steps.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
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 { 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(', ')");
});
});
/**
* 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([]);
});
});