dsh-hitl 0.1.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.
@@ -0,0 +1,818 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+
7
+ /**
8
+ * Load `client.js` the way the browser module table does — through
9
+ * `window.__ModuleLoader__` with a stubbed `require` — and take the pure
10
+ * helpers the factory exposes for tests.
11
+ */
12
+ async function loadClient() {
13
+ let plugin
14
+ const react = {
15
+ createElement: () => ({ type: 'stub' }),
16
+ Component: class Component {
17
+ constructor(props) { this.props = props }
18
+ },
19
+ useMemo: callback => callback(),
20
+ useEffect: () => {},
21
+ useRef: value => ({ current: value }),
22
+ useState: value => [typeof value === 'function' ? value() : value, () => {}],
23
+ useSyncExternalStore: () => ({}),
24
+ }
25
+ globalThis.window = {
26
+ __ModuleLoader__: {
27
+ load(definition) {
28
+ plugin = definition.factory(name => {
29
+ if (name === 'react') return react
30
+ return {}
31
+ })
32
+ },
33
+ },
34
+ }
35
+ const here = dirname(fileURLToPath(import.meta.url))
36
+ await import(pathToFileURL(join(here, '..', 'client.js')).href)
37
+ return plugin.__test
38
+ }
39
+
40
+ const client = await loadClient()
41
+
42
+ describe('client: escaping and markdown', () => {
43
+ it('escapes every HTML-significant character', () => {
44
+ assert.equal(client.escapeHtml('<img src=x onerror="a">'), '&lt;img src=x onerror=&quot;a&quot;&gt;')
45
+ assert.equal(client.escapeHtml("it's"), 'it&#39;s')
46
+ })
47
+
48
+ it('renders the supported block forms', () => {
49
+ const html = client.renderMarkdownHtml(['# Title', '', '- one', '- two', '', '> quoted', '', '```js', 'const a = 1', '```'].join('\n'))
50
+ assert.equal(html.includes('<h3 class="dsh-hitl-h">Title</h3>'), true)
51
+ assert.equal(html.includes('<ul class="dsh-hitl-list"><li>one</li><li>two</li></ul>'), true)
52
+ assert.equal(html.includes('<blockquote class="dsh-hitl-quote">quoted</blockquote>'), true)
53
+ assert.equal(html.includes('data-lang="js"'), true)
54
+ assert.equal(html.includes('const a = 1'), true)
55
+ })
56
+
57
+ it('renders the supported inline forms', () => {
58
+ const html = client.renderMarkdownHtml('a **bold** and *em* and `code` and [link](https://example.com)')
59
+ assert.equal(html.includes('<strong>bold</strong>'), true)
60
+ assert.equal(html.includes('<em>em</em>'), true)
61
+ assert.equal(html.includes('<code class="dsh-hitl-code">code</code>'), true)
62
+ assert.equal(html.includes('href="https://example.com"'), true)
63
+ })
64
+
65
+ it('never emits markup from the source text', () => {
66
+ const html = client.renderMarkdownHtml('<script>alert(1)</script>\n\n<img src=x onerror=alert(2)>')
67
+ assert.equal(html.includes('<script>'), false)
68
+ assert.equal(html.includes('<img'), false)
69
+ assert.equal(html.includes('&lt;script&gt;'), true)
70
+ })
71
+
72
+ it('drops unsafe link targets', () => {
73
+ const html = client.renderMarkdownHtml('[click](javascript:alert(1))')
74
+ assert.equal(html.includes('<a '), false)
75
+ assert.equal(html.includes('javascript:alert(1)'), true)
76
+ })
77
+
78
+ it('handles an empty document', () => {
79
+ assert.equal(client.renderMarkdownHtml(''), '')
80
+ })
81
+ })
82
+
83
+ describe('client: diff', () => {
84
+ it('aligns equal lines and marks the change', () => {
85
+ const diff = client.diffLines('a\nb\nc', 'a\nB\nc')
86
+ assert.equal(diff.added, 1)
87
+ assert.equal(diff.removed, 1)
88
+ assert.deepEqual(diff.rows.map(row => row.type), ['same', 'removed', 'added', 'same'])
89
+ assert.equal(diff.rows[1].left.text, 'b')
90
+ assert.equal(diff.rows[2].right.text, 'B')
91
+ })
92
+
93
+ it('reports pure additions and removals', () => {
94
+ const added = client.diffLines('', 'x\ny')
95
+ assert.equal(added.added, 2)
96
+ assert.equal(added.removed, 0)
97
+ const removed = client.diffLines('x\ny', '')
98
+ assert.equal(removed.removed, 2)
99
+ assert.equal(removed.added, 0)
100
+ })
101
+
102
+ it('refuses to align an oversized side', () => {
103
+ const huge = Array.from({ length: 2500 }, (_value, index) => `line ${index}`).join('\n')
104
+ assert.equal(client.diffLines(huge, 'small'), undefined)
105
+ })
106
+ })
107
+
108
+ describe('client: countdown and button label', () => {
109
+ it('rounds the remaining time up to whole seconds', () => {
110
+ assert.equal(client.formatRemaining(0), '0')
111
+ assert.equal(client.formatRemaining(1), '1')
112
+ assert.equal(client.formatRemaining(1400), '2')
113
+ assert.equal(client.formatRemaining(-500), '0')
114
+ })
115
+
116
+ it('turns Approve into Modify once a field was edited', () => {
117
+ const request = { buttons: {} }
118
+ const t = key => key
119
+ assert.equal(client.primaryLabel(false, request, t), 'button.approve')
120
+ assert.equal(client.primaryLabel(true, request, t), 'button.modify')
121
+ assert.equal(client.primaryLabel(true, { buttons: { modify: '改一下' } }, t), '改一下')
122
+ })
123
+
124
+ it('never makes a read-only field accept input', () => {
125
+ assert.equal(client.isFieldEditable({ editable: true }, true), true)
126
+ assert.equal(client.isFieldEditable({ editable: false }, true), false)
127
+ assert.equal(client.isFieldEditable({}, true), false)
128
+ assert.equal(client.isFieldEditable(undefined, true), false)
129
+ assert.equal(client.isFieldEditable({ editable: true }, false), false)
130
+ })
131
+
132
+ it('collects only editable fields whose text changed', () => {
133
+ const fields = [
134
+ { param: 'a', editable: true, value: 'one' },
135
+ { param: 'b', editable: false, value: 'two' },
136
+ { param: 'c', editable: true, value: 'three' },
137
+ ]
138
+ assert.deepEqual(client.changedFields(fields, { a: 'one', c: 'three' }), [])
139
+ assert.deepEqual(client.changedFields(fields, { a: 'one!', b: 'ignored', c: 'three' }), [{ param: 'a', text: 'one!' }])
140
+ assert.deepEqual(client.changedFields(fields, {}), [])
141
+ })
142
+ })
143
+
144
+ describe('client: store', () => {
145
+ it('publishes snapshots and keeps a stable reference when nothing changed', () => {
146
+ const store = client.createStore()
147
+ const seen = []
148
+ const stop = store.subscribe(() => { seen.push(store.getSnapshot()) })
149
+ const first = store.getSnapshot()
150
+ assert.equal(first.connection, 'connecting')
151
+ store.setConnection('open')
152
+ store.setConnection('open')
153
+ assert.equal(seen.length, 1)
154
+ assert.equal(store.getSnapshot().connection, 'open')
155
+ store.upsert({ id: 'r1', sessionId: 's1' })
156
+ assert.equal(store.request('r1').sessionId, 's1')
157
+ store.upsert({ id: 'r1', sessionId: 's1', title: 'later' })
158
+ assert.equal(store.getSnapshot().requests.length, 1)
159
+ assert.equal(store.request('r1').title, 'later')
160
+ store.replace([{ id: 'r2', sessionId: 's1' }])
161
+ assert.deepEqual(store.getSnapshot().requests.map(request => request.id), ['r2'])
162
+ store.settle('r2', 'timeout')
163
+ assert.deepEqual(store.getSnapshot().requests, [])
164
+ assert.equal(store.getSnapshot().settled.r2, 'timeout')
165
+ store.remove('gone')
166
+ stop()
167
+ store.setConnection('closed')
168
+ assert.equal(seen.length, 6)
169
+ })
170
+ })
171
+
172
+ describe('client: connection', () => {
173
+ /** One stubbed browser environment for the connection tests. */
174
+ function withBrowser(run, responder) {
175
+ const events = []
176
+ const posts = []
177
+ class FakeEventSource {
178
+ constructor(url) {
179
+ this.url = url
180
+ this.readyState = 1
181
+ events.push(this)
182
+ }
183
+
184
+ close() { this.readyState = 2 }
185
+ }
186
+ const previous = {
187
+ EventSource: globalThis.EventSource,
188
+ fetch: globalThis.fetch,
189
+ }
190
+ globalThis.EventSource = FakeEventSource
191
+ globalThis.fetch = async (url, init) => {
192
+ posts.push({ url, init })
193
+ if (responder !== undefined) return responder(url, init)
194
+ return { ok: true, status: 200, json: async () => ({ ok: true, accepted: true }) }
195
+ }
196
+ try {
197
+ return run({ events, posts, FakeEventSource })
198
+ } finally {
199
+ globalThis.EventSource = previous.EventSource
200
+ globalThis.fetch = previous.fetch
201
+ }
202
+ }
203
+
204
+ it('reports an unavailable channel without a token', async () => {
205
+ const store = client.createStore()
206
+ const connection = client.createConnection({ boot: undefined, store })
207
+ connection.start()
208
+ assert.equal(store.getSnapshot().connection, 'unavailable')
209
+ await assert.rejects(() => connection.decide('r1', { kind: 'approve' }), /unavailable/)
210
+ })
211
+
212
+ it('streams frames into the store and posts decisions with the bearer token', async () => {
213
+ await withBrowser(async ({ events, posts }) => {
214
+ const store = client.createStore()
215
+ const connection = client.createConnection({ boot: { token: 'tok', endpoint: '/dsh-hitl' }, store })
216
+ connection.start()
217
+ assert.equal(events.length, 1)
218
+ assert.equal(events[0].url, '/dsh-hitl/events?token=tok')
219
+ events[0].onopen()
220
+ assert.equal(store.getSnapshot().connection, 'open')
221
+ events[0].onmessage({ data: JSON.stringify({ type: 'snapshot', requests: [{ id: 'r1', sessionId: 's1' }] }) })
222
+ assert.deepEqual(store.getSnapshot().requests.map(request => request.id), ['r1'])
223
+ events[0].onmessage({ data: JSON.stringify({ type: 'request', request: { id: 'r2', sessionId: 's1' } }) })
224
+ assert.deepEqual(store.getSnapshot().requests.map(request => request.id), ['r1', 'r2'])
225
+ events[0].onmessage({ data: JSON.stringify({ type: 'settled', id: 'r1', outcome: 'timeout' }) })
226
+ // A timeout is nobody's decision, so it stays readable for a moment with
227
+ // the outcome recorded instead of disappearing the instant it fires.
228
+ assert.deepEqual(store.getSnapshot().requests.map(request => request.id), ['r1', 'r2'])
229
+ assert.equal(store.getSnapshot().outcomes.r1, 'timeout')
230
+ events[0].onmessage({ data: JSON.stringify({ type: 'settled', id: 'r1', outcome: 'user' }) })
231
+ assert.deepEqual(store.getSnapshot().requests.map(request => request.id), ['r2'], 'a human outcome closes it at once')
232
+ events[0].onmessage({ data: 'not json' })
233
+ assert.equal(store.getSnapshot().requests.length, 1)
234
+ await connection.decide('r2', { kind: 'reject', feedback: 'no' })
235
+ assert.equal(posts[0].url, '/dsh-hitl/decide')
236
+ assert.deepEqual(JSON.parse(posts[0].init.body), { type: 'decide', id: 'r2', decision: { kind: 'reject', feedback: 'no' } })
237
+ assert.equal(posts[0].init.headers.authorization, 'Bearer tok')
238
+ connection.dispose()
239
+ assert.equal(events[0].readyState, 2)
240
+ })
241
+ })
242
+
243
+ it('surfaces the host message when a decision is refused', async () => {
244
+ await withBrowser(async ({ events, posts }) => {
245
+ const store = client.createStore()
246
+ const connection = client.createConnection({ boot: { token: 'tok', endpoint: '/dsh-hitl' }, store })
247
+ connection.start()
248
+ await assert.rejects(() => connection.decide('r1', { kind: 'approve' }), /no longer open/)
249
+ assert.equal(posts.length, 1)
250
+ events[0].readyState = 0
251
+ events[0].onerror()
252
+ assert.equal(store.getSnapshot().connection, 'connecting')
253
+ events[0].readyState = 2
254
+ events[0].onerror()
255
+ assert.equal(store.getSnapshot().connection, 'unavailable')
256
+ events[0].onopen()
257
+ assert.equal(store.getSnapshot().connection, 'open')
258
+ connection.dispose()
259
+ }, () => ({
260
+ ok: false,
261
+ status: 409,
262
+ json: async () => ({ ok: false, code: 'already-settled', message: 'that decision is no longer open' }),
263
+ }))
264
+ })
265
+
266
+ it('swallows hold failures so editing never blocks the panel', async () => {
267
+ await withBrowser(async ({ events }) => {
268
+ const store = client.createStore()
269
+ const connection = client.createConnection({ boot: { token: 'tok', endpoint: '/dsh-hitl' }, store })
270
+ connection.start()
271
+ assert.equal(await connection.hold('r1', true), undefined)
272
+ events[0].onmessage({ data: JSON.stringify({ type: 'holdAck', id: 'r1', held: true }) })
273
+ connection.dispose()
274
+ }, () => { throw new Error('offline') })
275
+ })
276
+ })
277
+
278
+ describe('client: dictionaries', () => {
279
+ it('keeps the two locales on the same key set', () => {
280
+ assert.deepEqual(Object.keys(client.zh).sort(), Object.keys(client.en).sort())
281
+ })
282
+ })
283
+
284
+ describe('client: countdown emphasis and holds', () => {
285
+ it('escalates the countdown as the deadline approaches', () => {
286
+ assert.equal(client.countdownTone(120000, false, false), 'normal')
287
+ assert.equal(client.countdownTone(10001, false, false), 'normal')
288
+ assert.equal(client.countdownTone(10000, false, false), 'warn')
289
+ assert.equal(client.countdownTone(1200, false, false), 'warn')
290
+ assert.equal(client.countdownTone(1200, true, false), 'held')
291
+ assert.equal(client.countdownTone(0, false, true), 'timeout')
292
+ assert.equal(client.countdownTone(undefined, false, false), 'normal')
293
+ })
294
+
295
+ it('tracks the host verdict on interaction holds', () => {
296
+ const store = client.createStore()
297
+ const seen = []
298
+ const stop = store.subscribe(() => { seen.push(store.getSnapshot()) })
299
+ assert.deepEqual(store.getSnapshot().holds, {})
300
+ store.setHold('r1', true)
301
+ assert.equal(store.getSnapshot().holds.r1, true)
302
+ store.setHold('r1', true)
303
+ assert.equal(seen.length, 1, 'a repeated verdict does not republish')
304
+ store.setHold('r1', false)
305
+ assert.equal(store.getSnapshot().holds.r1, false)
306
+ stop()
307
+ })
308
+
309
+ it('applies nothing when a hold frame names an unknown request', () => {
310
+ const store = client.createStore()
311
+ store.setHold('ghost', true)
312
+ assert.equal(store.getSnapshot().holds.ghost, true)
313
+ assert.deepEqual(store.getSnapshot().requests, [])
314
+ })
315
+ })
316
+
317
+ describe('client: live countdown readings', () => {
318
+ it('counts a fresh reading as a revision so the panel re-bases its clock', () => {
319
+ const store = client.createStore()
320
+ assert.deepEqual(store.getSnapshot().revisions, {})
321
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: { remainingMs: 600000, action: 'reject' } })
322
+ assert.equal(store.getSnapshot().revisions.r1, 1)
323
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: { remainingMs: 120000, action: 'reject' } })
324
+ assert.equal(store.getSnapshot().revisions.r1, 2)
325
+ assert.equal(store.request('r1').countdown.remainingMs, 120000)
326
+ store.replace([{ id: 'r2', sessionId: 's1', countdown: { remainingMs: 5000, action: 'reject' } }])
327
+ assert.equal(store.getSnapshot().revisions.r2, 1)
328
+ store.replace([{ id: 'r2', sessionId: 's1', countdown: { remainingMs: 3000, action: 'reject' } }])
329
+ assert.equal(store.getSnapshot().revisions.r2, 2)
330
+ })
331
+
332
+ it('seeds the hold state a reconnect snapshot projects', () => {
333
+ const store = client.createStore()
334
+ const connection = client.createConnection({ boot: { token: 't' }, store })
335
+ globalThis.window.__DSH_HITL__ = { token: 't', endpoint: '/dsh-hitl' }
336
+ class FakeEventSource {
337
+ constructor() {
338
+ this.readyState = 1
339
+ events.push(this)
340
+ }
341
+
342
+ close() { this.readyState = 2 }
343
+ }
344
+ const events = []
345
+ const previous = globalThis.EventSource
346
+ globalThis.EventSource = FakeEventSource
347
+ try {
348
+ connection.start()
349
+ events[0].onmessage({
350
+ data: JSON.stringify({
351
+ type: 'snapshot',
352
+ requests: [{ id: 'r1', sessionId: 's1', countdown: { remainingMs: 4000, action: 'reject', held: true } }],
353
+ }),
354
+ })
355
+ assert.equal(store.getSnapshot().holds.r1, true)
356
+ events[0].onmessage({
357
+ data: JSON.stringify({ type: 'request', request: { id: 'r2', sessionId: 's1', countdown: { remainingMs: 9, action: 'reject', held: true } } }),
358
+ })
359
+ assert.equal(store.getSnapshot().holds.r2, true)
360
+ connection.dispose()
361
+ } finally {
362
+ globalThis.EventSource = previous
363
+ delete globalThis.window.__DSH_HITL__
364
+ }
365
+ })
366
+ })
367
+
368
+ describe('client: stream watchdog', () => {
369
+ it('distrusts a stream that went quiet', () => {
370
+ assert.equal(client.streamStale(0, 44000), false)
371
+ assert.equal(client.streamStale(0, 45000), false)
372
+ assert.equal(client.streamStale(0, 45001), true)
373
+ assert.equal(client.streamStale(1_000_000, 1_100_000), true)
374
+ })
375
+
376
+ it('rebuilds a stream a reload orphaned, and keeps counting frames meanwhile', () => {
377
+ const store = client.createStore()
378
+ const events = []
379
+ class FakeEventSource {
380
+ constructor(url) {
381
+ this.url = url
382
+ this.readyState = 1
383
+ events.push(this)
384
+ }
385
+
386
+ close() { this.readyState = 2 }
387
+ }
388
+ const previous = { EventSource: globalThis.EventSource, fetch: globalThis.fetch }
389
+ globalThis.EventSource = FakeEventSource
390
+ globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })
391
+ try {
392
+ const connection = client.createConnection({ boot: { token: 't' }, store })
393
+ connection.start()
394
+ assert.equal(events.length, 1)
395
+ events[0].onopen()
396
+ assert.equal(store.getSnapshot().connection, 'open')
397
+ // Any frame — the liveness ping included — is evidence the stream is live.
398
+ events[0].onmessage({ data: JSON.stringify({ type: 'ping', version: 1 }) })
399
+ assert.equal(client.streamStale(Date.now(), Date.now()), false)
400
+ connection.dispose()
401
+ assert.equal(events[0].readyState, 2)
402
+ } finally {
403
+ globalThis.EventSource = previous.EventSource
404
+ globalThis.fetch = previous.fetch
405
+ }
406
+ })
407
+ })
408
+
409
+ describe('client: host readings for holds', () => {
410
+ it('adopts an acknowledged remainder as a new reading', () => {
411
+ const store = client.createStore()
412
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: { remainingMs: 600000, action: 'reject' } })
413
+ assert.equal(store.getSnapshot().revisions.r1, 1)
414
+ store.applyReading('r1', 421000)
415
+ assert.equal(store.request('r1').countdown.remainingMs, 421000)
416
+ assert.equal(store.getSnapshot().revisions.r1, 2)
417
+ })
418
+
419
+ it('ignores a reading for a request it does not hold, or a non-number', () => {
420
+ const store = client.createStore()
421
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: { remainingMs: 1000, action: 'reject' } })
422
+ const before = store.getSnapshot()
423
+ store.applyReading('ghost', 5000)
424
+ store.applyReading('r1', undefined)
425
+ store.applyReading('r1', Number.NaN)
426
+ assert.equal(store.getSnapshot(), before, 'nothing republishes')
427
+ assert.equal(store.request('r1').countdown.remainingMs, 1000)
428
+ })
429
+
430
+ it('does not fault a request without a countdown', () => {
431
+ const store = client.createStore()
432
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: null })
433
+ store.applyReading('r1', 5000)
434
+ assert.equal(store.request('r1').countdown, null)
435
+ })
436
+ })
437
+
438
+ describe('client: countdown readings are anchored at arrival', () => {
439
+ const at = 1_700_000_000_000
440
+
441
+ it('turns a reading into a deadline dated from when it arrived', () => {
442
+ assert.equal(client.deadlineOf({ countdown: { remainingMs: 600000 }, observedAt: at }, at), at + 600000)
443
+ })
444
+
445
+ it('never pushes a deadline forward when the same reading is reused later', () => {
446
+ // A remount fifty seconds later re-reads the same stored reading: the
447
+ // deadline must stay put, not become "now + 600s".
448
+ const reading = { countdown: { remainingMs: 600000 }, observedAt: at }
449
+ assert.equal(client.deadlineOf(reading, at + 50000), at + 600000)
450
+ assert.notEqual(client.deadlineOf(reading, at + 50000), at + 50000 + 600000)
451
+ })
452
+
453
+ it('falls back to now only when no arrival time is known', () => {
454
+ assert.equal(client.deadlineOf({ countdown: { remainingMs: 1000 } }, at), at + 1000)
455
+ })
456
+
457
+ it('has no deadline without a countdown', () => {
458
+ assert.equal(client.deadlineOf({ countdown: null, observedAt: at }, at), undefined)
459
+ assert.equal(client.deadlineOf({}, at), undefined)
460
+ assert.equal(client.deadlineOf(undefined, at), undefined)
461
+ })
462
+
463
+ it('stamps every stored request with its arrival time', () => {
464
+ const store = client.createStore()
465
+ store.upsert({ id: 'r1', sessionId: 's1', countdown: { remainingMs: 1000, action: 'reject' } })
466
+ assert.equal(typeof store.request('r1').observedAt, 'number')
467
+ store.replace([{ id: 'r2', sessionId: 's1', countdown: { remainingMs: 1000, action: 'reject' } }])
468
+ assert.equal(typeof store.request('r2').observedAt, 'number')
469
+ const before = store.request('r2').observedAt
470
+ store.applyReading('r2', 900)
471
+ assert.equal(store.request('r2').countdown.remainingMs, 900)
472
+ assert.equal(store.request('r2').observedAt >= before, true, 'a host reading re-stamps arrival')
473
+ })
474
+ })
475
+
476
+ describe('client: the pause clock', () => {
477
+ const id = 'r1'
478
+ const T = 1_700_000_000_000
479
+
480
+ it('counts down when no hold is in force', () => {
481
+ const first = client.reconcileClock({ id, at: T + 600000, frozen: undefined, hostHeld: false, now: T })
482
+ assert.equal(first.remaining, 600000)
483
+ const later = client.reconcileClock({ id, at: first.at, frozen: first.frozen, hostHeld: false, now: T + 5000 })
484
+ assert.equal(later.remaining, 595000)
485
+ })
486
+
487
+ it('captures the remainder once when the hold starts, then holds still', () => {
488
+ const held = client.reconcileClock({ id, at: T + 600000, frozen: undefined, hostHeld: true, now: T + 10000 })
489
+ assert.deepEqual(held.frozen, { id, remainingMs: 590000 })
490
+ assert.equal(held.remaining, 590000)
491
+ // Ten seconds later the panel is still drawing the same number — the defect
492
+ // this function exists to prevent.
493
+ const later = client.reconcileClock({ id, at: held.at, frozen: held.frozen, hostHeld: true, now: T + 20000 })
494
+ assert.equal(later.remaining, 590000)
495
+ assert.deepEqual(later.frozen, { id, remainingMs: 590000 })
496
+ })
497
+
498
+ it('resumes from the frozen number in the same call that releases it', () => {
499
+ const held = client.reconcileClock({ id, at: T + 600000, frozen: undefined, hostHeld: true, now: T + 10000 })
500
+ const released = client.reconcileClock({ id, at: held.at, frozen: held.frozen, hostHeld: false, now: T + 70000 })
501
+ assert.equal(released.frozen, undefined)
502
+ assert.equal(released.remaining, 590000, 'the release frame draws the frozen number, not a stale deadline')
503
+ assert.equal(released.at, T + 70000 + 590000, 'the deadline is re-based by the frozen remainder')
504
+ const after = client.reconcileClock({ id, at: released.at, frozen: released.frozen, hostHeld: false, now: T + 70000 + 1000 })
505
+ assert.equal(after.remaining, 589000)
506
+ })
507
+
508
+ it('re-derives a capture for a different request', () => {
509
+ const held = client.reconcileClock({ id, at: T + 600000, frozen: undefined, hostHeld: true, now: T + 10000 })
510
+ const other = client.reconcileClock({ id: 'r2', at: T + 30000, frozen: held.frozen, hostHeld: true, now: T + 10000 })
511
+ assert.deepEqual(other.frozen, { id: 'r2', remainingMs: 20000 })
512
+ })
513
+
514
+ it('has no remainder without a deadline', () => {
515
+ const idle = client.reconcileClock({ id, at: undefined, frozen: undefined, hostHeld: false, now: T })
516
+ assert.equal(idle.remaining, undefined)
517
+ const held = client.reconcileClock({ id, at: undefined, frozen: undefined, hostHeld: true, now: T })
518
+ assert.equal(held.remaining, 0)
519
+ })
520
+ })
521
+
522
+ describe('client: subagent requests reach the human', () => {
523
+ const parent = 'session-03704d7a-b08d-466d-b84d-1a30c347057b'
524
+ const child = '7e3ebefc-356a-4bcf-85c9-4e05464d5b7d'
525
+
526
+ it('files a request under the asking Session alone when that is the displayed one', () => {
527
+ assert.deepEqual(client.filingSessions({ sessionId: parent }, parent), [parent])
528
+ assert.deepEqual(client.filingSessions({ sessionId: child }, child), [child])
529
+ })
530
+
531
+ it('files a delegated request a second time under the displayed Session', () => {
532
+ assert.deepEqual(client.filingSessions({ sessionId: child }, parent), [child, parent])
533
+ })
534
+
535
+ it('files nothing for a request without an asking Session', () => {
536
+ assert.deepEqual(client.filingSessions({ sessionId: undefined }, parent), [])
537
+ assert.deepEqual(client.filingSessions(undefined, parent), [])
538
+ })
539
+
540
+ it('never duplicates a filing when no Session is displayed', () => {
541
+ assert.deepEqual(client.filingSessions({ sessionId: child }, undefined), [child])
542
+ })
543
+
544
+ it('knows which seats answer somebody else\'s call', () => {
545
+ assert.equal(client.isForeignRequest({ sessionId: child }, parent), true)
546
+ assert.equal(client.isForeignRequest({ sessionId: child }, child), false)
547
+ assert.equal(client.isForeignRequest({ sessionId: child }, undefined), false)
548
+ })
549
+
550
+ it('keeps the frame-level notice for requests no reachable seat shows', () => {
551
+ const requests = [{ id: 'r1', sessionId: child }]
552
+ const seat = { sessionId: parent, obscured: false }
553
+ assert.equal(client.noticeVisible(requests, undefined), true)
554
+ assert.equal(client.noticeVisible(requests, null), true)
555
+ assert.equal(client.noticeVisible(requests, seat), false, 'a reachable panel already covers the request')
556
+ assert.equal(client.noticeVisible(requests, { ...seat, obscured: true }), true, 'a covered panel is no surface')
557
+ assert.equal(client.noticeVisible([], undefined), false)
558
+ assert.equal(client.noticeVisible(undefined, undefined), false)
559
+ })
560
+
561
+ it('releases a seat only for the Session that claimed it', () => {
562
+ const seat = client.createSeat()
563
+ const seen = []
564
+ const stop = seat.subscribe(() => { seen.push(seat.getSnapshot()) })
565
+ const release = seat.hold(parent)
566
+ assert.deepEqual(seat.getSnapshot(), { sessionId: parent, obscured: false })
567
+ release()
568
+ assert.equal(seat.getSnapshot(), undefined)
569
+ // A stale release from a previous mount must not free the live claim: the
570
+ // seat is what tells the notice a panel is really on screen.
571
+ const first = seat.hold(parent)
572
+ const second = seat.hold(child)
573
+ first()
574
+ assert.deepEqual(seat.getSnapshot(), { sessionId: child, obscured: false })
575
+ second()
576
+ assert.equal(seat.getSnapshot(), undefined)
577
+ stop()
578
+ assert.deepEqual(seen, [
579
+ { sessionId: parent, obscured: false },
580
+ undefined,
581
+ { sessionId: parent, obscured: false },
582
+ { sessionId: child, obscured: false },
583
+ undefined,
584
+ ])
585
+ })
586
+
587
+ it('tracks reachability per claim and keeps the snapshot stable', () => {
588
+ const seat = client.createSeat()
589
+ assert.equal(seat.getSnapshot(), undefined)
590
+ // A report without a claim is ignored: an unmounted panel cannot describe a seat.
591
+ seat.reportObscured(true)
592
+ assert.equal(seat.getSnapshot(), undefined)
593
+ const release = seat.hold(parent)
594
+ const held = seat.getSnapshot()
595
+ seat.reportObscured(false)
596
+ assert.equal(seat.getSnapshot(), held, 'an unchanged verdict must not churn React snapshots')
597
+ seat.reportObscured(true)
598
+ assert.deepEqual(seat.getSnapshot(), { sessionId: parent, obscured: true })
599
+ release()
600
+ assert.equal(seat.getSnapshot(), undefined)
601
+ })
602
+
603
+ it('tracks the displayed Session in the store and re-files on a switch', () => {
604
+ const store = client.createStore()
605
+ assert.equal(store.getSnapshot().viewSessionId, undefined)
606
+ let notifications = 0
607
+ const stop = store.subscribe(() => { notifications += 1 })
608
+ store.setView(parent)
609
+ assert.equal(store.getSnapshot().viewSessionId, parent)
610
+ assert.equal(notifications, 1)
611
+ store.setView(parent)
612
+ assert.equal(notifications, 1, 'the same Session is not a change')
613
+ store.setView(child)
614
+ assert.equal(store.getSnapshot().viewSessionId, child)
615
+ store.setView(undefined)
616
+ assert.equal(store.getSnapshot().viewSessionId, undefined)
617
+ stop()
618
+ })
619
+
620
+ it('shortens a Session identity to a readable tail', () => {
621
+ assert.equal(client.shortSession(child), '4e05464d5b7d')
622
+ assert.equal(client.shortSession(parent), '1a30c347057b')
623
+ assert.equal(client.shortSession('abcdef'), 'abcdef')
624
+ assert.equal(client.shortSession(undefined), '')
625
+ assert.equal(client.shortSession(''), '')
626
+ })
627
+
628
+ it('localizes the delegated-call wording in both dictionaries', () => {
629
+ for (const dictionary of [client.zh, client.en]) {
630
+ for (const key of ['label.foreign', 'button.gotoSession', 'aria.notice', 'notice.title', 'notice.more', 'notice.own', 'notice.subagent', 'notice.hint']) {
631
+ assert.equal(typeof dictionary[key], 'string', `${key} is missing`)
632
+ }
633
+ }
634
+ assert.equal(client.zh['label.foreign'].includes('{session}'), true)
635
+ assert.equal(client.en['notice.title'].includes('{count}'), true)
636
+ })
637
+ })
638
+
639
+ describe('client: the viewed-seat filing is a copy, not a second owner', () => {
640
+ const parent = 'session-03704d7a-b08d-466d-b84d-1a30c347057b'
641
+ const child = '7e3ebefc-356a-4bcf-85c9-4e05464d5b7d'
642
+ const makePrimary = () => {
643
+ const sent = []
644
+ const connection = { decide: (id, decision) => { sent.push([id, decision]); return Promise.resolve({ ok: true }) } }
645
+ const request = { id: 'req-1', sessionId: child, toolName: 'glob' }
646
+ return { primary: new client.PendingHitl(request, connection, 3), sent, request }
647
+ }
648
+
649
+ it('files the copy under the viewed Session with its own key', () => {
650
+ const { primary } = makePrimary()
651
+ const view = new client.PendingHitlView(primary, parent)
652
+ assert.equal(view.key, 'req-1@view')
653
+ assert.equal(view.sessionId, parent)
654
+ assert.equal(view.request, primary.request)
655
+ assert.equal(view.sequence, 3, 'the copy keeps the request age, so precedence still ranks requests')
656
+ assert.equal(view.kind, 'hitl')
657
+ })
658
+
659
+ it('sends a decision from either seat for the one call', () => {
660
+ const { primary, sent, request } = makePrimary()
661
+ const view = new client.PendingHitlView(primary, parent)
662
+ view.answer({ kind: 'approve' })
663
+ primary.answer({ kind: 'reject' })
664
+ assert.deepEqual(sent, [[request.id, { kind: 'approve' }], [request.id, { kind: 'reject' }]])
665
+ })
666
+
667
+ it('never delegates the request when only the copy is released', () => {
668
+ const { primary } = makePrimary()
669
+ const view = new client.PendingHitlView(primary, parent)
670
+ view.delegate()
671
+ assert.equal(primary.answerable, true, 'releasing the copy must leave the asking Session its own seat')
672
+ assert.equal(view.answerable, true)
673
+ primary.delegate()
674
+ assert.equal(primary.answerable, false)
675
+ assert.equal(view.answerable, false, 'the copy follows the owner it copies')
676
+ })
677
+ })
678
+
679
+ describe('client: the field header never promises more than the control', () => {
680
+ const markdown = { param: 'pattern', render: 'markdown', editable: true }
681
+ const text = { param: 'path', render: 'text', editable: true }
682
+ const readOnly = { param: 'target', render: 'markdown', editable: false }
683
+ const diff = { param: 'change', render: 'diff', editable: true }
684
+
685
+ it('claims editable only while the field really takes input', () => {
686
+ assert.equal(client.claimsEditable(text, true), true)
687
+ assert.equal(client.claimsEditable(markdown, true), true)
688
+ })
689
+
690
+ it('drops the claim while the panel is submitting, settled, or expired', () => {
691
+ // A text field renders as a read-only block in exactly these states, so the
692
+ // header must stop saying "editable" the moment the panel stops taking one.
693
+ assert.equal(client.claimsEditable(text, false), false)
694
+ assert.equal(client.claimsEditable(markdown, false), false)
695
+ })
696
+
697
+ it('never claims editable for a field without an editor', () => {
698
+ assert.equal(client.claimsEditable(readOnly, true), false)
699
+ assert.equal(client.claimsEditable(diff, true), false)
700
+ assert.equal(client.claimsEditable(undefined, true), false)
701
+ })
702
+ })
703
+
704
+ describe('client: a timeout stays readable instead of vanishing', () => {
705
+ const request = { id: 'req-1', sessionId: 'session-a', toolName: 'glob' }
706
+
707
+ it('keeps a closed request on screen and marks why it closed', () => {
708
+ const store = client.createStore()
709
+ store.upsert(request)
710
+ store.finish('req-1', 'timeout')
711
+ assert.equal(store.getSnapshot().requests.length, 1, 'the panel still has something to draw')
712
+ assert.equal(store.getSnapshot().outcomes['req-1'], 'timeout')
713
+ assert.equal(store.getSnapshot().settled['req-1'], 'timeout')
714
+ store.remove('req-1')
715
+ assert.deepEqual(store.getSnapshot().requests, [])
716
+ assert.equal(store.getSnapshot().outcomes['req-1'], undefined, 'removal clears the closed mark')
717
+ })
718
+
719
+ it('drops an ordinary settle at once and clears any closed mark', () => {
720
+ const store = client.createStore()
721
+ store.upsert(request)
722
+ store.finish('req-1', 'timeout')
723
+ store.settle('req-1', 'user')
724
+ assert.deepEqual(store.getSnapshot().requests, [])
725
+ assert.equal(store.getSnapshot().outcomes['req-1'], undefined)
726
+ assert.equal(store.getSnapshot().settled['req-1'], 'user')
727
+ })
728
+
729
+ it('ignores a close for a request it never had', () => {
730
+ const store = client.createStore()
731
+ let notifications = 0
732
+ const stop = store.subscribe(() => { notifications += 1 })
733
+ store.finish('missing', 'timeout')
734
+ assert.equal(notifications, 0)
735
+ stop()
736
+ })
737
+ })
738
+
739
+ describe('client: the frame-level notice fails silently', () => {
740
+ it('localizes an empty state instead of an empty box', () => {
741
+ // The notice wraps itself in the panel's error boundary with a `null`
742
+ // fallback: a surface that renders over the whole frame must be able to
743
+ // disappear, not draw an empty panel where the app used to be.
744
+ const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8')
745
+ .slice(readFileSync(new URL('../client.js', import.meta.url), 'utf8').indexOf('class PanelBoundary'))
746
+ assert.equal(source.includes('if (this.props.fallback === null) return null'), true)
747
+ assert.equal(source.includes('fallback: null'), true, 'the notice must ask for silence')
748
+ })
749
+ })
750
+
751
+ describe('client: reachability of a seated panel', () => {
752
+ const withDom = (elementFromPoint, size = { width: 400, height: 200 }) => {
753
+ const previousDocument = globalThis.document
754
+ const previousWindow = globalThis.window
755
+ globalThis.window = { innerWidth: 1200, innerHeight: 800, __ModuleLoader__: globalThis.window?.__ModuleLoader__ }
756
+ globalThis.document = { elementFromPoint }
757
+ const element = {
758
+ getBoundingClientRect: () => ({ left: 400, top: 500, width: size.width, height: size.height }),
759
+ contains: candidate => candidate === 'inside' || candidate === element,
760
+ }
761
+ return {
762
+ element,
763
+ restore() {
764
+ globalThis.document = previousDocument
765
+ globalThis.window = previousWindow
766
+ },
767
+ }
768
+ }
769
+
770
+ it('reports reachable when hit testing lands inside the panel', () => {
771
+ const dom = withDom(() => 'inside')
772
+ try {
773
+ assert.equal(client.isObscured(dom.element), false)
774
+ } finally {
775
+ dom.restore()
776
+ }
777
+ })
778
+
779
+ it('reports obscured when a mask, a dialog, or anything else is on top', () => {
780
+ const dom = withDom(() => 'mask')
781
+ try {
782
+ assert.equal(client.isObscured(dom.element), true)
783
+ } finally {
784
+ dom.restore()
785
+ }
786
+ })
787
+
788
+ it('reports obscured when nothing is hit-testable at the sample point', () => {
789
+ const dom = withDom(() => null)
790
+ try {
791
+ assert.equal(client.isObscured(dom.element), true)
792
+ } finally {
793
+ dom.restore()
794
+ }
795
+ })
796
+
797
+ it('reports obscured for a collapsed or absent panel', () => {
798
+ const dom = withDom(() => 'inside', { width: 0, height: 0 })
799
+ try {
800
+ assert.equal(client.isObscured(dom.element), true)
801
+ assert.equal(client.isObscured(undefined), true)
802
+ assert.equal(client.isObscured(null), true)
803
+ } finally {
804
+ dom.restore()
805
+ }
806
+ })
807
+
808
+ it('stays silent where the DOM cannot answer', () => {
809
+ const previousDocument = globalThis.document
810
+ globalThis.document = undefined
811
+ try {
812
+ const element = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }), contains: () => true }
813
+ assert.equal(client.isObscured(element), false, 'without a DOM the seat keeps its claim')
814
+ } finally {
815
+ globalThis.document = previousDocument
816
+ }
817
+ })
818
+ })