@morscherlab/mint-sdk 1.0.51 → 1.0.52
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.
- package/dist/__tests__/components/JobsStatusTray.test.d.ts +1 -0
- package/dist/__tests__/composables/useJobsStatusTray.test.d.ts +1 -0
- package/dist/__tests__/utils/jobs.test.d.ts +1 -0
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +26 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-C7UFkNIp.js → components-Lk_h_rON.js} +1663 -1339
- package/dist/components-Lk_h_rON.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +3 -3
- package/dist/composables/useJobsStatusTray.d.ts +43 -0
- package/dist/composables/usePluginClient.d.ts +4 -1
- package/dist/{composables-CpBhNKHf.js → composables-DyfGpYZw.js} +50 -17
- package/dist/{composables-CpBhNKHf.js.map → composables-DyfGpYZw.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -4
- package/dist/install.js +1 -1
- package/dist/jobs.d.ts +19 -0
- package/dist/styles.css +731 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/jobs.d.ts +49 -0
- package/dist/{useProtocolTemplates-C8-YlHj1.js → useJobsStatusTray-DAH999_N.js} +250 -2
- package/dist/useJobsStatusTray-DAH999_N.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/JobsStatusTray.test.ts +141 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +241 -0
- package/src/__tests__/composables/usePluginClient.test.ts +243 -0
- package/src/__tests__/utils/jobs.test.ts +95 -0
- package/src/components/JobsStatusTray.story.vue +120 -0
- package/src/components/JobsStatusTray.vue +385 -0
- package/src/components/index.ts +1 -0
- package/src/composables/index.ts +8 -0
- package/src/composables/useJobsStatusTray.ts +304 -0
- package/src/composables/usePluginClient.ts +65 -15
- package/src/index.ts +3 -0
- package/src/jobs.ts +75 -0
- package/src/styles/components/jobs-status-tray.css +420 -0
- package/src/styles/index.css +1 -0
- package/src/types/index.ts +10 -0
- package/src/types/jobs.ts +61 -0
- package/dist/components-C7UFkNIp.js.map +0 -1
- 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.
|
|
3
|
+
"version": "1.0.52",
|
|
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,220 @@ 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('keeps generated string event streams as text by default', async () => {
|
|
582
|
+
const onMessage = vi.fn()
|
|
583
|
+
const streamBody = new ReadableStream({
|
|
584
|
+
start(controller) {
|
|
585
|
+
controller.enqueue(new TextEncoder().encode('event: ready\ndata: ready\n\n'))
|
|
586
|
+
controller.close()
|
|
587
|
+
},
|
|
588
|
+
})
|
|
589
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(streamBody, { status: 200 })))
|
|
590
|
+
|
|
591
|
+
usePluginEventStream(
|
|
592
|
+
contract,
|
|
593
|
+
{
|
|
594
|
+
method: 'get',
|
|
595
|
+
path: '/jobs/events',
|
|
596
|
+
eventStream: true,
|
|
597
|
+
responseType: 'string',
|
|
598
|
+
},
|
|
599
|
+
{ reconnect: false, onMessage },
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
await vi.waitFor(() => {
|
|
603
|
+
expect(onMessage).toHaveBeenCalledOnce()
|
|
604
|
+
})
|
|
605
|
+
expect(onMessage.mock.calls[0]![0].data).toBe('ready')
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('should keep start idempotent when an event stream is active', async () => {
|
|
609
|
+
const active = pendingEventStreamResponse()
|
|
610
|
+
const fetchMock = vi.fn(async () => active.response)
|
|
611
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
612
|
+
const stream = usePluginEventStream(
|
|
613
|
+
contract,
|
|
614
|
+
{ method: 'get', path: '/downloads/events' },
|
|
615
|
+
{ immediate: false },
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
stream.start()
|
|
620
|
+
stream.start()
|
|
621
|
+
await Promise.resolve()
|
|
622
|
+
|
|
623
|
+
expect(fetchMock).toHaveBeenCalledOnce()
|
|
624
|
+
} finally {
|
|
625
|
+
stream.stop()
|
|
626
|
+
active.close()
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('should keep replacement stream connected when an old connection finishes', async () => {
|
|
631
|
+
const oldFetch = deferred<Response>()
|
|
632
|
+
const replacement = pendingEventStreamResponse()
|
|
633
|
+
const fetchMock = vi.fn()
|
|
634
|
+
.mockReturnValueOnce(oldFetch.promise)
|
|
635
|
+
.mockResolvedValueOnce(replacement.response)
|
|
636
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
637
|
+
const stream = usePluginEventStream(
|
|
638
|
+
contract,
|
|
639
|
+
{ method: 'get', path: '/downloads/events' },
|
|
640
|
+
{ immediate: false },
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
stream.start()
|
|
645
|
+
stream.stop()
|
|
646
|
+
stream.start()
|
|
647
|
+
await Promise.resolve()
|
|
648
|
+
await Promise.resolve()
|
|
649
|
+
oldFetch.resolve(new Response('', { status: 200 }))
|
|
650
|
+
await Promise.resolve()
|
|
651
|
+
await Promise.resolve()
|
|
652
|
+
|
|
653
|
+
expect(stream.isConnected.value).toBe(true)
|
|
654
|
+
} finally {
|
|
655
|
+
stream.stop()
|
|
656
|
+
replacement.close()
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('should not reconnect after a stale event stream finishes', async () => {
|
|
661
|
+
vi.useFakeTimers()
|
|
662
|
+
const oldFetch = deferred<Response>()
|
|
663
|
+
const replacement = pendingEventStreamResponse()
|
|
664
|
+
const fetchMock = vi.fn()
|
|
665
|
+
.mockReturnValueOnce(oldFetch.promise)
|
|
666
|
+
.mockResolvedValueOnce(replacement.response)
|
|
667
|
+
.mockResolvedValue(new Response('', { status: 200 }))
|
|
668
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
669
|
+
const stream = usePluginEventStream(
|
|
670
|
+
contract,
|
|
671
|
+
{ method: 'get', path: '/downloads/events' },
|
|
672
|
+
{ immediate: false, reconnectDelayMs: 10 },
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
stream.start()
|
|
677
|
+
stream.stop()
|
|
678
|
+
stream.start()
|
|
679
|
+
await Promise.resolve()
|
|
680
|
+
oldFetch.resolve(new Response('', { status: 200 }))
|
|
681
|
+
await Promise.resolve()
|
|
682
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
683
|
+
|
|
684
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
685
|
+
} finally {
|
|
686
|
+
stream.stop()
|
|
687
|
+
replacement.close()
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('should not call onOpen after a synchronous restart invalidates the stream', async () => {
|
|
692
|
+
const oldConnection = pendingEventStreamResponse()
|
|
693
|
+
const replacement = pendingEventStreamResponse()
|
|
694
|
+
const fetchMock = vi.fn()
|
|
695
|
+
.mockResolvedValueOnce(oldConnection.response)
|
|
696
|
+
.mockResolvedValueOnce(replacement.response)
|
|
697
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
698
|
+
const onOpen = vi.fn()
|
|
699
|
+
const stream = usePluginEventStream(
|
|
700
|
+
contract,
|
|
701
|
+
{ method: 'get', path: '/downloads/events' },
|
|
702
|
+
{ immediate: false, onOpen },
|
|
703
|
+
)
|
|
704
|
+
let restarted = false
|
|
705
|
+
const stopWatching = watch(
|
|
706
|
+
stream.isConnected,
|
|
707
|
+
(connected) => {
|
|
708
|
+
if (!connected || restarted) return
|
|
709
|
+
restarted = true
|
|
710
|
+
stream.stop()
|
|
711
|
+
stream.start()
|
|
712
|
+
},
|
|
713
|
+
{ flush: 'sync' },
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
stream.start()
|
|
718
|
+
await Promise.resolve()
|
|
719
|
+
await Promise.resolve()
|
|
720
|
+
await Promise.resolve()
|
|
721
|
+
|
|
722
|
+
expect(onOpen).toHaveBeenCalledOnce()
|
|
723
|
+
} finally {
|
|
724
|
+
stopWatching()
|
|
725
|
+
stream.stop()
|
|
726
|
+
oldConnection.close()
|
|
727
|
+
replacement.close()
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('should not fetch after a synchronous setup restart invalidates the stream', async () => {
|
|
732
|
+
const replacement = pendingEventStreamResponse()
|
|
733
|
+
const stale = pendingEventStreamResponse()
|
|
734
|
+
const fetchMock = vi.fn()
|
|
735
|
+
.mockResolvedValueOnce(replacement.response)
|
|
736
|
+
.mockResolvedValueOnce(stale.response)
|
|
737
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
738
|
+
const stream = usePluginEventStream(
|
|
739
|
+
contract,
|
|
740
|
+
{ method: 'get', path: '/downloads/events' },
|
|
741
|
+
{ immediate: false },
|
|
742
|
+
)
|
|
743
|
+
let restarted = false
|
|
744
|
+
const stopWatching = watch(
|
|
745
|
+
stream.isConnecting,
|
|
746
|
+
(connecting) => {
|
|
747
|
+
if (!connecting || restarted) return
|
|
748
|
+
restarted = true
|
|
749
|
+
stream.stop()
|
|
750
|
+
stream.start()
|
|
751
|
+
},
|
|
752
|
+
{ flush: 'sync' },
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
try {
|
|
756
|
+
stream.start()
|
|
757
|
+
await Promise.resolve()
|
|
758
|
+
|
|
759
|
+
expect(fetchMock).toHaveBeenCalledOnce()
|
|
760
|
+
} finally {
|
|
761
|
+
stopWatching()
|
|
762
|
+
stream.stop()
|
|
763
|
+
replacement.close()
|
|
764
|
+
stale.close()
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
|
|
525
768
|
it('can build path-only endpoint URLs with inferred experiment ids', () => {
|
|
526
769
|
window.history.replaceState({}, '', '/experiments/42/plugins/drp')
|
|
527
770
|
|