@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.
- package/README.md +9 -7
- package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DyPAUCLw.js} +3 -3
- package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DyPAUCLw.js.map} +1 -1
- package/dist/{SettingsModal-BW3Ar-uy.js → SettingsModal-BgKY93zh.js} +4 -4
- package/dist/{SettingsModal-BW3Ar-uy.js.map → SettingsModal-BgKY93zh.js.map} +1 -1
- package/dist/__tests__/composables/useEventStream.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +2 -9
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/{components-DQt0tcpY.js → components-DkHddRMl.js} +10 -16
- package/dist/components-DkHddRMl.js.map +1 -0
- package/dist/composables/index.d.ts +2 -3
- package/dist/composables/index.js +8 -8
- package/dist/composables/useEventStream.d.ts +43 -0
- package/dist/composables/useGeneratedAnalysis.d.ts +1 -3
- package/dist/composables/useJobsStatusTray.d.ts +1 -25
- package/dist/composables/usePluginClient.d.ts +3 -18
- package/dist/{composables-CkXDe7nm.js → composables-C_i90sGS.js} +32 -308
- package/dist/composables-C_i90sGS.js.map +1 -0
- package/dist/{experiment-Y9Iz7KYi.js → experiment-Cq145oEW.js} +2 -2
- package/dist/{experiment-Y9Iz7KYi.js.map → experiment-Cq145oEW.js.map} +1 -1
- package/dist/index.js +12 -12
- package/dist/install.js +4 -4
- package/dist/stores/auth.d.ts +0 -6
- package/dist/stores/index.js +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/{templates-CovvRZ-9.js → templates-DsP51i-S.js} +2 -2
- package/dist/{templates-CovvRZ-9.js.map → templates-DsP51i-S.js.map} +1 -1
- package/dist/types/auth.d.ts +0 -1
- package/dist/{useApi-CDBq6z23.js → useApi-J6zFkHmn.js} +1 -2
- package/dist/useApi-J6zFkHmn.js.map +1 -0
- package/dist/{useControlSchema-BhoJzWzx.js → useControlSchema-BDx2Olha.js} +239 -103
- package/dist/useControlSchema-BDx2Olha.js.map +1 -0
- package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-CV4iNfMS.js} +2 -2
- package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-CV4iNfMS.js.map} +1 -1
- package/dist/{useFormBuilder-DiEcDis2.js → useFormBuilder-BLxG3BOX.js} +2 -2
- package/dist/{useFormBuilder-DiEcDis2.js.map → useFormBuilder-BLxG3BOX.js.map} +1 -1
- package/dist/{useProtocolTemplates-DHwSveE6.js → useProtocolTemplates-CS_SQl6U.js} +22 -113
- package/dist/useProtocolTemplates-CS_SQl6U.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/GeneratedPluginView.test.ts +0 -5
- package/src/__tests__/components/JobsStatusTray.test.ts +20 -18
- package/src/__tests__/composables/useAuth.test.ts +23 -0
- package/src/__tests__/composables/useEventStream.test.ts +140 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +103 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +25 -161
- package/src/__tests__/composables/usePluginClient.test.ts +8 -13
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +22 -1
- package/src/components/JobsStatusTray.story.vue +6 -10
- package/src/components/JobsStatusTray.vue +1 -16
- package/src/composables/index.ts +0 -7
- package/src/composables/useAuth.ts +0 -3
- package/src/composables/useEventStream.ts +314 -0
- package/src/composables/useGeneratedAnalysis.ts +40 -109
- package/src/composables/useJobsStatusTray.ts +17 -184
- package/src/composables/usePluginClient.ts +45 -249
- package/src/stores/auth.ts +0 -1
- package/src/types/auth.ts +0 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
- package/dist/components-DQt0tcpY.js.map +0 -1
- package/dist/composables/usePluginConfig.d.ts +0 -25
- package/dist/composables-CkXDe7nm.js.map +0 -1
- package/dist/useApi-CDBq6z23.js.map +0 -1
- package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
- package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
- package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
- 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.
|
|
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
|
-
|
|
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
|
|
72
|
+
it('shows only actions supported by both capability and source', async () => {
|
|
73
73
|
const wrapper = await openTray({
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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,
|
|
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
|
|
8
|
+
type UsePluginJobCenterOptions,
|
|
10
9
|
} from '../../composables/useJobsStatusTray'
|
|
11
|
-
import type {
|
|
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:
|
|
48
|
-
let tray!: ReturnType<typeof
|
|
46
|
+
function mountComposable(options: UsePluginJobCenterOptions) {
|
|
47
|
+
let tray!: ReturnType<typeof usePluginJobCenter>
|
|
49
48
|
const wrapper = mount(defineComponent({
|
|
50
49
|
setup() {
|
|
51
|
-
tray =
|
|
50
|
+
tray = usePluginJobCenter(options)
|
|
52
51
|
return () => null
|
|
53
52
|
},
|
|
54
53
|
}))
|
|
55
54
|
return { tray, wrapper }
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
|
|
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('
|
|
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
|
-
|
|
275
|
-
|
|
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('
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
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.
|
|
266
|
+
await tray.refresh()
|
|
267
|
+
await nextTick()
|
|
315
268
|
|
|
316
|
-
expect(
|
|
317
|
-
|
|
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
|
})
|