@gohcltech/edge-print-client 2.0.31-develop → 2.0.40-develop

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,680 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { EdgePrintClient, type ConnectOptions, type PrintData } from './edge-print'
3
+
4
+ /**
5
+ * Minimal stand-in for the browser WebSocket.
6
+ *
7
+ * Only the surface the client actually touches: the four handler properties,
8
+ * `send`, and the static `OPEN`. `onopen` fires in a microtask so the client
9
+ * has assigned its handlers by the time it runs, matching the real ordering.
10
+ */
11
+ class FakeWebSocket {
12
+ static OPEN = 1
13
+ static instances: FakeWebSocket[] = []
14
+
15
+ readyState = FakeWebSocket.OPEN
16
+ sent: string[] = []
17
+
18
+ onopen: (() => void) | null = null
19
+ onmessage: ((ev: { data: string }) => void) | null = null
20
+ onclose: (() => void) | null = null
21
+ onerror: (() => void) | null = null
22
+
23
+ /**
24
+ * Fires `onclose` re-entrantly from inside `send`.
25
+ *
26
+ * A real WebSocket never does this — `send` returns synchronously and close
27
+ * is dispatched in a later task — so this is a deliberately synthetic worst
28
+ * case. It exists to pin the invariant that a close arriving at any point
29
+ * around a send settles the request and leaves the pending map empty, rather
30
+ * than to reproduce a browser-reachable ordering.
31
+ */
32
+ closeOnSend = false
33
+ /** Called with each parsed outbound frame, so a test can answer it. */
34
+ onSend: ((frame: Record<string, unknown>) => void) | null = null
35
+ /**
36
+ * Answers frames on every socket, including ones a retry has not created
37
+ * yet. A per-instance handler has to be attached after the socket exists,
38
+ * which races the frame it is meant to answer.
39
+ */
40
+ static answer: ((frame: Record<string, unknown>, ws: FakeWebSocket) => void) | null = null
41
+
42
+ /** When set, every socket fires `onerror` instead of `onopen` — an agent
43
+ * that cannot be reached at all, as opposed to one that refuses a token. */
44
+ static failToOpen = false
45
+ /** When set, sockets neither open nor fail, so a connect stays in flight. */
46
+ static stayConnecting = false
47
+
48
+ constructor(public url: string) {
49
+ FakeWebSocket.instances.push(this)
50
+ if (FakeWebSocket.stayConnecting) return
51
+ if (FakeWebSocket.failToOpen) {
52
+ queueMicrotask(() => this.onerror?.())
53
+ } else {
54
+ queueMicrotask(() => this.onopen?.())
55
+ }
56
+ }
57
+
58
+ /** Fires the open that `stayConnecting` withheld. */
59
+ openNow(): void {
60
+ this.onopen?.()
61
+ }
62
+
63
+ send(raw: string): void {
64
+ this.sent.push(raw)
65
+ const frame = JSON.parse(raw) as Record<string, unknown>
66
+ if (this.closeOnSend) {
67
+ this.onclose?.()
68
+ return
69
+ }
70
+ this.onSend?.(frame)
71
+ FakeWebSocket.answer?.(frame, this)
72
+ }
73
+
74
+ close(): void {
75
+ this.onclose?.()
76
+ }
77
+ }
78
+
79
+ function socket(): FakeWebSocket {
80
+ // Index rather than `.at(-1)`: this package targets ES2020, and the test is
81
+ // held to the same library level as the code it guards.
82
+ const ws = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
83
+ if (!ws) throw new Error('no socket was opened')
84
+ return ws
85
+ }
86
+
87
+ function deliver(msg: Record<string, unknown>): void {
88
+ socket().onmessage?.({ data: JSON.stringify(msg) })
89
+ }
90
+
91
+ function frames(): Array<Record<string, unknown>> {
92
+ return socket().sent.map(s => JSON.parse(s) as Record<string, unknown>)
93
+ }
94
+
95
+ /** The client's in-flight request map, which has no public accessor. */
96
+ function pendingSize(client: EdgePrintClient): number {
97
+ return (client as unknown as { pending: Map<string, unknown> }).pending.size
98
+ }
99
+
100
+ /** Connects a client, answering the auth handshake. */
101
+ async function connected(options: Partial<ConnectOptions> = {}): Promise<EdgePrintClient> {
102
+ const client = new EdgePrintClient()
103
+ const promise = client.connect({ token: 'tok', ...options })
104
+ await Promise.resolve()
105
+ socket().onSend = frame => {
106
+ if (frame['type'] === 'auth') deliver({ type: 'auth_ok', id: frame['id'] })
107
+ }
108
+ await promise
109
+ return client
110
+ }
111
+
112
+ // Deliberately typed rather than cast: the suite doubles as a signature guard,
113
+ // so a breaking change to PrintConfig or PrintData fails the typecheck here.
114
+ const PDF: PrintData[] = [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: 'x' }]
115
+
116
+ beforeEach(() => {
117
+ FakeWebSocket.instances = []
118
+ FakeWebSocket.failToOpen = false
119
+ FakeWebSocket.stayConnecting = false
120
+ FakeWebSocket.answer = null
121
+ ;(globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket
122
+ })
123
+
124
+ afterEach(() => {
125
+ delete (globalThis as { WebSocket?: unknown }).WebSocket
126
+ })
127
+
128
+ describe('print() failure reporting', () => {
129
+ it('rejects when the agent reports the job failed after accepting it', async () => {
130
+ const client = await connected()
131
+ socket().onSend = frame => {
132
+ if (frame['type'] === 'print') {
133
+ deliver({
134
+ type: 'print_error',
135
+ id: frame['id'],
136
+ jobId: 'job-1',
137
+ message: 'Printer offline',
138
+ })
139
+ }
140
+ }
141
+
142
+ await expect(client.print({ printer: 'Nope' }, PDF))
143
+ .rejects.toThrow('Printer offline')
144
+ })
145
+
146
+ it('carries the job id on the thrown error so a failure can be chased', async () => {
147
+ const client = await connected()
148
+ socket().onSend = frame => {
149
+ if (frame['type'] === 'print') {
150
+ deliver({
151
+ type: 'print_error',
152
+ id: frame['id'],
153
+ jobId: 'job-7',
154
+ message: 'Printer offline',
155
+ })
156
+ }
157
+ }
158
+
159
+ const failure = await client
160
+ .print({ printer: 'Nope' }, PDF)
161
+ .then(() => null, (e: unknown) => e as Error & { jobId?: string })
162
+
163
+ expect(failure?.message).toBe('Printer offline')
164
+ expect(failure?.jobId).toBe('job-7')
165
+ })
166
+
167
+ it('leaves the job id off an error raised before a job existed', async () => {
168
+ const client = await connected()
169
+ socket().onSend = frame => {
170
+ if (frame['type'] === 'print') {
171
+ deliver({ type: 'error', id: frame['id'], message: 'Printer not found' })
172
+ }
173
+ }
174
+
175
+ const failure = await client
176
+ .print({ printer: 'Ghost' }, PDF)
177
+ .then(() => null, (e: unknown) => e as Error & { jobId?: string })
178
+
179
+ expect(failure?.jobId).toBeUndefined()
180
+ })
181
+
182
+ it('resolves with the job id when the job completes', async () => {
183
+ const client = await connected()
184
+ socket().onSend = frame => {
185
+ if (frame['type'] === 'print') {
186
+ deliver({ type: 'print_complete', id: frame['id'], jobId: 'job-2' })
187
+ }
188
+ }
189
+
190
+ await expect(client.print({ printer: 'Office Laser' }, PDF))
191
+ .resolves.toBe('job-2')
192
+ })
193
+
194
+ it('rejects on an error type it has never seen before', async () => {
195
+ // Matching is by `_error` suffix precisely so error types added to the
196
+ // protocol later reject by default instead of resolving as successes.
197
+ const client = await connected()
198
+ socket().onSend = frame => {
199
+ if (frame['type'] === 'print') {
200
+ deliver({ type: 'validation_error', id: frame['id'], message: 'Unknown tray' })
201
+ }
202
+ }
203
+
204
+ await expect(client.print({ printer: 'Office Laser' }, PDF))
205
+ .rejects.toThrow('Unknown tray')
206
+ })
207
+
208
+ it('rejects before a job exists when the request itself is refused', async () => {
209
+ const client = await connected()
210
+ socket().onSend = frame => {
211
+ if (frame['type'] === 'print') {
212
+ deliver({ type: 'error', id: frame['id'], message: 'Printer not found' })
213
+ }
214
+ }
215
+
216
+ await expect(client.print({ printer: 'Ghost' }, PDF))
217
+ .rejects.toThrow('Printer not found')
218
+ })
219
+ })
220
+
221
+ describe('connect() identifies the application', () => {
222
+ it('sends clientName when one is given', async () => {
223
+ await connected({ clientName: 'Warehouse App' })
224
+ const auth = frames().find(f => f['type'] === 'auth')
225
+ expect(auth?.['clientName']).toBe('Warehouse App')
226
+ })
227
+
228
+ it('omits the key entirely when no name is given', async () => {
229
+ await connected()
230
+ const auth = frames().find(f => f['type'] === 'auth')
231
+ expect(auth && 'clientName' in auth).toBe(false)
232
+ })
233
+ })
234
+
235
+ describe('pending request bookkeeping', () => {
236
+ it('leaves nothing in the pending map when the socket closes mid-send', async () => {
237
+ const client = await connected()
238
+ socket().closeOnSend = true
239
+
240
+ await expect(client.print({ printer: 'Office Laser' }, PDF))
241
+ .rejects.toThrow('Connection closed')
242
+
243
+ // Guards the single-insert structure: the entry used to be inserted raw and
244
+ // then replaced by one closing over a value read back out of the map, which
245
+ // only holds together if nothing observes the map in between.
246
+ expect(pendingSize(client)).toBe(0)
247
+ })
248
+
249
+ it('still rejects later requests cleanly after a close during send', async () => {
250
+ // Under the old structure a half-built entry made the next rejectPending
251
+ // throw partway through its loop, stranding every other in-flight request.
252
+ // Synthetic ordering (see closeOnSend), but the invariant is worth holding.
253
+ const client = await connected()
254
+ socket().closeOnSend = true
255
+ await expect(client.print({ printer: 'A' }, PDF)).rejects.toThrow()
256
+
257
+ const again = await connected()
258
+ socket().closeOnSend = true
259
+ await expect(again.print({ printer: 'B' }, PDF))
260
+ .rejects.toThrow('Connection closed')
261
+ expect(pendingSize(again)).toBe(0)
262
+ })
263
+
264
+ it('stops calling a close listener once it unsubscribes', async () => {
265
+ const client = await connected()
266
+ const seen: string[] = []
267
+ client.onClose(() => seen.push('kept'))
268
+ const stop = client.onClose(() => seen.push('removed'))
269
+
270
+ stop()
271
+ socket().close()
272
+
273
+ expect(seen).toEqual(['kept'])
274
+ })
275
+
276
+ it('still runs later listeners when one removes itself mid-close', async () => {
277
+ // The unsubscribe splices the array onclose is iterating, so iterating the
278
+ // live array would skip whichever listener followed the one that removed
279
+ // itself — and removing yourself from a close handler is the natural shape
280
+ // of a one-shot reconnect.
281
+ const client = await connected()
282
+ const seen: string[] = []
283
+ const stopFirst = client.onClose(() => { seen.push('first'); stopFirst() })
284
+ client.onClose(() => seen.push('second'))
285
+
286
+ socket().close()
287
+
288
+ expect(seen).toEqual(['first', 'second'])
289
+ })
290
+
291
+ it('rejects in-flight requests when the socket closes normally', async () => {
292
+ const client = await connected()
293
+ socket().onSend = null
294
+
295
+ const inFlight = client.printers()
296
+ socket().close()
297
+
298
+ await expect(inFlight).rejects.toThrow('Connection closed')
299
+ expect(pendingSize(client)).toBe(0)
300
+ })
301
+ })
302
+
303
+ describe('connection lifecycle', () => {
304
+ /** Answers the auth handshake on whichever socket is current. */
305
+ function acceptAuth() {
306
+ socket().onSend = frame => {
307
+ if (frame['type'] === 'auth') deliver({ type: 'auth_ok', id: frame['id'] })
308
+ }
309
+ }
310
+
311
+ /** Refuses the auth handshake the way the agent does — an error response on
312
+ * a socket it deliberately leaves open. */
313
+ function refuseAuth() {
314
+ socket().onSend = frame => {
315
+ if (frame['type'] === 'auth') {
316
+ deliver({ type: 'error', id: frame['id'], message: 'Invalid token' })
317
+ }
318
+ }
319
+ }
320
+
321
+ /// The agent answers a token awaiting approval with the same "Invalid token"
322
+ /// it gives a genuinely bad one, so the client cannot tell them apart — and
323
+ /// the documented flow is to keep retrying while someone clicks Approve.
324
+ /// Repeat attempts cost one counter increment on the pending entry; only the
325
+ /// first raises a notification.
326
+ it('keeps retrying a refused token, since approval may still be coming', async () => {
327
+ FakeWebSocket.answer = (frame, ws) => {
328
+ if (frame['type'] === 'auth') {
329
+ ws.onmessage?.({
330
+ data: JSON.stringify({ type: 'error', id: frame['id'], message: 'Invalid token' }),
331
+ })
332
+ }
333
+ }
334
+
335
+ const client = new EdgePrintClient()
336
+ await expect(
337
+ client.connect({ token: 'pending', retries: 2, retryDelay: 1 }),
338
+ ).rejects.toThrow('Invalid token')
339
+
340
+ expect(FakeWebSocket.instances.length).toBe(3)
341
+ })
342
+
343
+ /// Approval landing mid-retry is the whole point of retrying.
344
+ it('connects when approval arrives between attempts', async () => {
345
+ let approved = false
346
+ FakeWebSocket.answer = (frame, ws) => {
347
+ if (frame['type'] !== 'auth') return
348
+ const id = frame['id']
349
+ // Refused twice, then the user clicks Approve.
350
+ const reply = approved
351
+ ? { type: 'auth_ok', id }
352
+ : { type: 'error', id, message: 'Invalid token' }
353
+ approved = true
354
+ ws.onmessage?.({ data: JSON.stringify(reply) })
355
+ }
356
+
357
+ const client = new EdgePrintClient()
358
+ await client.connect({ token: 'pending', retries: 3, retryDelay: 1 })
359
+
360
+ expect(client.isConnected()).toBe(true)
361
+ expect(FakeWebSocket.instances.length).toBe(2)
362
+ })
363
+
364
+ it('still retries when the agent cannot be reached', async () => {
365
+ FakeWebSocket.failToOpen = true
366
+ const client = new EdgePrintClient()
367
+
368
+ await expect(
369
+ client.connect({ token: 'tok', retries: 2, retryDelay: 1 }),
370
+ ).rejects.toThrow('Cannot reach')
371
+
372
+ // One initial attempt plus two retries — reachability can change between
373
+ // attempts, unlike a refusal.
374
+ expect(FakeWebSocket.instances.length).toBe(3)
375
+ })
376
+
377
+ it('leaves no socket behind when a connect is refused', async () => {
378
+ const client = new EdgePrintClient()
379
+ const promise = client.connect({ token: 'refused', retries: 0 })
380
+ await Promise.resolve()
381
+ refuseAuth()
382
+ await expect(promise).rejects.toThrow()
383
+
384
+ const orphan = socket()
385
+ let closes = 0
386
+ client.onClose(() => closes++)
387
+
388
+ // Reconnect successfully, then let the refused attempt's socket drop.
389
+ const second = client.connect({ token: 'tok' })
390
+ await Promise.resolve()
391
+ acceptAuth()
392
+ await second
393
+
394
+ orphan.close()
395
+
396
+ expect(closes).toBe(0)
397
+ expect(client.isConnected()).toBe(true)
398
+ })
399
+
400
+ it('does not strand the previous socket when connect is called on a live client', async () => {
401
+ const client = await connected()
402
+ const first = socket()
403
+
404
+ const again = client.connect({ token: 'tok' })
405
+ await Promise.resolve()
406
+ acceptAuth()
407
+ await again
408
+ expect(socket()).not.toBe(first)
409
+
410
+ let closes = 0
411
+ client.onClose(() => closes++)
412
+ first.close()
413
+
414
+ expect(closes).toBe(0)
415
+ expect(client.isConnected()).toBe(true)
416
+ })
417
+
418
+ /// onClose documents that it fires on "a network drop, an agent restart, or
419
+ /// an explicit disconnect() call". Routing disconnect through the same
420
+ /// teardown as a socket replacement detaches the handlers, so the socket's
421
+ /// own close never arrives and the listener has to be called here instead —
422
+ /// a silent disconnect leaves an app that tracks connectivity through
423
+ /// onClose believing it is still connected.
424
+ it('tells close listeners about an explicit disconnect', async () => {
425
+ const client = await connected()
426
+ let closes = 0
427
+ client.onClose(() => closes++)
428
+
429
+ client.disconnect()
430
+
431
+ expect(closes).toBe(1)
432
+ expect(client.isConnected()).toBe(false)
433
+ })
434
+
435
+ it('says nothing when disconnect is called on an idle client', async () => {
436
+ const client = new EdgePrintClient()
437
+ let closes = 0
438
+ client.onClose(() => closes++)
439
+
440
+ client.disconnect()
441
+
442
+ expect(closes).toBe(0)
443
+ })
444
+
445
+ /// A reconnect that fails has destroyed the live connection and put nothing
446
+ /// in its place, so the app is disconnected and has to be told — otherwise
447
+ /// anything tracking connectivity through onClose believes it is still up.
448
+ it('tells listeners when a reconnect fails and takes the live connection with it', async () => {
449
+ const client = await connected()
450
+ let closes = 0
451
+ client.onClose(() => closes++)
452
+
453
+ const doomed = client.connect({ token: 'refused', retries: 0 })
454
+ const outcome = doomed.then(() => 'resolved', () => 'rejected')
455
+ await Promise.resolve()
456
+ refuseAuth()
457
+ await new Promise(r => setTimeout(r, 5))
458
+
459
+ expect(await outcome).toBe('rejected')
460
+ expect(client.isConnected()).toBe(false)
461
+ expect(closes).toBe(1)
462
+ })
463
+
464
+ /// A socket that dropped before authenticating was never a connection, so
465
+ /// announcing one would have an onClose-driven reconnect racing the retry
466
+ /// loop that is already running.
467
+ it('says nothing when a socket drops before it ever authenticated', async () => {
468
+ const client = new EdgePrintClient()
469
+ const attempt = client.connect({ token: 'tok', retries: 0 })
470
+ const outcome = attempt.then(() => 'resolved', () => 'rejected')
471
+ await Promise.resolve()
472
+
473
+ let closes = 0
474
+ client.onClose(() => closes++)
475
+ socket().close()
476
+
477
+ await outcome
478
+ expect(closes).toBe(0)
479
+ })
480
+
481
+ it('does not let a disconnected socket tear down the next connection', async () => {
482
+ const client = await connected()
483
+ const first = socket()
484
+ client.disconnect()
485
+
486
+ const again = client.connect({ token: 'tok' })
487
+ await Promise.resolve()
488
+ acceptAuth()
489
+ await again
490
+
491
+ let closes = 0
492
+ client.onClose(() => closes++)
493
+ // The browser dispatches the close for the disconnected socket later.
494
+ first.close()
495
+
496
+ expect(closes).toBe(0)
497
+ expect(client.isConnected()).toBe(true)
498
+ })
499
+
500
+ /// The regression that killed an earlier attempt at this: discarding a socket
501
+ /// detaches its handlers, so a connect still waiting on `onopen` had nothing
502
+ /// left to settle it and hung forever with no timeout to rescue it.
503
+ it('settles an in-flight connect when a second one supersedes it', async () => {
504
+ FakeWebSocket.stayConnecting = true
505
+ const client = new EdgePrintClient()
506
+
507
+ const first = client.connect({ token: 'tok', retries: 0 })
508
+ const firstOutcome = first.then(() => 'resolved').catch(() => 'rejected')
509
+ await Promise.resolve()
510
+
511
+ FakeWebSocket.stayConnecting = false
512
+ const second = client.connect({ token: 'tok', retries: 0 })
513
+ await Promise.resolve()
514
+ acceptAuth()
515
+ await second
516
+
517
+ const settled = await Promise.race([
518
+ firstOutcome,
519
+ new Promise(r => setTimeout(() => r('HUNG'), 50)),
520
+ ])
521
+ expect(settled).toBe('rejected')
522
+ })
523
+
524
+ it('rejects in-flight requests when a connect supersedes the socket', async () => {
525
+ const client = await connected()
526
+ socket().onSend = null
527
+ const inFlight = client.printers()
528
+
529
+ const again = client.connect({ token: 'tok' })
530
+ await Promise.resolve()
531
+ acceptAuth()
532
+ await again
533
+
534
+ await expect(inFlight).rejects.toThrow()
535
+ })
536
+ })
537
+
538
+ describe('connection lifecycle — supersession and cancellation', () => {
539
+ function acceptAuth() {
540
+ socket().onSend = frame => {
541
+ if (frame['type'] === 'auth') deliver({ type: 'auth_ok', id: frame['id'] })
542
+ }
543
+ }
544
+
545
+ /// A connect that failed and is sleeping before its next attempt must not
546
+ /// wake up and tear down a connection someone else established in the
547
+ /// meantime. The generation is checked when an attempt fails; it has to be
548
+ /// checked again before the next one starts.
549
+ it('a sleeping retry does not discard a newer live connection', async () => {
550
+ FakeWebSocket.failToOpen = true
551
+ const client = new EdgePrintClient()
552
+
553
+ const stale = client.connect({ token: 'tok', retries: 3, retryDelay: 20 })
554
+ const staleOutcome = stale.then(() => 'resolved', () => 'rejected')
555
+ await new Promise(r => setTimeout(r, 5))
556
+
557
+ // A second connect succeeds while the first is between attempts.
558
+ FakeWebSocket.failToOpen = false
559
+ const fresh = client.connect({ token: 'tok', retries: 0 })
560
+ await Promise.resolve()
561
+ acceptAuth()
562
+ await fresh
563
+ const live = socket()
564
+
565
+ live.onSend = frame => {
566
+ if (frame['type'] === 'get_printers') {
567
+ deliver({ type: 'printers', id: frame['id'], printers: [] })
568
+ }
569
+ }
570
+ const inFlight = client.printers()
571
+
572
+ // Let the stale attempt's sleep elapse.
573
+ await new Promise(r => setTimeout(r, 60))
574
+
575
+ expect(await staleOutcome).toBe('rejected')
576
+ expect(client.isConnected()).toBe(true)
577
+ expect(socket()).toBe(live)
578
+ await expect(inFlight).resolves.toBeDefined()
579
+ })
580
+
581
+ /// Disconnect has to stop a connect that is still retrying, or the client
582
+ /// quietly reconnects a second later while the app believes it is closed.
583
+ it('disconnect cancels a connect that is still retrying', async () => {
584
+ FakeWebSocket.failToOpen = true
585
+ const client = new EdgePrintClient()
586
+
587
+ const attempt = client.connect({ token: 'tok', retries: 3, retryDelay: 20 })
588
+ const outcome = attempt.then(() => 'resolved', () => 'rejected')
589
+ await new Promise(r => setTimeout(r, 5))
590
+
591
+ const socketsBefore = FakeWebSocket.instances.length
592
+ client.disconnect()
593
+ FakeWebSocket.failToOpen = false
594
+
595
+ await new Promise(r => setTimeout(r, 60))
596
+
597
+ expect(await outcome).toBe('rejected')
598
+ expect(client.isConnected()).toBe(false)
599
+ expect(FakeWebSocket.instances.length).toBe(socketsBefore)
600
+ })
601
+
602
+ /// A socket that closes during the handshake never fires onopen or onerror,
603
+ /// so the connect has nothing to settle it and no timeout covers it.
604
+ /// A caller computing `retries: maxAttempts - 1` from config can reach a
605
+ /// negative value. Skipping the loop would resolve connect() without ever
606
+ /// opening a socket, so the caller believes it is connected and every later
607
+ /// call rejects with "Not connected" for no visible reason.
608
+ it('still makes one attempt when given a negative retry count', async () => {
609
+ FakeWebSocket.failToOpen = true
610
+ const client = new EdgePrintClient()
611
+
612
+ await expect(
613
+ client.connect({ token: 'tok', retries: -1 }),
614
+ ).rejects.toThrow('Cannot reach')
615
+
616
+ expect(FakeWebSocket.instances.length).toBe(1)
617
+ expect(client.isConnected()).toBe(false)
618
+ })
619
+
620
+ it('settles a connect whose socket closes before it opens', async () => {
621
+ FakeWebSocket.stayConnecting = true
622
+ const client = new EdgePrintClient()
623
+
624
+ const attempt = client.connect({ token: 'tok', retries: 0 })
625
+ const outcome = attempt.then(() => 'resolved', () => 'rejected')
626
+ await Promise.resolve()
627
+
628
+ socket().close()
629
+
630
+ const settled = await Promise.race([
631
+ outcome,
632
+ new Promise(r => setTimeout(() => r('HUNG'), 50)),
633
+ ])
634
+ expect(settled).toBe('rejected')
635
+ })
636
+
637
+ /// onClose documents a *connection* closing. A connect that never opened was
638
+ /// never a connection, so announcing one would report a drop that never
639
+ /// happened to an app using onClose as its connectivity signal.
640
+ it('says nothing when disconnecting a socket that never opened', async () => {
641
+ FakeWebSocket.stayConnecting = true
642
+ const client = new EdgePrintClient()
643
+
644
+ const attempt = client.connect({ token: 'tok', retries: 0 })
645
+ const outcome = attempt.then(() => 'resolved', () => 'rejected')
646
+ await Promise.resolve()
647
+
648
+ let closes = 0
649
+ client.onClose(() => closes++)
650
+ client.disconnect()
651
+ await outcome
652
+
653
+ expect(closes).toBe(0)
654
+ })
655
+
656
+ /// An agent that opens the socket and then never answers is stalled, not
657
+ /// refusing — the transient condition retries exist for. Only a response
658
+ /// counts as a definitive answer.
659
+ it('retries when the agent opens the socket but never answers', async () => {
660
+ vi.useFakeTimers()
661
+ try {
662
+ const client = new EdgePrintClient()
663
+ // Never answers the auth frame, so the request falls to its own timeout.
664
+ const attempt = client.connect({ token: 'tok', retries: 1, retryDelay: 1 })
665
+ const outcome = attempt.then(() => 'resolved', () => 'rejected')
666
+
667
+ // Each attempt: let the socket open, then run out the request timeout
668
+ // and the retry delay.
669
+ for (let i = 0; i < 2; i++) {
670
+ await Promise.resolve()
671
+ await vi.advanceTimersByTimeAsync(31_000)
672
+ }
673
+
674
+ expect(await outcome).toBe('rejected')
675
+ expect(FakeWebSocket.instances.length).toBe(2)
676
+ } finally {
677
+ vi.useRealTimers()
678
+ }
679
+ })
680
+ })