| 1 | import { expect, mock, test } from 'claude-code/testing' |
| 2 | import type { On } from 'claude-code' |
| 3 | |
| 4 | import { |
| 5 | checksLine, |
| 6 | parsePr, |
| 7 | pollDelay, |
| 8 | publishes, |
| 9 | remoteRepo, |
| 10 | statusText, |
| 11 | touchesPr, |
| 12 | wakeFor, |
| 13 | wakeText, |
| 14 | } from '../hooks/lib' |
| 15 | |
| 16 | const TARGET = { host: 'diffinery.com', repo: 'codician/demo', branch: 'feature/x', dir: '/work/demo' } |
| 17 | |
| 18 | const run = (name: string, status: string, conclusion: string | null) => ({ |
| 19 | __typename: 'CheckRun', |
| 20 | name, |
| 21 | status, |
| 22 | conclusion, |
| 23 | detailsUrl: `https://diffinery.com/codician/demo/actions/runs/r1/job/${name.length}`, |
| 24 | startedAt: '2026-10-23T10:00:00.000Z', |
| 25 | completedAt: status === 'COMPLETED' ? '2026-10-23T10:01:05.000Z' : null, |
| 26 | checkSuite: { workflowRun: { workflow: { name: 'CI' } } }, |
| 27 | }) |
| 28 | |
| 29 | const answer = (sha: string, contexts: unknown[], extra: Record<string, unknown> = {}) => ({ |
| 30 | data: { |
| 31 | repository: { |
| 32 | pullRequests: { |
| 33 | nodes: [ |
| 34 | { |
| 35 | number: 7, |
| 36 | title: 'Add retries', |
| 37 | url: 'https://diffinery.com/codician/demo/pull/7', |
| 38 | state: 'OPEN', |
| 39 | isDraft: false, |
| 40 | reviewDecision: 'REVIEW_REQUIRED', |
| 41 | mergeStateStatus: 'BLOCKED', |
| 42 | headRefOid: sha, |
| 43 | autoMergeRequest: { enabledAt: '2026-10-23T10:00:00Z' }, |
| 44 | commits: { nodes: [{ commit: { oid: sha, statusCheckRollup: { state: 'PENDING', contexts: { nodes: contexts } } } }] }, |
| 45 | ...extra, |
| 46 | }, |
| 47 | ], |
| 48 | }, |
| 49 | }, |
| 50 | }, |
| 51 | }) |
| 52 | |
| 53 | test('remotes on the Diffinery host are read, lookalikes are not', async () => { |
| 54 | expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo') |
| 55 | expect(remoteRepo('https://x-token@diffinery.com/codician/demo', 'diffinery.com')).toBe('codician/demo') |
| 56 | expect(remoteRepo('git@diffinery.com:codician/demo.git', 'diffinery.com')).toBe('codician/demo') |
| 57 | expect(remoteRepo('ssh://git@diffinery.com:22/codician/demo.git', 'diffinery.com')).toBe('codician/demo') |
| 58 | expect(remoteRepo('https://diffinery.com.evil.example/codician/demo', 'diffinery.com')).toBe(null) |
| 59 | expect(remoteRepo('https://github.com/codician/demo', 'diffinery.com')).toBe(null) |
| 60 | expect(remoteRepo('https://diffinery.com/codician/demo/extra', 'diffinery.com')).toBe(null) |
| 61 | expect(remoteRepo('https://diffinery.com/../demo', 'diffinery.com')).toBe(null) |
| 62 | }) |
| 63 | |
| 64 | test('the answer reads into a pull request with its checks', async () => { |
| 65 | const pr = parsePr( |
| 66 | answer('abc1234def', [ |
| 67 | run('Typecheck', 'COMPLETED', 'SUCCESS'), |
| 68 | run('Unit tests', 'IN_PROGRESS', null), |
| 69 | run('Lint', 'COMPLETED', 'FAILURE'), |
| 70 | { __typename: 'StatusContext', context: 'deploy/preview', state: 'SUCCESS', targetUrl: 'https://example.com' }, |
| 71 | ]), |
| 72 | TARGET, |
| 73 | ) |
| 74 | expect(pr?.number).toBe(7) |
| 75 | expect(pr?.rollup).toBe('fail') |
| 76 | expect(pr?.autoMerge).toBe(true) |
| 77 | expect(pr && checksLine(pr.checks)).toBe('✗ 1 failed · ● 1 running · ✓ 2') |
| 78 | expect(pr && statusText(pr)).toBe('PR #7 ✗ 1 failed') |
| 79 | expect(parsePr({ data: { repository: { pullRequests: { nodes: [] } } } }, TARGET)).toBe(null) |
| 80 | expect(() => parsePr({ errors: [{ message: 'nope' }] }, TARGET)).toThrow('nope') |
| 81 | }) |
| 82 | |
| 83 | test('Claude is told about failures, and about passes only when asked', async () => { |
| 84 | const failing = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'FAILURE')]), TARGET)! |
| 85 | const passing = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'SUCCESS')]), TARGET)! |
| 86 | expect(wakeText(failing, 'failures')).toContain('Diffinery CI failed on codician/demo#7') |
| 87 | expect(wakeText(failing, 'failures')).toContain('CI / Lint') |
| 88 | expect(wakeText(failing, 'off')).toBe(null) |
| 89 | expect(wakeText(passing, 'failures')).toBe(null) |
| 90 | expect(wakeText(passing, 'all')).toContain('Diffinery CI passed') |
| 91 | // Unset, it tells about passes too where nothing draws the band. |
| 92 | expect(wakeFor('auto', true)).toBe('failures') |
| 93 | expect(wakeFor('auto', false)).toBe('all') |
| 94 | expect(wakeFor('off', false)).toBe('off') |
| 95 | }) |
| 96 | |
| 97 | test('it looks often while checks run, rarely when nothing moves, and backs off on errors', async () => { |
| 98 | const running = parsePr(answer('abc1234def', [run('Lint', 'IN_PROGRESS', null)]), TARGET)! |
| 99 | const done = parsePr(answer('abc1234def', [run('Lint', 'COMPLETED', 'SUCCESS')]), TARGET)! |
| 100 | const merged = parsePr(answer('abc1234def', [], { state: 'MERGED' }), TARGET)! |
| 101 | expect(pollDelay(running, 0)).toBe(15) |
| 102 | expect(pollDelay(done, 0)).toBe(60) |
| 103 | expect(pollDelay(merged, 0)).toBe(300) |
| 104 | expect(pollDelay(null, 0)).toBe(60) |
| 105 | expect([1, 2, 3, 4, 9].map(n => pollDelay(null, n))).toEqual([30, 60, 120, 240, 240]) |
| 106 | }) |
| 107 | |
| 108 | test('the commands that publish or move a branch are recognised', async () => { |
| 109 | expect(publishes('cd demo && git push -u origin feature/x')).toBe(true) |
| 110 | expect(publishes('git -C demo push')).toBe(true) |
| 111 | expect(publishes('cd demo && git -c http.extraHeader="Authorization: Bearer $TOKEN" push -u origin feature/x')).toBe(true) |
| 112 | expect(publishes("git -c credential.helper='!f() { echo password=$T; }; f' -C demo push")).toBe(true) |
| 113 | expect(publishes('git --no-pager -c push.default=current --git-dir=demo/.git push')).toBe(true) |
| 114 | expect(publishes('git --work-tree demo -P push origin HEAD')).toBe(true) |
| 115 | expect(publishes('git -c core.pager=cat log --oneline')).toBe(false) |
| 116 | expect(publishes('git config push.default current')).toBe(false) |
| 117 | expect(publishes('git commit -m "push later"')).toBe(false) |
| 118 | expect(publishes('diffinery pr create --intent x')).toBe(true) |
| 119 | expect(publishes('gh pr view')).toBe(false) |
| 120 | expect(touchesPr('gh pr view')).toBe(true) |
| 121 | expect(touchesPr('git switch main')).toBe(true) |
| 122 | expect(touchesPr('git -c user.name="A B" -c user.email=a@b.c commit -qam x')).toBe(true) |
| 123 | expect(touchesPr('git -c core.pager=cat log')).toBe(false) |
| 124 | expect(touchesPr('ls -la')).toBe(false) |
| 125 | }) |
| 126 | |
| 127 | // A session in /work, with a Diffinery checkout at /work/demo one level down (as in a cloud |
| 128 | // thread), signed in through DIFFINERY_TOKEN. `server` is what Diffinery answers. |
| 129 | type Server = { body: unknown; status?: number; head: string; seen: string[]; token?: string | null; surfaces?: ('terminal' | 'mobile')[]; store?: Map<string, unknown> } |
| 130 | |
| 131 | function engine(on: On, server: Server) { |
| 132 | const store = (server.store ??= new Map()) |
| 133 | on('store.get', (_, e) => ({ value: store.get(e.key) })) |
| 134 | on('store.set', (_, e) => (store.set(e.key, e.value), { value: undefined })) |
| 135 | on('session.surfaces', () => ({ value: server.surfaces ?? ['terminal'] })) |
| 136 | on('command.register', (_, e) => ({ value: { command: e.name } })) |
| 137 | on('ui.open', () => ({ value: { isPlaced: true } })) |
| 138 | on('ui.status', () => ({ value: undefined })) |
| 139 | on('ui.toast', () => ({ value: undefined })) |
| 140 | on('ui.log', (_, e) => (server.seen.push(`log ${JSON.stringify(e)}`), { value: undefined })) |
| 141 | on('session.start', (_, e) => ({ cwd: e.cwd })) |
| 142 | on('session.cwd', () => ({ value: '/work' })) |
| 143 | on('env.get', (_, e) => ({ value: e.name === 'DIFFINERY_TOKEN' ? (server.token === null ? undefined : 'dfy_test') : e.name === 'HOME' ? '/home/me' : undefined })) |
| 144 | on('fs.read', () => ({ deny: 'no such file' })) |
| 145 | on('fs.list', (_, e) => ({ |
| 146 | value: e.path === '/work' ? [{ name: 'demo', kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false }] : [], |
| 147 | })) |
| 148 | on('process.run', (_, e) => { |
| 149 | const [cmd, , dir, ...args] = e.argv |
| 150 | const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '' } }) |
| 151 | if (cmd !== 'git' || dir !== '/work/demo') return { value: { exitCode: 128, stdout: '', stderr: 'not a git repository' } } |
| 152 | if (args[0] === 'config') return ok('remote.origin.url https://diffinery.com/codician/demo.git\n') |
| 153 | if (args[0] === 'branch') return ok('feature/x\n') |
| 154 | if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') return ok('/work/demo\n') |
| 155 | if (args[0] === 'rev-parse') return ok(`${server.head}\n`) |
| 156 | return ok('') |
| 157 | }) |
| 158 | on('http.fetch', (_, e) => { |
| 159 | server.seen.push(`${e.url} ${e.init?.headers?.Authorization ?? 'no-auth'}`) |
| 160 | const status = server.status ?? 200 |
| 161 | return { value: { status, ok: status < 400, headers: {}, text: JSON.stringify(server.body) } } |
| 162 | }) |
| 163 | on('tool.call', () => ({ result: {} })) |
| 164 | } |
| 165 | |
| 166 | const BAND = { |
| 167 | plugin: 'diffinery-ci', |
| 168 | component: 'AbovePrompt', |
| 169 | props: { hasSurvey: false, isWorking: false, rows: 10, bodyColumns: 100 }, |
| 170 | } as const |
| 171 | |
| 172 | const PANE = { |
| 173 | plugin: 'diffinery-ci', |
| 174 | component: 'Pane', |
| 175 | requestId: 'diffinery-ci', |
| 176 | props: { title: 'Diffinery CI', isFocused: false, bodyColumns: 60, placement: 'dock', scroll: { offset: 0, bodyRows: 30 }, view: {} }, |
| 177 | } as const |
| 178 | |
| 179 | test('the band and the pane show the branch PR and its checks, on every surface', async ($, on) => { |
| 180 | const server = { |
| 181 | body: answer('abc1234def', [run('Typecheck', 'COMPLETED', 'SUCCESS'), run('Lint', 'COMPLETED', 'FAILURE')]), |
| 182 | head: 'abc1234def', |
| 183 | seen: [] as string[], |
| 184 | } |
| 185 | engine(on, server) |
| 186 | mock.clock(on, { now: 1_000_000 }) |
| 187 | await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true }) |
| 188 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 189 | |
| 190 | expect(server.seen[0]).toBe('https://diffinery.com/api/graphql Bearer dfy_test') |
| 191 | for (const surface of ['terminal', 'desktop'] as const) { |
| 192 | const ui = await $.ui.mount({ ...BAND, surface }) |
| 193 | expect((await ui.find({ type: 'Text', text: /PR #7/ }))?.text).toBe('PR #7') |
| 194 | expect((await ui.find({ type: 'Text', text: /failed/ }))?.text).toBe('✗ 1 failed · ✓ 1') |
| 195 | await ui.unmount() |
| 196 | } |
| 197 | for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) { |
| 198 | const ui = await $.ui.mount({ ...PANE, surface }) |
| 199 | expect(await ui.find({ type: 'Text', text: /Lint/ })).toBeDefined() |
| 200 | expect(await ui.find({ key: 'fix' })).toBeDefined() |
| 201 | await ui.unmount() |
| 202 | } |
| 203 | }) |
| 204 | |
| 205 | test('a push from the session is watched, and Claude hears once its checks fail', async ($, on) => { |
| 206 | const server = { |
| 207 | body: answer('old0000000', [run('Lint', 'COMPLETED', 'FAILURE')]), |
| 208 | head: 'new1111111', |
| 209 | seen: [] as string[], |
| 210 | } |
| 211 | engine(on, server) |
| 212 | const clock = mock.clock(on, { now: 1_000_000 }) |
| 213 | const prompts: string[] = [] |
| 214 | on('prompt.submit', (_, e) => { |
| 215 | prompts.push(e.text) |
| 216 | return { text: e.text } |
| 217 | }) |
| 218 | await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true }) |
| 219 | await $.tool.call({ tool: 'Bash', command: 'cd demo && git push origin feature/x' }) |
| 220 | await clock.advance(1000) |
| 221 | |
| 222 | // Diffinery still reports the old commit's failure: nothing to say about the new one yet. |
| 223 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 224 | expect(prompts).toEqual([]) |
| 225 | |
| 226 | server.body = answer('new1111111', [run('Lint', 'IN_PROGRESS', null)]) |
| 227 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 228 | expect(prompts).toEqual([]) |
| 229 | |
| 230 | server.body = answer('new1111111', [run('Lint', 'COMPLETED', 'FAILURE')]) |
| 231 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 232 | await clock.advance(10) |
| 233 | expect(prompts.length).toBe(1) |
| 234 | expect(prompts[0]).toContain('Diffinery CI failed on codician/demo#7') |
| 235 | |
| 236 | // Once per pushed commit. |
| 237 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 238 | await clock.advance(10) |
| 239 | expect(prompts.length).toBe(1) |
| 240 | }) |
| 241 | |
| 242 | test('without a token the band says how to sign in', async ($, on) => { |
| 243 | const server = { body: { message: 'Requires authentication' }, status: 401, head: 'abc', seen: [] as string[], token: null } |
| 244 | engine(on, server) |
| 245 | mock.clock(on, { now: 1_000_000 }) |
| 246 | await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: true }) |
| 247 | const out = await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 248 | expect(out.text).toContain('not signed in to Diffinery') |
| 249 | const ui = await $.ui.mount({ ...BAND, surface: 'terminal' }) |
| 250 | expect(await ui.find({ type: 'Text', text: /diffinery login/ })).toBeDefined() |
| 251 | await ui.unmount() |
| 252 | }) |
| 253 | |
| 254 | // A cloud session draws nothing: the band can't show a pass, so Claude is told about it too. |
| 255 | test('where nothing draws, Claude also hears when CI passes; notes picks which results', async ($, on) => { |
| 256 | const server: Server = { body: answer('new1111111', [run('Lint', 'IN_PROGRESS', null)]), head: 'new1111111', seen: [], surfaces: [] } |
| 257 | engine(on, server) |
| 258 | const clock = mock.clock(on, { now: 1_000_000 }) |
| 259 | const prompts: string[] = [] |
| 260 | on('prompt.submit', (_, e) => { |
| 261 | prompts.push(e.text) |
| 262 | return { text: e.text } |
| 263 | }) |
| 264 | await $.session.start({ cwd: '/work', surface: 'terminal', isInteractive: false }) |
| 265 | await $.tool.call({ tool: 'Bash', command: 'cd demo && git push origin feature/x' }) |
| 266 | await clock.advance(1000) |
| 267 | server.body = answer('new1111111', [run('Lint', 'COMPLETED', 'SUCCESS')]) |
| 268 | await $.command.run({ command: 'diffinery-ci', args: 'refresh' }) |
| 269 | await clock.advance(10) |
| 270 | expect(prompts.length).toBe(1) |
| 271 | expect(prompts[0]).toContain('Diffinery CI passed on codician/demo#7') |
| 272 | |
| 273 | const out = await $.command.run({ command: 'diffinery-ci', args: 'notes failures' }) |
| 274 | expect(out.text).toContain('from now on Claude is told about failures') |
| 275 | expect(server.store?.get('wake')).toBe('failures') |
| 276 | expect((await $.command.run({ command: 'diffinery-ci', args: 'notes' })).text).toContain('Claude is told about failures.') |
| 277 | }) |