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
| Amods/diff/tests/boot-probe.test.ts |
| Amods/diff/tests/command.test.ts |
| Amods/diff/tests/pane.test.ts |
| Amods/sec-default/tests/sec-default.test.ts |
| Amods/telemetry/tests/telemetry.test.ts |
| @@ -1,0 +1,58 @@ | ||
| 1 | +import type { | |
| 2 | + CommandRunInput, | |
| 3 | + ProcessRunResult, | |
| 4 | + SessionStartInput, | |
| 5 | +} from 'claude-code' | |
| 6 | +import { clock, expect, seat, test } from 'claude-code/testing' | |
| 7 | + | |
| 8 | +seat('builtin') | |
| 9 | + | |
| 10 | +const SESSION: SessionStartInput = { | |
| 11 | + surface: 'terminal', | |
| 12 | + isInteractive: true, | |
| 13 | + cwd: '/work', | |
| 14 | +} | |
| 15 | +const DIFF: CommandRunInput = { | |
| 16 | + command: 'diff', | |
| 17 | + args: '', | |
| 18 | + origin: { kind: 'composer' }, | |
| 19 | +} | |
| 20 | +const NOT_A_REPOSITORY: ProcessRunResult = { | |
| 21 | + exitCode: 128, | |
| 22 | + stdout: '', | |
| 23 | + stderr: 'fatal: not a git repository', | |
| 24 | +} | |
| 25 | +const GIT_TIMEOUT_MS = 5000 | |
| 26 | +const GIT_HUNG = `git aborted: still running after ${GIT_TIMEOUT_MS}ms` | |
| 27 | + | |
| 28 | +test('/diff at boot joins the boot probe, then asks again', async ($, on) => { | |
| 29 | + const probes: (readonly string[])[] = [] | |
| 30 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 31 | + on('command.register', ($, e) => ({ value: { command: e.name } })) | |
| 32 | + on('process.run', async ($, e) => { | |
| 33 | + probes.push(e.argv) | |
| 34 | + | |
| 35 | + if (probes.length === 1) { | |
| 36 | + await clock.sleep(GIT_TIMEOUT_MS) | |
| 37 | + | |
| 38 | + return { deny: GIT_HUNG } | |
| 39 | + } | |
| 40 | + | |
| 41 | + return { value: NOT_A_REPOSITORY } | |
| 42 | + }) | |
| 43 | + | |
| 44 | + const booting = $.session.start(SESSION) | |
| 45 | + await clock.advance(0) | |
| 46 | + const ran = $.command.run(DIFF) | |
| 47 | + await clock.advance(0) | |
| 48 | + | |
| 49 | + expect(probes, 'the boot’s probe, which /diff joined').toHaveLength(1) | |
| 50 | + | |
| 51 | + await clock.advance(GIT_TIMEOUT_MS) | |
| 52 | + await booting | |
| 53 | + | |
| 54 | + expect(await ran).toEqual({ | |
| 55 | + text: expect.stringContaining('isn’t in a git repository'), | |
| 56 | + }) | |
| 57 | + expect(probes, 'then one more of its own').toHaveLength(2) | |
| 58 | +}) |
| @@ -1,0 +1,88 @@ | ||
| 1 | +import type { | |
| 2 | + CommandRunInput, | |
| 3 | + ProcessRunResult, | |
| 4 | + SessionStartInput, | |
| 5 | +} from 'claude-code' | |
| 6 | +import { expect, seat, test } from 'claude-code/testing' | |
| 7 | + | |
| 8 | +seat('builtin') | |
| 9 | + | |
| 10 | +const SESSION: SessionStartInput = { | |
| 11 | + surface: 'terminal', | |
| 12 | + isInteractive: true, | |
| 13 | + cwd: '/work', | |
| 14 | +} | |
| 15 | +const DIFF: CommandRunInput = { | |
| 16 | + command: 'diff', | |
| 17 | + args: '', | |
| 18 | + origin: { kind: 'composer' }, | |
| 19 | +} | |
| 20 | +const NOT_A_REPOSITORY: ProcessRunResult = { | |
| 21 | + exitCode: 128, | |
| 22 | + stdout: '', | |
| 23 | + stderr: 'fatal: not a git repository', | |
| 24 | +} | |
| 25 | +const BUILTIN_HOLDS = '"/diff" refused: it is the built-in /diff' | |
| 26 | + | |
| 27 | +test('outside a git repository /diff says so and opens nothing', async ($, on) => { | |
| 28 | + const opened: string[] = [] | |
| 29 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 30 | + on('command.register', ($, e) => ({ value: { command: e.name } })) | |
| 31 | + on('process.run', () => ({ value: NOT_A_REPOSITORY })) | |
| 32 | + on('ui.open', ($, e, next) => { | |
| 33 | + opened.push(e.id) | |
| 34 | + return next(e) | |
| 35 | + }) | |
| 36 | + | |
| 37 | + await $.session.start(SESSION) | |
| 38 | + const { text } = await $.command.run(DIFF) | |
| 39 | + | |
| 40 | + expect(text).toContain('isn’t in a git repository') | |
| 41 | + expect(opened).toEqual([]) | |
| 42 | +}) | |
| 43 | + | |
| 44 | +test('a git that never answers is not “no repository”', async ($, on) => { | |
| 45 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 46 | + on('command.register', ($, e) => ({ value: { command: e.name } })) | |
| 47 | + on('process.run', () => ({ | |
| 48 | + deny: 'git aborted: still running after 5000ms', | |
| 49 | + })) | |
| 50 | + | |
| 51 | + await $.session.start(SESSION) | |
| 52 | + const { text } = await $.command.run(DIFF) | |
| 53 | + | |
| 54 | + expect(text).toContain('git didn’t answer') | |
| 55 | +}) | |
| 56 | + | |
| 57 | +test('with the built-in holding /diff, the plugin stands down', async ($, on) => { | |
| 58 | + const logged: string[] = [] | |
| 59 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 60 | + on('command.register', () => ({ deny: BUILTIN_HOLDS })) | |
| 61 | + on('command.run', () => ({ text: 'the built-in /diff ran' })) | |
| 62 | + on('ui.log', ($, e) => { | |
| 63 | + logged.push(e.text) | |
| 64 | + return { value: undefined } | |
| 65 | + }) | |
| 66 | + | |
| 67 | + await $.session.start(SESSION) | |
| 68 | + | |
| 69 | + expect(await $.command.run(DIFF)).toEqual({ text: 'the built-in /diff ran' }) | |
| 70 | + expect(logged).toEqual([]) | |
| 71 | +}) | |
| 72 | + | |
| 73 | +test('a refusal the built-in did not cause is said aloud', async ($, on) => { | |
| 74 | + const logged: string[] = [] | |
| 75 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 76 | + on('command.register', () => ({ deny: '32 commands are registered already' })) | |
| 77 | + on('ui.log', ($, e) => { | |
| 78 | + logged.push(e.text) | |
| 79 | + return { value: undefined } | |
| 80 | + }) | |
| 81 | + | |
| 82 | + await $.session.start(SESSION) | |
| 83 | + | |
| 84 | + expect(logged).toEqual([ | |
| 85 | + 'could not register /diff: diff: $.command.register: 32 commands are ' + | |
| 86 | + 'registered already; the diff panel is unavailable this session', | |
| 87 | + ]) | |
| 88 | +}) |
| @@ -1,0 +1,177 @@ | ||
| 1 | +import type { | |
| 2 | + Args, | |
| 3 | + CommandRunInput, | |
| 4 | + On, | |
| 5 | + ProcessRunResult, | |
| 6 | + RenderElement, | |
| 7 | + RenderInput, | |
| 8 | + SessionStartInput, | |
| 9 | +} from 'claude-code' | |
| 10 | +import { | |
| 11 | + clock, | |
| 12 | + expect, | |
| 13 | + memoryStore, | |
| 14 | + seat, | |
| 15 | + test, | |
| 16 | + textOf, | |
| 17 | +} from 'claude-code/testing' | |
| 18 | + | |
| 19 | +seat('builtin') | |
| 20 | + | |
| 21 | +const SESSION: SessionStartInput = { | |
| 22 | + surface: 'terminal', | |
| 23 | + isInteractive: true, | |
| 24 | + cwd: '/work', | |
| 25 | +} | |
| 26 | +const DIFF: CommandRunInput = { | |
| 27 | + command: 'diff', | |
| 28 | + args: '', | |
| 29 | + origin: { kind: 'composer' }, | |
| 30 | +} | |
| 31 | +const CLEAR: CommandRunInput = { | |
| 32 | + command: 'clear', | |
| 33 | + args: '', | |
| 34 | + origin: { kind: 'composer' }, | |
| 35 | +} | |
| 36 | +const PANE: RenderInput<'Pane'> = { | |
| 37 | + component: 'Pane', | |
| 38 | + surface: 'terminal', | |
| 39 | + requestId: 'diff', | |
| 40 | + viewport: { columns: 160, rows: 40 }, | |
| 41 | + props: { | |
| 42 | + title: 'Diff', | |
| 43 | + isFocused: false, | |
| 44 | + bodyColumns: 80, | |
| 45 | + placement: 'dock', | |
| 46 | + scroll: { offset: 0, bodyRows: 30 }, | |
| 47 | + }, | |
| 48 | +} | |
| 49 | +const HINT: RenderInput<'PromptHint'> = { | |
| 50 | + component: 'PromptHint', | |
| 51 | + surface: 'terminal', | |
| 52 | + requestId: 'hint', | |
| 53 | + viewport: { columns: 160, rows: 40 }, | |
| 54 | + props: { isDraft: false, isWorking: false, hint: '' }, | |
| 55 | +} | |
| 56 | +const HINT_DRAWN: RenderElement = { | |
| 57 | + type: 'Text', | |
| 58 | + children: ['? for shortcuts'], | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * A repository at /work with one changed file: an invocation of git whose | |
| 63 | + * command line holds a key answers that output, and any other fails as git | |
| 64 | + * does outside a repository. | |
| 65 | + */ | |
| 66 | +const REPOSITORY: Readonly<Record<string, string>> = { | |
| 67 | + 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n', | |
| 68 | + 'HEAD --shortstat': ' 1 file changed, 1 insertion(+)', | |
| 69 | + 'HEAD --numstat': '1\t0\tapp.ts\0', | |
| 70 | + 'ls-files': '', | |
| 71 | + '-- app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n', | |
| 72 | +} | |
| 73 | + | |
| 74 | +function gitIn(argv: readonly string[]): ProcessRunResult { | |
| 75 | + const line = argv.join(' ') | |
| 76 | + const found = Object.entries(REPOSITORY).find(([key]) => line.includes(key)) | |
| 77 | + | |
| 78 | + return found | |
| 79 | + ? { exitCode: 0, stdout: found[1], stderr: '' } | |
| 80 | + : { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** | |
| 84 | + * A session in that repository: /diff registers, git answers, the store | |
| 85 | + * starts empty, the engine draws the prompt's hint, and what the plugin | |
| 86 | + * asks of the world (each git run, each pane opened or closed) is kept. | |
| 87 | + */ | |
| 88 | +function inRepository(on: On) { | |
| 89 | + const runs: Args<'process.run'>[] = [] | |
| 90 | + const opened: Args<'ui.open'>[] = [] | |
| 91 | + const closed: Args<'ui.close'>[] = [] | |
| 92 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 93 | + on('command.register', ($, e) => ({ value: { command: e.name } })) | |
| 94 | + on('process.run', ($, e) => { | |
| 95 | + runs.push(e) | |
| 96 | + return { value: gitIn(e.argv) } | |
| 97 | + }) | |
| 98 | + on('ui.open', ($, e) => { | |
| 99 | + opened.push(e) | |
| 100 | + return { value: undefined } | |
| 101 | + }) | |
| 102 | + on('ui.close', ($, e) => { | |
| 103 | + closed.push(e) | |
| 104 | + return { value: undefined } | |
| 105 | + }) | |
| 106 | + on('ui.invalidate', () => ({ value: undefined })) | |
| 107 | + on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN) | |
| 108 | + on('session.messages', () => ({ value: [] })) | |
| 109 | + memoryStore(on) | |
| 110 | + | |
| 111 | + return { runs, opened, closed } | |
| 112 | +} | |
| 113 | + | |
| 114 | +test('/diff opens the pane over the session’s changes', async ($, on) => { | |
| 115 | + const world = inRepository(on) | |
| 116 | + | |
| 117 | + await $.session.start(SESSION) | |
| 118 | + | |
| 119 | + expect(await $.command.run(DIFF)).toEqual({}) | |
| 120 | + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) | |
| 121 | + | |
| 122 | + await clock.advance(1000) | |
| 123 | + const drawn = textOf(await $.ui.render(PANE)) | |
| 124 | + | |
| 125 | + expect(drawn).toContain('1 file changed') | |
| 126 | + expect(drawn).toContain('app.ts') | |
| 127 | +}) | |
| 128 | + | |
| 129 | +test('every git child is pinned to the repository and reads the C locale', async ($, on) => { | |
| 130 | + const world = inRepository(on) | |
| 131 | + | |
| 132 | + await $.session.start(SESSION) | |
| 133 | + await $.command.run(DIFF) | |
| 134 | + await clock.advance(1000) | |
| 135 | + const [discovery, ...pinned] = world.runs | |
| 136 | + | |
| 137 | + expect(discovery?.argv).toContain('--show-toplevel') | |
| 138 | + expect(discovery?.init?.cwd).toBeUndefined() | |
| 139 | + expect(pinned.length).toBeGreaterThan(0) | |
| 140 | + | |
| 141 | + for (const run of pinned) { | |
| 142 | + expect(run.argv.slice(0, 3)).toEqual([ | |
| 143 | + 'git', | |
| 144 | + '--git-dir=/work/.git', | |
| 145 | + '--work-tree=/work', | |
| 146 | + ]) | |
| 147 | + expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } }) | |
| 148 | + } | |
| 149 | +}) | |
| 150 | + | |
| 151 | +test('Claude’s first edit opens the pane on a wide terminal', async ($, on) => { | |
| 152 | + const world = inRepository(on) | |
| 153 | + on('tool.call', () => ({ result: 'edited' })) | |
| 154 | + | |
| 155 | + await $.session.start(SESSION) | |
| 156 | + await $.ui.render(HINT) | |
| 157 | + await $.tool.call({ | |
| 158 | + tool: 'Edit', | |
| 159 | + file_path: '/work/app.ts', | |
| 160 | + old_string: '1', | |
| 161 | + new_string: '2', | |
| 162 | + }) | |
| 163 | + await clock.advance(1000) | |
| 164 | + | |
| 165 | + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) | |
| 166 | +}) | |
| 167 | + | |
| 168 | +test('/clear closes the pane it finds open', async ($, on) => { | |
| 169 | + const world = inRepository(on) | |
| 170 | + on('command.run', { command: 'clear' }, () => ({})) | |
| 171 | + | |
| 172 | + await $.session.start(SESSION) | |
| 173 | + await $.command.run(DIFF) | |
| 174 | + await $.command.run(CLEAR) | |
| 175 | + | |
| 176 | + expect(world.closed.map(pane => pane.id)).toEqual(['diff']) | |
| 177 | +}) |
| @@ -1,0 +1,196 @@ | ||
| 1 | +import type { | |
| 2 | + CommandRunInput, | |
| 3 | + On, | |
| 4 | + PromptSectionInput, | |
| 5 | + SessionStartInput, | |
| 6 | + Settings, | |
| 7 | + ToolInfo, | |
| 8 | +} from 'claude-code' | |
| 9 | +import type { Plugin } from 'claude-code/testing' | |
| 10 | +import { expect, seat, test } from 'claude-code/testing' | |
| 11 | + | |
| 12 | +seat('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: 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 | + * A session starting, where each tool a plugin asks for is registered and | |
| 58 | + * kept by the name of the plugin that asked. | |
| 59 | + */ | |
| 60 | +function toolsRegistered(on: On) { | |
| 61 | + const registered: string[] = [] | |
| 62 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 63 | + on('tool.register', ($, e, next) => { | |
| 64 | + registered.push(next.origin.plugin) | |
| 65 | + | |
| 66 | + return { value: { tool: `mcp__plugin_${next.origin.plugin}__${e.name}` } } | |
| 67 | + }) | |
| 68 | + | |
| 69 | + return registered | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** | |
| 73 | + * A plugin the person installed that hides the organization's tools from | |
| 74 | + * the listing and relabels every other one. | |
| 75 | + */ | |
| 76 | +const relabeling: Plugin = { | |
| 77 | + name: 'relabeling', | |
| 78 | + register(on) { | |
| 79 | + on('tool.list', async ($, e, next) => { | |
| 80 | + const listed = await next(e) | |
| 81 | + | |
| 82 | + return listed.value === undefined | |
| 83 | + ? listed | |
| 84 | + : { | |
| 85 | + value: listed.value | |
| 86 | + .filter(tool => !tool.name.startsWith('mcp__corp__')) | |
| 87 | + .map(tool => ({ ...tool, description: 'relabeled' })), | |
| 88 | + } | |
| 89 | + }) | |
| 90 | + }, | |
| 91 | +} | |
| 92 | + | |
| 93 | +/** | |
| 94 | + * A plugin whose `/tools` answers the tool listing it reads, one | |
| 95 | + * `name: description` line per tool. | |
| 96 | + */ | |
| 97 | +const listing: Plugin = { | |
| 98 | + name: 'listing', | |
| 99 | + register(on) { | |
| 100 | + on('command.run', { command: 'tools' }, async $ => ({ | |
| 101 | + text: (await $.tool.list()) | |
| 102 | + .map(tool => `${tool.name}: ${tool.description}`) | |
| 103 | + .join('\n'), | |
| 104 | + })) | |
| 105 | + }, | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** | |
| 109 | + * A plugin the person installed that drops the memory section. | |
| 110 | + */ | |
| 111 | +const dropping: Plugin = { | |
| 112 | + name: 'dropping', | |
| 113 | + register(on) { | |
| 114 | + on('prompt.section', () => ({ text: null })) | |
| 115 | + }, | |
| 116 | +} | |
| 117 | + | |
| 118 | +/** | |
| 119 | + * The organization's own plugin, seated last, which signs the section. | |
| 120 | + */ | |
| 121 | +const signing: Plugin = { | |
| 122 | + name: 'signing', | |
| 123 | + tier: 'append', | |
| 124 | + register(on) { | |
| 125 | + on('prompt.section', ($, e, next) => | |
| 126 | + next({ ...e, text: `${e.text} (signed)` }), | |
| 127 | + ) | |
| 128 | + }, | |
| 129 | +} | |
| 130 | + | |
| 131 | +test( | |
| 132 | + 'under an MCP allowlist a plugin the person installed may not add a tool', | |
| 133 | + { plugins: [registering('mine'), registering('bundled', 'builtin')] }, | |
| 134 | + async ($, on) => { | |
| 135 | + on('settings.read', () => ({ value: ALLOWLIST })) | |
| 136 | + const registered = toolsRegistered(on) | |
| 137 | + | |
| 138 | + await $.session.start(SESSION) | |
| 139 | + | |
| 140 | + expect(registered).toEqual(['bundled']) | |
| 141 | + }, | |
| 142 | +) | |
| 143 | + | |
| 144 | +test( | |
| 145 | + 'with no allowlist, the person’s plugin adds its tool', | |
| 146 | + { plugins: [registering('mine')] }, | |
| 147 | + async ($, on) => { | |
| 148 | + on('settings.read', () => ({ value: NO_ALLOWLIST })) | |
| 149 | + const registered = toolsRegistered(on) | |
| 150 | + | |
| 151 | + await $.session.start(SESSION) | |
| 152 | + | |
| 153 | + expect(registered).toEqual(['mine']) | |
| 154 | + }, | |
| 155 | +) | |
| 156 | + | |
| 157 | +test( | |
| 158 | + 'a policy that cannot be read counts as one in force', | |
| 159 | + { plugins: [registering('mine')] }, | |
| 160 | + async ($, on) => { | |
| 161 | + on('settings.read', () => ({ deny: 'managed settings unreadable' })) | |
| 162 | + const registered = toolsRegistered(on) | |
| 163 | + | |
| 164 | + await $.session.start(SESSION) | |
| 165 | + | |
| 166 | + expect(registered).toEqual([]) | |
| 167 | + }, | |
| 168 | +) | |
| 169 | + | |
| 170 | +test( | |
| 171 | + 'the organization’s tools are listed as its tiers listed them', | |
| 172 | + { plugins: [relabeling, listing] }, | |
| 173 | + async ($, on) => { | |
| 174 | + on('settings.read', () => ({ value: ALLOWLIST })) | |
| 175 | + on('tool.list', () => ({ value: TOOLS })) | |
| 176 | + | |
| 177 | + const { text } = await $.command.run(TOOLS_COMMAND) | |
| 178 | + | |
| 179 | + expect(text?.split('\n')).toEqual([ | |
| 180 | + 'mcp__corp__search: Searches the corp wiki.', | |
| 181 | + 'Bash: relabeled', | |
| 182 | + ]) | |
| 183 | + }, | |
| 184 | +) | |
| 185 | + | |
| 186 | +test( | |
| 187 | + 'a prompt section continues past the person’s plugins, not the organization’s', | |
| 188 | + { plugins: [dropping, signing] }, | |
| 189 | + async ($, on) => { | |
| 190 | + on('prompt.section', ($, e) => ({ text: e.text })) | |
| 191 | + | |
| 192 | + expect(await $.prompt.section(MEMORY)).toEqual({ | |
| 193 | + text: 'the org says hi (signed)', | |
| 194 | + }) | |
| 195 | + }, | |
| 196 | +) |
| @@ -1,0 +1,158 @@ | ||
| 1 | +import type { | |
| 2 | + Args, | |
| 3 | + CommandRunInput, | |
| 4 | + HttpResponse, | |
| 5 | + On, | |
| 6 | + SessionAuthorization, | |
| 7 | +} from 'claude-code' | |
| 8 | +import type { Plugin } from 'claude-code/testing' | |
| 9 | +import { expect, memoryEnv, seat, test } from 'claude-code/testing' | |
| 10 | + | |
| 11 | +seat('builtin') | |
| 12 | + | |
| 13 | +const SURVEY: CommandRunInput = { | |
| 14 | + command: 'survey', | |
| 15 | + args: '', | |
| 16 | + origin: { kind: 'composer' }, | |
| 17 | +} | |
| 18 | +const SHOWN: CommandRunInput = { | |
| 19 | + command: 'shown', | |
| 20 | + args: '', | |
| 21 | + origin: { kind: 'composer' }, | |
| 22 | +} | |
| 23 | +const BEARER: SessionAuthorization = { handle: 'the-handle', kind: 'bearer' } | |
| 24 | +const ACCEPTED: HttpResponse = { status: 200, ok: true, headers: {}, text: '' } | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * A plugin that records through `$.telemetry`: `/survey` logs an answered | |
| 28 | + * survey, `/shown` a row under the built-in panel's own name; each answers | |
| 29 | + * "sent", or why the row was refused. | |
| 30 | + */ | |
| 31 | +const recording: Plugin = { | |
| 32 | + name: 'recording', | |
| 33 | + register(on) { | |
| 34 | + on('command.run', { command: 'survey' }, $ => | |
| 35 | + $.telemetry | |
| 36 | + .log({ event: 'survey_answered', props: { answer: 2, seen: true } }) | |
| 37 | + .then( | |
| 38 | + () => ({ text: 'sent' }), | |
| 39 | + (error: unknown) => ({ text: String(error) }), | |
| 40 | + ), | |
| 41 | + ) | |
| 42 | + on('command.run', { command: 'shown' }, $ => | |
| 43 | + $.telemetry | |
| 44 | + .log({ event: 'tengu_repl_diff_panel_shown', props: { seen: true } }) | |
| 45 | + .then( | |
| 46 | + () => ({ text: 'sent' }), | |
| 47 | + (error: unknown) => ({ text: String(error) }), | |
| 48 | + ), | |
| 49 | + ) | |
| 50 | + }, | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** | |
| 54 | + * A first-party session: its id and model, the credential it holds, and | |
| 55 | + * the ingest accepting each row, which is kept as it was posted. | |
| 56 | + */ | |
| 57 | +function firstPartySession( | |
| 58 | + on: On, | |
| 59 | + authorization: SessionAuthorization = BEARER, | |
| 60 | +) { | |
| 61 | + const posts: Args<'http.fetch'>[] = [] | |
| 62 | + on('session.id', () => ({ value: 'the-session' })) | |
| 63 | + on('session.model', () => ({ value: 'the-model' })) | |
| 64 | + on('session.authorize', () => ({ value: authorization })) | |
| 65 | + on('http.fetch', ($, e) => { | |
| 66 | + posts.push(e) | |
| 67 | + | |
| 68 | + return { value: ACCEPTED } | |
| 69 | + }) | |
| 70 | + | |
| 71 | + return posts | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * The one row a post to the ingest carries. | |
| 76 | + */ | |
| 77 | +function rowOf(post: Args<'http.fetch'> | undefined) { | |
| 78 | + const batch: { events: { event_data: Readonly<Record<string, unknown>> }[] } = | |
| 79 | + JSON.parse(String(post?.init?.body)) | |
| 80 | + | |
| 81 | + return batch.events[0]?.event_data | |
| 82 | +} | |
| 83 | + | |
| 84 | +test( | |
| 85 | + 'a plugin’s $.telemetry.log 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(SURVEY)).toEqual({ text: 'sent' }) | |
| 92 | + expect(posts).toHaveLength(1) | |
| 93 | + expect(posts[0]?.init).toMatchObject({ method: 'POST', auth: 'the-handle' }) | |
| 94 | + expect(rowOf(posts[0])).toMatchObject({ | |
| 95 | + event_name: 'tengu_plugin_survey_answered', | |
| 96 | + session_id: 'the-session', | |
| 97 | + model: 'the-model', | |
| 98 | + user_type: 'ant', | |
| 99 | + }) | |
| 100 | + }, | |
| 101 | +) | |
| 102 | + | |
| 103 | +test( | |
| 104 | + 'a row already named tengu_ is sent under its own name', | |
| 105 | + { plugins: [recording] }, | |
| 106 | + async ($, on) => { | |
| 107 | + memoryEnv(on, {}) | |
| 108 | + const posts = firstPartySession(on) | |
| 109 | + | |
| 110 | + await $.command.run(SHOWN) | |
| 111 | + | |
| 112 | + expect(rowOf(posts[0])).toMatchObject({ | |
| 113 | + event_name: 'tengu_repl_diff_panel_shown', | |
| 114 | + user_type: 'external', | |
| 115 | + }) | |
| 116 | + }, | |
| 117 | +) | |
| 118 | + | |
| 119 | +test( | |
| 120 | + 'nothing is sent for a person who asked not to be tracked', | |
| 121 | + { plugins: [recording] }, | |
| 122 | + async ($, on) => { | |
| 123 | + memoryEnv(on, { DO_NOT_TRACK: '1' }) | |
| 124 | + const posts = firstPartySession(on) | |
| 125 | + | |
| 126 | + expect(await $.command.run(SURVEY)).toEqual({ text: 'sent' }) | |
| 127 | + expect(posts).toEqual([]) | |
| 128 | + }, | |
| 129 | +) | |
| 130 | + | |
| 131 | +test( | |
| 132 | + 'nothing is sent on a third-party provider', | |
| 133 | + { plugins: [recording] }, | |
| 134 | + async ($, on) => { | |
| 135 | + memoryEnv(on, { CLAUDE_CODE_USE_BEDROCK: '1' }) | |
| 136 | + const posts = firstPartySession(on) | |
| 137 | + | |
| 138 | + await $.command.run(SURVEY) | |
| 139 | + | |
| 140 | + expect(posts).toEqual([]) | |
| 141 | + }, | |
| 142 | +) | |
| 143 | + | |
| 144 | +test( | |
| 145 | + 'a session with no first-party credential is refused, nothing sent', | |
| 146 | + { plugins: [recording] }, | |
| 147 | + async ($, on) => { | |
| 148 | + memoryEnv(on, {}) | |
| 149 | + const posts = firstPartySession(on, null) | |
| 150 | + const { text } = await $.command.run(SURVEY) | |
| 151 | + | |
| 152 | + expect(text).toEndWith( | |
| 153 | + '$.telemetry.log: this session has no first-party credential to ' + | |
| 154 | + 'authorize', | |
| 155 | + ) | |
| 156 | + expect(posts).toEqual([]) | |
| 157 | + }, | |
| 158 | +) |