spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods: each test file is named for the hooks/ subject it covers and holds one describe, shared inputs under tests/fixtures; memoryClock answers $.clock beneath the mod, and the diff pane's close button is pressed through $.ui.pressBrowse files
poteat authored on Sep 11, 2026 parent f43a16b · commit 59bb8853c1bd
45 changed files+921 -737compared with f43a16b
Mmods/README.md
Dmods/diff/tests/boot-probe.test.ts
Dmods/diff/tests/command.test.ts
Amods/diff/tests/fixtures/builtin-holds.ts
Amods/diff/tests/fixtures/clear.ts
Amods/diff/tests/fixtures/git-hung.ts
Amods/diff/tests/fixtures/git-in.ts
Amods/diff/tests/fixtures/hint-drawn.ts
Amods/diff/tests/fixtures/hint.ts
Amods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/keeping.ts
Amods/diff/tests/fixtures/pane.ts
Amods/diff/tests/fixtures/repository.ts
Amods/diff/tests/fixtures/settle-ms.ts
Mmods/diff/tests/fixtures/starts-session.ts
Amods/diff/tests/git.test.ts
Dmods/diff/tests/pane.test.ts
Amods/diff/tests/register.test.ts
Amods/sec-default/tests/fixtures/allowlist.ts
Amods/sec-default/tests/fixtures/dropping.ts
Amods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/listing.ts
Amods/sec-default/tests/fixtures/memory.ts
Amods/sec-default/tests/fixtures/no-allowlist.ts
Amods/sec-default/tests/fixtures/registered-tool-of.ts
Amods/sec-default/tests/fixtures/registering.ts
Amods/sec-default/tests/fixtures/relabeling.ts
Amods/sec-default/tests/fixtures/session.ts
Amods/sec-default/tests/fixtures/signing.ts
Amods/sec-default/tests/fixtures/tools-command.ts
Amods/sec-default/tests/fixtures/tools-registered.ts
Amods/sec-default/tests/fixtures/tools.ts
Amods/sec-default/tests/register.test.ts
Dmods/sec-default/tests/sec-default.test.ts
Amods/telemetry/tests/fixtures/accepted.ts
Amods/telemetry/tests/fixtures/batch-of.ts
Amods/telemetry/tests/fixtures/bearer.ts
Amods/telemetry/tests/fixtures/first-party-session.ts
Amods/telemetry/tests/fixtures/index.ts
Amods/telemetry/tests/fixtures/record.ts
Amods/telemetry/tests/fixtures/recording.ts
Amods/telemetry/tests/fixtures/survey-answer.ts
Amods/telemetry/tests/register.test.ts
Dmods/telemetry/tests/telemetry.test.ts
@@ -30,33 +30,51 @@
3030 would be, and nothing is beneath them: a call they leave unanswered throws,
3131 naming its event.
3232
33+A test file is named for what it covers under `hooks/` (`register.test.ts`
34+beside `hooks/register.ts`, `git.test.ts` beside `hooks/git/`), and holds its
35+imports, the tier the mod loads in, and one `describe` titled with that name;
36+what several tests share sits under `tests/fixtures/`, one export a file.
37+
3338 ```ts
34-import { expect, test, tier } from 'claude-code/testing'
39+import { describe, expect, memoryClock, test, tier } from 'claude-code/testing'
3540
3641 tier('builtin')
3742
38-test('outside a git repository /diff says so and opens nothing', async ($, on) => {
39- const opened: string[] = []
40- on('session.start', ($, e) => ({ cwd: e.cwd }))
41- on('command.register', ($, e) => ({ value: { command: e.name } }))
42- on('process.run', () => ({ value: { exitCode: 128, stdout: '', stderr: '' } }))
43- on('ui.open', ($, e, next) => {
44- opened.push(e.id)
45- return next(e)
46- })
43+describe('register', () => {
44+ test('outside a git repository /diff says so, opens nothing', async ($, on) => {
45+ const opened: string[] = []
46+ memoryClock(on)
47+ on('session.start', ($, e) => ({ cwd: e.cwd }))
48+ on('command.register', ($, e) => ({ value: { command: e.name } }))
49+ on('process.run', () => ({
50+ value: { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' },
51+ }))
52+ on('ui.open', ($, e, next) => {
53+ opened.push(e.id)
54+ return next(e)
55+ })
4756
48- await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
49- const { text } = await $.command.run({
50- command: 'diff',
51- args: '',
52- origin: { kind: 'composer' },
53- })
57+ await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
58+ const { text } = await $.command.run({
59+ command: 'diff',
60+ args: '',
61+ origin: { kind: 'composer' },
62+ })
5463
55- expect(text).toContain("isn't in a git repository")
56- expect(opened).toEqual([])
64+ expect(text).toContain("isn't in a git repository")
65+ expect(opened).toEqual([])
66+ })
5767 })
5868 ```
5969
70+The kit's helpers are plain functions over `on`, each answering one noun
71+beneath the mod where the test calls it: `memoryEnv(on, variables)` for
72+`$.env`, `memoryStore(on, entries)` for `$.store`, and `memoryClock(on)` for
73+`$.clock`, whose `advance(ms)` resolves every wait the mod asked for
74+(`$.clock.sleep`, `after`, `every`) as the clock crosses it. `textOf(tree)`
75+reads a rendered tree's text, and `$.ui.press({ plugin, key })` presses a
76+`Button` the test rendered, as a click in the terminal does.
77+
6078 `tsc -p mods/tsconfig.json` typechecks every mod's hooks and tests against
6179 `types/`.
6280
Dmods/diff/tests/boot-probe.test.ts+0 -41
@@ -1,41 +1,0 @@
1-import { clock, describe, expect, test, tier } from 'claude-code/testing'
2-
3-import Limits from '../hooks/limits'
4-import Fixtures from './fixtures'
5-
6-const GIT_HUNG = `git aborted: still running after ${Limits.GIT_TIMEOUT_MS}ms`
7-
8-tier('builtin')
9-
10-describe('boot-probe', () => {
11- test('/diff at boot joins the boot probe, then asks again', async ($, on) => {
12- const probes: (readonly string[])[] = []
13- Fixtures.startsSession(on)
14- on('process.run', async ($, e) => {
15- probes.push(e.argv)
16-
17- if (probes.length === 1) {
18- await clock.sleep(Limits.GIT_TIMEOUT_MS)
19-
20- return { deny: GIT_HUNG }
21- }
22-
23- return { value: Fixtures.NOT_A_REPOSITORY }
24- })
25-
26- const booting = $.session.start(Fixtures.SESSION)
27- await clock.advance(0)
28- const ran = $.command.run(Fixtures.DIFF)
29- await clock.advance(0)
30-
31- expect(probes, 'the boot probe, which /diff joined').toHaveLength(1)
32-
33- await clock.advance(Limits.GIT_TIMEOUT_MS)
34- await booting
35-
36- expect(await ran).toEqual({
37- text: expect.stringContaining("isn't in a git repository"),
38- })
39- expect(probes, 'then one more of its own').toHaveLength(2)
40- })
41-})
Dmods/diff/tests/command.test.ts+0 -77
@@ -1,77 +1,0 @@
1-import { describe, expect, test, tier } from 'claude-code/testing'
2-
3-import Fixtures from './fixtures'
4-
5-const BUILTIN_HOLDS = '"/diff" refused: it is the built-in /diff'
6-
7-tier('builtin')
8-
9-describe('command', () => {
10- test('outside a repository /diff says so, opens nothing', async ($, on) => {
11- const opened: string[] = []
12- Fixtures.startsSession(on)
13- on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY }))
14- on('ui.open', ($, e, next) => {
15- opened.push(e.id)
16-
17- return next(e)
18- })
19-
20- await $.session.start(Fixtures.SESSION)
21- const { text } = await $.command.run(Fixtures.DIFF)
22-
23- expect(text).toContain("isn't in a git repository")
24- expect(opened).toEqual([])
25- })
26-
27- test('a git that never answers is not "no repository"', async ($, on) => {
28- Fixtures.startsSession(on)
29- on('process.run', () => ({
30- deny: 'git aborted: still running after 5000ms',
31- }))
32-
33- await $.session.start(Fixtures.SESSION)
34- const { text } = await $.command.run(Fixtures.DIFF)
35-
36- expect(text).toContain("git didn't answer")
37- })
38-
39- test('when the built-in holds /diff, the mod stands down', async ($, on) => {
40- const logged: string[] = []
41- on('session.start', ($, e) => ({ cwd: e.cwd }))
42- on('command.register', () => ({ deny: BUILTIN_HOLDS }))
43- on('command.run', () => ({ text: 'the built-in /diff ran' }))
44- on('ui.log', ($, e) => {
45- logged.push(e.text)
46-
47- return { value: undefined }
48- })
49-
50- await $.session.start(Fixtures.SESSION)
51-
52- expect(await $.command.run(Fixtures.DIFF)).toEqual({
53- text: 'the built-in /diff ran',
54- })
55- expect(logged).toEqual([])
56- })
57-
58- test('a refusal the built-in did not cause is said aloud', async ($, on) => {
59- const logged: string[] = []
60- on('session.start', ($, e) => ({ cwd: e.cwd }))
61- on('command.register', () => ({
62- deny: '32 commands are registered already',
63- }))
64- on('ui.log', ($, e) => {
65- logged.push(e.text)
66-
67- return { value: undefined }
68- })
69-
70- await $.session.start(Fixtures.SESSION)
71-
72- expect(logged).toEqual([
73- 'could not register /diff: diff: $.command.register: 32 commands are ' +
74- 'registered already; the diff panel is unavailable this session',
75- ])
76- })
77-})
@@ -1,0 +1,4 @@
1+/**
2+ * How `$.command.register` refuses `/diff` while the built-in one is listed.
3+ */
4+export const BUILTIN_HOLDS = '"/diff" refused: it is the built-in /diff'
@@ -1,0 +1,10 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+/**
4+ * The command that starts the conversation over, as the person types it.
5+ */
6+export const CLEAR: CommandRunInput = {
7+ command: 'clear',
8+ args: '',
9+ origin: { kind: 'composer' },
10+}
@@ -1,0 +1,7 @@
1+import Limits from '../../hooks/limits'
2+
3+/**
4+ * How `$.process.run` refuses a git that outran its timeout.
5+ */
6+export const GIT_HUNG =
7+ 'git aborted: still running after ' + `${Limits.GIT_TIMEOUT_MS}ms`
@@ -1,0 +1,20 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+import { NOT_A_REPOSITORY } from './not-a-repository.js'
4+import { REPOSITORY } from './repository.js'
5+
6+/**
7+ * What git answers in /work (REPOSITORY); an invocation it does not know
8+ * fails as git does outside a repository.
9+ *
10+ * @param argv the invocation, program first
11+ * @returns git's exit code and output
12+ */
13+export function gitIn(argv: readonly string[]): ProcessRunResult {
14+ const line = argv.join(' ')
15+ const found = Object.entries(REPOSITORY).find(([key]) => line.includes(key))
16+
17+ return found
18+ ? { exitCode: 0, stdout: found[1], stderr: '' }
19+ : NOT_A_REPOSITORY
20+}
@@ -1,0 +1,9 @@
1+import type { RenderElement } from 'claude-code'
2+
3+/**
4+ * What the engine draws for the hint, standing in beneath the plugin.
5+ */
6+export const HINT_DRAWN: RenderElement = {
7+ type: 'Text',
8+ children: ['? for shortcuts'],
9+}
@@ -1,0 +1,13 @@
1+import type { RenderInput } from 'claude-code'
2+
3+/**
4+ * The prompt's hint on a 160-column terminal: drawing it is how the plugin
5+ * learns how wide the terminal is.
6+ */
7+export const HINT: RenderInput<'PromptHint'> = {
8+ component: 'PromptHint',
9+ surface: 'terminal',
10+ requestId: 'hint',
11+ viewport: { columns: 160, rows: 40 },
12+ props: { isDraft: false, isWorking: false, hint: '' },
13+}
@@ -1,0 +1,36 @@
1+import type { Args, On } from 'claude-code'
2+import { memoryStore } from 'claude-code/testing'
3+
4+import { gitIn } from './git-in.js'
5+import { HINT_DRAWN } from './hint-drawn.js'
6+import { keeping } from './keeping.js'
7+import { startsSession } from './starts-session.js'
8+
9+/**
10+ * A session in /work (REPOSITORY), keeping each git run and each pane
11+ * opened or closed.
12+ *
13+ * The store starts empty, the clock at 0, and the engine draws the hint.
14+ *
15+ * @param on the test's `on`
16+ * @returns the runs, the panes opened and closed, and the session's clock
17+ */
18+export function inRepository(on: On) {
19+ const runs: Args<'process.run'>[] = []
20+ const opened = keeping<Args<'ui.open'>>()
21+ const closed = keeping<Args<'ui.close'>>()
22+ const clock = startsSession(on)
23+ on('process.run', ($, e) => {
24+ runs.push(e)
25+
26+ return { value: gitIn(e.argv) }
27+ })
28+ on('ui.open', opened.hook)
29+ on('ui.close', closed.hook)
30+ on('ui.invalidate', () => ({ value: undefined }))
31+ on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
32+ on('session.messages', () => ({ value: [] }))
33+ memoryStore(on)
34+
35+ return { runs, opened: opened.kept, closed: closed.kept, clock }
36+}
@@ -1,6 +1,17 @@
1+export * from './builtin-holds.js'
2+export * from './clear.js'
13 export * from './diff.js'
4+export * from './git-hung.js'
5+export * from './git-in.js'
6+export * from './hint.js'
7+export * from './hint-drawn.js'
8+export * from './in-repository.js'
9+export * from './keeping.js'
210 export * from './not-a-repository.js'
11+export * from './pane.js'
12+export * from './repository.js'
313 export * from './session.js'
14+export * from './settle-ms.js'
415 export * from './starts-session.js'
516
617 export * as default from '.'
@@ -1,0 +1,17 @@
1+/**
2+ * A hook that keeps each input it is asked with, and answers, beside what
3+ * it kept.
4+ *
5+ * @returns the hook, and each input in the order it came
6+ */
7+export function keeping<E>() {
8+ const kept: E[] = []
9+
10+ function hook(_engine: unknown, e: E) {
11+ kept.push(e)
12+
13+ return { value: undefined }
14+ }
15+
16+ return { hook, kept }
17+}
@@ -1,0 +1,19 @@
1+import type { RenderInput } from 'claude-code'
2+
3+/**
4+ * The diff pane docked on a 160-column terminal, unfocused, 80 columns of
5+ * body and 30 rows in view.
6+ */
7+export const PANE: RenderInput<'Pane'> = {
8+ component: 'Pane',
9+ surface: 'terminal',
10+ requestId: 'diff',
11+ viewport: { columns: 160, rows: 40 },
12+ props: {
13+ title: 'Diff',
14+ isFocused: false,
15+ bodyColumns: 80,
16+ placement: 'dock',
17+ scroll: { offset: 0, bodyRows: 30 },
18+ },
19+}
@@ -1,0 +1,11 @@
1+/**
2+ * Git's output in /work, where one file changed, for each invocation whose
3+ * command line holds the key.
4+ */
5+export const REPOSITORY: Readonly<Record<string, string>> = {
6+ 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n',
7+ 'HEAD --shortstat': ' 1 file changed, 1 insertion(+)',
8+ 'HEAD --numstat': '1\t0\tapp.ts\0',
9+ 'ls-files': '',
10+ '-- app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n',
11+}
@@ -1,0 +1,5 @@
1+/**
2+ * Longer than every wait the pane schedules before it fetches and draws
3+ * (its open probe, refresh debounce and redraw), short of its HEAD poll.
4+ */
5+export const SETTLE_MS = 1000
@@ -1,12 +1,17 @@
11 import type { On } from 'claude-code'
2+import { memoryClock } from 'claude-code/testing'
3+import type { MemoryClock } from 'claude-code/testing'
24
35 /**
46 * Answers what every /diff session asks first: its start, with its own
5- * directory, and each command it registers, under the name asked for.
7+ * directory, the time, and each command it registers, under its name.
68 *
79 * @param on the test's `on`
10+ * @returns the clock the session reads, at 0 until the test moves it
811 */
9-export function startsSession(on: On) {
12+export function startsSession(on: On): MemoryClock {
1013 on('session.start', ($, e) => ({ cwd: e.cwd }))
1114 on('command.register', ($, e) => ({ value: { command: e.name } }))
15+
16+ return memoryClock(on)
1217 }
@@ -1,0 +1,29 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Fixtures from './fixtures'
4+
5+tier('builtin')
6+
7+describe('git', () => {
8+ test('each git child runs pinned to /work, in C locale', async ($, on) => {
9+ const world = Fixtures.inRepository(on)
10+
11+ await $.session.start(Fixtures.SESSION)
12+ await $.command.run(Fixtures.DIFF)
13+ await world.clock.advance(Fixtures.SETTLE_MS)
14+ const [discovery, ...pinned] = world.runs
15+
16+ expect(discovery?.argv).toContain('--show-toplevel')
17+ expect(discovery?.init?.cwd).toBeUndefined()
18+ expect(pinned.length).toBeGreaterThan(0)
19+
20+ for (const run of pinned) {
21+ expect(run.argv.slice(0, 3)).toEqual([
22+ 'git',
23+ '--git-dir=/work/.git',
24+ '--work-tree=/work',
25+ ])
26+ expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } })
27+ }
28+ })
29+})
Dmods/diff/tests/pane.test.ts+0 -196
@@ -1,196 +1,0 @@
1-import type {
2- Args,
3- CommandRunInput,
4- On,
5- ProcessRunResult,
6- RenderElement,
7- RenderInput,
8-} from 'claude-code'
9-import {
10- clock,
11- describe,
12- expect,
13- memoryStore,
14- test,
15- textOf,
16- tier,
17-} from 'claude-code/testing'
18-
19-import Fixtures from './fixtures'
20-
21-const CLEAR: CommandRunInput = {
22- command: 'clear',
23- args: '',
24- origin: { kind: 'composer' },
25-}
26-const PANE: RenderInput<'Pane'> = {
27- component: 'Pane',
28- surface: 'terminal',
29- requestId: 'diff',
30- viewport: { columns: 160, rows: 40 },
31- props: {
32- title: 'Diff',
33- isFocused: false,
34- bodyColumns: 80,
35- placement: 'dock',
36- scroll: { offset: 0, bodyRows: 30 },
37- },
38-}
39-/**
40- * The prompt's hint on a 160-column terminal: drawing it is how the plugin
41- * learns how wide the terminal is.
42- */
43-const HINT: RenderInput<'PromptHint'> = {
44- component: 'PromptHint',
45- surface: 'terminal',
46- requestId: 'hint',
47- viewport: { columns: 160, rows: 40 },
48- props: { isDraft: false, isWorking: false, hint: '' },
49-}
50-/**
51- * What the engine draws for the hint, standing in beneath the plugin.
52- */
53-const HINT_DRAWN: RenderElement = {
54- type: 'Text',
55- children: ['? for shortcuts'],
56-}
57-/**
58- * Git's output in /work, where one file changed, for each invocation whose
59- * command line holds the key.
60- */
61-const REPOSITORY: Readonly<Record<string, string>> = {
62- 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n',
63- 'HEAD --shortstat': ' 1 file changed, 1 insertion(+)',
64- 'HEAD --numstat': '1\t0\tapp.ts\0',
65- 'ls-files': '',
66- '-- app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n',
67-}
68-/**
69- * Longer than every wait the pane schedules before it fetches and draws
70- * (its open probe, refresh debounce and redraw), short of its HEAD poll.
71- */
72-const SETTLE_MS = 1000
73-
74-tier('builtin')
75-
76-/**
77- * What git answers in that repository; an invocation it does not know
78- * fails as git does outside a repository.
79- */
80-function gitIn(argv: readonly string[]): ProcessRunResult {
81- const line = argv.join(' ')
82- const found = Object.entries(REPOSITORY).find(([key]) => line.includes(key))
83-
84- return found
85- ? { exitCode: 0, stdout: found[1], stderr: '' }
86- : Fixtures.NOT_A_REPOSITORY
87-}
88-
89-/**
90- * A hook that keeps each input it is asked with, and answers, beside what
91- * it kept.
92- *
93- * @returns the hook, and each input in the order it came
94- */
95-function keeping<E>() {
96- const kept: E[] = []
97-
98- function hook(_engine: unknown, e: E) {
99- kept.push(e)
100-
101- return { value: undefined }
102- }
103-
104- return { hook, kept }
105-}
106-
107-/**
108- * A session in that repository, keeping each git run and each pane opened
109- * or closed; the store starts empty and the engine draws the hint.
110- */
111-function inRepository(on: On) {
112- const runs: Args<'process.run'>[] = []
113- const opened = keeping<Args<'ui.open'>>()
114- const closed = keeping<Args<'ui.close'>>()
115- Fixtures.startsSession(on)
116- on('process.run', ($, e) => {
117- runs.push(e)
118-
119- return { value: gitIn(e.argv) }
120- })
121- on('ui.open', opened.hook)
122- on('ui.close', closed.hook)
123- on('ui.invalidate', () => ({ value: undefined }))
124- on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
125- on('session.messages', () => ({ value: [] }))
126- memoryStore(on)
127-
128- return { runs, opened: opened.kept, closed: closed.kept }
129-}
130-
131-describe('pane', () => {
132- test('/diff opens the pane over the session changes', async ($, on) => {
133- const world = inRepository(on)
134-
135- await $.session.start(Fixtures.SESSION)
136-
137- expect(await $.command.run(Fixtures.DIFF)).toEqual({})
138- expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
139-
140- await clock.advance(SETTLE_MS)
141- const drawn = textOf(await $.ui.render(PANE))
142-
143- expect(drawn).toContain('1 file changed')
144- expect(drawn).toContain('app.ts')
145- })
146-
147- test('each git child runs pinned to /work, in C locale', async ($, on) => {
148- const world = inRepository(on)
149-
150- await $.session.start(Fixtures.SESSION)
151- await $.command.run(Fixtures.DIFF)
152- await clock.advance(SETTLE_MS)
153- const [discovery, ...pinned] = world.runs
154-
155- expect(discovery?.argv).toContain('--show-toplevel')
156- expect(discovery?.init?.cwd).toBeUndefined()
157- expect(pinned.length).toBeGreaterThan(0)
158-
159- for (const run of pinned) {
160- expect(run.argv.slice(0, 3)).toEqual([
161- 'git',
162- '--git-dir=/work/.git',
163- '--work-tree=/work',
164- ])
165- expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } })
166- }
167- })
168-
169- test('a wide terminal opens the pane at the first edit', async ($, on) => {
170- const world = inRepository(on)
171- on('tool.call', () => ({ result: 'edited' }))
172-
173- await $.session.start(Fixtures.SESSION)
174- await $.ui.render(HINT)
175- await $.tool.call({
176- tool: 'Edit',
177- file_path: '/work/app.ts',
178- old_string: '1',
179- new_string: '2',
180- })
181- await clock.advance(SETTLE_MS)
182-
183- expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
184- })
185-
186- test('/clear closes the pane it finds open', async ($, on) => {
187- const world = inRepository(on)
188- on('command.run', { command: 'clear' }, () => ({}))
189-
190- await $.session.start(Fixtures.SESSION)
191- await $.command.run(Fixtures.DIFF)
192- await $.command.run(CLEAR)
193-
194- expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
195- })
196-})
@@ -1,0 +1,176 @@
1+import {
2+ describe,
3+ expect,
4+ memoryClock,
5+ test,
6+ textOf,
7+ tier,
8+} from 'claude-code/testing'
9+
10+import Limits from '../hooks/limits'
11+import Fixtures from './fixtures'
12+
13+tier('builtin')
14+
15+describe('register', () => {
16+ test('/diff at boot joins the boot probe, then asks again', async ($, on) => {
17+ const probes: (readonly string[])[] = []
18+ const clock = Fixtures.startsSession(on)
19+ on('process.run', async ($, e) => {
20+ probes.push(e.argv)
21+
22+ if (probes.length === 1) {
23+ await clock.sleep(Limits.GIT_TIMEOUT_MS)
24+
25+ return { deny: Fixtures.GIT_HUNG }
26+ }
27+
28+ return { value: Fixtures.NOT_A_REPOSITORY }
29+ })
30+
31+ const booting = $.session.start(Fixtures.SESSION)
32+ await clock.advance(0)
33+ const ran = $.command.run(Fixtures.DIFF)
34+ await clock.advance(0)
35+
36+ expect(probes, 'the boot probe, which /diff joined').toHaveLength(1)
37+
38+ await clock.advance(Limits.GIT_TIMEOUT_MS)
39+ await booting
40+
41+ expect(await ran).toEqual({
42+ text: expect.stringContaining("isn't in a git repository"),
43+ })
44+ expect(probes, 'then one more of its own').toHaveLength(2)
45+ })
46+
47+ test('outside a repository /diff says so, opens nothing', async ($, on) => {
48+ const opened: string[] = []
49+ Fixtures.startsSession(on)
50+ on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY }))
51+ on('ui.open', ($, e, next) => {
52+ opened.push(e.id)
53+
54+ return next(e)
55+ })
56+
57+ await $.session.start(Fixtures.SESSION)
58+ const { text } = await $.command.run(Fixtures.DIFF)
59+
60+ expect(text).toContain("isn't in a git repository")
61+ expect(opened).toEqual([])
62+ })
63+
64+ test('a git that never answers is not "no repository"', async ($, on) => {
65+ Fixtures.startsSession(on)
66+ on('process.run', () => ({ deny: Fixtures.GIT_HUNG }))
67+
68+ await $.session.start(Fixtures.SESSION)
69+ const { text } = await $.command.run(Fixtures.DIFF)
70+
71+ expect(text).toContain("git didn't answer")
72+ })
73+
74+ test('when the built-in holds /diff, the mod stands down', async ($, on) => {
75+ const logged: string[] = []
76+ memoryClock(on)
77+ on('session.start', ($, e) => ({ cwd: e.cwd }))
78+ on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS }))
79+ on('command.run', () => ({ text: 'the built-in /diff ran' }))
80+ on('ui.log', ($, e) => {
81+ logged.push(e.text)
82+
83+ return { value: undefined }
84+ })
85+
86+ await $.session.start(Fixtures.SESSION)
87+
88+ expect(await $.command.run(Fixtures.DIFF)).toEqual({
89+ text: 'the built-in /diff ran',
90+ })
91+ expect(logged).toEqual([])
92+ })
93+
94+ test('a refusal the built-in did not cause is said aloud', async ($, on) => {
95+ const logged: string[] = []
96+ memoryClock(on)
97+ on('session.start', ($, e) => ({ cwd: e.cwd }))
98+ on('command.register', () => ({
99+ deny: '32 commands are registered already',
100+ }))
101+ on('ui.log', ($, e) => {
102+ logged.push(e.text)
103+
104+ return { value: undefined }
105+ })
106+
107+ await $.session.start(Fixtures.SESSION)
108+
109+ expect(logged).toEqual([
110+ 'could not register /diff: diff: $.command.register: 32 commands are ' +
111+ 'registered already; the diff panel is unavailable this session',
112+ ])
113+ })
114+
115+ test('/diff opens the pane over the session changes', async ($, on) => {
116+ const world = Fixtures.inRepository(on)
117+
118+ await $.session.start(Fixtures.SESSION)
119+
120+ expect(await $.command.run(Fixtures.DIFF)).toEqual({})
121+ expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
122+
123+ await world.clock.advance(Fixtures.SETTLE_MS)
124+ const drawn = textOf(await $.ui.render(Fixtures.PANE))
125+
126+ expect(drawn).toContain('1 file changed')
127+ expect(drawn).toContain('app.ts')
128+ })
129+
130+ test('the close button closes the pane; /diff reopens it', async ($, on) => {
131+ const world = Fixtures.inRepository(on)
132+
133+ await $.session.start(Fixtures.SESSION)
134+ await $.command.run(Fixtures.DIFF)
135+ await world.clock.advance(Fixtures.SETTLE_MS)
136+ await $.ui.render(Fixtures.PANE)
137+
138+ expect(await $.ui.press({ plugin: 'diff', key: 'close' })).toEqual({
139+ element: 'close',
140+ })
141+
142+ await world.clock.advance(0)
143+
144+ expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
145+ expect(await $.command.run(Fixtures.DIFF)).toEqual({})
146+ expect(world.opened.map(pane => pane.id)).toEqual(['diff', 'diff'])
147+ })
148+
149+ test('a wide terminal opens the pane at the first edit', async ($, on) => {
150+ const world = Fixtures.inRepository(on)
151+ on('tool.call', () => ({ result: 'edited' }))
152+
153+ await $.session.start(Fixtures.SESSION)
154+ await $.ui.render(Fixtures.HINT)
155+ await $.tool.call({
156+ tool: 'Edit',
157+ file_path: '/work/app.ts',
158+ old_string: '1',
159+ new_string: '2',
160+ })
161+ await world.clock.advance(Fixtures.SETTLE_MS)
162+
163+ expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
164+ })
165+
166+ test('/clear closes the pane it finds open', async ($, on) => {
167+ const world = Fixtures.inRepository(on)
168+ on('command.run', { command: 'clear' }, () => ({}))
169+
170+ await $.session.start(Fixtures.SESSION)
171+ await $.command.run(Fixtures.DIFF)
172+ await $.command.run(Fixtures.CLEAR)
173+
174+ expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
175+ })
176+})
@@ -1,0 +1,8 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Managed settings holding an MCP allowlist: a tool policy in force.
5+ */
6+export const ALLOWLIST: Settings = {
7+ allowedMcpServers: [{ serverName: 'corp' }],
8+}
@@ -1,0 +1,11 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that drops the memory section.
5+ */
6+export const dropping: Plugin = {
7+ name: 'dropping',
8+ register(on) {
9+ on('prompt.section', () => ({ text: null }))
10+ },
11+}
@@ -1,0 +1,15 @@
1+export * from './allowlist.js'
2+export * from './dropping.js'
3+export * from './listing.js'
4+export * from './memory.js'
5+export * from './no-allowlist.js'
6+export * from './registered-tool-of.js'
7+export * from './registering.js'
8+export * from './relabeling.js'
9+export * from './session.js'
10+export * from './signing.js'
11+export * from './tools.js'
12+export * from './tools-command.js'
13+export * from './tools-registered.js'
14+
15+export * as default from '.'
@@ -1,0 +1,16 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin whose `/tools` answers the tool listing it reads, one
5+ * `name: description` line per tool.
6+ */
7+export const listing: Plugin = {
8+ name: 'listing',
9+ register(on) {
10+ on('command.run', { command: 'tools' }, async $ => ({
11+ text: (await $.tool.list())
12+ .map(tool => `${tool.name}: ${tool.description}`)
13+ .join('\n'),
14+ }))
15+ },
16+}
@@ -1,0 +1,9 @@
1+import type { PromptSectionInput } from 'claude-code'
2+
3+/**
4+ * The system prompt's memory section, as the engine asks for it.
5+ */
6+export const MEMORY: PromptSectionInput = {
7+ name: 'memory',
8+ text: 'the org says hi',
9+}
@@ -1,0 +1,6 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Managed settings with permissions but no MCP allowlist or denylist.
5+ */
6+export const NO_ALLOWLIST: Settings = { permissions: { allow: [] } }
@@ -1,0 +1,10 @@
1+/**
2+ * What registering a plugin's tool answers: the name it is called by.
3+ *
4+ * @param plugin the plugin that asked
5+ * @param name the tool's own name
6+ * @returns the `tool.register` result
7+ */
8+export const registeredToolOf = (plugin: string, name: string) => ({
9+ value: { tool: `mcp__plugin_${plugin}__${name}` },
10+})
@@ -1,0 +1,25 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin that registers a tool when the session starts, in the tier
5+ * given: the person's own by default.
6+ *
7+ * @param name the plugin's name
8+ * @param tier the tier it loads in
9+ * @returns the plugin
10+ */
11+export const registering = (name: string, tier?: Plugin['tier']): Plugin => ({
12+ name,
13+ tier,
14+ register(on) {
15+ on('session.start', async ($, e, next) => {
16+ await $.tool.register({
17+ name: 'greet',
18+ description: 'Says hello.',
19+ inputSchema: { type: 'object' },
20+ })
21+
22+ return next(e)
23+ })
24+ },
25+})
@@ -1,0 +1,24 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin the person installed that hides the organization's tools from
5+ * the listing and relabels every other one.
6+ */
7+export const relabeling: Plugin = {
8+ name: 'relabeling',
9+ register(on) {
10+ on('tool.list', async ($, e, next) => {
11+ const listed = await next(e)
12+ const { value } = listed
13+ const isListed = value !== undefined
14+
15+ return isListed
16+ ? {
17+ value: value
18+ .filter(tool => !tool.name.startsWith('mcp__corp__'))
19+ .map(tool => ({ ...tool, description: 'relabeled' })),
20+ }
21+ : listed
22+ })
23+ },
24+}
@@ -1,0 +1,10 @@
1+import type { SessionStartInput } from 'claude-code'
2+
3+/**
4+ * An interactive terminal session in /work.
5+ */
6+export const SESSION: SessionStartInput = {
7+ surface: 'terminal',
8+ isInteractive: true,
9+ cwd: '/work',
10+}
@@ -1,0 +1,14 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * The organization's own plugin, in its last tier, which signs the section.
5+ */
6+export const signing: Plugin = {
7+ name: 'signing',
8+ tier: 'append',
9+ register(on) {
10+ on('prompt.section', ($, e, next) =>
11+ next({ ...e, text: `${e.text} (signed)` }),
12+ )
13+ },
14+}
@@ -1,0 +1,10 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+/**
4+ * `/tools` as the person types it: the listing plugin's command.
5+ */
6+export const TOOLS_COMMAND: CommandRunInput = {
7+ command: 'tools',
8+ args: '',
9+ origin: { kind: 'composer' },
10+}
@@ -1,0 +1,22 @@
1+import type { On } from 'claude-code'
2+
3+import { registeredToolOf } from './registered-tool-of.js'
4+
5+/**
6+ * A session starting, where each tool a plugin asks for is registered and
7+ * kept by the name of the plugin that asked.
8+ *
9+ * @param on the test's `on`
10+ * @returns the plugins whose tools were registered, in order
11+ */
12+export function toolsRegistered(on: On) {
13+ const registered: string[] = []
14+ on('session.start', ($, e) => ({ cwd: e.cwd }))
15+ on('tool.register', ($, e, next) => {
16+ registered.push(next.origin.plugin)
17+
18+ return registeredToolOf(next.origin.plugin, e.name)
19+ })
20+
21+ return registered
22+}
@@ -1,0 +1,13 @@
1+import type { ToolInfo } from 'claude-code'
2+
3+/**
4+ * The session's tools: one from the organization's MCP server, one built in.
5+ */
6+export const TOOLS: readonly ToolInfo[] = [
7+ {
8+ name: 'mcp__corp__search',
9+ description: 'Searches the corp wiki.',
10+ mcp: true,
11+ },
12+ { name: 'Bash', description: 'Runs a command.', mcp: false },
13+]
@@ -1,0 +1,79 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Fixtures from './fixtures'
4+
5+tier('prepend')
6+
7+describe('register', () => {
8+ test(
9+ 'under an MCP allowlist a plugin the person installed may not add a tool',
10+ {
11+ plugins: [
12+ Fixtures.registering('mine'),
13+ Fixtures.registering('bundled', 'builtin'),
14+ ],
15+ },
16+ async ($, on) => {
17+ on('settings.read', () => ({ value: Fixtures.ALLOWLIST }))
18+ const registered = Fixtures.toolsRegistered(on)
19+
20+ await $.session.start(Fixtures.SESSION)
21+
22+ expect(registered).toEqual(['bundled'])
23+ },
24+ )
25+
26+ test(
27+ 'with no allowlist, a plugin the person installed adds its tool',
28+ { plugins: [Fixtures.registering('mine')] },
29+ async ($, on) => {
30+ on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST }))
31+ const registered = Fixtures.toolsRegistered(on)
32+
33+ await $.session.start(Fixtures.SESSION)
34+
35+ expect(registered).toEqual(['mine'])
36+ },
37+ )
38+
39+ test(
40+ 'a policy that cannot be read counts as one in force',
41+ { plugins: [Fixtures.registering('mine')] },
42+ async ($, on) => {
43+ on('settings.read', () => ({ deny: 'managed settings unreadable' }))
44+ const registered = Fixtures.toolsRegistered(on)
45+
46+ await $.session.start(Fixtures.SESSION)
47+
48+ expect(registered).toEqual([])
49+ },
50+ )
51+
52+ test(
53+ 'the organization tools are listed as its tiers listed them',
54+ { plugins: [Fixtures.relabeling, Fixtures.listing] },
55+ async ($, on) => {
56+ on('settings.read', () => ({ value: Fixtures.ALLOWLIST }))
57+ on('tool.list', () => ({ value: [...Fixtures.TOOLS] }))
58+
59+ const { text } = await $.command.run(Fixtures.TOOLS_COMMAND)
60+
61+ expect(text?.split('\n')).toEqual([
62+ 'mcp__corp__search: Searches the corp wiki.',
63+ 'Bash: relabeled',
64+ ])
65+ },
66+ )
67+
68+ test(
69+ 'a prompt section passes over the plugins the person installed',
70+ { plugins: [Fixtures.dropping, Fixtures.signing] },
71+ async ($, on) => {
72+ on('prompt.section', ($, e) => ({ text: e.text }))
73+
74+ expect(await $.prompt.section(Fixtures.MEMORY)).toEqual({
75+ text: 'the org says hi (signed)',
76+ })
77+ },
78+ )
79+})
Dmods/sec-default/tests/sec-default.test.ts+0 -207
@@ -1,207 +1,0 @@
1-import type {
2- CommandRunInput,
3- On,
4- PromptSectionInput,
5- SessionStartInput,
6- Settings,
7- ToolInfo,
8-} from 'claude-code'
9-import { describe, expect, test, tier } from 'claude-code/testing'
10-import type { Plugin } from 'claude-code/testing'
11-
12-tier('prepend')
13-
14-const SESSION: SessionStartInput = {
15- surface: 'terminal',
16- isInteractive: true,
17- cwd: '/work',
18-}
19-const TOOLS_COMMAND: CommandRunInput = {
20- command: 'tools',
21- args: '',
22- origin: { kind: 'composer' },
23-}
24-const ALLOWLIST: Settings = { allowedMcpServers: [{ serverName: 'corp' }] }
25-const NO_ALLOWLIST: Settings = { permissions: { allow: [] } }
26-const MEMORY: PromptSectionInput = { name: 'memory', text: 'the org says hi' }
27-const TOOLS: readonly ToolInfo[] = [
28- {
29- name: 'mcp__corp__search',
30- description: 'Searches the corp wiki.',
31- mcp: true,
32- },
33- { name: 'Bash', description: 'Runs a command.', mcp: false },
34-]
35-
36-/**
37- * A plugin that registers a tool when the session starts, in the tier
38- * given: the person's own by default.
39- */
40-const registering = (name: string, tier?: Plugin['tier']): Plugin => ({
41- name,
42- tier,
43- register(on) {
44- on('session.start', async ($, e, next) => {
45- await $.tool.register({
46- name: 'greet',
47- description: 'Says hello.',
48- inputSchema: { type: 'object' },
49- })
50-
51- return next(e)
52- })
53- },
54-})
55-
56-/**
57- * What registering a plugin's tool answers: the name it is called by.
58- */
59-const registeredToolOf = (plugin: string, name: string) => ({
60- value: { tool: `mcp__plugin_${plugin}__${name}` },
61-})
62-
63-/**
64- * A session starting, where each tool a plugin asks for is registered and
65- * kept by the name of the plugin that asked.
66- */
67-function toolsRegistered(on: On) {
68- const registered: string[] = []
69- on('session.start', ($, e) => ({ cwd: e.cwd }))
70- on('tool.register', ($, e, next) => {
71- registered.push(next.origin.plugin)
72-
73- return registeredToolOf(next.origin.plugin, e.name)
74- })
75-
76- return registered
77-}
78-
79-/**
80- * A plugin the person installed that hides the organization's tools from
81- * the listing and relabels every other one.
82- */
83-const relabeling: Plugin = {
84- name: 'relabeling',
85- register(on) {
86- on('tool.list', async ($, e, next) => {
87- const listed = await next(e)
88- const { value } = listed
89- const isListed = value !== undefined
90-
91- return isListed
92- ? {
93- value: value
94- .filter(tool => !tool.name.startsWith('mcp__corp__'))
95- .map(tool => ({ ...tool, description: 'relabeled' })),
96- }
97- : listed
98- })
99- },
100-}
101-
102-/**
103- * A plugin whose `/tools` answers the tool listing it reads, one
104- * `name: description` line per tool.
105- */
106-const listing: Plugin = {
107- name: 'listing',
108- register(on) {
109- on('command.run', { command: 'tools' }, async $ => ({
110- text: (await $.tool.list())
111- .map(tool => `${tool.name}: ${tool.description}`)
112- .join('\n'),
113- }))
114- },
115-}
116-
117-/**
118- * A plugin the person installed that drops the memory section.
119- */
120-const dropping: Plugin = {
121- name: 'dropping',
122- register(on) {
123- on('prompt.section', () => ({ text: null }))
124- },
125-}
126-
127-/**
128- * The organization's own plugin, in its last tier, which signs the section.
129- */
130-const signing: Plugin = {
131- name: 'signing',
132- tier: 'append',
133- register(on) {
134- on('prompt.section', ($, e, next) =>
135- next({ ...e, text: `${e.text} (signed)` }),
136- )
137- },
138-}
139-
140-describe('sec-default', () => {
141- test(
142- 'under an MCP allowlist a plugin the person installed may not add a tool',
143- { plugins: [registering('mine'), registering('bundled', 'builtin')] },
144- async ($, on) => {
145- on('settings.read', () => ({ value: ALLOWLIST }))
146- const registered = toolsRegistered(on)
147-
148- await $.session.start(SESSION)
149-
150- expect(registered).toEqual(['bundled'])
151- },
152- )
153-
154- test(
155- 'with no allowlist, a plugin the person installed adds its tool',
156- { plugins: [registering('mine')] },
157- async ($, on) => {
158- on('settings.read', () => ({ value: NO_ALLOWLIST }))
159- const registered = toolsRegistered(on)
160-
161- await $.session.start(SESSION)
162-
163- expect(registered).toEqual(['mine'])
164- },
165- )
166-
167- test(
168- 'a policy that cannot be read counts as one in force',
169- { plugins: [registering('mine')] },
170- async ($, on) => {
171- on('settings.read', () => ({ deny: 'managed settings unreadable' }))
172- const registered = toolsRegistered(on)
173-
174- await $.session.start(SESSION)
175-
176- expect(registered).toEqual([])
177- },
178- )
179-
180- test(
181- 'the organization tools are listed as its tiers listed them',
182- { plugins: [relabeling, listing] },
183- async ($, on) => {
184- on('settings.read', () => ({ value: ALLOWLIST }))
185- on('tool.list', () => ({ value: [...TOOLS] }))
186-
187- const { text } = await $.command.run(TOOLS_COMMAND)
188-
189- expect(text?.split('\n')).toEqual([
190- 'mcp__corp__search: Searches the corp wiki.',
191- 'Bash: relabeled',
192- ])
193- },
194- )
195-
196- test(
197- 'a prompt section passes over the plugins the person installed',
198- { plugins: [dropping, signing] },
199- async ($, on) => {
200- on('prompt.section', ($, e) => ({ text: e.text }))
201-
202- expect(await $.prompt.section(MEMORY)).toEqual({
203- text: 'the org says hi (signed)',
204- })
205- },
206- )
207-})
@@ -1,0 +1,11 @@
1+import type { HttpResponse } from 'claude-code'
2+
3+/**
4+ * The ingest's answer to a batch it took.
5+ */
6+export const ACCEPTED: HttpResponse = {
7+ status: 200,
8+ ok: true,
9+ headers: {},
10+ text: '',
11+}
@@ -1,0 +1,10 @@
1+import type { Args } from 'claude-code'
2+
3+/**
4+ * The batch a post to the ingest carries, as it was sent.
5+ *
6+ * @param post the `http.fetch` the plugin made
7+ * @returns the body, parsed
8+ */
9+export const batchOf = (post: Args<'http.fetch'>): unknown =>
10+ JSON.parse(String(post.init?.body))
@@ -1,0 +1,9 @@
1+import type { SessionAuthorization } from 'claude-code'
2+
3+/**
4+ * The credential a session signed in first party holds, by its handle.
5+ */
6+export const BEARER: SessionAuthorization = {
7+ handle: 'the-handle',
8+ kind: 'bearer',
9+}
@@ -1,0 +1,29 @@
1+import type { Args, On, SessionAuthorization } from 'claude-code'
2+
3+import { ACCEPTED } from './accepted.js'
4+import { BEARER } from './bearer.js'
5+
6+/**
7+ * Answers what the telemetry plugin reads of a session signed in first
8+ * party, and keeps each post the ingest accepts.
9+ *
10+ * @param on the test's `on`
11+ * @param authorization the credential the session holds
12+ * @returns each post, as it was made
13+ */
14+export function firstPartySession(
15+ on: On,
16+ authorization: SessionAuthorization = BEARER,
17+) {
18+ const posts: Args<'http.fetch'>[] = []
19+ on('session.id', () => ({ value: 'the-session' }))
20+ on('session.model', () => ({ value: 'the-model' }))
21+ on('session.authorize', () => ({ value: authorization }))
22+ on('http.fetch', ($, e) => {
23+ posts.push(e)
24+
25+ return { value: ACCEPTED }
26+ })
27+
28+ return posts
29+}
@@ -1,0 +1,9 @@
1+export * from './accepted.js'
2+export * from './batch-of.js'
3+export * from './bearer.js'
4+export * from './first-party-session.js'
5+export * from './record.js'
6+export * from './recording.js'
7+export * from './survey-answer.js'
8+
9+export * as default from '.'
@@ -1,0 +1,16 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+import type { LogEntry } from '../../hooks/telemetry-types'
4+
5+/**
6+ * The command that has the recording plugin log an entry, typed as the
7+ * person would type it.
8+ *
9+ * @param entry what to log
10+ * @returns `/record <entry>`
11+ */
12+export const record = (entry: LogEntry): CommandRunInput => ({
13+ command: 'record',
14+ args: JSON.stringify(entry),
15+ origin: { kind: 'composer' },
16+})
@@ -1,0 +1,17 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin whose `/record <entry>` logs the entry through `$.telemetry`,
5+ * answering "sent", or why the row was refused.
6+ */
7+export const recording: Plugin = {
8+ name: 'recording',
9+ register(on) {
10+ on('command.run', { command: 'record' }, ($, e) =>
11+ $.telemetry.log(JSON.parse(e.args)).then(
12+ () => ({ text: 'sent' }),
13+ (error: unknown) => ({ text: String(error) }),
14+ ),
15+ )
16+ },
17+}
@@ -1,0 +1,11 @@
1+import type { LogEntry } from '../../hooks/telemetry-types'
2+
3+/**
4+ * A survey answered, as a plugin logs it.
5+ *
6+ * @returns the entry, fresh each call
7+ */
8+export const surveyAnswer = (): LogEntry => ({
9+ event: 'survey_answered',
10+ props: { answer: 2, seen: true },
11+})
@@ -1,0 +1,127 @@
1+import { describe, expect, memoryEnv, test, tier } from 'claude-code/testing'
2+
3+import Fixtures from './fixtures'
4+
5+tier('builtin')
6+
7+describe('register', () => {
8+ test(
9+ 'a $.telemetry.log call from a plugin posts one first-party row',
10+ { plugins: [Fixtures.recording] },
11+ async ($, on) => {
12+ memoryEnv(on, { USER_TYPE: 'ant' })
13+ const posts = Fixtures.firstPartySession(on)
14+
15+ expect(
16+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer())),
17+ ).toEqual({ text: 'sent' })
18+ expect(posts.map(post => post.init)).toMatchObject([
19+ { method: 'POST', auth: 'the-handle' },
20+ ])
21+ expect(posts.map(Fixtures.batchOf)).toMatchObject([
22+ {
23+ events: [
24+ {
25+ event_data: {
26+ event_name: 'tengu_plugin_survey_answered',
27+ session_id: 'the-session',
28+ model: 'the-model',
29+ user_type: 'ant',
30+ },
31+ },
32+ ],
33+ },
34+ ])
35+ },
36+ )
37+
38+ test(
39+ 'a row already named tengu_ is sent under its own name',
40+ { plugins: [Fixtures.recording] },
41+ async ($, on) => {
42+ memoryEnv(on, {})
43+ const posts = Fixtures.firstPartySession(on)
44+
45+ await $.command.run(
46+ Fixtures.record({
47+ ...Fixtures.surveyAnswer(),
48+ event: 'tengu_repl_diff_panel_shown',
49+ }),
50+ )
51+
52+ expect(posts.map(Fixtures.batchOf)).toMatchObject([
53+ {
54+ events: [
55+ {
56+ event_data: {
57+ event_name: 'tengu_repl_diff_panel_shown',
58+ user_type: 'external',
59+ },
60+ },
61+ ],
62+ },
63+ ])
64+ },
65+ )
66+
67+ test(
68+ 'nothing is sent for a person who asked not to be tracked',
69+ { plugins: [Fixtures.recording] },
70+ async ($, on) => {
71+ memoryEnv(on, { DO_NOT_TRACK: '1' })
72+ const posts = Fixtures.firstPartySession(on)
73+ const { text } = await $.command.run(
74+ Fixtures.record(Fixtures.surveyAnswer()),
75+ )
76+
77+ expect({ text, posts }).toEqual({ text: 'sent', posts: [] })
78+ },
79+ )
80+
81+ test(
82+ 'nothing is sent on a third-party provider',
83+ { plugins: [Fixtures.recording] },
84+ async ($, on) => {
85+ memoryEnv(on, { CLAUDE_CODE_USE_BEDROCK: '1' })
86+ const posts = Fixtures.firstPartySession(on)
87+
88+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
89+
90+ expect(posts).toEqual([])
91+ },
92+ )
93+
94+ test(
95+ 'a session with no first-party credential is refused, nothing sent',
96+ { plugins: [Fixtures.recording] },
97+ async ($, on) => {
98+ memoryEnv(on, {})
99+ const posts = Fixtures.firstPartySession(on, null)
100+ const { text } = await $.command.run(
101+ Fixtures.record(Fixtures.surveyAnswer()),
102+ )
103+
104+ expect(text).toEndWith(
105+ '$.telemetry.log: this session has no first-party credential to ' +
106+ 'authorize',
107+ )
108+ expect(posts).toEqual([])
109+ },
110+ )
111+
112+ test(
113+ 'free text in a row is refused, nothing sent',
114+ { plugins: [Fixtures.recording] },
115+ async ($, on) => {
116+ memoryEnv(on, {})
117+ const posts = Fixtures.firstPartySession(on)
118+ const { text } = await $.command.run({
119+ ...Fixtures.record(Fixtures.surveyAnswer()),
120+ args: '{"event":"survey_answered","props":{"note":"hello world"}}',
121+ })
122+
123+ expect(text).toContain('props.note: free text is refused')
124+ expect(posts).toEqual([])
125+ },
126+ )
127+})
Dmods/telemetry/tests/telemetry.test.ts+0 -196
@@ -1,196 +1,0 @@
1-import type {
2- Args,
3- CommandRunInput,
4- HttpResponse,
5- On,
6- SessionAuthorization,
7-} from 'claude-code'
8-import { describe, expect, memoryEnv, test, tier } from 'claude-code/testing'
9-import type { Plugin } from 'claude-code/testing'
10-
11-import type { LogEntry } from '../hooks/telemetry-types'
12-
13-const BEARER: SessionAuthorization = { handle: 'the-handle', kind: 'bearer' }
14-const ACCEPTED: HttpResponse = { status: 200, ok: true, headers: {}, text: '' }
15-
16-/**
17- * A survey answered, as a plugin logs it.
18- */
19-const surveyAnswer = (): LogEntry => ({
20- event: 'survey_answered',
21- props: { answer: 2, seen: true },
22-})
23-
24-/**
25- * The command that has the recording plugin log an entry, typed as the
26- * person would type it.
27- */
28-const record = (entry: LogEntry): CommandRunInput => ({
29- command: 'record',
30- args: JSON.stringify(entry),
31- origin: { kind: 'composer' },
32-})
33-
34-/**
35- * A plugin whose `/record <entry>` logs the entry through `$.telemetry`,
36- * answering "sent", or why the row was refused.
37- */
38-const recording: Plugin = {
39- name: 'recording',
40- register(on) {
41- on('command.run', { command: 'record' }, ($, e) =>
42- $.telemetry.log(JSON.parse(e.args)).then(
43- () => ({ text: 'sent' }),
44- (error: unknown) => ({ text: String(error) }),
45- ),
46- )
47- },
48-}
49-
50-/**
51- * The batch a post to the ingest carries, as it was sent.
52- */
53-const batchOf = (post: Args<'http.fetch'>): unknown =>
54- JSON.parse(String(post.init?.body))
55-
56-tier('builtin')
57-
58-/**
59- * Answers what the telemetry plugin reads of a session signed in first
60- * party, and keeps each post the ingest accepts.
61- *
62- * @param on the test's `on`
63- * @param authorization the credential the session holds
64- * @returns each post, as it was made
65- */
66-function firstPartySession(
67- on: On,
68- authorization: SessionAuthorization = BEARER,
69-) {
70- const posts: Args<'http.fetch'>[] = []
71- on('session.id', () => ({ value: 'the-session' }))
72- on('session.model', () => ({ value: 'the-model' }))
73- on('session.authorize', () => ({ value: authorization }))
74- on('http.fetch', ($, e) => {
75- posts.push(e)
76-
77- return { value: ACCEPTED }
78- })
79-
80- return posts
81-}
82-
83-describe('telemetry', () => {
84- test(
85- 'a $.telemetry.log call from a plugin posts one first-party row',
86- { plugins: [recording] },
87- async ($, on) => {
88- memoryEnv(on, { USER_TYPE: 'ant' })
89- const posts = firstPartySession(on)
90-
91- expect(await $.command.run(record(surveyAnswer()))).toEqual({
92- text: 'sent',
93- })
94- expect(posts.map(post => post.init)).toMatchObject([
95- { method: 'POST', auth: 'the-handle' },
96- ])
97- expect(posts.map(batchOf)).toMatchObject([
98- {
99- events: [
100- {
101- event_data: {
102- event_name: 'tengu_plugin_survey_answered',
103- session_id: 'the-session',
104- model: 'the-model',
105- user_type: 'ant',
106- },
107- },
108- ],
109- },
110- ])
111- },
112- )
113-
114- test(
115- 'a row already named tengu_ is sent under its own name',
116- { plugins: [recording] },
117- async ($, on) => {
118- memoryEnv(on, {})
119- const posts = firstPartySession(on)
120-
121- await $.command.run(
122- record({ ...surveyAnswer(), event: 'tengu_repl_diff_panel_shown' }),
123- )
124-
125- expect(posts.map(batchOf)).toMatchObject([
126- {
127- events: [
128- {
129- event_data: {
130- event_name: 'tengu_repl_diff_panel_shown',
131- user_type: 'external',
132- },
133- },
134- ],
135- },
136- ])
137- },
138- )
139-
140- test(
141- 'nothing is sent for a person who asked not to be tracked',
142- { plugins: [recording] },
143- async ($, on) => {
144- memoryEnv(on, { DO_NOT_TRACK: '1' })
145- const posts = firstPartySession(on)
146- const { text } = await $.command.run(record(surveyAnswer()))
147-
148- expect({ text, posts }).toEqual({ text: 'sent', posts: [] })
149- },
150- )
151-
152- test(
153- 'nothing is sent on a third-party provider',
154- { plugins: [recording] },
155- async ($, on) => {
156- memoryEnv(on, { CLAUDE_CODE_USE_BEDROCK: '1' })
157- const posts = firstPartySession(on)
158-
159- await $.command.run(record(surveyAnswer()))
160-
161- expect(posts).toEqual([])
162- },
163- )
164-
165- test(
166- 'a session with no first-party credential is refused, nothing sent',
167- { plugins: [recording] },
168- async ($, on) => {
169- memoryEnv(on, {})
170- const posts = firstPartySession(on, null)
171- const { text } = await $.command.run(record(surveyAnswer()))
172-
173- expect(text).toEndWith(
174- '$.telemetry.log: this session has no first-party credential to ' +
175- 'authorize',
176- )
177- expect(posts).toEqual([])
178- },
179- )
180-
181- test(
182- 'free text in a row is refused, nothing sent',
183- { plugins: [recording] },
184- async ($, on) => {
185- memoryEnv(on, {})
186- const posts = firstPartySession(on)
187- const { text } = await $.command.run({
188- ...record(surveyAnswer()),
189- args: '{"event":"survey_answered","props":{"note":"hello world"}}',
190- })
191-
192- expect(text).toContain('props.note: free text is refused')
193- expect(posts).toEqual([])
194- },
195- )
196-})