mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
resolveGitHooksDir now returns a directory only when it is the repo's own <git-common-dir>/hooks (compared on canonical paths), so a core.hooksPath elsewhere, global or repo-local, is never written to by postinstall, while a core.hooksPath pointing back at the repo's own .git/hooks still resolves. The pre-push hook skips with a one-line notice when a pushed ref is not the checked-out HEAD (tags peeled) or when git status shows uncommitted or untracked changes under a path the checks read (src, config, scripts, test, package.json, package-lock.json, install.sh), since the checks read the working tree rather than the pushed commit. Also: honest timing (~10-40s instead of ~15s), CLAUDE.md Session Safety note on CODEMAN_SKIP_PREPUSH for another session's WIP, 14 (not 9) Playwright tests, and a note that the browser-excludes check only sees direct imports.
153 lines
5.9 KiB
JavaScript
153 lines
5.9 KiB
JavaScript
#!/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();
|
|
}
|