@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.
- 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-DoS2fzyz.js → SettingsModal-BgKY93zh.js} +5 -4
- package/dist/SettingsModal-BgKY93zh.js.map +1 -0
- 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 +29 -10
- 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-CW3hE1T3.js → components-DkHddRMl.js} +39 -40
- 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 +43 -19
- 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 +16 -10
- package/src/components/JobsStatusTray.vue +22 -28
- package/src/components/SettingsModal.schema.ts +3 -0
- 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/SettingsModal-DoS2fzyz.js.map +0 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
- package/dist/components-CW3hE1T3.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.1
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
...
|
|
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
|
|
72
|
+
it('shows only actions supported by both capability and source', async () => {
|
|
69
73
|
const wrapper = await openTray({
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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')
|