Passer au contenu principal
failproofai dispose de deux suites de tests : les tests unitaires (rapides, avec mocks) et les tests de bout en bout (invocations réelles de sous-processus).

Exécution des tests

# Exécuter tous les tests unitaires une seule fois
bun run test:run

# Exécuter les tests unitaires en mode watch
bun run test

# Exécuter les tests E2E (nécessite une configuration préalable - voir ci-dessous)
bun run test:e2e

# Vérification des types sans compilation
bunx tsc --noEmit

# Lint
bun run lint

Tests unitaires

Les tests unitaires se trouvent dans __tests__/ et utilisent Vitest avec happy-dom.
__tests__/
  hooks/
    builtin-policies.test.ts      # Logique des politiques pour chaque builtin
    hooks-config.test.ts          # Chargement de la config et fusion des scopes
    policy-evaluator.test.ts      # Injection de paramètres et ordre d'évaluation
    custom-hooks-registry.test.ts # Registre globalThis : add/get/clear
    custom-hooks-loader.test.ts   # Chargeur ESM, imports transitifs, gestion des erreurs
    manager.test.ts               # Opérations install/remove/list
  components/
    sessions-list.test.tsx        # Composant liste des sessions
    project-list.test.tsx         # Composant liste des projets
    ...
  lib/
    logger.test.ts
    paths.test.ts
    date-filters.test.ts
    telemetry.test.ts
    ...
  actions/
    get-hooks-config.test.ts
    get-hook-activity.test.ts
    ...
  contexts/
    ThemeContext.test.tsx
    AutoRefreshContext.test.tsx

Écrire un test unitaire pour une politique

import { describe, it, expect, beforeEach } from "vitest";
import { getBuiltinPolicies } from "../../src/hooks/builtin-policies";
import { allow, deny } from "../../src/hooks/policy-types";

describe("block-sudo", () => {
  const policy = getBuiltinPolicies().find((p) => p.name === "block-sudo")!;

  it("denies sudo commands", () => {
    const ctx = {
      eventType: "PreToolUse" as const,
      payload: {},
      toolName: "Bash",
      toolInput: { command: "sudo apt install nodejs" },
      params: { allowPatterns: [] },
    };
    expect(policy.fn(ctx)).toEqual(deny("sudo command blocked by failproofai"));
  });

  it("allows non-sudo commands", () => {
    const ctx = {
      eventType: "PreToolUse" as const,
      payload: {},
      toolName: "Bash",
      toolInput: { command: "ls -la" },
      params: { allowPatterns: [] },
    };
    expect(policy.fn(ctx)).toEqual(allow());
  });

  it("allows patterns in allowPatterns", () => {
    const ctx = {
      eventType: "PreToolUse" as const,
      payload: {},
      toolName: "Bash",
      toolInput: { command: "sudo systemctl status nginx" },
      params: { allowPatterns: ["sudo systemctl status"] },
    };
    expect(policy.fn(ctx)).toEqual(allow());
  });
});

Tests de bout en bout

Les tests E2E invoquent le binaire failproofai réel en tant que sous-processus, transmettent un payload JSON via stdin et vérifient la sortie stdout ainsi que le code de sortie. Cela teste le chemin d’intégration complet utilisé par Claude Code.

Configuration

Les tests E2E exécutent le binaire directement depuis les sources du dépôt. Avant la première exécution, compilez le bundle CJS utilisé par les fichiers de hooks personnalisés lorsqu’ils importent depuis 'failproofai' :
bun build src/index.ts --outdir dist --target node --format cjs
Puis lancez les tests :
bun run test:e2e
Recompilez dist/ chaque fois que vous modifiez l’API publique des hooks (src/hooks/custom-hooks-registry.ts, src/hooks/policy-helpers.ts ou src/hooks/policy-types.ts).

Structure des tests E2E

__tests__/e2e/
  helpers/
    hook-runner.ts      # Lance le binaire, transmet le payload JSON, capture le code de sortie + stdout + stderr
    fixture-env.ts      # Répertoires temporaires isolés par test avec fichiers de config
    payloads.ts         # Factories de payloads fidèles à Claude pour chaque type d'événement
  hooks/
    builtin-policies.e2e.test.ts   # Chaque politique builtin avec un vrai sous-processus
    custom-hooks.e2e.test.ts       # Chargement et évaluation des hooks personnalisés
    config-scopes.e2e.test.ts      # Fusion de config entre scopes project/local/global
    policy-params.e2e.test.ts      # Injection de paramètres pour chaque politique paramétrée

Utilisation des utilitaires E2E

FixtureEnv — environnement isolé par test :
import { createFixtureEnv } from "../helpers/fixture-env";

const env = createFixtureEnv();
// env.cwd    - répertoire temporaire ; à passer comme payload.cwd pour charger .failproofai/policies-config.json
// env.home   - répertoire home isolé ; aucune fuite depuis le vrai ~/.failproofai

env.writeConfig({
  enabledPolicies: ["block-sudo"],
  policyParams: {
    "block-sudo": { allowPatterns: ["sudo systemctl status"] },
  },
});
createFixtureEnv() enregistre automatiquement le nettoyage via afterEach. runHook — invoquer le binaire :
import { runHook } from "../helpers/hook-runner";
import { Payloads } from "../helpers/payloads";

const result = await runHook(
  "PreToolUse",
  Payloads.preToolUse.bash("sudo apt install nodejs", env.cwd),
  { homeDir: env.home }
);

expect(result.exitCode).toBe(0);
expect(result.parsed?.hookSpecificOutput?.permissionDecision).toBe("deny");
Payloads — factories de payloads prêts à l’emploi :
Payloads.preToolUse.bash(command, cwd)
Payloads.preToolUse.write(filePath, content, cwd)
Payloads.preToolUse.read(filePath, cwd)
Payloads.postToolUse.bash(command, output, cwd)
Payloads.postToolUse.read(filePath, content, cwd)
Payloads.notification(message, cwd)
Payloads.stop(cwd)

Écrire un test E2E

import { describe, it, expect } from "vitest";
import { createFixtureEnv } from "../helpers/fixture-env";
import { runHook } from "../helpers/hook-runner";
import { Payloads } from "../helpers/payloads";

describe("block-rm-rf (E2E)", () => {
  it("denies rm -rf", async () => {
    const env = createFixtureEnv();
    env.writeConfig({ enabledPolicies: ["block-rm-rf"] });

    const result = await runHook(
      "PreToolUse",
      Payloads.preToolUse.bash("rm -rf /", env.cwd),
      { homeDir: env.home }
    );

    expect(result.exitCode).toBe(0);
    expect(result.parsed?.hookSpecificOutput?.permissionDecision).toBe("deny");
  });

  it("allows non-recursive rm", async () => {
    const env = createFixtureEnv();
    env.writeConfig({ enabledPolicies: ["block-rm-rf"] });

    const result = await runHook(
      "PreToolUse",
      Payloads.preToolUse.bash("rm /tmp/file.txt", env.cwd),
      { homeDir: env.home }
    );

    expect(result.exitCode).toBe(0);
    expect(result.stdout).toBe("");  // allow → stdout vide
  });
});

Formats de réponse E2E

DécisionCode de sortiestdout
PreToolUse deny0{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"..."}}
PostToolUse deny0{"hookSpecificOutput":{"additionalContext":"Blocked ... because: ..."}}
Instruct (hors Stop)0{"hookSpecificOutput":{"additionalContext":"Instruction from failproofai: ..."}}
Stop instruct2stdout vide ; raison dans stderr
Allow0chaîne vide

Configuration Vitest

Les tests E2E utilisent vitest.config.e2e.mts avec :
  • environment: "node" — aucune variable globale navigateur requise
  • pool: "forks" — véritable isolation de processus (les tests lancent des sous-processus)
  • testTimeout: 20_000 — 20 s par test (démarrage du binaire + évaluation du hook)
Le pool forks est essentiel : les workers basés sur des threads partagent globalThis, ce qui peut interférer avec les tests qui lancent des sous-processus. Les forks basés sur des processus évitent ce problème.

Intégration continue

L’exécution CI complète (bun run lint && bunx tsc --noEmit && bun run test:run && bun run build) doit passer avant toute fusion. La suite E2E s’exécute en tant que job CI distinct, en parallèle. Consultez Contributing pour la liste de contrôle complète avant fusion.