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