Mobile UI test gate
No mobile screen or component ships without its test
Blocks the session from ending when a React Native screen or component (src/screens/**/*.tsx, src/components/**/*.tsx — monorepo-prefix aware) was changed without its matching regression test (flat tests/screens|components/<Name>.test.tsx convention, same package prefix). Detects changes via git diff and untracked files. No-op on non-git projects.
What does the Mobile UI test gate hook do?
Mobile UI test gate is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. No mobile screen or component ships without its test.
As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.
Use cases
- Stop a React Native screen or component from shipping without its regression test
- Enforce a flat tests/screens|components convention across a mobile app
Tags
settings.json fragment
{
"hooks": {
"Stop": [
{
"hooks": [
{
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-mobile-ui-test-gate.mjs",
"type": "command"
}
]
}
]
}
}Script · .claude/hooks/stop-mobile-ui-test-gate.mjs
#!/usr/bin/env node
// @hookstack stop-mobile-ui-test-gate
// Bloque la fin de session lorsqu'un écran ou composant mobile est modifié sans
// test de régression ajusté dans le même diff (Stop).
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
function defaultExec(command, options) {
return execSync(command, { encoding: "utf8", timeout: 10_000, ...options });
}
function changedFiles(exec, projectDir) {
const modified = exec("git diff --name-only HEAD", { cwd: projectDir });
const untracked = exec("git ls-files --others --exclude-standard", {
cwd: projectDir,
});
return new Set(
[...modified.split("\n"), ...untracked.split("\n")]
.map((file) => file.trim())
.filter(Boolean),
);
}
// Capture le préfixe avant src/ (rien en repo simple, "apps/mobile/" en monorepo)
// pour que le test attendu reste dans le même package — jamais de mélange entre apps.
const UI_FILE_RE = /^(.*?)src\/(screens|components)\/.+\.tsx$/;
// La structure de tests est plate (tests/screens|components/<Name>.test.tsx),
// sans miroir des sous-dossiers de src/ (ex. src/components/game/*.tsx).
function expectedTest(sourceFile) {
const match = sourceFile.match(UI_FILE_RE);
if (!match) return null;
const [, prefix, kind] = match;
const base = sourceFile
.split("/")
.pop()
.replace(/\.tsx$/, ".test.tsx");
return `${prefix}tests/${kind}/${base}`;
}
export function run(
_input,
{
exec = defaultExec,
projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
} = {},
) {
let files;
try {
files = changedFiles(exec, projectDir);
} catch {
return null; // pas un dépôt git ou git absent → no-op
}
const changedUi = [...files].filter((file) => UI_FILE_RE.test(file));
const missingTests = changedUi.filter(
(sourceFile) => !files.has(expectedTest(sourceFile)),
);
if (!missingTests.length) return null;
return {
exitCode: 2,
message:
`[mobile-ui-test-gate] Test de régression mobile non ajusté pour :\n${missingTests
.map((file) => ` - ${file} (attendu : ${expectedTest(file)})`)
.join("\n")}\n` +
"→ Créer ou modifier le test correspondant avant de terminer.\n",
};
}
/* v8 ignore next 6 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
JSON.parse(readFileSync(0, "utf8"));
const result = run();
if (result?.message) process.stderr.write(result.message);
if (result?.exitCode) process.exit(result.exitCode);
}