SpyBara
Go Premium

plugins/mods/test.md 2026-10-01 23:59 UTC to 2026-10-02 19:58 UTC

This page contains 74 additions and 74 deletions.

2026
Thu 1 23:59 Fri 2 20:57

Einen Mod testen

Schreiben Sie automatisierte Tests für einen Claude Code-Mod, die Ereignisse auslösen, die Antworten von Claude Code durch Stubs ersetzen und Schaltflächen betätigen, ganz ohne Sitzung, Anmeldung oder Netzwerk.

Sie können automatisierte Tests für einen Mod schreiben und sie mit claude plugin test aus Ihrer Shell ausführen. Ein Test löst die Ereignisse aus, die Ihre Hooks verarbeiten, und prüft, was die Hooks getan haben, sodass Sie ein Problem erkennen, bevor es eine Sitzung erreicht. Das erste Beispiel testet den Mod aus Einen Mod erstellen.

Einen Test schreiben

Ein Test lädt Ihren Mod, sendet Events so durch seine Hooks, wie Claude Code es tun würde, und prüft, was die Hooks getan haben, ohne Sitzung, Anmeldung oder Netzwerk. Sie führen Tests in Ihrer Shell mit claude plugin test aus, und jede Testdatei importiert das Test-Kit, eine Testbibliothek im Modul claude-code/testing.

Geben Sie jeder Testdatei einen Namen, der auf .test.ts endet, etwa first-mod.test.ts, und speichern Sie sie an beliebiger Stelle im Plugin-Verzeichnis. Jede Testdatei benötigt mindestens ein test(), sonst schlägt der Lauf mit declares no test(): nothing ran fehl. Eine Testdatei kann die eigenen Dateien Ihres Mods und benachbarte .ts-Hilfsdateien importieren, sodass Sie einfache Funktionen, etwa die Regeln eines Spiels, ohne das Kit per Unit-Test prüfen können.

Dieser Test löst zwei Tool-Aufrufe aus, führt den Befehl /tally aus Einen Mod erstellen aus und prüft, dass die Antwort beide zählt. Seine erste Zeile ist ein Stub, der die Tool-Aufrufe anstelle von Claude Code beantwortet. Speichern Sie ihn als first-mod/tests/first-mod.test.ts:

import { expect, test } from 'claude-code/testing'

test('/tally reports the tool calls the mod has seen', async ($, on) => {
  // Answer each tool call in Claude Code's place, so no tool runs
  on('tool.call', () => ({ result: 'ok' }))

  // Fire two tool calls, which the mod's tool.call hook counts
  await $.tool.call({ tool: 'Bash', command: 'ls' })
  await $.tool.call({ tool: 'Read', file_path: 'README.md' })

  // Run /tally and check the text its hook returns
  const answer = await $.command.run({ command: 'tally', args: '' })
  expect(answer.text).toBe('Claude has made 2 tool calls since this mod loaded')
})

Führen Sie die Tests in Ihrer Shell im Verzeichnis first-mod aus:

claude plugin test

Die Ausgabe nennt jeden Test und ob er bestanden wurde, mit Zeitangaben, die von Lauf zu Lauf variieren:

tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [22.87ms]

 1 pass
 0 fail
Ran 1 test across 1 file. [0.19s]

Jeder $.tool.call lief durch den tool.call-Hook des Mods, der seinen Zähler um eins erhöhte und den Aufruf an den Stub weitergab. Es wurde kein ls ausgeführt und keine Datei gelesen. $.command.run ging dann an den command.run-Hook des Mods, und answer ist das Objekt, das dieser Hook zurückgegeben hat.

Der Befehl endet mit Status 1, wenn ein Test fehlschlägt, und eignet sich daher für CI. Wenn Ihre eigenen Mods in der Shell, in der er ausgeführt wird, nicht geladen werden können, gibt er eine Zeile aus, die mit claude plugin test: hooks modules are turned off beginnt und den Grund nennt, und endet mit Status 1.

Stubs für die Antworten von Claude Code

In einem Test laufen weder Modell noch Speicher noch Tool. Überall dort, wo Ihr Mod eine Antwort von Claude Code erwartet, liefert der Test die Antwort daher über einen Stub. Eine Testfunktion erhält dafür zwei Argumente:

  • $: das eigene $ des Tests, das als Claude Code fungiert. Es ist nicht die Mods-API, die ein Hook erhält. Jede seiner Methoden löst das gleichnamige Event aus, sendet es durch die Hooks Ihres Mods und liefert das Ergebnis: $.tool.call({ tool: 'Bash', command: 'ls' }) löst tool.call aus. $.command.run, $.prompt.submit, $.session.start und $.turn.complete funktionieren genauso, und $.classic.Stop sowie die anderen $.classic-Methoden lösen ein Einstellungs-Hook-Event aus. Ein Test kann einen Mods-API-Aufruf wie ui.close nicht direkt auslösen. Lösen Sie ihn über Ihren Mod aus, zum Beispiel indem Sie die Schaltfläche drücken, die den Bereich schließt.
  • on: Rufen Sie es auf, um Stubs zu registrieren. Das sind Hooks, die anstelle von Claude Code antworten. Benennen Sie einen Stub für einen Mods-API-Aufruf ohne $., sodass ein als store.get registrierter Stub das $.store.get Ihres Mods beantwortet. Wenn Ihr Mod $.model.complete oder $.store.get aufruft, liefert ein Stub die Antwort.

Dieses Beispiel ersetzt einen Modellaufruf durch einen Stub. Der Hook gehört zu einem Mod namens grader und verarbeitet einen Befehl /grade, der einen Satz an ein Modell sendet und meldet, ob die Antwort mit PASS beginnt. Die Datei enthält nur den zu testenden Hook, daher benötigt der Mod zusätzlich eine plugin.json und eine hooks.json, wie in Einen Mod erstellen. Damit Sie /grade in einer Sitzung eingeben können, muss der Mod außerdem den Befehl registrieren:

export function register(on) {
  on('command.run', { command: 'grade' }, async ($, e) => {
    // e.args is the text typed after /grade
    const reply = await $.model.complete({
      model: 'haiku',
      system: 'Grade the sentence. Start your reply with PASS or FAIL.',
      prompt: e.args,
    })
    const passed = reply.isAnswered && reply.text.startsWith('PASS')
    return { text: passed ? 'Passed' : 'Try again' }
  })
}

Dieser Test ersetzt den Modellaufruf durch einen Stub, um zu prüfen, was der Hook mit einer positiven Antwort macht:

import { expect, test } from 'claude-code/testing'

test('a passing grade is reported', async ($, on) => {
  // Answer the mod's $.model.complete call with a fixed reply, so no model runs
  on('model.complete', () => ({
    value: {
      isAnswered: true,
      text: 'PASS\nNice sentence.',
      usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
    },
  }))

  // Run /grade, which makes the mod call the model
  const answer = await $.command.run({ command: 'grade', args: 'The cat sat on the mat.' })
  expect(answer.text).toBe('Passed')
})

Der Test wird bestanden, weil reply im Hook das Objekt unter value ist, dessen text mit PASS beginnt. Um den anderen Zweig zu prüfen, fügen Sie einen zweiten Test hinzu, dessen Stub einen text zurückgibt, der mit FAIL beginnt, und erwarten Sie Try again.

Ein Stub für einen Mods-API-Aufruf gibt ein Objekt mit einem Feld value zurück, das enthält, was der Aufruf in Ihrem Mod liefert: { value: 7 } sorgt dafür, dass $.store.get den Wert 7 liefert. Ein Stub für eines der Events von Claude Code, etwa turn.step oder tool.call, gibt das eigene Ergebnis dieses Events zurück, etwa { result: 'ok' }. $.session.send und $.prompt.fill erwarten ebenfalls das Ergebnis ihres Events, wie die Tabelle zeigt. Nachschlagen, was ein Stub zurückgibt zeigt, welche Form jeder gängige Name annimmt. Die folgenden Fehler bedeuten, dass ein Stub falsch ist oder fehlt. Die Ausgabe eines fehlgeschlagenen Tests enthält einen Block mit der Überschrift the engine reported:, in dem jeder Fehler erscheint:

  • returned neither { value } nor { deny }: Ein Stub für einen Mods-API-Aufruf hat einen bloßen Wert zurückgegeben
  • no implementation for, gefolgt von einem Namen: Ihr Mod hat diesen Aufruf ausgeführt, und kein Stub beantwortet ihn

Das Kit exportiert außerdem In-Memory-Mocks, die einen ganzen Namespace für Sie beantworten. mock.clock(on) beantwortet $.clock, mock.store(on, { count: 7 }) beantwortet $.store aus einem Speicher, der mit diesen Einträgen beginnt, und mock.env(on, { CI: 'true' }) beantwortet $.env.get aus diesen Variablen. mock.clock gibt eine Mock-Uhr zurück, die Ihr Test vorstellt, sodass ein Test eines Timers nicht warten muss. mock.store gibt nichts zurück. Um zu prüfen, was Ihr Mod gespeichert hat, schreiben Sie die beiden store-Stubs daher selbst, wie es der Zeichentest tut.

Die Regeln des Test-Kits befolgen

Das Test-Kit hat einige eigene Regeln, und ein Verstoß dagegen erzeugt die Fehler, auf die neue Testautoren zuerst stoßen:

  • Registrieren Sie jeden Stub vor dem ersten Aufruf des Tests auf $. Ein Aufruf von on danach löst einen Fehler wie on("ui.render") after the test first called $ aus.

  • session.start läuft nicht von selbst. Jeder Test beginnt mit Ihrem frisch geladenen Modul, ohne dass einer seiner Hooks aufgerufen wurde, sodass Variablen auf Modulebene ihre Anfangswerte haben. Wenn ein Hook von dem abhängt, was session.start einrichtet, lösen Sie es zuerst aus:

    // Answer the event after your hook passes it on with next(e)
    on('session.start', () => ({ cwd: '/work' }))
    // Answer the $.command.register call your hook makes
    on('command.register', () => ({ value: undefined }))
    // Fire the event, which runs your session.start hook
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
    

    Der zweite Stub beantwortet den $.command.register-Aufruf, den ein session.start-Hook wie der aus dem Tutorial ausführt. Ohne ihn wird dieser Aufruf mit no implementation for command.register abgelehnt, und das Kit überspringt Ihren Hook, sodass nichts nach dem Aufruf im Hook ausgeführt wird. Der Test schlägt an dieser Stelle nicht fehl. Der übersprungene Hook wird unter the engine reported: nur aufgeführt, wenn eine spätere Prüfung fehlschlägt.

  • Ein Hook, der next(e) zurückgibt, benötigt einen Stub, der antwortet. Wenn Ihr ui.render-Hook next(e) zurückgibt, etwa um nichts zu zeichnen, während Claude untätig ist, schlägt das Mounten mit no implementation for ui.render fehl. Registrieren Sie einen Stub, der ein Element als einfache Daten zurückgibt:

    // Stands for what Claude Code would draw at the site
    on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))
    

    Mit registriertem Stub gelingt das Mounten, und ui.find({ type: 'Text' }) gibt dieses Element zurück, wann immer Ihr Hook next(e) zurückgegeben hat.

  • Ein Stub für turn.step ist ein asynchroner Generator, und der Test liest den Stream bis zum Ende, um das Ergebnis zu erhalten:

    on('turn.step', async function* ($, e) {
      // Each yield is one piece of the model's streamed reply
      yield { kind: 'text', index: 0, text: 'ok' }
      // The return value is the result of the whole request
      return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: null }
    })
    
    // Fire one request to the model, which runs your turn.step hook
    const stream = $.turn.step({ turnId: 't', index: 0, model: 'claude-test', messageCount: 1 })
    // Read every piece until the stream says it's done
    let step = await stream.next()
    while (step.done !== true) step = await stream.next()
    const result = step.value
    

    Wenn die Schleife endet, ist result das Objekt, das der Stub zurückgegeben hat, nachdem Ihr turn.step-Hook die Gelegenheit hatte, es zu ändern. Hier ist result.answer gleich 'ok'.

  • Lösen Sie einen Tool-Aufruf mit dem Namen und den Argumenten des Tools als Felder aus, etwa await $.tool.call({ tool: 'Bash', command: 'ls' }), und registrieren Sie einen tool.call-Stub, der { result } zurückgibt.

Nachschlagen, was ein Stub zurückgibt

Jeder Mods-API-Aufruf, den Ihr Mod in einem Test ausführt, benötigt einen Stub, der anstelle von Claude Code antwortet, mit Ausnahme der wenigen, die das Kit selbst beantwortet: Aufrufe von $.ui.invalidate und $.state. Verwenden Sie für $.clock-Aufrufe mock.clock(on), sonst schlägt $.clock.now() in Ihrem Mod mit no implementation for clock.now fehl.

Diese Tabelle listet die Aufrufe auf, die Mods am häufigsten verwenden. Die erste Spalte ist der Aufruf, den Ihr Mod ausführt, oder das Event, das er mit next(e) weitergibt. Die zweite ist die Funktion, die Sie unter diesem Namen an on übergeben, sodass aus der Zeile $.store.get der Aufruf on('store.get', ($, e) => ({ value: saved.get(e.key) })) wird. Ein '...' in einem Stub markiert Text, den Sie selbst einsetzen:

Ihr Mod ruft auf oder gibt weiter Stub
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set () => ({ value: undefined }). Bei ui.toast und ui.log ist der Text e.text.
$.store.get ($, e) => ({ value: saved.get(e.key) })
$.fs.read ($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path kommt als absoluter Pfad an, vergleichen Sie daher mit endsWith.
$.ui.open () => ({ value: { isPlaced: true } })
$.ui.ask Ein tool.call-Stub, da die Frage ihn als Aufruf des Tools AskUserQuestion erreicht: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). Prüfen Sie zuerst e.tool, wenn Ihr Mod andere Tool-Aufrufe weitergibt.
$.model.complete () => ({ value: { isAnswered: true, text: '...', usage } })
$.process.run ($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv ist die Argumentliste, und e.init enthält cwd und timeoutMs.
Jeder Mods-API-Aufruf, der fehlschlagen soll () => ({ deny: 'the reason' }), wodurch der Aufruf in Ihrem Mod abgelehnt wird. Ein Stub, der eine Ausnahme auslöst, wird stattdessen übersprungen.
session.start () => ({ cwd: '/work' })
turn.start ($, e) => ({ turnId: e.turnId })
tool.call () => ({ result: '...' })
turn.complete () => ({ text: '' }). Lösen Sie es mit $.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null }) aus.
prompt.submit ($, e) => ({ text: e.text })
prompt.fill () => ({ isFilled: true })
$.prompt.read () => ({ value: { text: '...', cursor: 0 } })
$.ui.copy () => ({ value: { isCopied: true } })
$.session.messages () => ({ value: [{ role: 'assistant', text: '...', toolUses: [] }] })
$.session.id, $.agent.list () => ({ value: 'abc123' }), () => ({ value: [] })
session.send () => ({ isDelivered: true }). e.to kommt als String an, auch wenn Ihr Mod { sessionId } übergeben hat.
session.receive ($, e) => ({ text: e.text }). Lösen Sie es mit $.session.receive({ origin: { kind: 'peer-send-message' }, text }) aus.
ui.render () => ({ type: 'Text', props: {}, children: ['...'] })

expect bietet die Assertions toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined und toThrow sowie .not vor jeder von ihnen.

Einen Timer testen

Ein Mod, der Arbeit über einen Timer ausführt, benötigt eine Uhr, die der Test steuert, damit der Test die Zeit vorwärts bewegen kann, anstatt zu warten. const clock = mock.clock(on) gibt eine Mock-Uhr zurück, die bei 0 beginnt und sich nur bewegt, wenn Ihr Test sie bewegt. Um bei einer anderen Zeit zu beginnen, übergeben Sie diese in Millisekunden, wie in mock.clock(on, { now: 5000 }). Die Uhr hat folgende Methoden:

Methode Was sie tut
await clock.advance(1000) Bewegt die Zeit um so viele Millisekunden vorwärts und führt jeden Timer aus, der fällig wird
await clock.set(5000) Bewegt die Zeit auf diesen Wert vorwärts, wie es advance tun würde
clock.now() Gibt die Zeit zurück, zu der das $.clock.now() Ihres Mods aufgelöst wird
await clock.settle() Führt Timer aus, die bereits fällig sind, etwa eine Kette von $.clock.after-Aufrufen ohne Verzögerung, ohne die Zeit zu bewegen
await clock.sleep(2000) Lässt innerhalb eines Stubs diesen Stub erst antworten, wenn der Test so weit vorgerückt ist; so simulieren Sie ein langsames Modell oder einen langsamen Prozess

Dieser Hook gehört zu einem Mod namens countdown und verarbeitet einen /countdown-Befehl, der eine Anzahl von Sekunden entgegennimmt, einen einsekündigen $.clock.every-Timer startet und bei null einen Toast anzeigt. Wie bei grader enthält die Datei nur den zu testenden Hook und registriert den Befehl nicht:

export function register(on) {
  on('command.run', { command: 'countdown' }, async ($, e) => {
    // e.args is the text typed after /countdown
    let left = Number(e.args)
    const timer = $.clock.every(1000, () => {
      left -= 1
      if (left === 0) {
        timer.cancel()
        $.ui.toast('Time is up')
      }
    })
    // Print nothing in the transcript
    return {}
  })
}

Dieser Test führt /countdown 3 aus und bewegt die Mock-Uhr, sodass er drei Sekunden Verhalten prüft, ohne drei Sekunden zu warten:

import { expect, mock, test } from 'claude-code/testing'

test('the countdown ends with a toast', async ($, on) => {
  // Answer every $.clock call from a clock the test controls
  const clock = mock.clock(on)
  // Collect the text of each toast the mod shows
  const toasts: string[] = []
  on('ui.toast', ($, e) => {
    toasts.push(e.text)
    return { value: undefined }
  })

  await $.command.run({ command: 'countdown', args: '3' })
  // After two seconds the timer has fired twice, and no toast is due
  await clock.advance(2000)
  expect(toasts).toEqual([])
  // The third second brings the count to zero
  await clock.advance(1000)
  expect(toasts).toEqual(['Time is up'])
})

Das erste expect zeigt, dass der Toast nicht zu früh erscheint, und das zweite zeigt, dass er genau einmal erscheint. Jedes advance wird aufgelöst, nachdem die fällig gewordenen Timer ausgeführt wurden, sodass die Prüfung in der nächsten Zeile deren Wirkung sieht.

Eine Zeichnung testen

Ein Test kann eine der Render-Stellen Ihres Mods zeichnen und dann die gezeichneten Elemente drücken, in sie tippen und sie finden. $.ui.mount zeichnet die Stelle über den ui.render-Hook Ihres Mods und gibt ein Handle mit einer Methode für jede dieser Aktionen zurück. Um mehrere Apps in einem Test abzudecken, setzen Sie surface auf die App, für die gezeichnet werden soll. Dieser Test öffnet den Bereich aus Einen Bereich mit Tabs erstellen, wechselt die Tabs, drückt die Schaltfläche und prüft den Zähler im Terminal und in der Desktop-App:

import { expect, test } from 'claude-code/testing'

// What Claude Code passes to a ui.render hook for this pane, apart from the app
const PANE = {
  plugin: 'hello-tabs',
  component: 'Pane',
  requestId: 'hello-tabs',
  viewport: { columns: 100, rows: 30 },
  props: {
    title: 'Hello tabs',
    isFocused: true,
    bodyColumns: 60,
    placement: 'inline',
    scroll: { offset: 0, bodyRows: 10 },
    view: {},
  },
} as const

test('the second tab counts presses and saves the count', async ($, on) => {
  // Stub $.store with a Map, so the test can read what the mod saved
  const saved = new Map<string, unknown>()
  on('store.get', ($, e) => ({ value: saved.get(e.key) }))
  on('store.set', ($, e) => {
    saved.set(e.key, e.value)
    return { value: undefined }
  })

  // Draw the pane once for each app
  for (const surface of ['terminal', 'desktop'] as const) {
    const ui = await $.ui.mount({ ...PANE, surface })
    // Press the buttons by the key the mod gave them
    await ui.press({ key: 'tab-two' })
    await ui.press({ key: 'more' })
    // The second tab's count line is in the drawing
    expect(await ui.find({ type: 'Text', text: /^Count: \d+$/ })).toBeDefined()
    await ui.unmount()
  }

  // One press in each app makes two
  expect(saved.get('count')).toBe(2)
})

Führen Sie in Ihrer Shell claude plugin test im Verzeichnis hello-tabs aus. Der Test ist erfolgreich, wenn beide Apps die Zählerzeile zeichnen und der Mod 2 gespeichert hat. Der Zähler wird von der ersten App in die zweite übernommen, weil beide Mounts dasselbe geladene Modul verwenden.

Das Handle, das $.ui.mount zurückgibt, hat folgende Methoden, die Elemente über den key ansprechen, den Sie ihnen gegeben haben:

Methode Funktion
press({ key: 'more' }) Drückt den Button mit diesem Key
input({ key: 'new-note', text: 'buy milk' }) Tippt den Text in das Input mit diesem Key und drückt die Eingabetaste. Fügen Sie kind: 'change' hinzu, um zu tippen, ohne abzusenden.
select({ key: 'size', value: 'large' }) Wählt die Option mit diesem Wert im Select mit diesem Key aus
find({ key: 'more' }) oder find({ type: 'Text', text: 'Count: 2' }) Gibt das erste passende Element als { type, props, children } zurück oder undefined. text kann ein String oder ein regulärer Ausdruck sein.
unmount() Entfernt die Zeichnung

Jede Methode wird aufgelöst, nachdem Ihr Handler fertig ist, sodass Sie das Ergebnis in der nächsten Zeile prüfen können. Setzen Sie props auf das, was Claude Code für diese Stelle übergeben würde. Die Tabelle der Render-Stellen listet die Props jeder Stelle auf, und die Typen für Ihren Build enthalten deren Typen.

Ein Zeichnungstest prüft den Baum, den Ihr Hook zurückgibt, und ob er für diese App gültig ist. Er prüft nicht, wie die App ihn darstellt. Sehen Sie sich ein neues Layout daher auch in einer echten Sitzung an.

Eine Zeichnung nach `/clear` testen

Jeder Test beginnt mit allen $.state-Werten auf ihrem Standardwert, so wie /clear sie hinterlässt. Um zu testen, was Ihr Mod danach tut, überspringen Sie session.start, lösen classic.SessionStart mit source: 'clear' aus und prüfen, was Ihr Mod zeichnet.

Dieser Test prüft das Modul aus Einen gespeicherten Wert nach /clear erneut laden. Fügen Sie ihn der Datei aus Eine Zeichnung testen hinzu, in der PANE definiert ist. Der erste Test dieser Datei erwartet, dass die Schaltfläche den Zähler speichert, wie es die Schaltfläche in Aus mehr als einer Sitzung speichern tut:

test('the saved count comes back after /clear', async ($, on) => {
  // The store already holds a count of 7
  on('store.get', () => ({ value: 7 }))
  // Answer the event after your hook passes it on with next(e)
  on('classic.SessionStart', () => ({}))

  // Fire the event that follows /clear, which runs your hook
  await $.classic.SessionStart({ source: 'clear' })

  const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
  await ui.press({ key: 'tab-two' })
  // The pane shows the stored count, not the default of 0
  expect(await ui.find({ type: 'Text', text: 'Count: 7' })).toBeDefined()
})

Der Test ist erfolgreich, wenn Ihr classic.SessionStart-Hook die gespeicherte 7 in $.state kopiert hat, bevor der Bereich gezeichnet wird. Ohne diesen Hook in Ihrem Modul zeichnet der Bereich Count: 0, find gibt undefined zurück und der Test schlägt bei toBeDefined fehl.

Einen Richtlinien-Mod testen

Ein Mod, den Ihre Organisation in prependPlugins aufführt, kann einen anderen Mod ablehnen, bevor dieser geladen wird. Um einen solchen Mod zu testen, legen Sie die Stufe Ihres Mods fest und geben dem Test einen zweiten Mod, den Ihr Mod zulassen oder ablehnen soll:

  • tier: Rufen Sie diese Funktion einmal am Anfang der Testdatei auf, etwa als tier('prepend'), um Ihren Mod als prepend, append oder builtin zu laden, also an seiner Position in der Reihenfolge, in der Mods ausgeführt werden. Ohne diesen Aufruf wird Ihr Mod als user geladen.
  • plugins: Übergeben Sie test vor dem Testkörper ein Optionsobjekt. Dessen plugins-Array enthält Mods, die Sie inline schreiben, jeweils mit einem name und einer register-Funktion. Um einen davon an einer anderen Stelle als user zu laden, fügen Sie ihm tier hinzu.

Diese Testdatei lädt den Richtlinien-Mod von der Admin-Seite zuerst. Sie prüft, dass der Richtlinien-Mod einen Mod ablehnt, der einen Prozess startet, und einen Mod zulässt, der keinen startet:

import { expect, test, tier } from 'claude-code/testing'

// Load the mod under test ahead of every other mod
tier('prepend')

// A second mod whose code calls $.process.run, which the policy blocks
const runner = {
  name: 'runner',
  register(on) {
    on('tool.call', async ($, e, next) => {
      await $.process.run(['ls'])
      return { result: 'runner answered' }
    })
  },
}

// A second mod that calls nothing the policy blocks
const reader = {
  name: 'reader',
  register(on) {
    on('tool.call', async ($, e, next) => {
      return { result: 'reader answered' }
    })
  },
}

test('refuses a mod that starts a process', { plugins: [runner] }, async ($, on) => {
  on('tool.call', () => ({ result: 'claude code answered' }))
  let message = ''
  try {
    // The first call on $ loads the mods, so the refusal is thrown here
    await $.tool.call({ tool: 'Bash', command: 'ls' })
  } catch (error) {
    message = error.message
  }
  expect(message).toBe('runner: refused by acme-guard: Acme policy: mods may not call process.run')
})

test('admits a mod that starts no process', { plugins: [reader] }, async ($, on) => {
  on('tool.call', () => ({ result: 'claude code answered' }))
  const out = await $.tool.call({ tool: 'Bash', command: 'ls' })
  // The answer comes from reader, which shows that it loaded
  expect(out).toEqual({ result: 'reader answered' })
})

Führen Sie in Ihrer Shell claude plugin test im Verzeichnis acme-guard aus. Beide Tests bestehen mit dem Richtlinien-Mod in der Form, wie ihn die Admin-Seite zeigt.

Das Kit lädt jeden Mod beim ersten Aufruf des Tests auf $. Wenn Ihr Mod einen Mod ablehnt, löst dieser Aufruf einen Fehler aus, und die Meldung nennt den abgelehnten Mod, den Mod, der ihn abgelehnt hat, und Ihre Begründung. Im zweiten Test wird nichts abgelehnt, daher beantwortet reader den Tool-Aufruf, bevor dieser den Stub erreicht.

Nächste Schritte