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