Merge pull request #500 from aakhter/pr/prepush-browser-excludes

build: add a browser-test exclusion check and a pre-push static-check hook
This commit is contained in:
Codeman maintainer
2026-09-28 16:21:09 +02:00
10 changed files with 951 additions and 7 deletions
+5 -2
View File
@@ -28,12 +28,15 @@ The frontend is plain JS served from `src/web/public/` with no bundler in dev: e
CI runs all of these, so save yourself a round trip:
```bash
npm run typecheck # tsc --noEmit, strict mode
npm run typecheck # tsc --noEmit, strict mode
npm run lint
npm run format:check
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
```
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
### Tests
```bash
+7
View File
@@ -34,6 +34,13 @@ jobs:
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
# Asks `vitest list` what CI would actually collect, rather than matching
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
# (config/test-suites.ts) passes locally and dies in the test job with
# "browserType.launch: Executable doesn't exist".
- name: Browser-test exclusion check
run: npm run check:browser-excludes
- name: Format check
run: npm run format:check
+4 -1
View File
@@ -34,6 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
## CRITICAL: Always Test Before Deploying
@@ -112,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
| Production start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` |
@@ -120,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
+7
View File
@@ -42,10 +42,17 @@ npm run typecheck
npm run lint
npm run format:check
npm run check:frontend-syntax
npm run check:browser-excludes
npm test -- test/<file>.test.ts # one file, the normal way
npm run test:ci # the full CI sweep
```
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
machine-dependent) and blocks a push that would fail them. It skips itself when you push
something other than the checked-out HEAD, or when the tree has uncommitted changes the
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
`pre-push` hook of your own is never overwritten.
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
suites that need a live server, Chromium, and environment-specific baselines; they hang or
fail on a normal machine. `test:ci` is the honest "run everything".
+1
View File
@@ -28,6 +28,7 @@
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* Browser-test exclusion check.
*
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
* clean checkout) have no chromium, so such a file dies with
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
* BY HAND, and a new browser test simply does not appear in it unless someone
* remembers. The omission is invisible on a developer machine that has run
* `npx playwright install`, where the test passes, and only shows up on a clean
* runner.
*
* Two deliberate design choices:
*
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
* would miss the browser tests that predate that convention
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
* file dangerous is importing a browser driver, so that is what is tested.
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
*
* 2. **The exclusion side is answered by vitest itself**, via
* `vitest list --filesOnly`, rather than by re-implementing glob matching
* against the config's `exclude` array. Patterns there include `test/mobile/**`
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
* edge case would report a gap that does not exist, or miss one that does.
* Asking the real resolver cannot drift from the real behaviour.
*
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
* check itself only runs when this file is executed directly.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname, relative, sep, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
const SUITES_FILE = join('config', 'test-suites.ts');
/** Importing any one of these means the test needs a real browser binary. */
const BROWSER_DRIVER =
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
/** @param {string} source */
export function importsBrowserDriver(source) {
return BROWSER_DRIVER.test(source);
}
/** @param {string} dir @returns {string[]} */
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(path));
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
}
return out;
}
/**
* Every `*.test.ts` under `<root>/test` that imports a browser driver, as sorted
* repo-relative POSIX paths (the form `vitest list` prints).
*
* @param {string} root
* @returns {string[]}
*/
export function findBrowserTests(root) {
return walk(join(root, 'test'))
.filter((file) => importsBrowserDriver(readFileSync(file, 'utf8')))
.map((file) => relative(root, file).split(sep).join('/'))
.sort();
}
/**
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
* blank or decorative lines are ignored rather than assuming the format is pristine.
*
* @param {string} output
* @returns {Set<string>}
*/
export function parseVitestFileList(output) {
return new Set(
output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith('.test.ts'))
.map((line) => line.replace(/^\.\//, ''))
);
}
/**
* @param {string[]} browserTests
* @param {Set<string>} ciFiles
* @returns {string[]} browser-driven files that the CI config would still collect
*/
export function findLeaks(browserTests, ciFiles) {
return browserTests.filter((file) => ciFiles.has(file));
}
function main() {
const browserTests = findBrowserTests(ROOT);
let collected;
try {
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
console.error('✗ could not enumerate the CI test set via `vitest list`.');
console.error(err.stderr ? err.stderr.toString() : String(err));
process.exit(1);
}
const ciFiles = parseVitestFileList(collected);
if (ciFiles.size === 0) {
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
process.exit(1);
}
const leaked = findLeaks(browserTests, ciFiles);
if (leaked.length > 0) {
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
for (const file of leaked) console.error(` ${file}`);
console.error(`
These import a browser driver, so on a runner with no chromium they fail with
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
it, and \`npm run test:browser\` its includes).
They may well pass on this machine; that is the trap. To reproduce a clean
runner locally:
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
process.exit(1);
}
console.log(
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
+240
View File
@@ -0,0 +1,240 @@
/**
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
* pinned by test/git-hooks.test.ts.
*
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
*
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
* within a day. The checks below mirror the static CI job.
*
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
* the second case is usually another session's WIP, which must not block this push.
*
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
* survive `npm install`.
*/
import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
/** Ownership marker. Bump the version suffix when the body changes meaningfully. */
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
/**
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
* is the argument list for `npm run`, and each is a step of the static job in
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
*/
export const PRE_PUSH_CHECKS = [
['check:lockfile'],
['generate:cli-catalog', '--', '--check'],
['check:browser-excludes'],
['check:frontend-syntax'],
['format:check'],
['lint'],
['typecheck'],
];
/**
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
* Derived from what each check reads: src/ (format:check, lint, typecheck,
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
* package.json + package-lock.json (check:lockfile) and install.sh (generate:cli-catalog
* --check diffs its generated block).
*/
export const PRE_PUSH_WATCHED_PATHS = [
'src',
'config',
'scripts',
'test',
'package.json',
'package-lock.json',
'install.sh',
];
/**
* Render the pre-push hook script.
*
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
*/
export function renderPrePushHook() {
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
return `#!/bin/sh
${PRE_PUSH_MARKER}
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
# it is regenerated on npm install. Delete the marker line above to take ownership
# and the installer will leave your version alone.
#
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
# Skip always: remove this file.
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$repo_root" || exit 0
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
# npm install). Warn rather than blocking the push on a setup detail.
if [ ! -d node_modules ]; then
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
exit 0
fi
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
# The checks below read the working tree, so they only say something about a pushed commit
# that IS the checked-out HEAD (tags are peeled to their commit first).
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
has_content=0
not_head=''
while read -r localref localsha _remoteref _remotesha; do
[ -z "$localsha" ] && continue
case "$localsha" in
0000000000000000000000000000000000000000) ;;
*)
has_content=1
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
;;
esac
done
[ "$has_content" = "0" ] && exit 0
if [ -n "$not_head" ]; then
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
exit 0
fi
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
exit 0
fi
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
trap 'rm -f "$log"' EXIT
failed=''
run_check() {
if ! npm run --silent "$@" >"$log" 2>&1; then
echo ""
echo "pre-push: FAILED npm run $*"
tail -n 25 "$log"
failed="$failed $1"
fi
}
echo "pre-push: running static checks (~10-40s)..."
${runs}
if [ -n "$failed" ]; then
echo ""
echo "pre-push: blocked by:$failed"
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
exit 1
fi
echo "pre-push: static checks passed."
exit 0
`;
}
/**
* Decide what to do with an existing hook file.
*
* @param {{ existing: string | null | undefined, next: string }} args
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function planHookInstall({ existing, next }) {
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
return existing === next ? 'up-to-date' : 'write';
}
/** @param {string} cwd @param {string[]} args */
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}
/**
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
*
* @param {string} path
*/
function canonicalPath(path) {
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
}
/**
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
* is nothing to install into.
*
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
* `.git` is a FILE pointing at the parent repo, so the hooks live under
* `--git-common-dir`.
*
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
* shared hooks directory used by every repo on the machine); installing there would
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
* the comparison is on canonical paths rather than on whether the setting exists.
*
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
*
* @param {string} repoRoot
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
// Both are printed relative to the cwd (repoRoot) unless already absolute.
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
if (!hooks || !common) return null;
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
} catch {
return null;
}
}
/**
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
* {@link planHookInstall}: a hook without the marker is never touched.
*
* @param {string} hooksDir
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
*/
export function installPrePushHook(hooksDir) {
const path = join(hooksDir, 'pre-push');
const next = renderPrePushHook();
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
const action = planHookInstall({ existing, next });
if (action === 'write') {
mkdirSync(hooksDir, { recursive: true });
writeFileSync(path, next, { mode: 0o755 });
chmodSync(path, 0o755); // `mode` only applies when the file is created
}
return action;
}
+16 -4
View File
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
}
// ----------------------------------------------------------------------------
// 5. Install git pre-commit hook (format check)
// 5. Install git hooks (pre-commit format check, pre-push static checks)
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
const { writeFileSync, mkdirSync } = await import('fs');
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
// null when this directory is not the top of a git checkout.
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
if (gitHooksDir) {
mkdirSync(gitHooksDir, { recursive: true });
const hook = `#!/bin/bash
# Auto-installed by postinstall — prevents CI format failures
@@ -379,9 +382,18 @@ fi
const hookPath = join(gitHooksDir, 'pre-commit');
writeFileSync(hookPath, hook, { mode: 0o755 });
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
// hook the developer wrote themselves is left alone.
const action = installPrePushHook(gitHooksDir);
if (action === 'write') {
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
} else if (action === 'skip-foreign') {
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
}
}
} catch {
// Non-critical — git hook is a convenience
// Non-critical — git hooks are a convenience
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* @fileoverview scripts/check-browser-test-excludes.mjs: the detection side (which test
* files need a real browser) and the leak computation. The exclusion side is vitest's own
* `vitest list`, which `npm run check:browser-excludes` exercises for real in CI.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
findBrowserTests,
findLeaks,
importsBrowserDriver,
parseVitestFileList,
} from '../scripts/check-browser-test-excludes.mjs';
import { BROWSER_TEST_GLOBS } from '../config/test-suites';
const repoRoot = resolve(import.meta.dirname, '..');
// Fixture sources are assembled from the module name at runtime, so THIS file never contains
// a literal driver import and is not itself flagged by the checker it tests.
const fromImport = (mod: string) => `import { chromium, type Browser } from '${mod}';\n`;
describe('importsBrowserDriver', () => {
it.each([
fromImport('playwright'),
fromImport('playwright-core').replace(/'/g, '"'),
fromImport('@playwright/test'),
fromImport('puppeteer'),
`import type { Page } from '${'playwright'}';`,
`const { chromium } = require('${'playwright'}');`,
`const pw = await import('${'playwright'}');`,
])('flags %s', (src) => {
expect(importsBrowserDriver(src)).toBe(true);
});
it.each([
"import { describe } from 'vitest';",
"// needs ms-playwright's cache dir\nconst dir = '.cache/ms-playwright';",
fromImport('./playwright-helpers'),
fromImport('playwright-extra-thing'),
])('ignores %s', (src) => {
expect(importsBrowserDriver(src)).toBe(false);
});
});
describe('findBrowserTests (fixture tree)', () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-browser-excludes-'));
const put = (rel: string, src: string) => {
mkdirSync(join(root, rel, '..'), { recursive: true });
writeFileSync(join(root, rel), src);
};
put('test/unit.test.ts', "import { it } from 'vitest';\n");
put('test/legacy-name.test.ts', fromImport('playwright'));
put('test/new.browser.test.ts', fromImport('playwright'));
put('test/nested/deep.test.ts', fromImport('puppeteer'));
put('test/helpers/browser.ts', fromImport('playwright')); // not a test file
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
it('finds driver imports by content, recursively, as sorted repo-relative paths', () => {
expect(findBrowserTests(root)).toEqual([
'test/legacy-name.test.ts',
'test/nested/deep.test.ts',
'test/new.browser.test.ts',
]);
});
});
describe('parseVitestFileList + findLeaks', () => {
it('keeps only test paths and normalizes a leading ./', () => {
const out = '\n./test/a.test.ts\ntest/b.test.ts\nsome banner line\n test/c.test.ts \n';
expect([...parseVitestFileList(out)].sort()).toEqual(['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts']);
});
it('reports exactly the browser tests the CI set still collects', () => {
const ci = new Set(['test/unit.test.ts', 'test/legacy-name.test.ts']);
expect(findLeaks(['test/legacy-name.test.ts', 'test/new.browser.test.ts'], ci)).toEqual([
'test/legacy-name.test.ts',
]);
expect(findLeaks(['test/new.browser.test.ts'], ci)).toEqual([]);
});
});
describe('against this repository', () => {
it('detects every file already listed in BROWSER_TEST_GLOBS', () => {
// If detection stopped recognising a known browser test, the checker would go blind to
// exactly the class of file it exists for.
const detected = new Set(findBrowserTests(repoRoot));
const literals = BROWSER_TEST_GLOBS.filter((g) => !/[*?[{]/.test(g));
expect(literals.length).toBeGreaterThan(0);
for (const file of literals) expect(detected, file).toContain(file);
});
});
+422
View File
@@ -0,0 +1,422 @@
/**
* @fileoverview The pre-push hook that scripts/postinstall.js installs (scripts/git-hooks.mjs).
*
* Two properties matter more than the hook's contents, because the older pre-commit
* installer gets both wrong and this one must not copy it:
* 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten.
* 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE
* and `<root>/.git/hooks` does not exist, and it is ONLY ever the repo's own
* `<git-common-dir>/hooks`: a `core.hooksPath` elsewhere (typically a global one) is
* never written to.
*
* ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir.
* Never point the installer at this checkout: its hooks directory is shared with every
* worktree of it, including whatever the developer is running right now.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
PRE_PUSH_CHECKS,
PRE_PUSH_MARKER,
PRE_PUSH_WATCHED_PATHS,
installPrePushHook,
planHookInstall,
renderPrePushHook,
resolveGitHooksDir,
} from '../scripts/git-hooks.mjs';
const repoRoot = resolve(import.meta.dirname, '..');
const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf8');
/** git with no user/system config leaking in (a global core.hooksPath would redirect everything). */
const GIT_ENV = {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@example.invalid',
CODEMAN_SKIP_PREPUSH: '',
};
function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = GIT_ENV): string {
return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
let scratch: string;
beforeAll(() => {
scratch = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-git-hooks-')));
});
afterAll(() => {
rmSync(scratch, { recursive: true, force: true });
});
let counter = 0;
function newRepo(): string {
const dir = join(scratch, `repo-${++counter}`);
mkdirSync(dir, { recursive: true });
git(dir, ['init', '-q', '-b', 'main']);
git(dir, ['commit', '-q', '--allow-empty', '-m', 'init']);
return dir;
}
describe('pre-push hook body', () => {
const hook = renderPrePushHook();
it('carries the ownership marker', () => {
expect(hook).toContain(PRE_PUSH_MARKER);
});
it('runs every configured check through npm, and nothing slow', () => {
for (const args of PRE_PUSH_CHECKS) {
expect(hook).toContain(`run_check ${args.join(' ')}`);
}
expect(hook).toContain('npm run --silent "$@"');
// The whole point of the tier: the minutes-long suites stay out of a per-push hook.
expect(hook).not.toMatch(/\btest:(ci|browser|mobile|perf|all)\b/);
});
it('is POSIX sh', () => {
expect(hook.startsWith('#!/bin/sh\n')).toBe(true);
const r = spawnSync('sh', ['-n'], { input: hook });
expect(r.status).toBe(0);
});
});
describe('pre-push checks match the static CI job', () => {
const scripts = JSON.parse(read('package.json')).scripts as Record<string, string>;
const ci = read('.github/workflows/ci.yml');
it.each(PRE_PUSH_CHECKS.map((args) => [args.join(' ')] as const))('%s is a real script that CI runs', (joined) => {
const [name] = joined.split(' ');
expect(scripts[name], `package.json has no "${name}" script`).toBeTypeOf('string');
expect(ci).toContain(`npm run ${joined}`);
});
});
describe('planHookInstall', () => {
const hook = renderPrePushHook();
it('writes when no hook exists', () => {
expect(planHookInstall({ existing: null, next: hook })).toBe('write');
});
it('refuses to clobber a hook it does not own', () => {
expect(planHookInstall({ existing: '#!/bin/sh\nmake lint\n', next: hook })).toBe('skip-foreign');
});
it('refreshes its own hook when the body changed', () => {
expect(planHookInstall({ existing: `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, next: hook })).toBe('write');
});
it('is idempotent when already current', () => {
expect(planHookInstall({ existing: hook, next: hook })).toBe('up-to-date');
});
it('treats an empty file as absent rather than foreign', () => {
expect(planHookInstall({ existing: ' \n', next: hook })).toBe('write');
});
});
describe('resolveGitHooksDir (temp repos)', () => {
it('resolves <root>/.git/hooks in a plain checkout', () => {
const repo = newRepo();
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('resolves the SHARED hooks dir from a worktree, where .git is a file', () => {
const repo = newRepo();
const wt = join(scratch, `wt-${counter}`);
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-branch']);
expect(statSync(join(wt, '.git')).isFile()).toBe(true);
expect(resolveGitHooksDir(wt)).toBe(join(repo, '.git', 'hooks'));
});
it('returns null outside any git checkout', () => {
const dir = join(scratch, `plain-${++counter}`);
mkdirSync(dir);
expect(resolveGitHooksDir(dir)).toBeNull();
});
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
const repo = newRepo();
const outside = join(scratch, `shared-hooks-${counter}`);
mkdirSync(outside);
git(repo, ['config', 'core.hooksPath', outside]);
expect(resolveGitHooksDir(repo)).toBeNull();
});
it('returns null when core.hooksPath points at a directory that does not exist yet', () => {
const repo = newRepo();
git(repo, ['config', 'core.hooksPath', join(scratch, `missing-${counter}`, 'hooks')]);
expect(resolveGitHooksDir(repo)).toBeNull();
});
it("still resolves when core.hooksPath points at the repo's OWN .git/hooks", () => {
const repo = newRepo();
git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'hooks')]);
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('resolves before .git/hooks exists (compares the would-be path)', () => {
const repo = newRepo();
rmSync(join(repo, '.git', 'hooks'), { recursive: true, force: true });
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('returns null under a GLOBAL core.hooksPath, from a checkout and from a worktree', () => {
const repo = newRepo();
const wt = join(scratch, `wt-global-${counter}`);
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-global']);
const globalHooks = join(scratch, `global-hooks-${counter}`);
mkdirSync(globalHooks);
const globalConfig = join(scratch, `gitconfig-${counter}`);
writeFileSync(globalConfig, `[core]\n\thooksPath = ${globalHooks}\n`);
// resolveGitHooksDir runs git with the ambient environment, so scope the fake global
// config to this test through process.env (never the developer's real ~/.gitconfig).
const saved = {
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
};
process.env.GIT_CONFIG_GLOBAL = globalConfig;
process.env.GIT_CONFIG_NOSYSTEM = '1';
try {
expect(git(repo, ['rev-parse', '--git-path', 'hooks'], { ...GIT_ENV, GIT_CONFIG_GLOBAL: globalConfig })).toBe(
globalHooks
);
expect(resolveGitHooksDir(repo)).toBeNull();
expect(resolveGitHooksDir(wt)).toBeNull();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
// Control: the same repo resolves again once the global setting is gone.
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => {
const repo = newRepo();
const nested = join(repo, 'node_modules', 'aicodeman');
mkdirSync(nested, { recursive: true });
expect(resolveGitHooksDir(nested)).toBeNull();
});
});
describe('installPrePushHook (temp repos)', () => {
it('writes an executable hook into a fresh repo', () => {
const hooks = join(newRepo(), '.git', 'hooks');
expect(installPrePushHook(hooks)).toBe('write');
const path = join(hooks, 'pre-push');
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
expect(statSync(path).mode & 0o111).not.toBe(0);
expect(installPrePushHook(hooks)).toBe('up-to-date');
});
it('leaves a foreign pre-push hook byte-identical', () => {
const hooks = join(newRepo(), '.git', 'hooks');
const path = join(hooks, 'pre-push');
const mine = '#!/bin/sh\n# my own hook\nexit 0\n';
writeFileSync(path, mine, { mode: 0o755 });
expect(installPrePushHook(hooks)).toBe('skip-foreign');
expect(readFileSync(path, 'utf8')).toBe(mine);
});
it('refreshes a stale managed hook and keeps it executable', () => {
const hooks = join(newRepo(), '.git', 'hooks');
const path = join(hooks, 'pre-push');
writeFileSync(path, `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, { mode: 0o644 });
expect(installPrePushHook(hooks)).toBe('write');
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
expect(statSync(path).mode & 0o111).not.toBe(0);
});
});
/**
* Drive the rendered hook through a real `git push` to a local bare remote. The repo gets a
* stub package.json whose check scripts only record that they ran, so this exercises the
* hook's control flow (ref parsing, skips, blocking) without running the real checks.
*/
describe('the installed hook on a real push (temp repos)', () => {
function setup(opts: { failing?: string; nodeModules?: boolean } = {}) {
const repo = newRepo();
const remote = join(scratch, `remote-${counter}.git`);
git(scratch, ['init', '-q', '--bare', remote]);
git(repo, ['remote', 'add', 'origin', remote]);
const log = join(repo, 'ran.log');
const scripts: Record<string, string> = {};
for (const [name] of PRE_PUSH_CHECKS) {
scripts[name] =
name === opts.failing ? `echo ${name} >> ran.log && echo boom-${name} && exit 1` : `echo ${name} >> ran.log`;
}
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'hook-fixture', private: true, scripts }));
writeFileSync(join(repo, '.gitignore'), 'node_modules/\nran.log\n');
git(repo, ['add', 'package.json', '.gitignore']);
git(repo, ['commit', '-q', '-m', 'fixture']);
if (opts.nodeModules !== false) mkdirSync(join(repo, 'node_modules'));
installPrePushHook(join(repo, '.git', 'hooks'));
chmodSync(join(repo, '.git', 'hooks', 'pre-push'), 0o755);
const ran = () => {
try {
return readFileSync(log, 'utf8').trim().split('\n').filter(Boolean);
} catch {
return [];
}
};
const push = (args: string[], env: NodeJS.ProcessEnv = {}) =>
spawnSync('git', ['push', ...args], { cwd: repo, env: { ...GIT_ENV, ...env }, encoding: 'utf8' });
return { repo, remote, ran, push };
}
/** What the stubs record: npm appends the args after `--` to the script, so they prove forwarding. */
const expectedRuns = PRE_PUSH_CHECKS.map((args) => args.filter((a) => a !== '--').join(' '));
it('runs every check before a push, in order', () => {
const { ran, push } = setup();
const r = push(['-q', 'origin', 'main']);
expect(r.status, r.stderr + r.stdout).toBe(0);
expect(ran()).toEqual(expectedRuns);
});
it('blocks the push when a check fails, but still runs the rest', () => {
const { ran, push, remote } = setup({ failing: 'lint' });
const r = push(['origin', 'main']);
expect(r.status).not.toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
expect(r.stdout + r.stderr).toContain('boom-lint');
expect(ran()).toEqual(expectedRuns);
expect(spawnSync('git', ['rev-parse', '--verify', '-q', 'refs/heads/main'], { cwd: remote }).status).not.toBe(0);
});
it('CODEMAN_SKIP_PREPUSH=1 skips every check', () => {
const { ran, push } = setup({ failing: 'lint' });
const r = push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' });
expect(r.status, r.stderr).toBe(0);
expect(ran()).toEqual([]);
});
it('a delete-only push skips the checks', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
expect(push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
git(repo, ['branch', 'doomed']);
expect(push(['-q', 'origin', 'doomed'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
const r = push(['-q', 'origin', '--delete', 'doomed']);
expect(r.status, r.stderr).toBe(0);
expect(ran()).toEqual([]);
});
it('skips when the pushed ref is not the checked-out HEAD', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
git(repo, ['branch', 'other']);
git(repo, ['commit', '-q', '--allow-empty', '-m', 'only on main']);
git(repo, ['checkout', '-q', 'other']);
// HEAD is `other`; pushing `main` would check a working tree that is not main's.
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain(
'pre-push: skipping static checks: refs/heads/main is not the checked-out HEAD'
);
expect(ran()).toEqual([]);
});
it('skips when any one of several pushed refs is not HEAD', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
git(repo, ['branch', 'behind']);
git(repo, ['commit', '-q', '--allow-empty', '-m', 'ahead']);
const r = push(['origin', 'main', 'behind']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('is not the checked-out HEAD');
expect(ran()).toEqual([]);
});
it('still checks an annotated tag that points at HEAD (the tag is peeled)', () => {
const { ran, push, repo } = setup();
git(repo, ['tag', '-a', 'v1', '-m', 'v1']);
const r = push(['-q', 'origin', 'v1']);
expect(r.status, r.stderr + r.stdout).toBe(0);
expect(ran()).toEqual(expectedRuns);
});
it.each(['src/wip.ts', 'config/wip.json', 'scripts/wip.mjs', 'test/wip.test.ts', 'install.sh'])(
'skips when %s is untracked (another session may own it)',
(rel) => {
const { ran, push, repo } = setup({ failing: 'lint' });
mkdirSync(join(repo, rel, '..'), { recursive: true });
writeFileSync(join(repo, rel), 'wip\n');
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: skipping static checks: uncommitted changes under');
expect(ran()).toEqual([]);
}
);
it('skips when a tracked package.json has an unstaged edit', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
const pkg = join(repo, 'package.json');
writeFileSync(pkg, readFileSync(pkg, 'utf8') + '\n');
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('uncommitted changes under');
expect(ran()).toEqual([]);
});
it('still checks when the only uncommitted changes are outside the watched paths', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
mkdirSync(join(repo, 'docs'));
writeFileSync(join(repo, 'docs', 'notes.md'), 'draft\n');
writeFileSync(join(repo, 'README.md'), 'draft\n');
const r = push(['origin', 'main']);
expect(r.status).not.toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
expect(ran()).toEqual(expectedRuns);
});
it('watches exactly the paths the checks read', () => {
expect(PRE_PUSH_WATCHED_PATHS).toEqual([
'src',
'config',
'scripts',
'test',
'package.json',
'package-lock.json',
'install.sh',
]);
});
it('skips (never blocks) when node_modules is absent', () => {
const { ran, push } = setup({ failing: 'lint', nodeModules: false });
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('node_modules missing');
expect(ran()).toEqual([]);
});
});
describe('postinstall wiring', () => {
const postinstall = read('scripts/postinstall.js');
it('installs the pre-push hook through the shared module', () => {
expect(postinstall).toContain("import('./git-hooks.mjs')");
expect(postinstall).toContain('installPrePushHook(gitHooksDir)');
});
it('resolves the hooks dir through git, so worktrees work', () => {
expect(postinstall).toContain('resolveGitHooksDir(');
expect(postinstall).not.toContain("join(import.meta.dirname, '..', '.git', 'hooks')");
});
});