@morscherlab/mint-sdk 1.1.25 → 1.2.0-beta.2

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 (72) 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-BW3Ar-uy.js → SettingsModal-BgKY93zh.js} +4 -4
  5. package/dist/{SettingsModal-BW3Ar-uy.js.map → SettingsModal-BgKY93zh.js.map} +1 -1
  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 +2 -9
  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-DQt0tcpY.js → components-DkHddRMl.js} +10 -16
  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 +20 -18
  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 +6 -10
  55. package/src/components/JobsStatusTray.vue +1 -16
  56. package/src/composables/index.ts +0 -7
  57. package/src/composables/useAuth.ts +0 -3
  58. package/src/composables/useEventStream.ts +314 -0
  59. package/src/composables/useGeneratedAnalysis.ts +40 -109
  60. package/src/composables/useJobsStatusTray.ts +17 -184
  61. package/src/composables/usePluginClient.ts +45 -249
  62. package/src/stores/auth.ts +0 -1
  63. package/src/types/auth.ts +0 -1
  64. package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
  65. package/dist/components-DQt0tcpY.js.map +0 -1
  66. package/dist/composables/usePluginConfig.d.ts +0 -25
  67. package/dist/composables-CkXDe7nm.js.map +0 -1
  68. package/dist/useApi-CDBq6z23.js.map +0 -1
  69. package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
  70. package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
  71. package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
  72. 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.25",
3
+ "version": "1.2.0-beta.2",
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
 
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
3
3
  import { h } from 'vue'
4
4
 
5
5
  import JobsStatusTray from '../../components/JobsStatusTray.vue'
6
+ import type { PluginJobCenterSource } from '../../composables/useJobsStatusTray'
6
7
  import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
7
8
 
8
9
  function job(status: JobStatus, id: string = status): JobStateInput {
@@ -40,8 +41,7 @@ function job(status: JobStatus, id: string = status): JobStateInput {
40
41
  }
41
42
 
42
43
  async function openTray(options: {
43
- jobs: JobStateInput[]
44
- adapter?: Record<string, unknown>
44
+ source: PluginJobCenterSource
45
45
  slots?: Record<string, (props: { job: JobState }) => unknown>
46
46
  }) {
47
47
  const { slots, ...props } = options
@@ -60,7 +60,7 @@ async function openTray(options: {
60
60
 
61
61
  describe('JobsStatusTray', () => {
62
62
  it('shows awaiting input as active without default actions', async () => {
63
- const wrapper = await openTray({ jobs: [job('awaiting_input')] })
63
+ const wrapper = await openTray({ source: { jobs: [job('awaiting_input')] } })
64
64
 
65
65
  expect([
66
66
  wrapper.text().includes('Waiting for input'),
@@ -69,15 +69,15 @@ describe('JobsStatusTray', () => {
69
69
  ]).toEqual([true, false, false])
70
70
  })
71
71
 
72
- it('shows only actions supported by both capability and adapter', async () => {
72
+ it('shows only actions supported by both capability and source', async () => {
73
73
  const wrapper = await openTray({
74
- jobs: [
75
- { ...job('awaiting_input', 'input'), can_cancel: true },
76
- { ...job('completed', 'done'), can_delete: true },
77
- ],
78
- adapter: {
79
- cancelJob: vi.fn(),
80
- 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),
81
81
  },
82
82
  })
83
83
 
@@ -91,7 +91,7 @@ describe('JobsStatusTray', () => {
91
91
  })
92
92
 
93
93
  it('exposes dialog state and closes on Escape', async () => {
94
- const wrapper = await openTray({ jobs: [job('running')] })
94
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
95
95
  const trigger = wrapper.get('.mint-jobs-tray__trigger')
96
96
 
97
97
  await wrapper.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
@@ -104,15 +104,17 @@ describe('JobsStatusTray', () => {
104
104
 
105
105
  it('renders an individual delete action for an active capable job', async () => {
106
106
  const wrapper = await openTray({
107
- jobs: [{ ...job('awaiting_input'), can_delete: true }],
108
- adapter: { deleteJob: vi.fn() },
107
+ source: {
108
+ jobs: [{ ...job('awaiting_input'), can_delete: true }],
109
+ deleteJob: vi.fn(async () => true),
110
+ },
109
111
  })
110
112
 
111
113
  expect(wrapper.find('[aria-label="Delete job"]').exists()).toBe(true)
112
114
  })
113
115
 
114
116
  it('traps forward Tab navigation inside the modal dialog', async () => {
115
- const wrapper = await openTray({ jobs: [job('running')] })
117
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
116
118
  const focusable = wrapper.findAll('button')
117
119
  const firstInsidePanel = wrapper.get('[aria-label="Close jobs"]')
118
120
  const lastInsidePanel = focusable[focusable.length - 1]!
@@ -124,7 +126,7 @@ describe('JobsStatusTray', () => {
124
126
  })
125
127
 
126
128
  it('traps reverse Tab navigation from the dialog container', async () => {
127
- const wrapper = await openTray({ jobs: [job('running')] })
129
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
128
130
  const panel = wrapper.get('[role="dialog"]')
129
131
  const focusable = panel.findAll('button')
130
132
  const panelElement = panel.element as HTMLElement
@@ -136,7 +138,7 @@ describe('JobsStatusTray', () => {
136
138
  })
137
139
 
138
140
  it('exposes job selection through a keyboard-accessible button', async () => {
139
- const wrapper = await openTray({ jobs: [job('running')] })
141
+ const wrapper = await openTray({ source: { jobs: [job('running')] } })
140
142
 
141
143
  await wrapper.get('.mint-jobs-tray__job-select').trigger('click')
142
144
 
@@ -145,7 +147,7 @@ describe('JobsStatusTray', () => {
145
147
 
146
148
  it('renders the job-actions slot once per row in both tabs with the row job', async () => {
147
149
  const wrapper = await openTray({
148
- jobs: [job('running', 'R-1'), job('completed', 'C-1')],
150
+ source: { jobs: [job('running', 'R-1'), job('completed', 'C-1')] },
149
151
  slots: {
150
152
  'job-actions': ({ job: row }) =>
151
153
  h('button', { 'data-testid': 'open', 'data-job': row.job_id }, 'Open'),
@@ -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')
@@ -1,14 +1,13 @@
1
1
  import { mount } from '@vue/test-utils'
2
- import { defineComponent, nextTick, ref, shallowRef } from 'vue'
2
+ import { defineComponent, nextTick, shallowRef } from 'vue'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import {
6
6
  combinePluginJobSources,
7
- useJobsStatusTray,
8
7
  usePluginJobCenter,
9
- type UseJobsStatusTrayOptions,
8
+ type UsePluginJobCenterOptions,
10
9
  } from '../../composables/useJobsStatusTray'
11
- import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
10
+ import type { JobStateInput, JobStatus } from '../../types/jobs'
12
11
 
13
12
  function job(status: JobStatus, id: string = status): JobStateInput {
14
13
  return {
@@ -44,29 +43,18 @@ function job(status: JobStatus, id: string = status): JobStateInput {
44
43
  }
45
44
  }
46
45
 
47
- function mountComposable(options: UseJobsStatusTrayOptions) {
48
- let tray!: ReturnType<typeof useJobsStatusTray>
46
+ function mountComposable(options: UsePluginJobCenterOptions) {
47
+ let tray!: ReturnType<typeof usePluginJobCenter>
49
48
  const wrapper = mount(defineComponent({
50
49
  setup() {
51
- tray = useJobsStatusTray(options)
50
+ tray = usePluginJobCenter(options)
52
51
  return () => null
53
52
  },
54
53
  }))
55
54
  return { tray, wrapper }
56
55
  }
57
56
 
58
- function deferred<T>(): {
59
- promise: Promise<T>
60
- resolve: (value: T) => void
61
- } {
62
- let resolve!: (value: T) => void
63
- const promise = new Promise<T>((done) => {
64
- resolve = done
65
- })
66
- return { promise, resolve }
67
- }
68
-
69
- describe('useJobsStatusTray', () => {
57
+ describe('usePluginJobCenter', () => {
70
58
  it('combines live source jobs and fans out or routes source actions', async () => {
71
59
  const firstJobs = shallowRef([job('running', 'first')])
72
60
  const secondJobs = shallowRef([job('completed', 'second')])
@@ -242,37 +230,20 @@ describe('useJobsStatusTray', () => {
242
230
 
243
231
  it('keeps awaiting input jobs in the active group', () => {
244
232
  const { tray } = mountComposable({
245
- jobs: [job('awaiting_input')],
233
+ source: { jobs: [job('awaiting_input')] },
246
234
  loadOnMount: false,
247
235
  })
248
236
 
249
237
  expect(tray.activeJobs.value.map(item => item.job_id)).toEqual(['awaiting_input'])
250
238
  })
251
239
 
252
- it('replaces stale jobs when an SSE snapshot arrives', () => {
253
- const { tray } = mountComposable({ jobs: [job('running', 'old')], loadOnMount: false })
254
-
255
- tray.applyStreamMessage({
256
- event: 'snapshot',
257
- data: { jobs: [job('queued', 'new')] },
258
- })
259
-
260
- expect(tray.jobs.value.map(item => item.job_id)).toEqual(['new'])
261
- })
262
-
263
- it('moves an SSE terminal update into history', () => {
264
- const { tray } = mountComposable({ jobs: [job('running', 'fit')], loadOnMount: false })
265
-
266
- tray.applyStreamMessage({ event: 'terminal', data: job('completed', 'fit') })
267
-
268
- expect(tray.historyJobs.value.map(item => item.job_id)).toEqual(['fit'])
269
- })
270
-
271
- it('does not call a cancel adapter when the capability is disabled', async () => {
240
+ it('does not call a source cancel action when the capability is disabled', async () => {
272
241
  const cancelJob = vi.fn()
273
242
  const { tray } = mountComposable({
274
- jobs: [{ ...job('running'), can_cancel: false }],
275
- adapter: { cancelJob },
243
+ source: {
244
+ jobs: [{ ...job('running'), can_cancel: false }],
245
+ cancelJob,
246
+ },
276
247
  loadOnMount: false,
277
248
  })
278
249
 
@@ -281,139 +252,32 @@ describe('useJobsStatusTray', () => {
281
252
  expect(cancelJob).not.toHaveBeenCalled()
282
253
  })
283
254
 
284
- it('does not let a stale list response overwrite an SSE update', async () => {
285
- let resolveList!: (jobs: JobStateInput[]) => void
286
- const listJobs = vi.fn(() => new Promise<JobStateInput[]>((resolve) => {
287
- resolveList = resolve
288
- }))
289
- const { tray } = mountComposable({
290
- adapter: { listJobs },
291
- loadOnMount: false,
255
+ it('refreshes through the source without mirroring its jobs', async () => {
256
+ const sourceJobs = shallowRef([job('running', 'old')])
257
+ const refreshJobs = vi.fn(async () => {
258
+ sourceJobs.value = [job('queued', 'new')]
259
+ return sourceJobs.value
292
260
  })
293
-
294
- const refresh = tray.refresh()
295
- tray.applyStreamMessage({ event: 'job', data: job('running', 'live') })
296
- resolveList([job('queued', 'stale')])
297
- await refresh
298
- await nextTick()
299
-
300
- expect(tray.jobs.value.map(item => item.job_id)).toEqual(['live'])
301
- })
302
-
303
- it('passes only explicitly deletable jobs to the bulk clear adapter', async () => {
304
- const clearFinishedJobs = vi.fn(async (_jobs: readonly JobState[]) => undefined)
305
261
  const { tray } = mountComposable({
306
- jobs: [
307
- job('completed', 'legacy-deletable'),
308
- { ...job('failed', 'retained'), can_delete: false },
309
- ],
310
- adapter: { clearFinishedJobs },
262
+ source: { jobs: sourceJobs, refreshJobs },
311
263
  loadOnMount: false,
312
264
  })
313
265
 
314
- await tray.clearFinished()
266
+ await tray.refresh()
267
+ await nextTick()
315
268
 
316
- expect(clearFinishedJobs.mock.calls[0]![0].map(item => item.job_id)).toEqual([
317
- 'legacy-deletable',
269
+ expect([refreshJobs.mock.calls.length, tray.jobs.value.map(item => item.job_id)]).toEqual([
270
+ 1,
271
+ ['new'],
318
272
  ])
319
273
  })
320
274
 
321
275
  it('does not include active deletable jobs in bulk clear targets', () => {
322
276
  const { tray } = mountComposable({
323
- jobs: [{ ...job('awaiting_input'), can_delete: true }],
277
+ source: { jobs: [{ ...job('awaiting_input'), can_delete: true }] },
324
278
  loadOnMount: false,
325
279
  })
326
280
 
327
281
  expect(tray.deletableJobs.value).toEqual([])
328
282
  })
329
-
330
- it('does not let a late cancel response overwrite a newer SSE state', async () => {
331
- const pending = deferred<JobStateInput>()
332
- const { tray } = mountComposable({
333
- jobs: [job('running', 'fit')],
334
- adapter: { cancelJob: async () => pending.promise },
335
- loadOnMount: false,
336
- })
337
-
338
- const cancellation = tray.cancelJob(tray.jobs.value[0]!)
339
- tray.applyStreamMessage({ event: 'terminal', data: job('completed', 'fit') })
340
- pending.resolve(job('cancelled', 'fit'))
341
- await cancellation
342
-
343
- expect(tray.jobs.value[0]!.status).toBe('completed')
344
- })
345
-
346
- it('applies a cancel response after an unrelated job update', async () => {
347
- const pending = deferred<JobStateInput>()
348
- const { tray } = mountComposable({
349
- jobs: [job('running', 'fit'), job('running', 'export')],
350
- adapter: { cancelJob: async () => pending.promise },
351
- loadOnMount: false,
352
- })
353
-
354
- const cancellation = tray.cancelJob(tray.jobs.value[0]!)
355
- tray.applyStreamMessage({ event: 'job', data: job('running', 'export') })
356
- pending.resolve(job('cancelled', 'fit'))
357
- await cancellation
358
-
359
- expect(tray.jobs.value.find(item => item.job_id === 'fit')?.status).toBe('cancelled')
360
- })
361
-
362
- it('refreshes instead of removing every target after a partial bulk clear', async () => {
363
- const listJobs = vi.fn(async () => [job('completed', 'retained')])
364
- const clearFinishedJobs = vi.fn(async () => 1)
365
- const { tray } = mountComposable({
366
- jobs: [job('completed', 'deleted'), job('completed', 'retained')],
367
- adapter: { listJobs, clearFinishedJobs },
368
- loadOnMount: false,
369
- })
370
-
371
- const cleared = await tray.clearFinished()
372
-
373
- expect([
374
- cleared,
375
- listJobs.mock.calls.length,
376
- tray.jobs.value.map(item => item.job_id),
377
- ]).toEqual([false, 1, ['retained']])
378
- })
379
-
380
- it('uses a replacement reactive adapter for later actions', async () => {
381
- const first = vi.fn(async () => undefined)
382
- const second = vi.fn(async () => undefined)
383
- const adapter = shallowRef({ cancelJob: first })
384
- const { tray } = mountComposable({
385
- jobs: [job('running')],
386
- adapter,
387
- loadOnMount: false,
388
- })
389
- adapter.value = { cancelJob: second }
390
-
391
- await tray.cancelJob(tray.jobs.value[0]!)
392
-
393
- expect([first.mock.calls.length, second.mock.calls.length]).toEqual([0, 1])
394
- })
395
-
396
- it('applies the latest message from a replacement stream immediately', async () => {
397
- const eventStream = shallowRef({
398
- isConnected: ref(true),
399
- isConnecting: ref(false),
400
- error: ref<string | null>(null),
401
- lastMessage: shallowRef({
402
- event: 'snapshot',
403
- data: { jobs: [job('queued', 'replacement')] },
404
- raw: '',
405
- }),
406
- lastEventId: ref<string | null>(null),
407
- start: vi.fn(),
408
- stop: vi.fn(),
409
- })
410
-
411
- const { tray } = mountComposable({
412
- eventStream,
413
- loadOnMount: false,
414
- })
415
- await nextTick()
416
-
417
- expect(tray.jobs.value.map(item => item.job_id)).toEqual(['replacement'])
418
- })
419
283
  })