@morscherlab/mint-sdk 1.1.24 → 1.2.0-beta.1

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 (74) hide show
  1. package/README.md +9 -7
  2. package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DyPAUCLw.js} +3 -3
  3. package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DyPAUCLw.js.map} +1 -1
  4. package/dist/{SettingsModal-DoS2fzyz.js → SettingsModal-BgKY93zh.js} +5 -4
  5. package/dist/SettingsModal-BgKY93zh.js.map +1 -0
  6. package/dist/__tests__/composables/useEventStream.test.d.ts +1 -0
  7. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  8. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  9. package/dist/components/JobsStatusTray.vue.d.ts +29 -10
  10. package/dist/components/SampleLegend.vue.d.ts +1 -1
  11. package/dist/components/SampleSelector.vue.d.ts +1 -1
  12. package/dist/components/SettingsModal.vue.d.ts +1 -1
  13. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
  14. package/dist/components/index.js +4 -4
  15. package/dist/{components-CW3hE1T3.js → components-DkHddRMl.js} +39 -40
  16. package/dist/components-DkHddRMl.js.map +1 -0
  17. package/dist/composables/index.d.ts +2 -3
  18. package/dist/composables/index.js +8 -8
  19. package/dist/composables/useEventStream.d.ts +43 -0
  20. package/dist/composables/useGeneratedAnalysis.d.ts +1 -3
  21. package/dist/composables/useJobsStatusTray.d.ts +1 -25
  22. package/dist/composables/usePluginClient.d.ts +3 -18
  23. package/dist/{composables-CkXDe7nm.js → composables-C_i90sGS.js} +32 -308
  24. package/dist/composables-C_i90sGS.js.map +1 -0
  25. package/dist/{experiment-Y9Iz7KYi.js → experiment-Cq145oEW.js} +2 -2
  26. package/dist/{experiment-Y9Iz7KYi.js.map → experiment-Cq145oEW.js.map} +1 -1
  27. package/dist/index.js +12 -12
  28. package/dist/install.js +4 -4
  29. package/dist/stores/auth.d.ts +0 -6
  30. package/dist/stores/index.js +2 -2
  31. package/dist/templates/index.js +2 -2
  32. package/dist/{templates-CovvRZ-9.js → templates-DsP51i-S.js} +2 -2
  33. package/dist/{templates-CovvRZ-9.js.map → templates-DsP51i-S.js.map} +1 -1
  34. package/dist/types/auth.d.ts +0 -1
  35. package/dist/{useApi-CDBq6z23.js → useApi-J6zFkHmn.js} +1 -2
  36. package/dist/useApi-J6zFkHmn.js.map +1 -0
  37. package/dist/{useControlSchema-BhoJzWzx.js → useControlSchema-BDx2Olha.js} +239 -103
  38. package/dist/useControlSchema-BDx2Olha.js.map +1 -0
  39. package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-CV4iNfMS.js} +2 -2
  40. package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-CV4iNfMS.js.map} +1 -1
  41. package/dist/{useFormBuilder-DiEcDis2.js → useFormBuilder-BLxG3BOX.js} +2 -2
  42. package/dist/{useFormBuilder-DiEcDis2.js.map → useFormBuilder-BLxG3BOX.js.map} +1 -1
  43. package/dist/{useProtocolTemplates-DHwSveE6.js → useProtocolTemplates-CS_SQl6U.js} +22 -113
  44. package/dist/useProtocolTemplates-CS_SQl6U.js.map +1 -0
  45. package/package.json +1 -1
  46. package/src/__tests__/components/GeneratedPluginView.test.ts +0 -5
  47. package/src/__tests__/components/JobsStatusTray.test.ts +43 -19
  48. package/src/__tests__/composables/useAuth.test.ts +23 -0
  49. package/src/__tests__/composables/useEventStream.test.ts +140 -0
  50. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +103 -0
  51. package/src/__tests__/composables/useJobsStatusTray.test.ts +25 -161
  52. package/src/__tests__/composables/usePluginClient.test.ts +8 -13
  53. package/src/__tests__/docs/frontendDocsCatalog.test.ts +22 -1
  54. package/src/components/JobsStatusTray.story.vue +16 -10
  55. package/src/components/JobsStatusTray.vue +22 -28
  56. package/src/components/SettingsModal.schema.ts +3 -0
  57. package/src/composables/index.ts +0 -7
  58. package/src/composables/useAuth.ts +0 -3
  59. package/src/composables/useEventStream.ts +314 -0
  60. package/src/composables/useGeneratedAnalysis.ts +40 -109
  61. package/src/composables/useJobsStatusTray.ts +17 -184
  62. package/src/composables/usePluginClient.ts +45 -249
  63. package/src/stores/auth.ts +0 -1
  64. package/src/types/auth.ts +0 -1
  65. package/dist/SettingsModal-DoS2fzyz.js.map +0 -1
  66. package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
  67. package/dist/components-CW3hE1T3.js.map +0 -1
  68. package/dist/composables/usePluginConfig.d.ts +0 -25
  69. package/dist/composables-CkXDe7nm.js.map +0 -1
  70. package/dist/useApi-CDBq6z23.js.map +0 -1
  71. package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
  72. package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
  73. package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
  74. package/src/composables/usePluginConfig.ts +0 -250
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.24",
3
+ "version": "1.2.0-beta.1",
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",
@@ -65,7 +65,6 @@ vi.mock('../../composables/useGeneratedAnalysis', async () => {
65
65
  loadResult: generated.loadResult,
66
66
  resultForJob: generated.resultForJob,
67
67
  refreshJobs: vi.fn(),
68
- jobsAdapter: {},
69
68
  })
70
69
  },
71
70
  }
@@ -146,8 +145,6 @@ describe('GeneratedPluginView', () => {
146
145
  result: wrapper.findComponent(GeneratedResultRenderer).text(),
147
146
  jobs: wrapper.findComponent(JobsStatusTray).exists(),
148
147
  jobSource: wrapper.findComponent(JobsStatusTray).props('source'),
149
- legacyJobs: wrapper.findComponent(JobsStatusTray).props('jobs'),
150
- legacyAdapter: wrapper.findComponent(JobsStatusTray).props('adapter'),
151
148
  }).toMatchObject({
152
149
  workspace: {
153
150
  title: 'Demo analysis',
@@ -159,8 +156,6 @@ describe('GeneratedPluginView', () => {
159
156
  result: 'complete',
160
157
  jobs: true,
161
158
  jobSource: expect.any(Object),
162
- legacyJobs: undefined,
163
- legacyAdapter: undefined,
164
159
  })
165
160
  })
166
161
 
@@ -1,8 +1,10 @@
1
1
  import { mount } from '@vue/test-utils'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
+ import { h } from 'vue'
3
4
 
4
5
  import JobsStatusTray from '../../components/JobsStatusTray.vue'
5
- import type { JobStateInput, JobStatus } from '../../types/jobs'
6
+ import type { PluginJobCenterSource } from '../../composables/useJobsStatusTray'
7
+ import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
6
8
 
7
9
  function job(status: JobStatus, id: string = status): JobStateInput {
8
10
  return {
@@ -39,16 +41,18 @@ function job(status: JobStatus, id: string = status): JobStateInput {
39
41
  }
40
42
 
41
43
  async function openTray(options: {
42
- jobs: JobStateInput[]
43
- adapter?: Record<string, unknown>
44
+ source: PluginJobCenterSource
45
+ slots?: Record<string, (props: { job: JobState }) => unknown>
44
46
  }) {
47
+ const { slots, ...props } = options
45
48
  const wrapper = mount(JobsStatusTray, {
46
49
  attachTo: document.body,
47
50
  props: {
48
- ...options,
51
+ ...props,
49
52
  loadOnMount: false,
50
53
  teleportTo: false,
51
54
  },
55
+ slots,
52
56
  })
53
57
  await wrapper.get('.mint-jobs-tray__trigger').trigger('click')
54
58
  return wrapper
@@ -56,7 +60,7 @@ async function openTray(options: {
56
60
 
57
61
  describe('JobsStatusTray', () => {
58
62
  it('shows awaiting input as active without default actions', async () => {
59
- const wrapper = await openTray({ jobs: [job('awaiting_input')] })
63
+ const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } })
60
64
 
61
65
  expect([
62
66
  wrapper.text().includes('Waiting for input'),
@@ -65,15 +69,15 @@ describe('JobsStatusTray', () => {
65
69
  ]).toEqual([true, false, false])
66
70
  })
67
71
 
68
- it('shows only actions supported by both capability and adapter', async () => {
72
+ it('shows only actions supported by both capability and source', async () => {
69
73
  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(),
74
+ source: {
75
+ jobs: [
76
+ { ...job('awaiting_input', 'input'), can_cancel: true },
77
+ { ...job('completed', 'done'), can_delete: true },
78
+ ],
79
+ cancelJob: vi.fn(async () => undefined),
80
+ deleteJob: vi.fn(async () => true),
77
81
  },
78
82
  })
79
83
 
@@ -87,7 +91,7 @@ describe('JobsStatusTray', () => {
87
91
  })
88
92
 
89
93
  it('exposes dialog state and closes on Escape', async () => {
90
- const wrapper = await openTray({ jobs: [job('running')] })
94
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
91
95
  const trigger = wrapper.get('.mint-jobs-tray__trigger')
92
96
 
93
97
  await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
@@ -100,15 +104,17 @@ describe('JobsStatusTray', () => {
100
104
 
101
105
  it('renders an individual delete action for an active capable job', async () => {
102
106
  const wrapper = await openTray({
103
- jobs: [{ ...job('awaiting_input'), can_delete: true }],
104
- adapter: { deleteJob: vi.fn() },
107
+ source: {
108
+ jobs: [{ ...job('awaiting_input'), can_delete: true }],
109
+ deleteJob: vi.fn(async () => true),
110
+ },
105
111
  })
106
112
 
107
113
  expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
108
114
  })
109
115
 
110
116
  it('traps forward Tab navigation inside the modal dialog', async () => {
111
- const wrapper = await openTray({ jobs: [job('running')] })
117
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
112
118
  const focusable = wrapper.findAll('button')
113
119
  const firstInsidePanel = wrapper.get('[aria-label="Close jobs"]')
114
120
  const lastInsidePanel = focusable[focusable.length - 1]!
@@ -120,7 +126,7 @@ describe('JobsStatusTray', () => {
120
126
  })
121
127
 
122
128
  it('traps reverse Tab navigation from the dialog container', async () => {
123
- const wrapper = await openTray({ jobs: [job('running')] })
129
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
124
130
  const panel = wrapper.get('[role="dialog"]')
125
131
  const focusable = panel.findAll('button')
126
132
  const panelElement = panel.element as HTMLElement
@@ -132,10 +138,28 @@ describe('JobsStatusTray', () => {
132
138
  })
133
139
 
134
140
  it('exposes job selection through a keyboard-accessible button', async () => {
135
- const wrapper = await openTray({ jobs: [job('running')] })
141
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
136
142
 
137
143
  await wrapper.get('.mint-jobs-tray__job-select').trigger('click')
138
144
 
139
145
  expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ job_id: 'running' })
140
146
  })
147
+
148
+ it('renders the job-actions slot once per row in both tabs with the row job', async () => {
149
+ const wrapper = await openTray({
150
+ source: { jobs: [job('running', 'R-1'), job('completed', 'C-1')] },
151
+ slots: {
152
+ 'job-actions': ({ job: row }) =>
153
+ h('button', { 'data-testid': 'open', 'data-job': row.job_id }, 'Open'),
154
+ },
155
+ })
156
+
157
+ const active = wrapper.findAll('[data-testid="open"]')
158
+ expect(active.map((b) => b.attributes('data-job'))).toEqual(['R-1'])
159
+
160
+ await wrapper.get('[role="tab"][data-tab="history"]').trigger('click')
161
+
162
+ const history = wrapper.findAll('[data-testid="open"]')
163
+ expect(history.map((b) => b.attributes('data-job'))).toEqual(['C-1'])
164
+ })
141
165
  })
@@ -50,6 +50,29 @@ describe('useAuth', () => {
50
50
  vi.restoreAllMocks()
51
51
  })
52
52
 
53
+ it('loads the PostgreSQL-only public auth contract', async () => {
54
+ mockedAxios.get.mockResolvedValueOnce({
55
+ data: {
56
+ auth_required: true,
57
+ passkey_enabled: true,
58
+ passkey_registered: false,
59
+ registration_enabled: true,
60
+ access: { experiment_visibility_mode: 'restricted' },
61
+ },
62
+ })
63
+
64
+ const config = await useAuth().fetchAuthConfig()
65
+ const expected = {
66
+ authRequired: true,
67
+ passkeyEnabled: true,
68
+ passkeyRegistered: false,
69
+ registrationEnabled: true,
70
+ experimentVisibilityMode: 'restricted',
71
+ }
72
+
73
+ expect([config, useAuthStore().authConfig]).toEqual([expected, expected])
74
+ })
75
+
53
76
  describe('login', () => {
54
77
  it('should set token on successful login', async () => {
55
78
  mockedAxios.post.mockResolvedValueOnce({
@@ -0,0 +1,140 @@
1
+ import { effectScope } from 'vue'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { useEventStream } from '../../composables/useEventStream'
5
+
6
+ function pendingStreamResponse(): {
7
+ response: Response
8
+ close: () => void
9
+ } {
10
+ let close!: () => void
11
+ const body = new ReadableStream<Uint8Array>({
12
+ start(controller) {
13
+ close = () => {
14
+ try {
15
+ controller.close()
16
+ } catch {
17
+ // stop() may already have cancelled the reader.
18
+ }
19
+ }
20
+ },
21
+ })
22
+ return { response: new Response(body), close }
23
+ }
24
+
25
+ describe('useEventStream', () => {
26
+ afterEach(() => {
27
+ vi.useRealTimers()
28
+ })
29
+
30
+ it('should decode chunked UTF-8 and dispatch an unterminated multiline tail frame', async () => {
31
+ const encoded = new TextEncoder().encode(
32
+ ': keepalive\nevent: result\nid: event-7\ndata: first\ndata: 2 µmol',
33
+ )
34
+ const split = encoded.indexOf(0xc2) + 1
35
+ const body = new ReadableStream<Uint8Array>({
36
+ start(controller) {
37
+ controller.enqueue(encoded.slice(0, split))
38
+ controller.enqueue(encoded.slice(split))
39
+ controller.close()
40
+ },
41
+ })
42
+ const onMessage = vi.fn()
43
+ const scope = effectScope()
44
+ const stream = scope.run(() => useEventStream('/events', {
45
+ transport: vi.fn(async () => new Response(body)),
46
+ reconnect: false,
47
+ onMessage,
48
+ }))!
49
+
50
+ await vi.waitFor(() => expect(onMessage).toHaveBeenCalledOnce())
51
+
52
+ expect(stream.lastMessage.value).toEqual({
53
+ event: 'result',
54
+ data: 'first\n2 µmol',
55
+ id: 'event-7',
56
+ retry: undefined,
57
+ raw: ': keepalive\nevent: result\nid: event-7\ndata: first\ndata: 2 µmol',
58
+ })
59
+ scope.stop()
60
+ })
61
+
62
+ it('should keep server retry delay local and send Last-Event-ID on reconnect', async () => {
63
+ vi.useFakeTimers()
64
+ const first = new Response('id: event-1\nretry: 7\ndata: first\n\n')
65
+ const second = pendingStreamResponse()
66
+ const transport = vi.fn()
67
+ .mockResolvedValueOnce(first)
68
+ .mockResolvedValueOnce(second.response)
69
+ const options = {
70
+ transport,
71
+ reconnectDelayMs: 1_000,
72
+ }
73
+ const scope = effectScope()
74
+ const stream = scope.run(() => useEventStream('/events', options))!
75
+
76
+ await vi.waitFor(() => expect(stream.lastEventId.value).toBe('event-1'))
77
+ await vi.advanceTimersByTimeAsync(7)
78
+
79
+ const secondHeaders = new Headers(transport.mock.calls[1]?.[1]?.headers)
80
+ expect({
81
+ attempts: transport.mock.calls.length,
82
+ lastEventId: secondHeaders.get('Last-Event-ID'),
83
+ configuredDelay: options.reconnectDelayMs,
84
+ }).toEqual({
85
+ attempts: 2,
86
+ lastEventId: 'event-1',
87
+ configuredDelay: 1_000,
88
+ })
89
+ stream.stop()
90
+ second.close()
91
+ scope.stop()
92
+ })
93
+
94
+ it('should apply control-only frames without dispatching a data message', async () => {
95
+ vi.useFakeTimers()
96
+ const second = pendingStreamResponse()
97
+ const transport = vi.fn()
98
+ .mockResolvedValueOnce(new Response('id: event-2\nretry: 7\n\n'))
99
+ .mockResolvedValueOnce(second.response)
100
+ const onMessage = vi.fn()
101
+ const scope = effectScope()
102
+ const stream = scope.run(() => useEventStream('/events', {
103
+ transport,
104
+ reconnectDelayMs: 1_000,
105
+ onMessage,
106
+ }))!
107
+
108
+ await vi.waitFor(() => expect(stream.lastEventId.value).toBe('event-2'))
109
+ await vi.advanceTimersByTimeAsync(7)
110
+
111
+ expect({
112
+ messages: onMessage.mock.calls.length,
113
+ reconnectCursor: new Headers(transport.mock.calls[1]?.[1]?.headers).get('Last-Event-ID'),
114
+ }).toEqual({ messages: 0, reconnectCursor: 'event-2' })
115
+ stream.stop()
116
+ second.close()
117
+ scope.stop()
118
+ })
119
+
120
+ it('should reconnect after an ordinary stream close', async () => {
121
+ vi.useFakeTimers()
122
+ const second = pendingStreamResponse()
123
+ const transport = vi.fn()
124
+ .mockResolvedValueOnce(new Response('data: first\n\n'))
125
+ .mockResolvedValueOnce(second.response)
126
+ const scope = effectScope()
127
+ const stream = scope.run(() => useEventStream('/events', {
128
+ transport,
129
+ reconnectDelayMs: 5,
130
+ }))!
131
+
132
+ await vi.waitFor(() => expect(stream.lastMessage.value?.data).toBe('first'))
133
+ await vi.advanceTimersByTimeAsync(5)
134
+
135
+ expect(transport).toHaveBeenCalledTimes(2)
136
+ stream.stop()
137
+ second.close()
138
+ scope.stop()
139
+ })
140
+ })
@@ -126,6 +126,28 @@ function activeJob(
126
126
  }
127
127
  }
128
128
 
129
+ function streamingJobTransport(): ReturnType<typeof vi.fn<GeneratedAnalysisTransport>> {
130
+ return vi.fn<GeneratedAnalysisTransport>(async (input, init) => {
131
+ const url = String(input)
132
+ if (url.endsWith('/generated/manifest')) return response(manifest)
133
+ if (url.endsWith('/jobs/sessions') && init?.method === 'GET') {
134
+ return response({ sessions: [] })
135
+ }
136
+ if (url.endsWith('/jobs/sessions') && init?.method === 'POST') {
137
+ return response({
138
+ id: 'session-1',
139
+ tab_id: 'tab-1',
140
+ created_at: '',
141
+ last_seen_at: '',
142
+ active_job_ids: [],
143
+ })
144
+ }
145
+ if (url.endsWith('/jobs/events')) return eventStream()
146
+ if (url.endsWith('/jobs')) return response({ jobs: [activeJob('job-1')] })
147
+ throw new Error(`Unexpected request: ${url}`)
148
+ })
149
+ }
150
+
129
151
  describe('useGeneratedAnalysis', () => {
130
152
  it('is directly usable as a job center source', () => {
131
153
  const scope = effectScope()
@@ -283,6 +305,87 @@ describe('useGeneratedAnalysis', () => {
283
305
  }
284
306
  })
285
307
 
308
+ it('should poll active jobs while a closed stream waits to reconnect', async () => {
309
+ vi.useFakeTimers()
310
+ const transport = streamingJobTransport()
311
+ const scope = effectScope()
312
+ const generated = scope.run(() => useGeneratedAnalysis({
313
+ baseUrl: '/api/demo',
314
+ transport,
315
+ storage: memoryStorage(),
316
+ createId: () => 'tab-1',
317
+ pollInterval: 5,
318
+ streamReconnectDelay: 1_000,
319
+ }))!
320
+
321
+ try {
322
+ await generated.initialize()
323
+ await vi.advanceTimersByTimeAsync(5)
324
+
325
+ expect(transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs'))).toHaveLength(2)
326
+ } finally {
327
+ generated.dispose()
328
+ scope.stop()
329
+ vi.useRealTimers()
330
+ }
331
+ })
332
+
333
+ it('should not poll or reconnect a paused job stream', async () => {
334
+ vi.useFakeTimers()
335
+ const transport = streamingJobTransport()
336
+ const scope = effectScope()
337
+ const generated = scope.run(() => useGeneratedAnalysis({
338
+ baseUrl: '/api/demo',
339
+ transport,
340
+ storage: memoryStorage(),
341
+ createId: () => 'tab-1',
342
+ pollInterval: 5,
343
+ streamReconnectDelay: 10,
344
+ }))!
345
+
346
+ try {
347
+ await generated.initialize()
348
+ await vi.advanceTimersByTimeAsync(0)
349
+ generated.pause()
350
+ await vi.advanceTimersByTimeAsync(20)
351
+
352
+ expect({
353
+ jobLists: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs')).length,
354
+ streams: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs/events')).length,
355
+ }).toEqual({ jobLists: 1, streams: 1 })
356
+ } finally {
357
+ generated.dispose()
358
+ scope.stop()
359
+ vi.useRealTimers()
360
+ }
361
+ })
362
+
363
+ it('should not poll or reconnect a disposed job stream', async () => {
364
+ vi.useFakeTimers()
365
+ const transport = streamingJobTransport()
366
+ const scope = effectScope()
367
+ const generated = scope.run(() => useGeneratedAnalysis({
368
+ baseUrl: '/api/demo',
369
+ transport,
370
+ storage: memoryStorage(),
371
+ createId: () => 'tab-1',
372
+ pollInterval: 5,
373
+ streamReconnectDelay: 10,
374
+ }))!
375
+
376
+ await generated.initialize()
377
+ await vi.advanceTimersByTimeAsync(0)
378
+ generated.dispose()
379
+ await vi.advanceTimersByTimeAsync(20)
380
+
381
+ expect({
382
+ jobLists: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs')).length,
383
+ streams: transport.mock.calls.filter(([input]) => String(input).endsWith('/jobs/events')).length,
384
+ }).toEqual({ jobLists: 1, streams: 1 })
385
+ scope.stop()
386
+ vi.useRealTimers()
387
+ })
388
+
286
389
  it('does not let a late submit response regress a terminal stream update', async () => {
287
390
  const terminal = completedJob('fast-service-job')
288
391
  const lateQueued = activeJob('fast-service-job')