@seanmars/tospec 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +17 -3
- package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
- package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
- package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
- package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
- package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
- package/assets/dashboard/app.js +218 -1180
- package/assets/dashboard/index.html +31 -22
- package/assets/dashboard/lib/charts.js +355 -0
- package/assets/dashboard/lib/derive.js +369 -0
- package/assets/dashboard/lib/dom.js +121 -0
- package/assets/dashboard/lib/routes.js +52 -0
- package/assets/dashboard/lib/store.js +324 -0
- package/assets/dashboard/style.css +3103 -664
- package/assets/dashboard/views/alerts.js +170 -0
- package/assets/dashboard/views/change.js +232 -0
- package/assets/dashboard/views/docs.js +214 -0
- package/assets/dashboard/views/home.js +357 -0
- package/assets/dashboard/views/project.js +168 -0
- package/assets/dashboard/views/session.js +270 -0
- package/assets/dashboard/views/sidebar.js +131 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +74 -95
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard-relay.d.ts +11 -0
- package/dist/commands/dashboard-relay.d.ts.map +1 -0
- package/dist/commands/dashboard-relay.js +88 -0
- package/dist/commands/dashboard-relay.js.map +1 -0
- package/dist/commands/dashboard.d.ts +62 -102
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +375 -609
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/core/dashboard-hub.d.ts +258 -0
- package/dist/core/dashboard-hub.d.ts.map +1 -0
- package/dist/core/dashboard-hub.js +643 -0
- package/dist/core/dashboard-hub.js.map +1 -0
- package/dist/core/dashboard-routes.d.ts +56 -0
- package/dist/core/dashboard-routes.d.ts.map +1 -0
- package/dist/core/dashboard-routes.js +329 -0
- package/dist/core/dashboard-routes.js.map +1 -0
- package/dist/core/init.d.ts.map +1 -1
- package/dist/core/init.js +4 -1
- package/dist/core/init.js.map +1 -1
- package/dist/core/local-server.d.ts +1 -1
- package/dist/core/local-server.js +1 -1
- package/dist/core/shared/dashboard-mod.d.ts +24 -0
- package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
- package/dist/core/shared/dashboard-mod.js +44 -0
- package/dist/core/shared/dashboard-mod.js.map +1 -0
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.d.ts.map +1 -1
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/index.js.map +1 -1
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +4 -1
- package/dist/core/update.js.map +1 -1
- package/package.json +3 -2
- package/assets/dashboard/chart.umd.js +0 -14
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'claude-code/testing'
|
|
2
|
+
import type { Engine } from 'claude-code/testing'
|
|
3
|
+
import type { HttpResponse, On, ProcessRunResult } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
// Run with `claude plugin test assets/claude-mod/tospec-dashboard`: the engine
|
|
6
|
+
// itself, with the hub and the tospec CLI answered beneath the mod. Not shipped
|
|
7
|
+
// into projects (init/update copy everything but this folder).
|
|
8
|
+
|
|
9
|
+
const ROOT = '/work/project'
|
|
10
|
+
const HUB = 'http://hub.test'
|
|
11
|
+
const KEY = 'abcdef012345'
|
|
12
|
+
const SNAPSHOT = { protocol: 1, root: ROOT, capturedAt: '', entries: {} }
|
|
13
|
+
|
|
14
|
+
// What a hook beneath the mod answers for a `$` call: `{ value }`.
|
|
15
|
+
function ok(body: unknown): { value: HttpResponse } {
|
|
16
|
+
return { value: { status: 200, ok: true, headers: {}, text: JSON.stringify(body) } }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cli(payload: unknown): { value: ProcessRunResult } {
|
|
20
|
+
return {
|
|
21
|
+
value: {
|
|
22
|
+
exitCode: 0,
|
|
23
|
+
stdout: JSON.stringify({ version: '1.0', root: { path: ROOT, source: 'nearest' }, ...(payload as object) }),
|
|
24
|
+
stderr: '',
|
|
25
|
+
isStdoutTruncated: false,
|
|
26
|
+
isStderrTruncated: false,
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The session the mod runs in, answered beneath it. */
|
|
32
|
+
function session(on: On): void {
|
|
33
|
+
mock.env(on, { TOSPEC_DASHBOARD_URL: HUB })
|
|
34
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
35
|
+
on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
|
|
36
|
+
on('session.root', () => ({ value: ROOT }))
|
|
37
|
+
on('session.id', () => ({ value: 'session-1' }))
|
|
38
|
+
on('command.register', (_$, e) => ({ value: { command: e.name } }))
|
|
39
|
+
on('turn.complete', (_$, e) => ({ text: e.answer }))
|
|
40
|
+
on('ui.log', () => ({ value: undefined }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `tospec dashboard snapshot` / `query`, counted. */
|
|
44
|
+
function tospecCli(on: On): { snapshots: () => number; runs: Array<{ argv: readonly string[]; stdin?: string }> } {
|
|
45
|
+
const runs: Array<{ argv: readonly string[]; stdin?: string }> = []
|
|
46
|
+
on('process.run', (_$, e) => {
|
|
47
|
+
runs.push({ argv: e.argv, stdin: e.init?.stdin })
|
|
48
|
+
if (e.argv.includes('snapshot')) return cli({ snapshot: SNAPSHOT })
|
|
49
|
+
return cli({ response: { status: 200, body: { done: true } } })
|
|
50
|
+
})
|
|
51
|
+
return { runs, snapshots: () => runs.filter((r) => r.argv.includes('snapshot')).length }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Answer = { value: HttpResponse } | Promise<{ value: HttpResponse }>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A hub that accepts every push and holds every poll, releasing a session's
|
|
58
|
+
* held poll when it leaves, as the real one does. `route` answers a path first
|
|
59
|
+
* when it returns anything.
|
|
60
|
+
*/
|
|
61
|
+
function fakeHub(on: On, route?: (path: string, body: Record<string, unknown>) => Answer | undefined) {
|
|
62
|
+
const hub = {
|
|
63
|
+
up: true,
|
|
64
|
+
posted: [] as Array<{ path: string; body: Record<string, unknown> }>,
|
|
65
|
+
polls: 0,
|
|
66
|
+
held: [] as Array<(answer: { value: HttpResponse }) => void>,
|
|
67
|
+
of: (path: string) => hub.posted.filter((p) => p.path === path),
|
|
68
|
+
}
|
|
69
|
+
on('http.fetch', (_$, e) => {
|
|
70
|
+
if (!hub.up) throw new Error('connect ECONNREFUSED')
|
|
71
|
+
const path = e.url.slice(HUB.length)
|
|
72
|
+
if (path === '/api/health') return ok({ protocol: 2 })
|
|
73
|
+
const body = JSON.parse(e.init?.body ?? '{}') as Record<string, unknown>
|
|
74
|
+
hub.posted.push({ path, body })
|
|
75
|
+
const routed = route?.(path, body)
|
|
76
|
+
if (routed) return routed
|
|
77
|
+
if (path === '/api/mod/snapshot') return ok({ project: KEY })
|
|
78
|
+
if (path === '/api/mod/poll') {
|
|
79
|
+
hub.polls++
|
|
80
|
+
return new Promise<{ value: HttpResponse }>((resolve) => hub.held.push(resolve))
|
|
81
|
+
}
|
|
82
|
+
if (path === '/api/mod/leave') for (const release of hub.held.splice(0)) release(ok({ commands: [] }))
|
|
83
|
+
return ok({ ok: true })
|
|
84
|
+
})
|
|
85
|
+
return hub
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads the footer's right-side labels as the engine would draw them. Its hook
|
|
90
|
+
* stands for the engine beneath the mod, so it is registered before the
|
|
91
|
+
* test's first call on `$`.
|
|
92
|
+
*/
|
|
93
|
+
function footerProbe(on: On): ($: Engine) => Promise<readonly string[]> {
|
|
94
|
+
let modes: readonly string[] = []
|
|
95
|
+
on('ui.render', { component: 'SessionMode' }, (_$, e) => {
|
|
96
|
+
modes = e.props.modes
|
|
97
|
+
return { type: 'engine', ref: 0 }
|
|
98
|
+
})
|
|
99
|
+
return async ($) => {
|
|
100
|
+
await $.ui.render({ surface: 'terminal', component: 'SessionMode', requestId: 'modes', props: { modes: ['focus'] } })
|
|
101
|
+
return modes
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function start($: Engine): Promise<void> {
|
|
106
|
+
await $.session.start({ cwd: ROOT, surface: 'terminal', isInteractive: true })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `/tospec-dashboard <args>` as the person types it at the prompt. */
|
|
110
|
+
async function slash($: Engine, args = ''): Promise<string | undefined> {
|
|
111
|
+
const answer = await $.command.run({
|
|
112
|
+
command: 'tospec-dashboard',
|
|
113
|
+
args,
|
|
114
|
+
origin: { kind: 'composer' },
|
|
115
|
+
presentation: { isFullscreen: false, columns: 80 },
|
|
116
|
+
})
|
|
117
|
+
return answer.text
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('tospec-dashboard', () => {
|
|
121
|
+
test('registers with a snapshot, then relays a task write and pushes again', async ($, on) => {
|
|
122
|
+
const clock = mock.clock(on)
|
|
123
|
+
session(on)
|
|
124
|
+
const tospec = tospecCli(on)
|
|
125
|
+
const posted: Array<{ path: string; body: Record<string, unknown> }> = []
|
|
126
|
+
let replied!: (body: Record<string, unknown>) => void
|
|
127
|
+
const reply = new Promise<Record<string, unknown>>((resolve) => {
|
|
128
|
+
replied = resolve
|
|
129
|
+
})
|
|
130
|
+
let polls = 0
|
|
131
|
+
|
|
132
|
+
on('http.fetch', (_$, e) => {
|
|
133
|
+
const path = e.url.slice(HUB.length)
|
|
134
|
+
if (path === '/api/health') return ok({ protocol: 1 })
|
|
135
|
+
const body = JSON.parse(e.init?.body ?? '{}') as Record<string, unknown>
|
|
136
|
+
posted.push({ path, body })
|
|
137
|
+
if (path === '/api/mod/snapshot') return ok({ project: KEY })
|
|
138
|
+
if (path === '/api/mod/reply') {
|
|
139
|
+
replied(body)
|
|
140
|
+
return ok({ ok: true })
|
|
141
|
+
}
|
|
142
|
+
if (path === '/api/mod/poll' && polls++ === 0) {
|
|
143
|
+
return ok({
|
|
144
|
+
commands: [
|
|
145
|
+
{
|
|
146
|
+
id: 'cmd-1',
|
|
147
|
+
kind: 'query',
|
|
148
|
+
request: { method: 'POST', path: '/api/task', body: { file: 't.md', line: 1, done: true } },
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
// Later polls are held, as the hub holds them.
|
|
154
|
+
return new Promise<{ value: HttpResponse }>(() => {})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
await start($)
|
|
158
|
+
expect(await reply).toEqual({ id: 'cmd-1', status: 200, body: { done: true } })
|
|
159
|
+
|
|
160
|
+
// The snapshot ran in the project and was pushed under this session.
|
|
161
|
+
expect(tospec.runs[0]?.argv.slice(-2)).toEqual(['dashboard', 'snapshot'])
|
|
162
|
+
expect(posted[0]).toEqual({ path: '/api/mod/snapshot', body: { session: 'session-1', snapshot: SNAPSHOT } })
|
|
163
|
+
expect(posted[1]).toEqual({ path: '/api/mod/poll', body: { project: KEY, session: 'session-1' } })
|
|
164
|
+
// The relayed request reached `tospec dashboard query` on stdin.
|
|
165
|
+
expect(tospec.runs[1]?.argv.slice(-2)).toEqual(['dashboard', 'query'])
|
|
166
|
+
expect(JSON.parse(tospec.runs[1]?.stdin ?? '{}')).toMatchObject({ method: 'POST', path: '/api/task' })
|
|
167
|
+
|
|
168
|
+
// A successful write pushes a fresh snapshot once the debounce passes.
|
|
169
|
+
await clock.advance(1_500)
|
|
170
|
+
expect(tospec.snapshots()).toBe(2)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('starts no tospec process while the hub is down, and probes again later', async ($, on) => {
|
|
174
|
+
const clock = mock.clock(on)
|
|
175
|
+
session(on)
|
|
176
|
+
const tospec = tospecCli(on)
|
|
177
|
+
let probes = 0
|
|
178
|
+
on('http.fetch', () => {
|
|
179
|
+
probes++
|
|
180
|
+
throw new Error('connect ECONNREFUSED')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await start($)
|
|
184
|
+
await clock.advance(0)
|
|
185
|
+
expect(probes).toBe(1)
|
|
186
|
+
await clock.advance(20_000)
|
|
187
|
+
expect(probes).toBe(2)
|
|
188
|
+
expect(tospec.runs).toHaveLength(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('pushes after an edit under the tospec root, but not after one elsewhere', async ($, on) => {
|
|
192
|
+
const clock = mock.clock(on)
|
|
193
|
+
session(on)
|
|
194
|
+
const tospec = tospecCli(on)
|
|
195
|
+
fakeHub(on)
|
|
196
|
+
on('tool.call', () => ({ result: {} as never }))
|
|
197
|
+
|
|
198
|
+
await start($)
|
|
199
|
+
await clock.advance(0)
|
|
200
|
+
expect(tospec.snapshots()).toBe(1)
|
|
201
|
+
|
|
202
|
+
await $.tool.call({ tool: 'Edit', file_path: `${ROOT}/src/index.ts`, old_string: 'a', new_string: 'b' })
|
|
203
|
+
await clock.advance(1_500)
|
|
204
|
+
expect(tospec.snapshots()).toBe(1)
|
|
205
|
+
|
|
206
|
+
await $.tool.call({
|
|
207
|
+
tool: 'Edit',
|
|
208
|
+
file_path: `${ROOT}/tospec/changes/x/tasks.md`,
|
|
209
|
+
old_string: '- [ ]',
|
|
210
|
+
new_string: '- [x]',
|
|
211
|
+
})
|
|
212
|
+
await clock.advance(1_500)
|
|
213
|
+
expect(tospec.snapshots()).toBe(2)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('leaves the hub when the session ends', async ($, on) => {
|
|
217
|
+
const clock = mock.clock(on)
|
|
218
|
+
session(on)
|
|
219
|
+
tospecCli(on)
|
|
220
|
+
const hub = fakeHub(on)
|
|
221
|
+
|
|
222
|
+
await start($)
|
|
223
|
+
await clock.advance(0)
|
|
224
|
+
await $.session.end({ reason: 'prompt_input_exit', sessionId: 'session-1', resume: { id: 'session-1' } })
|
|
225
|
+
expect(hub.of('/api/mod/leave').map((p) => p.body)).toEqual([{ project: KEY, session: 'session-1' }])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('footer', () => {
|
|
229
|
+
test('shows the hub status after the engine\'s own modes', async ($, on) => {
|
|
230
|
+
const clock = mock.clock(on)
|
|
231
|
+
session(on)
|
|
232
|
+
tospecCli(on)
|
|
233
|
+
fakeHub(on)
|
|
234
|
+
const footer = footerProbe(on)
|
|
235
|
+
|
|
236
|
+
await start($)
|
|
237
|
+
await clock.advance(0)
|
|
238
|
+
expect(await footer($)).toEqual(['focus', 'tospec hub: connected'])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('says offline while no hub answers', async ($, on) => {
|
|
242
|
+
const clock = mock.clock(on)
|
|
243
|
+
session(on)
|
|
244
|
+
tospecCli(on)
|
|
245
|
+
const hub = fakeHub(on)
|
|
246
|
+
hub.up = false
|
|
247
|
+
const footer = footerProbe(on)
|
|
248
|
+
|
|
249
|
+
await start($)
|
|
250
|
+
await clock.advance(0)
|
|
251
|
+
expect(await footer($)).toEqual(['focus', 'tospec hub: offline'])
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('activity', () => {
|
|
256
|
+
test('reports a turn as working, its tool as the action and a log line, and its end as idle', async ($, on) => {
|
|
257
|
+
const clock = mock.clock(on)
|
|
258
|
+
session(on)
|
|
259
|
+
tospecCli(on)
|
|
260
|
+
const hub = fakeHub(on)
|
|
261
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
262
|
+
on('tool.call', () => ({ result: {} as never }))
|
|
263
|
+
|
|
264
|
+
await start($)
|
|
265
|
+
await clock.advance(0)
|
|
266
|
+
|
|
267
|
+
await $.turn.start({ text: 'fix it', turnId: 't1' })
|
|
268
|
+
await $.tool.call({ tool: 'Bash', command: 'pnpm test\necho done' })
|
|
269
|
+
await clock.advance(250)
|
|
270
|
+
const [working] = hub.of('/api/mod/activity').map((p) => p.body)
|
|
271
|
+
expect(working).toMatchObject({ project: KEY, session: 'session-1', status: 'working', action: null })
|
|
272
|
+
expect(working?.events).toEqual([{ kind: 'tool', tool: 'Bash', target: 'pnpm test', ok: true }])
|
|
273
|
+
|
|
274
|
+
await $.turn.complete({ answer: '', durationMs: 1, isAborted: false, turnId: 't1' } as never)
|
|
275
|
+
await clock.advance(250)
|
|
276
|
+
expect(hub.of('/api/mod/activity').at(-1)?.body).toMatchObject({ status: 'idle', action: null, events: [] })
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('names a file the tool touches relative to the project', async ($, on) => {
|
|
280
|
+
const clock = mock.clock(on)
|
|
281
|
+
session(on)
|
|
282
|
+
tospecCli(on)
|
|
283
|
+
const hub = fakeHub(on)
|
|
284
|
+
on('tool.call', () => ({ result: {} as never }))
|
|
285
|
+
|
|
286
|
+
await start($)
|
|
287
|
+
await clock.advance(0)
|
|
288
|
+
await $.tool.call({ tool: 'Edit', file_path: `${ROOT}/src/index.ts`, old_string: 'a', new_string: 'b' })
|
|
289
|
+
await clock.advance(250)
|
|
290
|
+
expect(hub.of('/api/mod/activity').at(-1)?.body.events).toEqual([{ kind: 'tool', tool: 'Edit', target: 'src/index.ts', ok: true }])
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('answering from the dashboard', () => {
|
|
295
|
+
const QUESTIONS = [{ question: 'Which plan?', header: 'Plan', multiSelect: false, options: [{ label: 'A', description: '' }, { label: 'B', description: '' }] }]
|
|
296
|
+
|
|
297
|
+
/** A hub that opens asks and holds each wait until the test answers it. */
|
|
298
|
+
function askingHub(on: On, hold = true) {
|
|
299
|
+
const waits: Array<(answer: { value: HttpResponse }) => void> = []
|
|
300
|
+
const hub = fakeHub(on, (path) => {
|
|
301
|
+
if (path === '/api/mod/ask') return ok({ id: 'ask-1', hold })
|
|
302
|
+
if (path === '/api/mod/ask/wait') return new Promise<{ value: HttpResponse }>((resolve) => waits.push(resolve))
|
|
303
|
+
return undefined
|
|
304
|
+
})
|
|
305
|
+
return { hub, answer: (result: unknown) => waits.shift()?.(ok(result)) }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
test('an AskUserQuestion the dashboard answers first is answered with its choice', async ($, on) => {
|
|
309
|
+
const clock = mock.clock(on)
|
|
310
|
+
session(on)
|
|
311
|
+
tospecCli(on)
|
|
312
|
+
on('tool.call', () => new Promise(() => {})) // the terminal dialog, never answered
|
|
313
|
+
const { hub, answer } = askingHub(on)
|
|
314
|
+
|
|
315
|
+
await start($)
|
|
316
|
+
await clock.advance(0)
|
|
317
|
+
const call = $.tool.call({ tool: 'AskUserQuestion', questions: QUESTIONS } as never)
|
|
318
|
+
await clock.advance(0)
|
|
319
|
+
expect(hub.of('/api/mod/ask').at(0)?.body).toMatchObject({ project: KEY, ask: { kind: 'question', questions: QUESTIONS } })
|
|
320
|
+
answer({ answer: { kind: 'question', answers: { 'Which plan?': 'B' } } })
|
|
321
|
+
expect(await call).toMatchObject({ result: { questions: QUESTIONS, answers: { 'Which plan?': 'B' } } })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('an AskUserQuestion answered in the terminal first is withdrawn from the dashboard', async ($, on) => {
|
|
325
|
+
const clock = mock.clock(on)
|
|
326
|
+
session(on)
|
|
327
|
+
tospecCli(on)
|
|
328
|
+
on('tool.call', () => ({ result: { questions: QUESTIONS, answers: { 'Which plan?': 'A' } } as never }))
|
|
329
|
+
const { hub } = askingHub(on)
|
|
330
|
+
|
|
331
|
+
await start($)
|
|
332
|
+
await clock.advance(0)
|
|
333
|
+
const result = await $.tool.call({ tool: 'AskUserQuestion', questions: QUESTIONS } as never)
|
|
334
|
+
expect(result).toMatchObject({ result: { answers: { 'Which plan?': 'A' } } })
|
|
335
|
+
await clock.advance(0)
|
|
336
|
+
expect(hub.of('/api/mod/ask/close').map((p) => p.body)).toEqual([{ id: 'ask-1' }])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('a permission the dashboard allows is allowed', async ($, on) => {
|
|
340
|
+
const clock = mock.clock(on)
|
|
341
|
+
session(on)
|
|
342
|
+
tospecCli(on)
|
|
343
|
+
on('classic.PermissionRequest', () => ({}))
|
|
344
|
+
on('ui.status', () => ({ value: undefined }))
|
|
345
|
+
const { hub, answer } = askingHub(on)
|
|
346
|
+
|
|
347
|
+
await start($)
|
|
348
|
+
await clock.advance(0)
|
|
349
|
+
const decided = $.classic.PermissionRequest({ tool_name: 'Bash', tool_input: { command: 'rm -rf build' } } as never)
|
|
350
|
+
await clock.advance(0)
|
|
351
|
+
expect(hub.of('/api/mod/ask').at(0)?.body).toMatchObject({ ask: { kind: 'permission', tool: 'Bash', target: 'rm -rf build' } })
|
|
352
|
+
answer({ pending: true })
|
|
353
|
+
await clock.advance(0)
|
|
354
|
+
answer({ answer: { kind: 'permission', allow: true } })
|
|
355
|
+
expect(await decided).toMatchObject({ decision: { behavior: 'allow' } })
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('a read-only dashboard leaves the permission to the terminal at once', async ($, on) => {
|
|
359
|
+
const clock = mock.clock(on)
|
|
360
|
+
session(on)
|
|
361
|
+
tospecCli(on)
|
|
362
|
+
on('classic.PermissionRequest', () => ({}))
|
|
363
|
+
askingHub(on, false)
|
|
364
|
+
|
|
365
|
+
await start($)
|
|
366
|
+
await clock.advance(0)
|
|
367
|
+
const decided = await $.classic.PermissionRequest({ tool_name: 'Bash', tool_input: { command: 'ls' } } as never)
|
|
368
|
+
expect(decided.decision).toBeUndefined()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// The terminal's dialog is open alongside the dashboard's: once the person
|
|
372
|
+
// answers there and the call goes on, the dashboard must not offer a second answer.
|
|
373
|
+
test("withdraws the dashboard's permission once the terminal settled the call", async ($, on) => {
|
|
374
|
+
const clock = mock.clock(on)
|
|
375
|
+
session(on)
|
|
376
|
+
tospecCli(on)
|
|
377
|
+
on('classic.PermissionRequest', () => ({}))
|
|
378
|
+
on('tool.call', () => ({ result: {} as never }))
|
|
379
|
+
const { hub } = askingHub(on)
|
|
380
|
+
|
|
381
|
+
await start($)
|
|
382
|
+
await clock.advance(0)
|
|
383
|
+
void $.classic.PermissionRequest({ tool_name: 'Bash', tool_input: { command: 'rm -rf build' } } as never)
|
|
384
|
+
await clock.advance(0)
|
|
385
|
+
expect(hub.of('/api/mod/ask/close')).toEqual([])
|
|
386
|
+
|
|
387
|
+
await $.tool.call({ tool: 'Bash', command: 'rm -rf build' })
|
|
388
|
+
await clock.advance(0)
|
|
389
|
+
expect(hub.of('/api/mod/ask/close').map((p) => p.body)).toEqual([{ id: 'ask-1' }])
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('/tospec-dashboard', () => {
|
|
394
|
+
test('unregister leaves the hub and stops pushing; register comes back', async ($, on) => {
|
|
395
|
+
const clock = mock.clock(on)
|
|
396
|
+
session(on)
|
|
397
|
+
const tospec = tospecCli(on)
|
|
398
|
+
const hub = fakeHub(on)
|
|
399
|
+
const footer = footerProbe(on)
|
|
400
|
+
|
|
401
|
+
await start($)
|
|
402
|
+
await clock.advance(0)
|
|
403
|
+
expect(hub.polls).toBe(1)
|
|
404
|
+
|
|
405
|
+
expect(await slash($, 'unregister')).toContain('Unregistered from the tospec dashboard')
|
|
406
|
+
expect(hub.of('/api/mod/leave').at(0)?.body).toEqual({ project: KEY, session: 'session-1' })
|
|
407
|
+
expect(await footer($)).toEqual(['focus', 'tospec hub: unregistered'])
|
|
408
|
+
|
|
409
|
+
// Unregistered: a finished turn pushes nothing and nothing polls.
|
|
410
|
+
await $.turn.complete({ answer: '', durationMs: 1, isAborted: false, turnId: 't1' } as never)
|
|
411
|
+
await clock.advance(30_000)
|
|
412
|
+
expect(tospec.snapshots()).toBe(1)
|
|
413
|
+
expect(hub.polls).toBe(1)
|
|
414
|
+
|
|
415
|
+
expect(await slash($, 'register')).toBe(`Registered with the tospec dashboard at ${HUB} (project ${KEY}).`)
|
|
416
|
+
expect(tospec.snapshots()).toBe(2)
|
|
417
|
+
await clock.advance(0)
|
|
418
|
+
expect(hub.polls).toBe(2)
|
|
419
|
+
expect(await footer($)).toEqual(['focus', 'tospec hub: connected'])
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('register says so when no hub answers', async ($, on) => {
|
|
423
|
+
const clock = mock.clock(on)
|
|
424
|
+
session(on)
|
|
425
|
+
tospecCli(on)
|
|
426
|
+
const hub = fakeHub(on)
|
|
427
|
+
hub.up = false
|
|
428
|
+
|
|
429
|
+
await start($)
|
|
430
|
+
await clock.advance(0)
|
|
431
|
+
const answer = await slash($, 'register')
|
|
432
|
+
expect(answer).toContain(`No tospec dashboard answered at ${HUB}`)
|
|
433
|
+
expect(answer).toContain('tospec dashboard start')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('with no argument reports the status, and rejects an unknown one', async ($, on) => {
|
|
437
|
+
const clock = mock.clock(on)
|
|
438
|
+
session(on)
|
|
439
|
+
tospecCli(on)
|
|
440
|
+
fakeHub(on)
|
|
441
|
+
|
|
442
|
+
await start($)
|
|
443
|
+
await clock.advance(0)
|
|
444
|
+
expect(await slash($)).toBe(`Registered with the tospec dashboard at ${HUB} (project ${KEY}).`)
|
|
445
|
+
expect(await slash($, 'nope')).toContain('Usage:')
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Where this session stands with the dashboard hub, as the footer shows it. */
|
|
2
|
+
export type HubStatus = 'connecting' | 'connected' | 'hub-offline' | 'unregistered' | 'error'
|
|
3
|
+
|
|
4
|
+
declare module 'claude-code' {
|
|
5
|
+
interface PluginState {
|
|
6
|
+
'tospec-dashboard': { status: HubStatus }
|
|
7
|
+
}
|
|
8
|
+
}
|