@morscherlab/mint-sdk 1.0.51 → 1.0.53

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.
Files changed (43) hide show
  1. package/dist/__tests__/components/JobsStatusTray.test.d.ts +1 -0
  2. package/dist/__tests__/composables/useJobsStatusTray.test.d.ts +1 -0
  3. package/dist/__tests__/utils/jobs.test.d.ts +1 -0
  4. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  5. package/dist/components/JobsStatusTray.vue.d.ts +26 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.js +2 -2
  8. package/dist/{components-C7UFkNIp.js → components-Lk_h_rON.js} +1663 -1339
  9. package/dist/components-Lk_h_rON.js.map +1 -0
  10. package/dist/composables/index.d.ts +1 -0
  11. package/dist/composables/index.js +3 -3
  12. package/dist/composables/useJobsStatusTray.d.ts +43 -0
  13. package/dist/composables/usePluginClient.d.ts +4 -1
  14. package/dist/{composables-CpBhNKHf.js → composables-DyfGpYZw.js} +50 -17
  15. package/dist/{composables-CpBhNKHf.js.map → composables-DyfGpYZw.js.map} +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +4 -4
  18. package/dist/install.js +1 -1
  19. package/dist/jobs.d.ts +19 -0
  20. package/dist/styles.css +731 -0
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/jobs.d.ts +49 -0
  23. package/dist/{useProtocolTemplates-C8-YlHj1.js → useJobsStatusTray-DAH999_N.js} +250 -2
  24. package/dist/useJobsStatusTray-DAH999_N.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/components/JobsStatusTray.test.ts +141 -0
  27. package/src/__tests__/composables/useJobsStatusTray.test.ts +241 -0
  28. package/src/__tests__/composables/usePluginClient.test.ts +291 -0
  29. package/src/__tests__/utils/jobs.test.ts +95 -0
  30. package/src/components/JobsStatusTray.story.vue +120 -0
  31. package/src/components/JobsStatusTray.vue +385 -0
  32. package/src/components/index.ts +1 -0
  33. package/src/composables/index.ts +8 -0
  34. package/src/composables/useJobsStatusTray.ts +304 -0
  35. package/src/composables/usePluginClient.ts +65 -15
  36. package/src/index.ts +3 -0
  37. package/src/jobs.ts +75 -0
  38. package/src/styles/components/jobs-status-tray.css +420 -0
  39. package/src/styles/index.css +1 -0
  40. package/src/types/index.ts +10 -0
  41. package/src/types/jobs.ts +61 -0
  42. package/dist/components-C7UFkNIp.js.map +0 -1
  43. package/dist/useProtocolTemplates-C8-YlHj1.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,141 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import JobsStatusTray from '../../components/JobsStatusTray.vue'
5
+ import type { JobStateInput, JobStatus } from '../../types/jobs'
6
+
7
+ function job(status: JobStatus, id: string = status): JobStateInput {
8
+ return {
9
+ id,
10
+ job_id: id,
11
+ kind: 'analysis',
12
+ name: `Job ${id}`,
13
+ status,
14
+ progress: {
15
+ current_file: 0,
16
+ total_files: 0,
17
+ current_filename: null,
18
+ percent: 0,
19
+ message: '',
20
+ stage: status,
21
+ },
22
+ queue_position: 0,
23
+ warnings: [],
24
+ error: null,
25
+ output_path: null,
26
+ save_path: null,
27
+ session_id: null,
28
+ folders: [],
29
+ folder_path: null,
30
+ user_id: '1',
31
+ owner_user_id: '1',
32
+ experiment_id: null,
33
+ extraction_result_id: null,
34
+ metadata: {},
35
+ created_at: '2026-07-14T10:00:00+00:00',
36
+ started_at: null,
37
+ completed_at: null,
38
+ }
39
+ }
40
+
41
+ async function openTray(options: {
42
+ jobs: JobStateInput[]
43
+ adapter?: Record<string, unknown>
44
+ }) {
45
+ const wrapper = mount(JobsStatusTray, {
46
+ attachTo: document.body,
47
+ props: {
48
+ ...options,
49
+ loadOnMount: false,
50
+ teleportTo: false,
51
+ },
52
+ })
53
+ await wrapper.get('.mint-jobs-tray__trigger').trigger('click')
54
+ return wrapper
55
+ }
56
+
57
+ describe('JobsStatusTray', () => {
58
+ it('shows awaiting input as active without default actions', async () => {
59
+ const wrapper = await openTray({ jobs: [job('awaiting_input')] })
60
+
61
+ expect([
62
+ wrapper.text().includes('Waiting for input'),
63
+ wrapper.find('[aria-label="Cancel job"]').exists(),
64
+ wrapper.find('[aria-label="Delete job"]').exists(),
65
+ ]).toEqual([true, false, false])
66
+ })
67
+
68
+ it('shows only actions supported by both capability and adapter', async () => {
69
+ const wrapper = await openTray({
70
+ jobs: [
71
+ { ...job('awaiting_input', 'input'), can_cancel: true },
72
+ { ...job('completed', 'done'), can_delete: true },
73
+ ],
74
+ adapter: {
75
+ cancelJob: vi.fn(),
76
+ deleteJob: vi.fn(),
77
+ },
78
+ })
79
+
80
+ expect([
81
+ wrapper.find('[aria-label="Cancel job"]').exists(),
82
+ wrapper.find('[aria-label="Delete job"]').exists(),
83
+ ]).toEqual([true, false])
84
+
85
+ await wrapper.get('[role="tab"][data-tab="history"]').trigger('click')
86
+ expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
87
+ })
88
+
89
+ it('exposes dialog state and closes on Escape', async () => {
90
+ const wrapper = await openTray({ jobs: [job('running')] })
91
+ const trigger = wrapper.get('.mint-jobs-tray__trigger')
92
+
93
+ await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
94
+
95
+ expect([trigger.attributes('aria-expanded'), wrapper.find('[role="dialog"]').exists()]).toEqual([
96
+ 'false',
97
+ false,
98
+ ])
99
+ })
100
+
101
+ it('renders an individual delete action for an active capable job', async () => {
102
+ const wrapper = await openTray({
103
+ jobs: [{ ...job('awaiting_input'), can_delete: true }],
104
+ adapter: { deleteJob: vi.fn() },
105
+ })
106
+
107
+ expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
108
+ })
109
+
110
+ it('traps forward Tab navigation inside the modal dialog', async () => {
111
+ const wrapper = await openTray({ jobs: [job('running')] })
112
+ const focusable = wrapper.findAll('button')
113
+ const firstInsidePanel = wrapper.get('[aria-label="Close jobs"]')
114
+ const lastInsidePanel = focusable[focusable.length - 1]!
115
+ lastInsidePanel.element.focus()
116
+
117
+ await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Tab' })
118
+
119
+ expect(document.activeElement).toBe(firstInsidePanel.element)
120
+ })
121
+
122
+ it('traps reverse Tab navigation from the dialog container', async () => {
123
+ const wrapper = await openTray({ jobs: [job('running')] })
124
+ const panel = wrapper.get('[role="dialog"]')
125
+ const focusable = panel.findAll('button')
126
+ const panelElement = panel.element as HTMLElement
127
+ panelElement.focus()
128
+
129
+ await panel.trigger('keydown', { key: 'Tab', shiftKey: true })
130
+
131
+ expect(document.activeElement).toBe(focusable[focusable.length - 1]!.element)
132
+ })
133
+
134
+ it('exposes job selection through a keyboard-accessible button', async () => {
135
+ const wrapper = await openTray({ jobs: [job('running')] })
136
+
137
+ await wrapper.get('.mint-jobs-tray__job-select').trigger('click')
138
+
139
+ expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ job_id: 'running' })
140
+ })
141
+ })
@@ -0,0 +1,241 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { defineComponent, nextTick, ref, shallowRef } from 'vue'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { useJobsStatusTray, type UseJobsStatusTrayOptions } from '../../composables/useJobsStatusTray'
6
+ import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
7
+
8
+ function job(status: JobStatus, id: string = status): JobStateInput {
9
+ return {
10
+ id,
11
+ job_id: id,
12
+ kind: 'analysis',
13
+ name: id,
14
+ status,
15
+ progress: {
16
+ current_file: 0,
17
+ total_files: 0,
18
+ current_filename: null,
19
+ percent: status === 'running' ? 42 : 0,
20
+ message: '',
21
+ stage: status,
22
+ },
23
+ queue_position: 0,
24
+ warnings: [],
25
+ error: null,
26
+ output_path: null,
27
+ save_path: null,
28
+ session_id: null,
29
+ folders: [],
30
+ folder_path: null,
31
+ user_id: '1',
32
+ owner_user_id: '1',
33
+ experiment_id: null,
34
+ extraction_result_id: null,
35
+ metadata: {},
36
+ created_at: '2026-07-14T10:00:00+00:00',
37
+ started_at: null,
38
+ completed_at: null,
39
+ }
40
+ }
41
+
42
+ function mountComposable(options: UseJobsStatusTrayOptions) {
43
+ let tray!: ReturnType<typeof useJobsStatusTray>
44
+ const wrapper = mount(defineComponent({
45
+ setup() {
46
+ tray = useJobsStatusTray(options)
47
+ return () => null
48
+ },
49
+ }))
50
+ return { tray, wrapper }
51
+ }
52
+
53
+ function deferred<T>(): {
54
+ promise: Promise<T>
55
+ resolve: (value: T) => void
56
+ } {
57
+ let resolve!: (value: T) => void
58
+ const promise = new Promise<T>((done) => {
59
+ resolve = done
60
+ })
61
+ return { promise, resolve }
62
+ }
63
+
64
+ describe('useJobsStatusTray', () => {
65
+ it('keeps awaiting input jobs in the active group', () => {
66
+ const { tray } = mountComposable({
67
+ jobs: [job('awaiting_input')],
68
+ loadOnMount: false,
69
+ })
70
+
71
+ expect(tray.activeJobs.value.map(item => item.job_id)).toEqual(['awaiting_input'])
72
+ })
73
+
74
+ it('replaces stale jobs when an SSE snapshot arrives', () => {
75
+ const { tray } = mountComposable({ jobs: [job('running', 'old')], loadOnMount: false })
76
+
77
+ tray.applyStreamMessage({
78
+ event: 'snapshot',
79
+ data: { jobs: [job('queued', 'new')] },
80
+ })
81
+
82
+ expect(tray.jobs.value.map(item => item.job_id)).toEqual(['new'])
83
+ })
84
+
85
+ it('moves an SSE terminal update into history', () => {
86
+ const { tray } = mountComposable({ jobs: [job('running', 'fit')], loadOnMount: false })
87
+
88
+ tray.applyStreamMessage({ event: 'terminal', data: job('completed', 'fit') })
89
+
90
+ expect(tray.historyJobs.value.map(item => item.job_id)).toEqual(['fit'])
91
+ })
92
+
93
+ it('does not call a cancel adapter when the capability is disabled', async () => {
94
+ const cancelJob = vi.fn()
95
+ const { tray } = mountComposable({
96
+ jobs: [{ ...job('running'), can_cancel: false }],
97
+ adapter: { cancelJob },
98
+ loadOnMount: false,
99
+ })
100
+
101
+ await tray.cancelJob(tray.jobs.value[0]!)
102
+
103
+ expect(cancelJob).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('does not let a stale list response overwrite an SSE update', async () => {
107
+ let resolveList!: (jobs: JobStateInput[]) => void
108
+ const listJobs = vi.fn(() => new Promise<JobStateInput[]>((resolve) => {
109
+ resolveList = resolve
110
+ }))
111
+ const { tray } = mountComposable({
112
+ adapter: { listJobs },
113
+ loadOnMount: false,
114
+ })
115
+
116
+ const refresh = tray.refresh()
117
+ tray.applyStreamMessage({ event: 'job', data: job('running', 'live') })
118
+ resolveList([job('queued', 'stale')])
119
+ await refresh
120
+ await nextTick()
121
+
122
+ expect(tray.jobs.value.map(item => item.job_id)).toEqual(['live'])
123
+ })
124
+
125
+ it('passes only explicitly deletable jobs to the bulk clear adapter', async () => {
126
+ const clearFinishedJobs = vi.fn(async (_jobs: readonly JobState[]) => undefined)
127
+ const { tray } = mountComposable({
128
+ jobs: [
129
+ job('completed', 'legacy-deletable'),
130
+ { ...job('failed', 'retained'), can_delete: false },
131
+ ],
132
+ adapter: { clearFinishedJobs },
133
+ loadOnMount: false,
134
+ })
135
+
136
+ await tray.clearFinished()
137
+
138
+ expect(clearFinishedJobs.mock.calls[0]![0].map(item => item.job_id)).toEqual([
139
+ 'legacy-deletable',
140
+ ])
141
+ })
142
+
143
+ it('does not include active deletable jobs in bulk clear targets', () => {
144
+ const { tray } = mountComposable({
145
+ jobs: [{ ...job('awaiting_input'), can_delete: true }],
146
+ loadOnMount: false,
147
+ })
148
+
149
+ expect(tray.deletableJobs.value).toEqual([])
150
+ })
151
+
152
+ it('does not let a late cancel response overwrite a newer SSE state', async () => {
153
+ const pending = deferred<JobStateInput>()
154
+ const { tray } = mountComposable({
155
+ jobs: [job('running', 'fit')],
156
+ adapter: { cancelJob: async () => pending.promise },
157
+ loadOnMount: false,
158
+ })
159
+
160
+ const cancellation = tray.cancelJob(tray.jobs.value[0]!)
161
+ tray.applyStreamMessage({ event: 'terminal', data: job('completed', 'fit') })
162
+ pending.resolve(job('cancelled', 'fit'))
163
+ await cancellation
164
+
165
+ expect(tray.jobs.value[0]!.status).toBe('completed')
166
+ })
167
+
168
+ it('applies a cancel response after an unrelated job update', async () => {
169
+ const pending = deferred<JobStateInput>()
170
+ const { tray } = mountComposable({
171
+ jobs: [job('running', 'fit'), job('running', 'export')],
172
+ adapter: { cancelJob: async () => pending.promise },
173
+ loadOnMount: false,
174
+ })
175
+
176
+ const cancellation = tray.cancelJob(tray.jobs.value[0]!)
177
+ tray.applyStreamMessage({ event: 'job', data: job('running', 'export') })
178
+ pending.resolve(job('cancelled', 'fit'))
179
+ await cancellation
180
+
181
+ expect(tray.jobs.value.find(item => item.job_id === 'fit')?.status).toBe('cancelled')
182
+ })
183
+
184
+ it('refreshes instead of removing every target after a partial bulk clear', async () => {
185
+ const listJobs = vi.fn(async () => [job('completed', 'retained')])
186
+ const clearFinishedJobs = vi.fn(async () => 1)
187
+ const { tray } = mountComposable({
188
+ jobs: [job('completed', 'deleted'), job('completed', 'retained')],
189
+ adapter: { listJobs, clearFinishedJobs },
190
+ loadOnMount: false,
191
+ })
192
+
193
+ const cleared = await tray.clearFinished()
194
+
195
+ expect([
196
+ cleared,
197
+ listJobs.mock.calls.length,
198
+ tray.jobs.value.map(item => item.job_id),
199
+ ]).toEqual([false, 1, ['retained']])
200
+ })
201
+
202
+ it('uses a replacement reactive adapter for later actions', async () => {
203
+ const first = vi.fn(async () => undefined)
204
+ const second = vi.fn(async () => undefined)
205
+ const adapter = shallowRef({ cancelJob: first })
206
+ const { tray } = mountComposable({
207
+ jobs: [job('running')],
208
+ adapter,
209
+ loadOnMount: false,
210
+ })
211
+ adapter.value = { cancelJob: second }
212
+
213
+ await tray.cancelJob(tray.jobs.value[0]!)
214
+
215
+ expect([first.mock.calls.length, second.mock.calls.length]).toEqual([0, 1])
216
+ })
217
+
218
+ it('applies the latest message from a replacement stream immediately', async () => {
219
+ const eventStream = shallowRef({
220
+ isConnected: ref(true),
221
+ isConnecting: ref(false),
222
+ error: ref<string | null>(null),
223
+ lastMessage: shallowRef({
224
+ event: 'snapshot',
225
+ data: { jobs: [job('queued', 'replacement')] },
226
+ raw: '',
227
+ }),
228
+ lastEventId: ref<string | null>(null),
229
+ start: vi.fn(),
230
+ stop: vi.fn(),
231
+ })
232
+
233
+ const { tray } = mountComposable({
234
+ eventStream,
235
+ loadOnMount: false,
236
+ })
237
+ await nextTick()
238
+
239
+ expect(tray.jobs.value.map(item => item.job_id)).toEqual(['replacement'])
240
+ })
241
+ })
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createPinia, setActivePinia } from 'pinia'
3
3
  import axios, { type AxiosRequestConfig } from 'axios'
4
+ import { watch } from 'vue'
4
5
 
5
6
  import {
6
7
  buildPluginEndpointUrl,
@@ -30,6 +31,33 @@ const contract: PluginContract = {
30
31
  hash: 'sha256:test',
31
32
  }
32
33
 
34
+ function deferred<T>(): {
35
+ promise: Promise<T>
36
+ resolve: (value: T) => void
37
+ } {
38
+ let resolve!: (value: T) => void
39
+ const promise = new Promise<T>((done) => {
40
+ resolve = done
41
+ })
42
+ return { promise, resolve }
43
+ }
44
+
45
+ function pendingEventStreamResponse(): {
46
+ response: Response
47
+ close: () => void
48
+ } {
49
+ let streamController!: ReadableStreamDefaultController<Uint8Array>
50
+ const body = new ReadableStream<Uint8Array>({
51
+ start(controller) {
52
+ streamController = controller
53
+ },
54
+ })
55
+ return {
56
+ response: new Response(body, { status: 200 }),
57
+ close: () => streamController.close(),
58
+ }
59
+ }
60
+
33
61
  describe('usePluginClient', () => {
34
62
  let requestConfigs: AxiosRequestConfig[] = []
35
63
  let requestBodies: unknown[] = []
@@ -99,6 +127,7 @@ describe('usePluginClient', () => {
99
127
  afterEach(() => {
100
128
  vi.useRealTimers()
101
129
  vi.restoreAllMocks()
130
+ vi.unstubAllGlobals()
102
131
  vi.unstubAllEnvs()
103
132
  window.history.replaceState({}, '', '/')
104
133
  delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
@@ -522,6 +551,268 @@ describe('usePluginClient', () => {
522
551
  expect(stream.lastEventId.value).toBe('7')
523
552
  })
524
553
 
554
+ it('parses generated typed event streams as JSON by default', async () => {
555
+ const onMessage = vi.fn()
556
+ const streamBody = new ReadableStream({
557
+ start(controller) {
558
+ controller.enqueue(new TextEncoder().encode('event: job\ndata: {"job_id":"fit"}\n\n'))
559
+ controller.close()
560
+ },
561
+ })
562
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(streamBody, { status: 200 })))
563
+
564
+ usePluginEventStream<{ job_id: string }>(
565
+ contract,
566
+ {
567
+ method: 'get',
568
+ path: '/jobs/events',
569
+ eventStream: true,
570
+ responseType: 'JobStatePayload | JobSnapshotPayload',
571
+ },
572
+ { reconnect: false, onMessage },
573
+ )
574
+
575
+ await vi.waitFor(() => {
576
+ expect(onMessage).toHaveBeenCalledOnce()
577
+ })
578
+ expect(onMessage.mock.calls[0]![0].data).toEqual({ job_id: 'fit' })
579
+ })
580
+
581
+ it('should deliver terminal enrichment on the original generated stream', async () => {
582
+ type JobStreamData = {
583
+ jobs?: Array<Record<string, unknown>>
584
+ job_id?: string
585
+ can_delete?: boolean
586
+ output_path?: string | null
587
+ }
588
+ const onMessage = vi.fn()
589
+ const streamBody = new ReadableStream({
590
+ start(controller) {
591
+ controller.enqueue(new TextEncoder().encode(
592
+ 'event: snapshot\ndata: {"jobs":[{"job_id":"fit","can_delete":false,"output_path":null}]}\n\n'
593
+ + 'event: terminal\ndata: {"job_id":"fit","can_delete":true,"output_path":"/results/final.csv"}\n\n',
594
+ ))
595
+ controller.close()
596
+ },
597
+ })
598
+ const fetchMock = vi.fn(async () => new Response(streamBody, { status: 200 }))
599
+ vi.stubGlobal('fetch', fetchMock)
600
+
601
+ const stream = usePluginEventStream<JobStreamData>(
602
+ contract,
603
+ {
604
+ method: 'get',
605
+ path: '/jobs/events',
606
+ eventStream: true,
607
+ responseType: 'JobStatePayload | JobSnapshotPayload',
608
+ },
609
+ { reconnect: false, onMessage },
610
+ )
611
+
612
+ await vi.waitFor(() => {
613
+ expect({
614
+ fetches: fetchMock.mock.calls.length,
615
+ events: onMessage.mock.calls.map(([message]) => message.event),
616
+ last: stream.lastMessage.value?.data,
617
+ }).toEqual({
618
+ fetches: 1,
619
+ events: ['snapshot', 'terminal'],
620
+ last: {
621
+ job_id: 'fit',
622
+ can_delete: true,
623
+ output_path: '/results/final.csv',
624
+ },
625
+ })
626
+ })
627
+ })
628
+
629
+ it('keeps generated string event streams as text by default', async () => {
630
+ const onMessage = vi.fn()
631
+ const streamBody = new ReadableStream({
632
+ start(controller) {
633
+ controller.enqueue(new TextEncoder().encode('event: ready\ndata: ready\n\n'))
634
+ controller.close()
635
+ },
636
+ })
637
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(streamBody, { status: 200 })))
638
+
639
+ usePluginEventStream(
640
+ contract,
641
+ {
642
+ method: 'get',
643
+ path: '/jobs/events',
644
+ eventStream: true,
645
+ responseType: 'string',
646
+ },
647
+ { reconnect: false, onMessage },
648
+ )
649
+
650
+ await vi.waitFor(() => {
651
+ expect(onMessage).toHaveBeenCalledOnce()
652
+ })
653
+ expect(onMessage.mock.calls[0]![0].data).toBe('ready')
654
+ })
655
+
656
+ it('should keep start idempotent when an event stream is active', async () => {
657
+ const active = pendingEventStreamResponse()
658
+ const fetchMock = vi.fn(async () => active.response)
659
+ vi.stubGlobal('fetch', fetchMock)
660
+ const stream = usePluginEventStream(
661
+ contract,
662
+ { method: 'get', path: '/downloads/events' },
663
+ { immediate: false },
664
+ )
665
+
666
+ try {
667
+ stream.start()
668
+ stream.start()
669
+ await Promise.resolve()
670
+
671
+ expect(fetchMock).toHaveBeenCalledOnce()
672
+ } finally {
673
+ stream.stop()
674
+ active.close()
675
+ }
676
+ })
677
+
678
+ it('should keep replacement stream connected when an old connection finishes', async () => {
679
+ const oldFetch = deferred<Response>()
680
+ const replacement = pendingEventStreamResponse()
681
+ const fetchMock = vi.fn()
682
+ .mockReturnValueOnce(oldFetch.promise)
683
+ .mockResolvedValueOnce(replacement.response)
684
+ vi.stubGlobal('fetch', fetchMock)
685
+ const stream = usePluginEventStream(
686
+ contract,
687
+ { method: 'get', path: '/downloads/events' },
688
+ { immediate: false },
689
+ )
690
+
691
+ try {
692
+ stream.start()
693
+ stream.stop()
694
+ stream.start()
695
+ await Promise.resolve()
696
+ await Promise.resolve()
697
+ oldFetch.resolve(new Response('', { status: 200 }))
698
+ await Promise.resolve()
699
+ await Promise.resolve()
700
+
701
+ expect(stream.isConnected.value).toBe(true)
702
+ } finally {
703
+ stream.stop()
704
+ replacement.close()
705
+ }
706
+ })
707
+
708
+ it('should not reconnect after a stale event stream finishes', async () => {
709
+ vi.useFakeTimers()
710
+ const oldFetch = deferred<Response>()
711
+ const replacement = pendingEventStreamResponse()
712
+ const fetchMock = vi.fn()
713
+ .mockReturnValueOnce(oldFetch.promise)
714
+ .mockResolvedValueOnce(replacement.response)
715
+ .mockResolvedValue(new Response('', { status: 200 }))
716
+ vi.stubGlobal('fetch', fetchMock)
717
+ const stream = usePluginEventStream(
718
+ contract,
719
+ { method: 'get', path: '/downloads/events' },
720
+ { immediate: false, reconnectDelayMs: 10 },
721
+ )
722
+
723
+ try {
724
+ stream.start()
725
+ stream.stop()
726
+ stream.start()
727
+ await Promise.resolve()
728
+ oldFetch.resolve(new Response('', { status: 200 }))
729
+ await Promise.resolve()
730
+ await vi.advanceTimersByTimeAsync(10)
731
+
732
+ expect(fetchMock).toHaveBeenCalledTimes(2)
733
+ } finally {
734
+ stream.stop()
735
+ replacement.close()
736
+ }
737
+ })
738
+
739
+ it('should not call onOpen after a synchronous restart invalidates the stream', async () => {
740
+ const oldConnection = pendingEventStreamResponse()
741
+ const replacement = pendingEventStreamResponse()
742
+ const fetchMock = vi.fn()
743
+ .mockResolvedValueOnce(oldConnection.response)
744
+ .mockResolvedValueOnce(replacement.response)
745
+ vi.stubGlobal('fetch', fetchMock)
746
+ const onOpen = vi.fn()
747
+ const stream = usePluginEventStream(
748
+ contract,
749
+ { method: 'get', path: '/downloads/events' },
750
+ { immediate: false, onOpen },
751
+ )
752
+ let restarted = false
753
+ const stopWatching = watch(
754
+ stream.isConnected,
755
+ (connected) => {
756
+ if (!connected || restarted) return
757
+ restarted = true
758
+ stream.stop()
759
+ stream.start()
760
+ },
761
+ { flush: 'sync' },
762
+ )
763
+
764
+ try {
765
+ stream.start()
766
+ await Promise.resolve()
767
+ await Promise.resolve()
768
+ await Promise.resolve()
769
+
770
+ expect(onOpen).toHaveBeenCalledOnce()
771
+ } finally {
772
+ stopWatching()
773
+ stream.stop()
774
+ oldConnection.close()
775
+ replacement.close()
776
+ }
777
+ })
778
+
779
+ it('should not fetch after a synchronous setup restart invalidates the stream', async () => {
780
+ const replacement = pendingEventStreamResponse()
781
+ const stale = pendingEventStreamResponse()
782
+ const fetchMock = vi.fn()
783
+ .mockResolvedValueOnce(replacement.response)
784
+ .mockResolvedValueOnce(stale.response)
785
+ vi.stubGlobal('fetch', fetchMock)
786
+ const stream = usePluginEventStream(
787
+ contract,
788
+ { method: 'get', path: '/downloads/events' },
789
+ { immediate: false },
790
+ )
791
+ let restarted = false
792
+ const stopWatching = watch(
793
+ stream.isConnecting,
794
+ (connecting) => {
795
+ if (!connecting || restarted) return
796
+ restarted = true
797
+ stream.stop()
798
+ stream.start()
799
+ },
800
+ { flush: 'sync' },
801
+ )
802
+
803
+ try {
804
+ stream.start()
805
+ await Promise.resolve()
806
+
807
+ expect(fetchMock).toHaveBeenCalledOnce()
808
+ } finally {
809
+ stopWatching()
810
+ stream.stop()
811
+ replacement.close()
812
+ stale.close()
813
+ }
814
+ })
815
+
525
816
  it('can build path-only endpoint URLs with inferred experiment ids', () => {
526
817
  window.history.replaceState({}, '', '/experiments/42/plugins/drp')
527
818