모드 테스트
이벤트를 발생시키고, Claude Code의 답변을 스텁하고, 버튼을 누르는 Claude Code 모드에 대한 자동화된 테스트를 작성합니다. 세션, 로그인, 네트워크가 필요하지 않습니다.
모드에 대한 자동화된 테스트를 작성하고 claude plugin test를 사용하여 셸에서 실행할 수 있습니다. 테스트는 훅이 처리하는 이벤트를 발생시키고 훅이 수행한 작업을 확인하므로 세션에 도달하기 전에 문제를 발견할 수 있습니다. 첫 번째 예제는 모드 만들기의 모드를 테스트합니다.
테스트 작성
테스트는 모드를 로드하고, Claude Code가 하는 방식으로 훅을 통해 이벤트를 보내고, 세션, 로그인, 네트워크 없이 훅이 수행한 작업을 확인합니다. 셸에서 claude plugin test를 사용하여 테스트를 실행하며, 각 테스트 파일은 claude-code/testing 모듈의 테스트 라이브러리인 테스트 키트를 가져옵니다.
각 테스트 파일에 first-mod.test.ts와 같이 .test.ts로 끝나는 이름을 지정하고 플러그인 디렉토리의 어디든지 저장합니다. 모든 테스트 파일에는 최소한 하나의 test()가 필요하며, 그렇지 않으면 declares no test(): nothing ran으로 실행이 실패합니다. 테스트 파일은 모드의 자체 파일과 형제 .ts 헬퍼를 가져올 수 있으므로 게임의 규칙과 같은 일반 함수를 키트 없이 단위 테스트할 수 있습니다.
이 테스트는 두 개의 도구 호출을 발생시키고, 모드 만들기의 /tally 명령을 실행하고, 회신이 둘 다 계산하는지 확인합니다. 첫 번째 줄은 스텁이며, Claude Code 대신 도구 호출에 답변합니다. 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' }))
// Raise 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')
})
셸에서 first-mod 디렉토리에서 테스트를 실행합니다:
claude plugin test
출력은 각 테스트와 통과 여부를 이름으로 지정하며, 실행마다 다양한 타이밍을 표시합니다:
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]
각 $.tool.call은 모드의 tool.call 훅을 통과했으며, 이는 개수에 1을 더하고 호출을 스텁으로 전달했습니다. ls는 실행되지 않았고 파일도 읽지 않았습니다. $.command.run은 모드의 command.run 훅으로 이동했으며, answer는 해당 훅이 반환한 객체입니다.
테스트가 실패하면 명령이 상태 1로 종료되므로 CI에서 작동합니다. 자신의 모드를 실행하는 셸에서 로드할 수 없으면 claude plugin test: hooks modules are turned off로 시작하는 줄을 이유와 함께 출력하고 상태 1로 종료합니다.
Claude Code가 답변할 내용 스텁하기
테스트에서는 모델, 저장소, 도구가 실행되지 않으므로 모드가 Claude Code의 답변을 기대하는 곳마다 테스트는 스텁으로 답변을 제공합니다. 테스트 함수는 다음 두 가지 인수를 받습니다:
$: 테스트의 자체$이며, Claude Code가 있는 곳에 서 있습니다. 훅이 받는 mods API가 아닙니다. 각 메서드는 같은 이름의 이벤트를 발생시키고, 모드의 훅을 통해 보내고, 결과로 해결됩니다:$.tool.call({ tool: 'Bash', command: 'ls' })는tool.call을 발생시킵니다.$.command.run,$.prompt.submit,$.session.start,$.turn.complete는 같은 방식으로 작동하며,$.classic.Stop및 기타$.classic메서드는 설정 훅 이벤트를 발생시킵니다. 테스트는ui.close와 같은 mods API 호출을 직접 발생시킬 수 없습니다. 예를 들어 창을 닫는 버튼을 눌러 모드를 통해 트리거합니다.on: 스텁을 등록하기 위해 호출합니다. 스텁은 Claude Code 대신 답변하는 훅입니다.$.없이 mods API 호출의 이름을 지정하므로store.get으로 등록된 스텁은 모드의$.store.get에 답변합니다. 모드가$.model.complete또는$.store.get을 호출할 때 스텁이 답변을 제공합니다.
이 예제는 모델 호출을 스텁합니다. 훅은 grader라는 모드에 속하며 문장을 모델로 보내고 회신이 PASS로 시작하는지 보고하는 /grade 명령을 처리합니다. 파일은 테스트 중인 훅만 포함하므로 모드에는 모드 만들기와 같이 plugin.json 및 hooks.json도 필요합니다. 세션에서 /grade를 입력하려면 모드도 명령을 등록해야 합니다:
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' }
})
}
이 테스트는 모델 호출을 스텁하여 훅이 통과 회신으로 수행하는 작업을 확인합니다:
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')
})
테스트는 훅의 reply가 value 아래의 객체이고 text가 PASS로 시작하기 때문에 통과합니다. 다른 분기를 확인하려면 스텁이 FAIL로 시작하는 text를 반환하는 두 번째 테스트를 추가하고 Try again을 기대합니다.
mods API 호출에 대한 스텁은 value 필드가 있는 객체를 반환하며, 이는 모드에서 호출이 해결되는 것을 보유합니다: { value: 7 }은 $.store.get이 7로 해결되도록 합니다. turn.step 또는 tool.call과 같은 Claude Code의 이벤트에 대한 스텁은 해당 이벤트의 자체 결과(예: { result: 'ok' })를 반환합니다. $.session.send 및 $.prompt.fill도 테이블이 표시하는 대로 이벤트의 결과를 사용합니다. 스텁이 반환하는 것 조회는 각 일반 이름이 취하는 형식을 보여줍니다. 두 가지 오류는 스텁이 잘못되었거나 누락되었음을 의미합니다. 실패한 테스트의 출력에는 the engine reported:로 시작하는 블록이 포함되며, 각 오류가 표시됩니다:
returned neither { value } nor { deny }: mods API 호출에 대한 스텁이 일반 값을 반환했습니다no implementation for다음에 이름: 모드가 해당 호출을 수행했고 스텁이 답변하지 않습니다
키트는 또한 전체 네임스페이스에 답변하는 메모리 내 모의 객체를 내보냅니다. mock.clock(on)은 $.clock에 답변하고, mock.store(on, { count: 7 })은 해당 항목으로 시작하는 저장소에서 $.store에 답변하고, mock.env(on, { CI: 'true' })는 해당 변수에서 $.env.get에 답변합니다. mock.clock은 테스트가 진행하는 모의 시계를 반환하므로 타이머 테스트는 대기하지 않습니다. mock.store은 아무것도 반환하지 않으므로 모드가 저장한 것을 확인하려면 그리기 테스트처럼 두 개의 store 스텁을 직접 작성합니다.
테스트 키트의 규칙 따르기
테스트 키트에는 자체 규칙이 몇 가지 있으며, 하나를 위반하면 새로운 테스트 작성자가 처음 만나는 오류가 발생합니다:
-
$의 첫 번째 호출 전에 모든 스텁을 등록합니다. 그 후에on을 호출하면on("ui.render") after the test first called $와 같은 오류가 발생합니다. -
session.start는 자체적으로 실행되지 않습니다. 각 테스트는 모듈이 새로 로드되고 훅이 호출되지 않은 상태로 시작되므로 모듈 수준 변수는 초기 값을 유지합니다. 훅이session.start가 설정하는 것에 의존하면 먼저 발생시킵니다:// 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 })) // Raise the event, which runs your session.start hook await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })두 번째 스텁은 튜토리얼과 같은
session.start훅이 수행하는$.command.register호출에 답변합니다. 없으면 해당 호출이no implementation for command.register로 거부되고 키트가 훅을 건너뛰므로 훅의 호출 후 아무것도 실행되지 않습니다. 테스트는 그 시점에서 실패하지 않습니다. 건너뛴 훅은 나중에 확인이 실패할 경우에만the engine reported:아래에 나열됩니다. -
next(e)를 반환하는 훅에는 답변할 스텁이 필요합니다. 예를 들어ui.render훅이next(e)를 반환할 때, Claude가 유휴 상태일 때 아무것도 그리지 않으려면 마운트가no implementation for ui.render로 실패합니다. 요소를 일반 데이터로 반환하는 스텁을 등록합니다:// Stands for what Claude Code would draw at the site on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))스텁이 등록되면 마운트가 성공하고,
ui.find({ type: 'Text' })는 훅이next(e)를 반환할 때마다 해당 요소를 반환합니다. -
turn.step에 대한 스텁은 비동기 생성기이며, 테스트는 결과를 얻기 위해 스트림을 끝까지 읽습니다: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 } }) // Raise 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루프가 끝나면
result는turn.step훅이 변경할 기회를 가진 후 스텁이 반환한 객체입니다. 여기서result.answer는'ok'입니다. -
도구 호출을 도구의 이름과 인수를 필드로 발생시킵니다, 예:
await $.tool.call({ tool: 'Bash', command: 'ls' }), 그리고{ result }를 반환하는tool.call스텁을 등록합니다.
스텁이 반환하는 것 조회
모드가 테스트에서 수행하는 모든 mods API 호출에는 Claude Code 대신 답변할 스텁이 필요합니다. 단, 키트가 자체적으로 답변하는 몇 가지는 제외됩니다: $.ui.invalidate 및 $.state 호출. $.clock 호출의 경우 mock.clock(on)을 사용하거나 모드의 $.clock.now()가 no implementation for clock.now로 실패합니다.
이 표는 모드가 가장 많이 사용하는 것들을 나열합니다. 첫 번째 열은 모드가 수행하거나 next(e)로 전달하는 호출 또는 이벤트입니다. 두 번째는 해당 이름 아래 on에 전달할 함수이므로 $.store.get 행은 on('store.get', ($, e) => ({ value: saved.get(e.key) }))가 됩니다. 스텁의 '...'는 채울 텍스트를 표시합니다:
| 모드가 호출하거나 전달하는 것 | 스텁 |
|---|---|
$.command.register, $.tool.register, $.ui.toast, $.ui.log, $.ui.status, $.ui.close, $.store.set |
() => ({ value: undefined }). ui.toast 및 ui.log의 경우 텍스트는 e.text입니다. |
$.store.get |
($, e) => ({ value: saved.get(e.key) }) |
$.fs.read |
($, e) => ({ value: e.path.endsWith('notes.md') ? '# Notes' : '' }). e.path는 절대 경로로 도착하므로 endsWith와 비교합니다. |
$.ui.open |
() => ({ value: { isPlaced: true } }) |
$.ui.ask |
tool.call 스텁입니다. 질문이 AskUserQuestion 도구에 대한 호출로 도착하기 때문입니다: ($, e) => ({ result: { answers: { [e.questions[0].question]: 'Run it' } } }). 모드가 다른 도구 호출을 전달하면 먼저 e.tool을 확인합니다. |
$.model.complete |
() => ({ value: { isAnswered: true, text: '...', usage } }) |
$.process.run |
($, e) => ({ value: { exitCode: 0, stdout: '...', stderr: '' } }). e.argv는 인수 목록이고 e.init은 cwd 및 timeoutMs를 보유합니다. |
| 실패해야 하는 모든 mods API 호출 | () => ({ deny: 'the reason' }). 이는 모드에서 호출이 거부되도록 합니다. 예외를 발생시키는 스텁은 대신 건너뜁니다. |
session.start |
() => ({ cwd: '/work' }) |
turn.start |
($, e) => ({ turnId: e.turnId }) |
tool.call |
() => ({ result: '...' }) |
turn.complete |
() => ({ text: '' }). $.turn.complete({ turnId, answer, durationMs, isAborted: false, usage: null })로 발생시킵니다. |
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는 모드가 { sessionId }를 전달했을 때도 문자열로 도착합니다. |
session.receive |
($, e) => ({ text: e.text }). $.session.receive({ origin: { kind: 'peer-send-message' }, text })로 발생시킵니다. |
ui.render |
() => ({ type: 'Text', props: {}, children: ['...'] }) |
expect는 toBe, toEqual, toMatch, toMatchObject, toContain, toBeDefined, toBeUndefined, toThrow 어설션을 가지며, 이들 중 어느 것 앞에도 .not을 사용할 수 있습니다.
타이머 테스트
타이머에서 작업을 실행하는 모드는 테스트가 대기하는 대신 시간을 앞으로 이동할 수 있도록 테스트가 제어하는 시계가 필요합니다. const clock = mock.clock(on)은 0에서 시작하고 테스트가 이동할 때만 이동하는 모의 시계를 반환합니다. 다른 시간에 시작하려면 mock.clock(on, { now: 5000 })과 같이 밀리초 단위로 전달합니다. 시계에는 다음 메서드가 있습니다:
| 메서드 | 수행하는 작업 |
|---|---|
await clock.advance(1000) |
시간을 해당 밀리초만큼 앞으로 이동하고 기한이 된 각 타이머를 실행합니다 |
await clock.set(5000) |
시간을 해당 값으로 앞으로 이동합니다. advance처럼 |
clock.now() |
시간을 반환합니다. 모드의 $.clock.now()가 해결되는 것입니다 |
await clock.settle() |
이미 기한이 된 타이머(예: 0 지연 $.clock.after 호출 체인)를 실행합니다. 시간을 이동하지 않습니다 |
await clock.sleep(2000) |
스텁 내부에서 테스트가 그 거리만큼 진행할 때까지만 해당 스텁이 답변하도록 합니다. 느린 모델이나 프로세스를 시뮬레이션하는 방법입니다 |
이 훅은 countdown이라는 모드에 속하며 초 단위로 숫자를 사용하는 /countdown 명령을 처리하고, 1초 $.clock.every 타이머를 시작하고, 0에서 토스트를 표시합니다. grader와 마찬가지로 파일은 테스트 중인 훅만 포함하고 명령을 등록하지 않습니다:
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 {}
})
}
이 테스트는 /countdown 3을 실행하고 모의 시계를 이동하므로 3초를 기다리지 않고 3초의 동작을 확인합니다:
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'])
})
첫 번째 expect는 토스트가 일찍 오지 않음을 보여주고, 두 번째는 한 번 옴을 보여줍니다. 각 advance는 기한이 된 타이머가 실행된 후 해결되므로 다음 줄의 확인은 그 효과를 봅니다.
그리기 테스트
테스트는 모드의 렌더 사이트 중 하나를 그리고, 그린 요소를 누르고, 입력하고, 찾을 수 있습니다. $.ui.mount는 모드의 ui.render 훅을 통해 사이트를 그리고 각각에 대한 메서드가 있는 핸들을 반환합니다. 한 테스트에서 여러 앱을 다루려면 surface를 그릴 앱으로 설정합니다. 이 테스트는 탭으로 창 만들기의 창을 열고, 탭을 전환하고, 버튼을 누르고, 터미널과 Desktop 앱의 개수를 확인합니다:
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)
})
셸에서 hello-tabs 디렉토리에서 claude plugin test를 실행합니다. 테스트는 두 앱이 모두 개수 줄을 그리고 모드가 2를 저장했을 때 통과합니다. 두 마운트가 모두 같은 로드된 모듈을 사용하기 때문에 개수는 첫 번째 앱에서 두 번째 앱으로 이월됩니다.
$.ui.mount가 반환하는 핸들에는 제공한 key로 요소를 주소 지정하는 다음 메서드가 있습니다:
| 메서드 | 수행하는 작업 |
|---|---|
press({ key: 'more' }) |
해당 키가 있는 Button을 누릅니다 |
input({ key: 'new-note', text: 'buy milk' }) |
텍스트를 해당 키가 있는 Input에 입력하고 Enter를 누릅니다. kind: 'change'를 추가하여 제출하지 않고 입력합니다. |
select({ key: 'size', value: 'large' }) |
해당 키가 있는 Select에서 해당 값이 있는 옵션을 선택합니다 |
find({ key: 'more' }) 또는 find({ type: 'Text', text: 'Count: 2' }) |
첫 번째 일치하는 요소를 { type, props, children }으로 반환하거나 undefined를 반환합니다. text는 문자열 또는 정규식일 수 있습니다. |
unmount() |
그리기를 제거합니다 |
각 메서드는 핸들러가 완료된 후 해결되므로 다음 줄에서 결과를 확인할 수 있습니다. props를 Claude Code가 해당 사이트에 전달할 것으로 설정합니다. 렌더 사이트 표는 각 사이트의 props를 나열하고, 빌드의 타입은 해당 타입을 가집니다.
그리기 테스트는 훅이 반환하는 트리와 해당 앱에 유효한지 확인합니다. 앱이 이를 그리는 방식을 확인하지 않으므로 실제 세션에서 새 레이아웃을 살펴봅니다.
`/clear` 후 그리기 테스트
각 테스트는 모든 $.state 값이 기본값으로 시작하며, 이는 /clear가 남기는 방식입니다. 모드가 다음에 수행하는 작업을 테스트하려면 session.start를 건너뛰고, source: 'clear'로 classic.SessionStart를 발생시키고, 모드가 그리는 것을 확인합니다.
이 테스트는 '/clear' 후 저장된 값 다시 로드의 모듈을 확인합니다. 그리기 테스트의 파일에 추가합니다. 여기서 PANE이 정의됩니다. 해당 파일의 첫 번째 테스트는 하나 이상의 세션에서 저장의 버튼처럼 버튼이 개수를 저장할 것으로 기대합니다:
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', () => ({}))
// Raise the event that fires after /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()
})
테스트는 classic.SessionStart 훅이 저장된 7을 창이 그리기 전에 $.state에 복사했을 때 통과합니다. 모듈에 해당 훅이 없으면 창이 Count: 0을 그리고, find가 undefined를 반환하고, 테스트가 toBeDefined에서 실패합니다.
다른 모드를 판단하는 모드 테스트
조직이 prependPlugins에 나열하는 모드는 다른 모드가 로드되기 전에 거부할 수 있습니다. 하나를 테스트하려면 모드의 계층을 설정하고 테스트에 모드가 허용하거나 거부할 두 번째 모드를 제공합니다:
tier: 테스트 파일의 맨 위에서 한 번 호출합니다. 예:tier('prepend')로 모드를prepend,append,builtin으로 로드합니다. 이는 모드가 실행되는 순서에서의 위치입니다. 없으면 모드가user로 로드됩니다.plugins: 테스트 본문 앞에test에 옵션 객체를 전달합니다. 해당plugins배열은 인라인으로 작성한 모드를 보유하며, 각각name및register함수를 가집니다.user이외의 곳에 하나를 로드하려면tier를 추가합니다.
이 테스트 파일은 관리 페이지의 정책 모드를 먼저 로드합니다. 정책 모드가 프로세스를 시작하는 모드를 거부하고 그렇지 않은 모드를 허용하는지 확인합니다:
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' })
})
셸에서 acme-guard 디렉토리에서 claude plugin test를 실행합니다. 두 테스트 모두 정책 모드가 관리 페이지에 표시된 대로 통과합니다.
키트는 테스트의 첫 번째 $ 호출에서 모든 모드를 로드합니다. 모드가 하나를 거부하면 해당 호출이 예외를 발생시키고, 메시지는 거부된 모드, 거부한 모드, 이유를 이름으로 지정합니다. 두 번째 테스트에서는 아무것도 거부되지 않으므로 reader가 스텁에 도달하기 전에 도구 호출에 답변합니다.