מדריך קלוד קוד בעברית

תיעוד 81

בדיקת מוד

כתבו בדיקות אוטומטיות למוד של Claude Code שיורות אירועים, שמות stub במקום התשובות של Claude Code, ולוחצות על כפתורים, בלי סשן, בלי התחברות ובלי רשת.

אפשר לכתוב בדיקות אוטומטיות למוד ולהריץ אותן מהמעטפת עם claude plugin test. בדיקה יורה את האירועים שה-hooks שלכם מטפלים בהם ובודקת מה ה-hooks עשו, כדי שתתפסו בעיה לפני שהיא מגיעה לסשן. הדוגמה הראשונה בודקת את המוד מתוך יצירת מוד.

#כתיבת בדיקה

בדיקה טוענת את המוד שלכם, שולחת אירועים דרך ה-hooks שלו כמו ש-Claude Code היה עושה, ובודקת מה ה-hooks עשו, בלי סשן, בלי התחברות ובלי רשת. מריצים בדיקות מהמעטפת עם claude plugin test, וכל קובץ בדיקה מייבא את ערכת הבדיקות, ספריית בדיקות במודול claude-code/testing.

תנו לכל קובץ בדיקה שם שמסתיים ב-.test.ts, למשל first-mod.test.ts, ושמרו אותו בכל מקום בתיקיית התוסף. כל קובץ בדיקה צריך לפחות test() אחד, אחרת ההרצה נכשלת עם declares no test(): nothing ran. קובץ בדיקה יכול לייבא את הקבצים של המוד עצמו ועוזרי .ts אחים, כך שאפשר לבדוק ביחידה פונקציות רגילות, למשל כללים של משחק, בלי הערכה.

הבדיקה הזו יורה שתי קריאות כלי, מריצה את הפקודה /tally מתוך יצירת מוד, ובודקת שהתשובה סופרת את שתיהן. השורה הראשונה שלה היא stub, שעונה על קריאות הכלי במקום 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' }))

  // 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')
})

במעטפת, הריצו את הבדיקות מתוך התיקייה 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 עבר דרך ה-hook tool.call של המוד, שהוסיף אחד לספירה והעביר את הקריאה הלאה ל-stub. שום ls לא רץ ושום קובץ לא נקרא. אחר כך $.command.run הגיע ל-hook command.run של המוד, ו-answer הוא האובייקט שה-hook הזה החזיר.

הפקודה יוצאת עם סטטוס 1 כשבדיקה נכשלת, ולכן היא עובדת ב-CI. אם המודים שלכם לא יכולים להיטען במעטפת שמריצה אותה, היא מדפיסה שורה שמתחילה ב-claude plugin test: hooks modules are turned off עם הסיבה, ויוצאת עם סטטוס 1.

#stub: מה ש-Claude Code היה עונה

אף מודל, מאגר או כלי לא רץ בבדיקה, ולכן בכל מקום שהמוד שלכם מצפה ש-Claude Code יענה, הבדיקה מספקת את התשובה עם stub. פונקציית בדיקה מקבלת שני ארגומנטים בשביל זה:

  • $: ה-$ של הבדיקה עצמה, שפועל כמו Claude Code. זה לא ה-API של mods ש-hook מקבל. כל אחת מהמתודות שלו יורה את האירוע באותו שם, שולחת אותו דרך ה-hooks של המוד שלכם, ומתרחשת לתוצאה: $.tool.call({ tool: 'Bash', command: 'ls' }) יורה tool.call. $.command.run, $.prompt.submit, $.session.start ו-$.turn.complete עובדים באותו אופן, ו-$.classic.Stop ושאר המתודות של $.classic יורים אירוע hook של הגדרות. בדיקה לא יכולה לירות קריאת API של mods כמו ui.close ישירות. הפעילו אותה דרך המוד שלכם, למשל בלחיצה על הכפתור שסוגר את החלונית.
  • on: קראו לו כדי לרשום stubs, שהם hooks שעונים במקום Claude Code. תנו ל-stub שם של קריאת API של mods בלי ה-$., כך ש-stub שנרשם כ-store.get עונה על $.store.get של המוד שלכם. כשהמוד שלכם קורא ל-$.model.complete או ל-$.store.get, stub מספק את התשובה.

הדוגמה הזו שמה stub על קריאת מודל. ה-hook שייך למוד בשם grader, ומטפל בפקודה /grade ששולחת משפט למודל ומדווחת אם התשובה מתחילה ב-PASS. הקובץ מחזיק רק את ה-hook שנבדק, ולכן המוד צריך גם 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' }
  })
}

הבדיקה הזו שמה stub על קריאת המודל כדי לבדוק מה ה-hook עושה עם תשובה שעוברת:

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 של ה-hook הוא האובייקט תחת value, שה-text שלו מתחיל ב-PASS. כדי לבדוק את הענף השני, הוסיפו בדיקה שנייה שה-stub שלה מחזיר text שמתחיל ב-FAIL, וצפו ל-Try again.

stub לקריאת API של mods מחזיר אובייקט עם שדה value, שמחזיק את מה שהקריאה מתרחשת אליו במוד שלכם: { value: 7 } גורם ל-$.store.get להתפרש ל-7. stub לאחד מהאירועים של Claude Code, למשל turn.step או tool.call, מחזיר את התוצאה של האירוע עצמו, למשל { result: 'ok' }. גם $.session.send ו-$.prompt.fill מקבלים את התוצאה של האירוע שלהם, כמו שהטבלה מראה. חפשו מה stub מחזיר מראה איזו צורה כל שם נפוץ מקבל. השגיאות האלה אומרות ש-stub שגוי או חסר. הפלט של בדיקה שנכשלה כולל בלוק שכותרתו the engine reported:, וכל שגיאה מופיעה שם:

  • returned neither { value } nor { deny }: stub לקריאת API של mods החזיר ערך חשוף, וזה נכשל בבדיקה
  • no implementation for ואחריו שם: המוד שלכם ביצע את הקריאה הזו ואין stub שעונה עליה

הערכה גם מייצאת mocks מוכנים לשעון, למאגר, למשתני סביבה ולשורות שנוספו לשיחה:

  • mock.clock(on): עונה על $.clock ומחזיר שעון mock שהבדיקה שלכם מקדמת, כך שבדיקה של טיימר לא ממתינה.
  • mock.store(on, { count: 7 }): עונה על $.store ממאגר שמתחיל עם הרשומות האלה. הוא לא מחזיר כלום, אז כדי לבדוק מה המוד שלכם שמר, כתבו את שני ה-stubs של store בעצמכם כמו שבדיקת ציור עושה.
  • mock.env(on, { CI: 'true' }): עונה על $.env.get מהמשתנים האלה.
  • mock.session(on): מחזיר סשן mock שהמתודה appended() שלו מפרטת את השורות שהמוד שלכם הוסיף עם $.session.append, מהישנה לחדשה. זה דורש Claude Code מגרסה v2.1.293 ואילך.

#עמידה בכללים של ערכת הבדיקות

לערכת הבדיקות יש כמה כללים משלה, והפרה של אחד מהם מייצרת את השגיאות שמחברי בדיקות חדשים נתקלים בהן קודם:

  • רשמו כל stub לפני הקריאה הראשונה של הבדיקה על $. קריאה ל-on אחרי זה זורקת שגיאה כמו on("ui.render") after the test first called $.

  • session.start לא רץ מעצמו. כל בדיקה מתחילה כשהמודול שלכם נטען מחדש ואף אחד מה-hooks שלו לא נקרא, כך שמשתנים ברמת המודול מחזיקים את הערכים ההתחלתיים שלהם. אם hook תלוי במה ש-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 }))
    // Fire the event, which runs your session.start hook
    await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })

    ה-stub השני עונה על קריאת $.command.register ש-hook של session.start כמו זה שבמדריך מבצע. בלי זה, הקריאה הזו נדחית עם no implementation for command.register והערכה מדלגת על ה-hook שלכם, כך ששום דבר אחרי הקריאה בתוך ה-hook לא רץ. הבדיקה לא נכשלת בנקודה הזו. ה-hook שדולג מופיע תחת the engine reported: רק אם בדיקה מאוחרת יותר נכשלת.

  • hook שמחזיר next(e) צריך stub שיענה. כשה-hook ui.render שלכם מחזיר next(e), למשל כדי לא לצייר כלום בזמן ש-Claude לא פעיל, הטעינה שלו נכשלת עם no implementation for ui.render. רשמו stub שמחזיר אלמנט כנתונים רגילים:

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

    כשה-stub רשום, הטעינה מצליחה, ו-ui.find({ type: 'Text' }) מחזיר את האלמנט הזה בכל פעם שה-hook שלכם החזיר next(e).

  • stub ל-turn.step הוא async generator, והבדיקה קוראת את הזרם עד סופו כדי לקבל את התוצאה:

    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

    כשהלולאה נגמרת, result הוא האובייקט שה-stub החזיר, אחרי של-hook turn.step שלכם הייתה הזדמנות לשנות אותו. כאן result.answer הוא 'ok'.

  • ירו קריאת כלי עם שם הכלי והארגומנטים כשדות, למשל await $.tool.call({ tool: 'Bash', command: 'ls' }), ורשמו stub של tool.call שמחזיר { result }.

#חיפוש מה stub מחזיר

כל קריאת API של mods שהמוד שלכם מבצע בבדיקה צריכה stub שעונה במקום Claude Code, חוץ מהמעטים שהערכה עונה עליהם בעצמה: קריאות $.ui.invalidate, $.state, ו-$.session.append. לקריאות $.clock, השתמשו ב-mock.clock(on), אחרת $.clock.now() של המוד שלכם נכשל עם no implementation for clock.now.

הטבלה הזו מפרטת את אלה ש-mods משתמשים בהם הכי הרבה. העמודה הראשונה היא הקריאה שהמוד שלכם מבצע או האירוע שהוא מעביר הלאה עם next(e). השנייה היא הפונקציה להעביר ל-on תחת השם הזה, כך ששורת $.store.get הופכת ל-on('store.get', ($, e) => ({ value: saved.get(e.key) })). '...' בתוך stub מסמן טקסט שאתם ממלאים:

המוד שלכם קורא או מעביר הלאהstub
$.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.askstub של 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.
כל קריאת API של mods שאמורה להיכשל() => ({ deny: 'the reason' }), מה שגורם לקריאה להידחות במוד שלכם. stub שזורק מדולג במקום זאת.
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 } })
$.ui.notify() => ({ value: { isSent: true, channel: 'terminal_bell' } }). הטקסט הוא e.text והכותרת היא e.title.
$.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 לפני כל אחת מהן.

כש-expect נכשל בתוך stub או hook שהעברתם ל-on כפונקציה רגילה ולא כ-async generator, הבדיקה נכשלת. המנוע מדלג על ה-hook הזה, ופלט הכישלון מציין אותו, למשל in the test's store.set hook.

#בדיקת טיימר

מוד שמריץ עבודה לפי טיימר צריך שעון שהבדיקה שולטת בו, כדי שהבדיקה תוכל להזיז את הזמן קדימה במקום לחכות. 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()מריצה טיימרים שכבר הגיע זמנם, כמו שרשרת קריאות $.clock.after עם השהיה אפס, בלי להזיז את הזמן
await clock.sleep(2000)בתוך stub, גורמת ל-stub הזה לענות רק אחרי שהבדיקה התקדמה עד לשם, וכך מדמים מודל או תהליך איטי

ה-hook הזה שייך למוד בשם countdown, ומטפל בפקודה /countdown שמקבלת מספר שניות, מתחילה טיימר $.clock.every של שנייה אחת, ומציגה toast באפס. כמו ב-grader, הקובץ מכיל רק את ה-hook שנבדק ולא רושם את הפקודה:

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 ומזיזה את השעון המדומה, ולכן היא בודקת שלוש שניות של התנהגות בלי לחכות שלוש שניות:

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 הראשון מראה שה-toast לא מגיע מוקדם, והשני מראה שהוא מגיע פעם אחת. כל advance נפתר אחרי שהטיימרים שהגיע זמנם רצו, ולכן הבדיקה בשורה הבאה רואה את ההשפעה שלהם.

#בדיקת ציור

בדיקה יכולה לצייר אחד מ-אתרי הרינדור של המוד, ואז ללחוץ, להקליד לתוך, ולמצוא את הרכיבים שהיא ציירה. $.ui.mount מצייר את האתר דרך ה-hook ui.render של המוד ומחזיר handle עם מתודה לכל אחת מהפעולות האלה. כדי לכסות כמה אפליקציות בבדיקה אחת, מגדירים את surface לאפליקציה שעבורה מציירים. הבדיקה הזו פותחת את ה-pane מ-בניית pane עם לשוניות, מחליפה לשוניות, לוחצת על הכפתור, ובודקת את הספירה בטרמינל ובאפליקציית 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)
})

במעטפת, הריצו claude plugin test מתוך התיקייה hello-tabs. הבדיקה עוברת כששתי האפליקציות מציירות את שורת הספירה והמוד שמר 2. הספירה עוברת מהאפליקציה הראשונה לשנייה כי שני ה-mount משתמשים באותו מודול שנטען.

ל-handle ש-$.ui.mount מחזיר יש המתודות האלה, שפונות לרכיבים לפי ה-key שנתתם להם:

מתודהמה היא עושה
press({ key: 'more' })לוחצת על ה-Button עם ה-key הזה
input({ key: 'new-note', text: 'buy milk' })מקלידה את הטקסט לתוך ה-Input עם ה-key הזה ולוחצת Enter. מוסיפים kind: 'change' כדי להקליד בלי לשלוח. בשדה עם rows, הטקסט יכול להכיל \n.
select({ key: 'size', value: 'large' })בוחרת את האפשרות עם הערך הזה ב-Select עם ה-key הזה
find({ key: 'more' }) או find({ type: 'Text', text: 'Count: 2' })מחזירה את הרכיב התואם הראשון כ-{ type, props, children }, או undefined. text יכול להיות מחרוזת או ביטוי רגולרי.
unmount()מסירה את הציור

כל מתודה נפתרת אחרי שה-handler שלכם סיים, כך שאפשר לבדוק את התוצאה בשורה הבאה. מגדירים את props למה ש-Claude Code היה מעביר לאתר הזה. טבלת אתרי הרינדור מפרטת את ה-props של כל אתר, ו-הטיפוסים לבילד שלכם מכילים את הטיפוסים שלהם.

כדי לתפוס עץ רכיבים שאינו תקף לאפליקציה שציינתם ב-surface, בודקים עם find רכיב שה-hook שלכם מוסיף. הבדיקה לא בודקת איך האפליקציה מציירת עץ, אז כדאי להסתכל גם על פריסה חדשה בסשן אמיתי.

#בדיקת ציור אחרי /clear

כל בדיקה מתחילה כשכל ערך $.state בברירת המחדל שלו, וכך /clear משאיר אותם. כדי לבדוק מה המוד עושה אחר כך, מדלגים על session.start, יורים classic.SessionStart עם source: 'clear', ובודקים מה המוד מצייר.

הבדיקה הזו בודקת את המודול מ-טעינת ערך שמור שוב אחרי /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', () => ({}))

  // 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()
})

הבדיקה עוברת כשה-hook classic.SessionStart שלכם העתיק את ה-7 השמור אל $.state לפני שה-pane מצייר. בלי ה-hook הזה במודול, ה-pane מצייר Count: 0, find מחזיר undefined, והבדיקה נכשלת ב-toBeDefined.

#בדיקת מוד מדיניות

מוד שהארגון שלכם מציין ב-prependPlugins יכול לסרב למוד אחר לפני שהוא נטען. כדי לבדוק אחד כזה, מגדירים את ה-tier של המוד ונותנים לבדיקה מוד שני שהמוד שלכם יתיר או יסרב לו:

  • tier: קוראים לו פעם אחת בראש קובץ הבדיקה, כמו ב-tier('prepend'), כדי לטעון את המוד כ-prepend, append או builtin, מקומו ב-סדר שבו מודים רצים. בלי זה, המוד נטען כ-user.
  • plugins: מעבירים ל-test אובייקט אפשרויות לפני גוף הבדיקה. מערך ה-plugins שלו מחזיק מודים שכותבים inline, כל אחד עם 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' })
})

במעטפת, הריצו claude plugin test מתוך התיקייה acme-guard. שתי הבדיקות עוברות עם מוד המדיניות כפי שדף הניהול מציג אותו.

ה-kit טוען כל מוד בקריאה הראשונה של הבדיקה על $. כשהמוד שלכם מסרב לאחד, הקריאה הזו זורקת, וההודעה מציינת את המוד שסורב, את המוד שסירב לו, ואת הסיבה שלכם. בבדיקה השנייה לא מסרבים לכלום, ולכן reader עונה לקריאת הכלי לפני שהיא מגיעה ל-stub.

#הצעדים הבאים