@morscherlab/mint-sdk 1.0.63 → 1.1.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 +101 -2
- package/dist/EmptyState-D1c4RdC7.js +638 -0
- package/dist/EmptyState-D1c4RdC7.js.map +1 -0
- package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
- package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
- package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
- package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
- package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
- package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
- package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
- package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
- package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
- package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
- package/dist/__tests__/generatedUi.test.d.ts +1 -0
- package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
- package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -0
- package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
- package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
- package/dist/chunk-BoAXSpZd.js +33 -0
- package/dist/components/AppSidebar.vue.d.ts +1 -1
- package/dist/components/AutoGroupModal.vue.d.ts +2 -2
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/CollapsibleCard.vue.d.ts +1 -1
- package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
- package/dist/components/ConfirmDialog.vue.d.ts +1 -1
- package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
- package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
- package/dist/components/FileUploader.vue.d.ts +4 -2
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/components/FormActions.vue.d.ts +1 -1
- package/dist/components/FormBuilder.vue.d.ts +1 -1
- package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
- package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
- package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
- package/dist/components/JobsStatusTray.vue.d.ts +5 -1
- package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +2 -2
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +8 -9
- package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
- package/dist/{components-DdzHh3a_.js → components-UfkPN1bK.js} +2255 -2303
- package/dist/components-UfkPN1bK.js.map +1 -0
- package/dist/composables/index.d.ts +2 -3
- package/dist/composables/index.js +10 -11
- package/dist/composables/platformContextHelpers.d.ts +0 -2
- package/dist/composables/useAnalysisArtifacts.d.ts +1 -3
- package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
- package/dist/composables/useJobsStatusTray.d.ts +48 -4
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/usePluginClient.d.ts +6 -3
- package/dist/{composables-BUUphBqg.js → composables-B512bWJP.js} +155 -74
- package/dist/composables-B512bWJP.js.map +1 -0
- package/dist/experiment-utils-CxkruZ3G.js +392 -0
- package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
- package/dist/generated/schema.d.ts +7 -0
- package/dist/generated-ui/main.d.ts +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -18
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -8
- package/dist/install.js.map +1 -1
- package/dist/plotly.min-BR53hja4.js +180634 -0
- package/dist/plotly.min-BR53hja4.js.map +1 -0
- package/dist/stores/auth.d.ts +2 -2
- package/dist/stores/index.js +1 -1
- package/dist/styles.css +10543 -10404
- package/dist/templates/index.js +4 -3
- package/dist/templates/packs.d.ts +0 -1
- package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
- package/dist/templates-Cttbjbyc.js.map +1 -0
- package/dist/types/analysisArtifactTypes.d.ts +0 -8
- package/dist/types/form-builder.d.ts +1 -1
- package/dist/types/generated-ui.d.ts +170 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.js +1 -1
- package/dist/types/instrument.d.ts +1 -0
- package/dist/types/jobs.d.ts +7 -1
- package/dist/types/platform.d.ts +2 -0
- package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
- package/dist/useControlSchema-Dallso-L.js.map +1 -0
- package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
- package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
- package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
- package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
- package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
- package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
- package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-BP7_4RGQ.js} +307 -475
- package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
- package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
- package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
- package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
- package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
- package/src/__tests__/composables/useAnalysisArtifacts.test.ts +1 -74
- package/src/__tests__/composables/useApi.test.ts +8 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
- package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
- package/src/__tests__/composables/usePluginClient.test.ts +366 -20
- package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
- package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
- package/src/__tests__/generatedUi.test.ts +119 -0
- package/src/__tests__/templates/templates.test.ts +1 -1
- package/src/components/FileUploader.vue +49 -11
- package/src/components/GeneratedPathInput.vue +218 -0
- package/src/components/GeneratedPlotlyResult.vue +87 -0
- package/src/components/GeneratedPluginView.vue +279 -0
- package/src/components/GeneratedResultRenderer.vue +137 -0
- package/src/components/InstrumentAlertLog.vue +2 -2
- package/src/components/JobsStatusTray.vue +14 -8
- package/src/components/index.ts +4 -0
- package/src/composables/formBuilderRegistry.ts +2 -0
- package/src/composables/index.ts +18 -5
- package/src/composables/platformContextHelpers.ts +3 -10
- package/src/composables/useAnalysisArtifacts.ts +0 -48
- package/src/composables/useApi.ts +1 -0
- package/src/composables/useGeneratedAnalysis.ts +1252 -0
- package/src/composables/useJobsStatusTray.ts +234 -45
- package/src/composables/usePluginClient.ts +152 -25
- package/src/composables/usePluginConfig.ts +156 -20
- package/src/generated/schema.ts +130 -0
- package/src/generated-ui/main.ts +21 -0
- package/src/index.ts +3 -0
- package/src/jobs.ts +5 -0
- package/src/plotly-dist.d.ts +3 -0
- package/src/templates/packs.ts +0 -3
- package/src/types/analysisArtifactTypes.ts +0 -9
- package/src/types/form-builder.ts +1 -0
- package/src/types/generated-ui.ts +176 -0
- package/src/types/index.ts +2 -1
- package/src/types/instrument.ts +1 -0
- package/src/types/jobs.ts +10 -1
- package/src/types/platform.ts +2 -0
- package/dist/BaseButton-Dgqrze41.js +0 -74
- package/dist/BaseButton-Dgqrze41.js.map +0 -1
- package/dist/BaseModal-B9UA8Y_I.js +0 -165
- package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
- package/dist/BaseSelect-ekgr9fDo.js +0 -179
- package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
- package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
- package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
- package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
- package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
- package/dist/analysisArtifactTypes-Cu40dzSG.js.map +0 -1
- package/dist/components-DdzHh3a_.js.map +0 -1
- package/dist/composables/analysisArtifactTarget.d.ts +0 -5
- package/dist/composables-BUUphBqg.js.map +0 -1
- package/dist/experiment-utils-Bfa7CwPU.js +0 -108
- package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
- package/dist/templates-CNbPQNID.js.map +0 -1
- package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
- package/dist/useControlSchema-BZNdalmL.js.map +0 -1
- package/dist/useEventListener-CfVkP9Xz.js +0 -57
- package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
- package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
- package/src/__tests__/composables/analysisArtifactTarget.test.ts +0 -77
- package/src/composables/analysisArtifactTarget.ts +0 -53
- /package/dist/__tests__/{composables/analysisArtifactTarget.test.d.ts → components/GeneratedPathInput.test.d.ts} +0 -0
|
@@ -0,0 +1,1740 @@
|
|
|
1
|
+
import { effectScope } from 'vue'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { useGeneratedAnalysis } from '../../composables/useGeneratedAnalysis'
|
|
5
|
+
import {
|
|
6
|
+
createGeneratedAnalysisTransport,
|
|
7
|
+
type GeneratedAnalysisTransport,
|
|
8
|
+
} from '../../composables/useGeneratedAnalysis'
|
|
9
|
+
import type { PluginJobCenterSource } from '../../composables/useJobsStatusTray'
|
|
10
|
+
import type {
|
|
11
|
+
GeneratedAnalysisJob,
|
|
12
|
+
GeneratedAnalysisManifest,
|
|
13
|
+
} from '../../types/generated-ui'
|
|
14
|
+
|
|
15
|
+
const manifest: GeneratedAnalysisManifest = {
|
|
16
|
+
schema_version: '1',
|
|
17
|
+
plugin: {
|
|
18
|
+
name: 'Demo',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
description: 'Demo plugin',
|
|
21
|
+
routes_prefix: '/demo',
|
|
22
|
+
},
|
|
23
|
+
ui: { title: 'Demo analysis', description: 'Run it' },
|
|
24
|
+
jobs: [],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function response(body: unknown, status = 200): Response {
|
|
28
|
+
return new Response(JSON.stringify(body), {
|
|
29
|
+
status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function eventStream(...frames: string[]): Response {
|
|
35
|
+
const encoder = new TextEncoder()
|
|
36
|
+
return new Response(new ReadableStream<Uint8Array>({
|
|
37
|
+
start(controller) {
|
|
38
|
+
for (const frame of frames) controller.enqueue(encoder.encode(frame))
|
|
39
|
+
controller.close()
|
|
40
|
+
},
|
|
41
|
+
}), {
|
|
42
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deferred<T>(): {
|
|
47
|
+
promise: Promise<T>
|
|
48
|
+
resolve: (value: T) => void
|
|
49
|
+
} {
|
|
50
|
+
let resolve!: (value: T) => void
|
|
51
|
+
const promise = new Promise<T>((next) => { resolve = next })
|
|
52
|
+
return { promise, resolve }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function memoryStorage(): Storage {
|
|
56
|
+
const values = new Map<string, string>()
|
|
57
|
+
return {
|
|
58
|
+
get length() { return values.size },
|
|
59
|
+
clear: () => values.clear(),
|
|
60
|
+
getItem: key => values.get(key) ?? null,
|
|
61
|
+
key: index => [...values.keys()][index] ?? null,
|
|
62
|
+
removeItem: key => values.delete(key),
|
|
63
|
+
setItem: (key, value) => values.set(key, value),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function completedJob(jobId: string): GeneratedAnalysisJob {
|
|
68
|
+
return {
|
|
69
|
+
id: jobId,
|
|
70
|
+
job_id: jobId,
|
|
71
|
+
kind: 'analysis',
|
|
72
|
+
name: 'Run analysis',
|
|
73
|
+
status: 'completed',
|
|
74
|
+
progress: {
|
|
75
|
+
current_file: 0,
|
|
76
|
+
total_files: 0,
|
|
77
|
+
current_filename: null,
|
|
78
|
+
percent: 100,
|
|
79
|
+
message: '',
|
|
80
|
+
stage: 'completed',
|
|
81
|
+
},
|
|
82
|
+
queue_position: 0,
|
|
83
|
+
warnings: [],
|
|
84
|
+
error: null,
|
|
85
|
+
output_path: null,
|
|
86
|
+
save_path: null,
|
|
87
|
+
session_id: 'session-1',
|
|
88
|
+
folders: [],
|
|
89
|
+
folder_path: null,
|
|
90
|
+
user_id: 'user-1',
|
|
91
|
+
owner_user_id: 'user-1',
|
|
92
|
+
experiment_id: null,
|
|
93
|
+
extraction_result_id: null,
|
|
94
|
+
metadata: { definition_id: 'run', has_result: true },
|
|
95
|
+
created_at: '',
|
|
96
|
+
started_at: '',
|
|
97
|
+
completed_at: '',
|
|
98
|
+
definition_id: 'run',
|
|
99
|
+
can_cancel: false,
|
|
100
|
+
can_delete: true,
|
|
101
|
+
result_status: 'available',
|
|
102
|
+
result_expires_at: null,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function activeJob(
|
|
107
|
+
jobId: string,
|
|
108
|
+
status: 'queued' | 'running' = 'queued',
|
|
109
|
+
): GeneratedAnalysisJob {
|
|
110
|
+
return {
|
|
111
|
+
...completedJob(jobId),
|
|
112
|
+
status,
|
|
113
|
+
progress: {
|
|
114
|
+
current_file: 0,
|
|
115
|
+
total_files: 0,
|
|
116
|
+
current_filename: null,
|
|
117
|
+
percent: 0,
|
|
118
|
+
message: status === 'queued' ? 'Queued' : 'Running',
|
|
119
|
+
stage: status,
|
|
120
|
+
},
|
|
121
|
+
completed_at: null,
|
|
122
|
+
can_cancel: true,
|
|
123
|
+
can_delete: false,
|
|
124
|
+
result_status: 'none',
|
|
125
|
+
metadata: { definition_id: 'run', has_result: false },
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('useGeneratedAnalysis', () => {
|
|
130
|
+
it('is directly usable as a job center source', () => {
|
|
131
|
+
const scope = effectScope()
|
|
132
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
133
|
+
baseUrl: '/api/demo',
|
|
134
|
+
transport: vi.fn<GeneratedAnalysisTransport>(),
|
|
135
|
+
storage: memoryStorage(),
|
|
136
|
+
stream: false,
|
|
137
|
+
}))!
|
|
138
|
+
|
|
139
|
+
const source: PluginJobCenterSource = generated
|
|
140
|
+
expect(source.jobs).toBe(generated.jobs)
|
|
141
|
+
scope.stop()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should report an immediate initialization failure without an unhandled rejection', async () => {
|
|
145
|
+
const scope = effectScope()
|
|
146
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
147
|
+
baseUrl: '/api/demo',
|
|
148
|
+
transport: vi.fn<GeneratedAnalysisTransport>()
|
|
149
|
+
.mockRejectedValue(new TypeError('offline')),
|
|
150
|
+
storage: memoryStorage(),
|
|
151
|
+
immediate: true,
|
|
152
|
+
stream: false,
|
|
153
|
+
}))!
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await vi.waitFor(() => expect(generated.status.value).toBe('error'))
|
|
157
|
+
expect(generated.error.value).toBe('offline')
|
|
158
|
+
} finally {
|
|
159
|
+
scope.stop()
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should initialize with page-local session state when browser storage is unavailable', async () => {
|
|
164
|
+
const unavailableStorage = {
|
|
165
|
+
get length(): number { throw new DOMException('Blocked', 'SecurityError') },
|
|
166
|
+
clear: () => { throw new DOMException('Blocked', 'SecurityError') },
|
|
167
|
+
getItem: () => { throw new DOMException('Blocked', 'SecurityError') },
|
|
168
|
+
key: () => { throw new DOMException('Blocked', 'SecurityError') },
|
|
169
|
+
removeItem: () => { throw new DOMException('Blocked', 'SecurityError') },
|
|
170
|
+
setItem: () => { throw new DOMException('Blocked', 'SecurityError') },
|
|
171
|
+
} satisfies Storage
|
|
172
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
173
|
+
.mockResolvedValueOnce(response(manifest))
|
|
174
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
175
|
+
.mockResolvedValueOnce(response({
|
|
176
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
177
|
+
}))
|
|
178
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
179
|
+
const scope = effectScope()
|
|
180
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
181
|
+
baseUrl: '/api/demo',
|
|
182
|
+
transport,
|
|
183
|
+
storage: unavailableStorage,
|
|
184
|
+
createId: () => 'tab-1',
|
|
185
|
+
stream: false,
|
|
186
|
+
}))!
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
await generated.initialize()
|
|
190
|
+
|
|
191
|
+
expect(generated.session.value?.id).toBe('session-1')
|
|
192
|
+
} finally {
|
|
193
|
+
scope.stop()
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should add the current bearer token to JSON and event-stream requests', async () => {
|
|
198
|
+
const fetcher = vi.fn().mockResolvedValue(response({ ok: true }))
|
|
199
|
+
const transport = createGeneratedAnalysisTransport({
|
|
200
|
+
fetcher,
|
|
201
|
+
getAccessToken: () => 'viewer-token',
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await transport('/api/demo/generated/manifest', {
|
|
205
|
+
headers: { Accept: 'application/json' },
|
|
206
|
+
})
|
|
207
|
+
await transport('/api/demo/jobs/events', {
|
|
208
|
+
headers: { Accept: 'text/event-stream' },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(fetcher.mock.calls.map(call => {
|
|
212
|
+
const headers = new Headers(call[1]?.headers)
|
|
213
|
+
return [headers.get('Accept'), headers.get('Authorization')]
|
|
214
|
+
})).toEqual([
|
|
215
|
+
['application/json', 'Bearer viewer-token'],
|
|
216
|
+
['text/event-stream', 'Bearer viewer-token'],
|
|
217
|
+
])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should consume job updates from an authenticated fetch stream without EventSource', async () => {
|
|
221
|
+
const storage = memoryStorage()
|
|
222
|
+
const runningJob = activeJob('job-1', 'running')
|
|
223
|
+
const completedJob = {
|
|
224
|
+
...runningJob,
|
|
225
|
+
status: 'completed',
|
|
226
|
+
completed_at: '',
|
|
227
|
+
can_cancel: false,
|
|
228
|
+
can_delete: true,
|
|
229
|
+
result_status: 'available',
|
|
230
|
+
metadata: { definition_id: 'run', has_result: true },
|
|
231
|
+
}
|
|
232
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
233
|
+
.mockResolvedValueOnce(response(manifest))
|
|
234
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
235
|
+
.mockResolvedValueOnce(response({
|
|
236
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
237
|
+
}))
|
|
238
|
+
.mockResolvedValueOnce(response({ jobs: [runningJob] }))
|
|
239
|
+
.mockResolvedValueOnce(eventStream(
|
|
240
|
+
`event: snapshot\ndata: ${JSON.stringify({ jobs: [runningJob] })}\n\n`,
|
|
241
|
+
`event: terminal\ndata: ${JSON.stringify(completedJob)}\n\n`,
|
|
242
|
+
))
|
|
243
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'done' }))
|
|
244
|
+
const eventSource = vi.fn(() => {
|
|
245
|
+
throw new Error('Generated job streams must not use EventSource')
|
|
246
|
+
})
|
|
247
|
+
vi.stubGlobal('EventSource', eventSource)
|
|
248
|
+
const scope = effectScope()
|
|
249
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
250
|
+
baseUrl: '/api/demo',
|
|
251
|
+
transport,
|
|
252
|
+
storage,
|
|
253
|
+
createId: () => 'tab-1',
|
|
254
|
+
streamReconnectDelay: 60_000,
|
|
255
|
+
}))!
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await generated.initialize()
|
|
259
|
+
await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed'))
|
|
260
|
+
|
|
261
|
+
expect(transport.mock.calls.some(call => String(call[0]).endsWith('/result'))).toBe(false)
|
|
262
|
+
|
|
263
|
+
await generated.loadResult(generated.jobs.value[0]!)
|
|
264
|
+
|
|
265
|
+
const streamCall = transport.mock.calls.find(call => String(call[0]).endsWith('/jobs/events'))
|
|
266
|
+
const resultCall = transport.mock.calls.find(call => String(call[0]).endsWith('/jobs/job-1/result'))
|
|
267
|
+
expect({
|
|
268
|
+
eventSourceCalls: eventSource.mock.calls.length,
|
|
269
|
+
accept: new Headers(streamCall?.[1]?.headers).get('Accept'),
|
|
270
|
+
signal: streamCall?.[1]?.signal instanceof AbortSignal,
|
|
271
|
+
resultMethod: resultCall?.[1]?.method,
|
|
272
|
+
result: generated.latestResult.value,
|
|
273
|
+
}).toEqual({
|
|
274
|
+
eventSourceCalls: 0,
|
|
275
|
+
accept: 'text/event-stream',
|
|
276
|
+
signal: true,
|
|
277
|
+
resultMethod: 'GET',
|
|
278
|
+
result: { kind: 'text', text: 'done' },
|
|
279
|
+
})
|
|
280
|
+
} finally {
|
|
281
|
+
scope.stop()
|
|
282
|
+
vi.unstubAllGlobals()
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('does not let a late submit response regress a terminal stream update', async () => {
|
|
287
|
+
const terminal = completedJob('fast-service-job')
|
|
288
|
+
const lateQueued = activeJob('fast-service-job')
|
|
289
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
290
|
+
.mockResolvedValueOnce(response(manifest))
|
|
291
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
292
|
+
.mockResolvedValueOnce(response({
|
|
293
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
294
|
+
}))
|
|
295
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
296
|
+
.mockResolvedValueOnce(eventStream(
|
|
297
|
+
`event: terminal\ndata: ${JSON.stringify(terminal)}\n\n`,
|
|
298
|
+
))
|
|
299
|
+
.mockResolvedValueOnce(response(lateQueued))
|
|
300
|
+
const scope = effectScope()
|
|
301
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
302
|
+
baseUrl: '/api/demo',
|
|
303
|
+
transport,
|
|
304
|
+
storage: memoryStorage(),
|
|
305
|
+
createId: () => 'tab-1',
|
|
306
|
+
streamReconnectDelay: 60_000,
|
|
307
|
+
}))!
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await generated.initialize()
|
|
311
|
+
await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed'))
|
|
312
|
+
|
|
313
|
+
const submitted = await generated.submit('run', {})
|
|
314
|
+
|
|
315
|
+
expect({
|
|
316
|
+
submitted: submitted.status,
|
|
317
|
+
stored: generated.jobs.value[0]?.status,
|
|
318
|
+
}).toEqual({ submitted: 'completed', stored: 'completed' })
|
|
319
|
+
} finally {
|
|
320
|
+
scope.stop()
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('creates one server session for the current sessionStorage tab and exposes results', async () => {
|
|
325
|
+
const storage = memoryStorage()
|
|
326
|
+
const fetcher = vi.fn()
|
|
327
|
+
.mockResolvedValueOnce(response(manifest))
|
|
328
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
329
|
+
.mockResolvedValueOnce(response({
|
|
330
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
331
|
+
}))
|
|
332
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
333
|
+
.mockResolvedValueOnce(response(activeJob('job-1')))
|
|
334
|
+
.mockResolvedValueOnce(response({
|
|
335
|
+
jobs: [completedJob('job-1')],
|
|
336
|
+
}))
|
|
337
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'done' }))
|
|
338
|
+
const scope = effectScope()
|
|
339
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
340
|
+
baseUrl: '/api/demo',
|
|
341
|
+
transport: fetcher,
|
|
342
|
+
storage,
|
|
343
|
+
createId: () => 'tab-1',
|
|
344
|
+
pollInterval: 60_000,
|
|
345
|
+
stream: false,
|
|
346
|
+
}))!
|
|
347
|
+
|
|
348
|
+
await generated.initialize()
|
|
349
|
+
await generated.submit('run', { threshold: 2.5 })
|
|
350
|
+
await generated.refreshJobs()
|
|
351
|
+
await generated.loadResult(generated.jobs.value[0]!)
|
|
352
|
+
scope.stop()
|
|
353
|
+
|
|
354
|
+
expect({
|
|
355
|
+
session: generated.session.value?.id,
|
|
356
|
+
result: generated.latestResult.value,
|
|
357
|
+
resultRequest: fetcher.mock.calls[6]?.[0],
|
|
358
|
+
storedTab: storage.getItem('mint:generated:/api/demo:tab'),
|
|
359
|
+
submitted: JSON.parse(String(fetcher.mock.calls[4]?.[1]?.body)),
|
|
360
|
+
}).toEqual({
|
|
361
|
+
session: 'session-1',
|
|
362
|
+
result: { kind: 'text', text: 'done' },
|
|
363
|
+
resultRequest: '/api/demo/jobs/job-1/result',
|
|
364
|
+
storedTab: 'tab-1',
|
|
365
|
+
submitted: {
|
|
366
|
+
definition_id: 'run',
|
|
367
|
+
inputs: { threshold: 2.5 },
|
|
368
|
+
session_id: 'session-1',
|
|
369
|
+
client_request_id: expect.any(String),
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should stage a selected path before submitting its opaque token', async () => {
|
|
375
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
376
|
+
.mockResolvedValueOnce(response(manifest))
|
|
377
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
378
|
+
.mockResolvedValueOnce(response({
|
|
379
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
380
|
+
}))
|
|
381
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
382
|
+
.mockResolvedValueOnce(response({ token: 'upload-token', kind: 'file', name: 'data.csv', size: 4 }))
|
|
383
|
+
.mockResolvedValueOnce(response(activeJob('job-1')))
|
|
384
|
+
const scope = effectScope()
|
|
385
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
386
|
+
baseUrl: '/api/demo',
|
|
387
|
+
transport,
|
|
388
|
+
storage: memoryStorage(),
|
|
389
|
+
createId: () => 'tab-1',
|
|
390
|
+
stream: false,
|
|
391
|
+
}))!
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
await generated.initialize()
|
|
395
|
+
await generated.submit('run', {
|
|
396
|
+
source: {
|
|
397
|
+
kind: 'path',
|
|
398
|
+
files: [new File(['data'], 'data.csv')],
|
|
399
|
+
relativePaths: ['data.csv'],
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const upload = transport.mock.calls[4]
|
|
404
|
+
const form = upload?.[1]?.body as FormData
|
|
405
|
+
const submit = JSON.parse(String(transport.mock.calls[5]?.[1]?.body))
|
|
406
|
+
expect({
|
|
407
|
+
uploadUrl: upload?.[0],
|
|
408
|
+
uploadMethod: upload?.[1]?.method,
|
|
409
|
+
uploadSessionHeader: new Headers(upload?.[1]?.headers).get('X-Mint-Job-Session'),
|
|
410
|
+
sessionId: form.get('session_id'),
|
|
411
|
+
relativePaths: form.getAll('relative_paths'),
|
|
412
|
+
submittedSource: submit.inputs.source,
|
|
413
|
+
}).toEqual({
|
|
414
|
+
uploadUrl: '/api/demo/jobs/uploads',
|
|
415
|
+
uploadMethod: 'POST',
|
|
416
|
+
uploadSessionHeader: 'session-1',
|
|
417
|
+
sessionId: 'session-1',
|
|
418
|
+
relativePaths: ['data.csv'],
|
|
419
|
+
submittedSource: { token: 'upload-token' },
|
|
420
|
+
})
|
|
421
|
+
} finally {
|
|
422
|
+
scope.stop()
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('uploads, inspects, and seals staged job parts through the canonical runtime', async () => {
|
|
427
|
+
const stagingSummary = {
|
|
428
|
+
job_id: 'staged/job',
|
|
429
|
+
sealed: false,
|
|
430
|
+
part_count: 1,
|
|
431
|
+
total_bytes: 3,
|
|
432
|
+
}
|
|
433
|
+
const part = {
|
|
434
|
+
key: 'sample one/chunk#1',
|
|
435
|
+
size: 3,
|
|
436
|
+
sha256: 'a'.repeat(64),
|
|
437
|
+
metadata: { sampleIndex: 1 },
|
|
438
|
+
}
|
|
439
|
+
const staging = {
|
|
440
|
+
...stagingSummary,
|
|
441
|
+
parts: [part],
|
|
442
|
+
offset: 0,
|
|
443
|
+
limit: 10,
|
|
444
|
+
next_offset: null,
|
|
445
|
+
has_more: false,
|
|
446
|
+
}
|
|
447
|
+
const sealedJob = {
|
|
448
|
+
...activeJob('staged/job'),
|
|
449
|
+
definition_id: 'client-xic',
|
|
450
|
+
metadata: { definition_id: 'client-xic', has_result: false },
|
|
451
|
+
}
|
|
452
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
453
|
+
.mockResolvedValueOnce(response(manifest))
|
|
454
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
455
|
+
.mockResolvedValueOnce(response({
|
|
456
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
457
|
+
}))
|
|
458
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
459
|
+
.mockResolvedValueOnce(response({ part, staging: stagingSummary }))
|
|
460
|
+
.mockResolvedValueOnce(response(staging))
|
|
461
|
+
.mockResolvedValueOnce(response(sealedJob))
|
|
462
|
+
const scope = effectScope()
|
|
463
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
464
|
+
baseUrl: '/api/demo',
|
|
465
|
+
transport,
|
|
466
|
+
storage: memoryStorage(),
|
|
467
|
+
createId: () => 'tab-1',
|
|
468
|
+
stream: false,
|
|
469
|
+
}))!
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
await generated.initialize()
|
|
473
|
+
const uploaded = await generated.uploadStagedPart({
|
|
474
|
+
job: 'staged/job',
|
|
475
|
+
key: 'sample one/chunk#1',
|
|
476
|
+
file: new Blob(['xic']),
|
|
477
|
+
metadata: { sampleIndex: 1 },
|
|
478
|
+
expectedSize: 3,
|
|
479
|
+
expectedSha256: 'a'.repeat(64),
|
|
480
|
+
})
|
|
481
|
+
const inspected = await generated.getJobStaging<{ sampleIndex: number }>(
|
|
482
|
+
'staged/job',
|
|
483
|
+
{ offset: 0, limit: 10 },
|
|
484
|
+
)
|
|
485
|
+
const sealed = await generated.sealJob('staged/job')
|
|
486
|
+
|
|
487
|
+
const upload = transport.mock.calls[4]
|
|
488
|
+
const body = upload?.[1]?.body as FormData
|
|
489
|
+
expect({
|
|
490
|
+
uploadUrl: upload?.[0],
|
|
491
|
+
uploadMethod: upload?.[1]?.method,
|
|
492
|
+
file: body.get('file'),
|
|
493
|
+
metadata: body.get('metadata'),
|
|
494
|
+
expectedSize: body.get('expected_size'),
|
|
495
|
+
expectedSha256: body.get('expected_sha256'),
|
|
496
|
+
uploadedMetadata: uploaded.part.metadata,
|
|
497
|
+
inspectedMetadata: inspected.parts[0]?.metadata,
|
|
498
|
+
stagingUrl: transport.mock.calls[5]?.[0],
|
|
499
|
+
sealRequest: transport.mock.calls[6],
|
|
500
|
+
sealedDefinition: sealed.definition_id,
|
|
501
|
+
jobs: generated.jobs.value.map(job => job.job_id),
|
|
502
|
+
}).toEqual({
|
|
503
|
+
uploadUrl: '/api/demo/jobs/staged%2Fjob/parts/sample%20one/chunk%231',
|
|
504
|
+
uploadMethod: 'PUT',
|
|
505
|
+
file: expect.any(Blob),
|
|
506
|
+
metadata: '{"sampleIndex":1}',
|
|
507
|
+
expectedSize: '3',
|
|
508
|
+
expectedSha256: 'a'.repeat(64),
|
|
509
|
+
uploadedMetadata: { sampleIndex: 1 },
|
|
510
|
+
inspectedMetadata: { sampleIndex: 1 },
|
|
511
|
+
stagingUrl: '/api/demo/jobs/staged%2Fjob/staging?offset=0&limit=10',
|
|
512
|
+
sealRequest: [
|
|
513
|
+
'/api/demo/jobs/staged%2Fjob/seal',
|
|
514
|
+
{ method: 'POST', headers: { Accept: 'application/json' } },
|
|
515
|
+
],
|
|
516
|
+
sealedDefinition: 'client-xic',
|
|
517
|
+
jobs: ['staged/job'],
|
|
518
|
+
})
|
|
519
|
+
} finally {
|
|
520
|
+
scope.stop()
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('keeps a forward seal response after an awaiting-input snapshot bumps its revision', async () => {
|
|
525
|
+
const awaiting = {
|
|
526
|
+
...activeJob('seal-race'),
|
|
527
|
+
status: 'awaiting_input' as const,
|
|
528
|
+
progress: {
|
|
529
|
+
...activeJob('seal-race').progress,
|
|
530
|
+
message: 'Awaiting input',
|
|
531
|
+
stage: 'awaiting_input',
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
const queued = activeJob('seal-race')
|
|
535
|
+
const sealRequest = deferred<Response>()
|
|
536
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
537
|
+
.mockResolvedValueOnce(response(manifest))
|
|
538
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
539
|
+
.mockResolvedValueOnce(response({
|
|
540
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
541
|
+
}))
|
|
542
|
+
.mockResolvedValueOnce(response({ jobs: [awaiting] }))
|
|
543
|
+
.mockReturnValueOnce(sealRequest.promise)
|
|
544
|
+
.mockResolvedValueOnce(response({ jobs: [awaiting] }))
|
|
545
|
+
const scope = effectScope()
|
|
546
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
547
|
+
baseUrl: '/api/demo',
|
|
548
|
+
transport,
|
|
549
|
+
storage: memoryStorage(),
|
|
550
|
+
createId: () => 'tab-1',
|
|
551
|
+
stream: false,
|
|
552
|
+
}))!
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
await generated.initialize()
|
|
556
|
+
const sealing = generated.sealJob('seal-race')
|
|
557
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5))
|
|
558
|
+
await generated.refreshJobs()
|
|
559
|
+
sealRequest.resolve(response(queued))
|
|
560
|
+
|
|
561
|
+
await expect(sealing).resolves.toMatchObject({ status: 'queued' })
|
|
562
|
+
expect(generated.jobs.value[0]?.status).toBe('queued')
|
|
563
|
+
} finally {
|
|
564
|
+
scope.stop()
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('reuses a live server session after a tab reload', async () => {
|
|
569
|
+
const storage = memoryStorage()
|
|
570
|
+
storage.setItem('mint:generated:/api/demo:tab', 'tab-1')
|
|
571
|
+
storage.setItem('mint:generated:/api/demo:session', 'session-1')
|
|
572
|
+
const fetcher = vi.fn()
|
|
573
|
+
.mockResolvedValueOnce(response(manifest))
|
|
574
|
+
.mockResolvedValueOnce(response({
|
|
575
|
+
sessions: [{
|
|
576
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
577
|
+
}],
|
|
578
|
+
}))
|
|
579
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
580
|
+
const scope = effectScope()
|
|
581
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
582
|
+
baseUrl: '/api/demo',
|
|
583
|
+
transport: fetcher,
|
|
584
|
+
storage,
|
|
585
|
+
createId: () => 'new-tab',
|
|
586
|
+
stream: false,
|
|
587
|
+
}))!
|
|
588
|
+
|
|
589
|
+
await generated.initialize()
|
|
590
|
+
scope.stop()
|
|
591
|
+
|
|
592
|
+
expect(fetcher.mock.calls.map(call => [call[0], call[1]?.method])).toEqual([
|
|
593
|
+
['/api/demo/generated/manifest', 'GET'],
|
|
594
|
+
['/api/demo/jobs/sessions', 'GET'],
|
|
595
|
+
['/api/demo/jobs', 'GET'],
|
|
596
|
+
])
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('should recreate an expired session and retry one job with the same request id', async () => {
|
|
600
|
+
const storage = memoryStorage()
|
|
601
|
+
const createId = vi.fn()
|
|
602
|
+
.mockReturnValueOnce('tab-1')
|
|
603
|
+
.mockReturnValueOnce('request-1')
|
|
604
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
605
|
+
.mockResolvedValueOnce(response(manifest))
|
|
606
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
607
|
+
.mockResolvedValueOnce(response({
|
|
608
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
609
|
+
}))
|
|
610
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
611
|
+
.mockResolvedValueOnce(response({ detail: 'Session not found' }, 404))
|
|
612
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
613
|
+
.mockResolvedValueOnce(response({
|
|
614
|
+
id: 'session-2', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
615
|
+
}))
|
|
616
|
+
.mockResolvedValueOnce(response({ ...activeJob('job-1'), session_id: 'session-2' }))
|
|
617
|
+
const scope = effectScope()
|
|
618
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
619
|
+
baseUrl: '/api/demo',
|
|
620
|
+
transport,
|
|
621
|
+
storage,
|
|
622
|
+
createId,
|
|
623
|
+
stream: false,
|
|
624
|
+
}))!
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
await generated.initialize()
|
|
628
|
+
const job = await generated.submit('run', { threshold: 2.5 })
|
|
629
|
+
const jobBodies = transport.mock.calls
|
|
630
|
+
.filter(call => String(call[0]).endsWith('/jobs') && call[1]?.method === 'POST')
|
|
631
|
+
.map(call => JSON.parse(String(call[1]?.body)))
|
|
632
|
+
|
|
633
|
+
expect({
|
|
634
|
+
jobId: job.job_id,
|
|
635
|
+
sessionId: generated.session.value?.id,
|
|
636
|
+
storedSessionId: storage.getItem('mint:generated:/api/demo:session'),
|
|
637
|
+
jobBodies,
|
|
638
|
+
}).toEqual({
|
|
639
|
+
jobId: 'job-1',
|
|
640
|
+
sessionId: 'session-2',
|
|
641
|
+
storedSessionId: 'session-2',
|
|
642
|
+
jobBodies: [
|
|
643
|
+
{
|
|
644
|
+
definition_id: 'run',
|
|
645
|
+
inputs: { threshold: 2.5 },
|
|
646
|
+
session_id: 'session-1',
|
|
647
|
+
client_request_id: 'request-1',
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
definition_id: 'run',
|
|
651
|
+
inputs: { threshold: 2.5 },
|
|
652
|
+
session_id: 'session-2',
|
|
653
|
+
client_request_id: 'request-1',
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
})
|
|
657
|
+
} finally {
|
|
658
|
+
scope.stop()
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it.each([
|
|
663
|
+
{ label: 'another 404', status: 404, detail: 'Unknown job definition' },
|
|
664
|
+
{ label: 'a server error', status: 500, detail: 'Internal server error' },
|
|
665
|
+
])('should not retry submission for $label', async ({ status, detail }) => {
|
|
666
|
+
const storage = memoryStorage()
|
|
667
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
668
|
+
.mockResolvedValueOnce(response(manifest))
|
|
669
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
670
|
+
.mockResolvedValueOnce(response({
|
|
671
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
672
|
+
}))
|
|
673
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
674
|
+
.mockResolvedValueOnce(response({ detail }, status))
|
|
675
|
+
const scope = effectScope()
|
|
676
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
677
|
+
baseUrl: '/api/demo',
|
|
678
|
+
transport,
|
|
679
|
+
storage,
|
|
680
|
+
createId: () => 'request-1',
|
|
681
|
+
stream: false,
|
|
682
|
+
}))!
|
|
683
|
+
let caught: unknown
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
await generated.initialize()
|
|
687
|
+
await generated.submit('run', {})
|
|
688
|
+
} catch (error) {
|
|
689
|
+
caught = error
|
|
690
|
+
} finally {
|
|
691
|
+
scope.stop()
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
expect({
|
|
695
|
+
status: (caught as { status?: number } | undefined)?.status,
|
|
696
|
+
message: caught instanceof Error ? caught.message : null,
|
|
697
|
+
jobPosts: transport.mock.calls.filter(call => (
|
|
698
|
+
String(call[0]).endsWith('/jobs') && call[1]?.method === 'POST'
|
|
699
|
+
)).length,
|
|
700
|
+
sessionRequests: transport.mock.calls.filter(call => String(call[0]).endsWith('/sessions')).length,
|
|
701
|
+
}).toEqual({
|
|
702
|
+
status,
|
|
703
|
+
message: detail,
|
|
704
|
+
jobPosts: 1,
|
|
705
|
+
sessionRequests: 2,
|
|
706
|
+
})
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('should keep polling active jobs when one refresh request fails', async () => {
|
|
710
|
+
vi.useFakeTimers()
|
|
711
|
+
const storage = memoryStorage()
|
|
712
|
+
const runningJob = activeJob('job-1', 'running')
|
|
713
|
+
const fetcher = vi.fn()
|
|
714
|
+
.mockResolvedValueOnce(response(manifest))
|
|
715
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
716
|
+
.mockResolvedValueOnce(response({
|
|
717
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: ['job-1'],
|
|
718
|
+
}))
|
|
719
|
+
.mockResolvedValueOnce(response({ jobs: [runningJob] }))
|
|
720
|
+
.mockRejectedValueOnce(new TypeError('temporary network failure'))
|
|
721
|
+
.mockResolvedValueOnce(response({ jobs: [completedJob('job-1')] }))
|
|
722
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'done' }))
|
|
723
|
+
const scope = effectScope()
|
|
724
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
725
|
+
baseUrl: '/api/demo',
|
|
726
|
+
transport: fetcher,
|
|
727
|
+
storage,
|
|
728
|
+
createId: () => 'tab-1',
|
|
729
|
+
pollInterval: 10,
|
|
730
|
+
stream: false,
|
|
731
|
+
}))!
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
await generated.initialize()
|
|
735
|
+
await vi.advanceTimersByTimeAsync(20)
|
|
736
|
+
|
|
737
|
+
expect({
|
|
738
|
+
status: generated.jobs.value[0]?.status,
|
|
739
|
+
error: generated.error.value,
|
|
740
|
+
}).toEqual({ status: 'completed', error: null })
|
|
741
|
+
} finally {
|
|
742
|
+
scope.stop()
|
|
743
|
+
vi.useRealTimers()
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('should deduplicate result loads and keep the newest result when older requests finish later', async () => {
|
|
748
|
+
const storage = memoryStorage()
|
|
749
|
+
const oldResult = deferred<Response>()
|
|
750
|
+
const newResult = deferred<Response>()
|
|
751
|
+
const oldJob = completedJob('job-old')
|
|
752
|
+
const newJob = {
|
|
753
|
+
...oldJob,
|
|
754
|
+
id: 'job-new',
|
|
755
|
+
job_id: 'job-new',
|
|
756
|
+
}
|
|
757
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
758
|
+
.mockResolvedValueOnce(response(manifest))
|
|
759
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
760
|
+
.mockResolvedValueOnce(response({
|
|
761
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
762
|
+
}))
|
|
763
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
764
|
+
.mockResolvedValueOnce(response({ jobs: [oldJob] }))
|
|
765
|
+
.mockReturnValueOnce(oldResult.promise)
|
|
766
|
+
.mockResolvedValueOnce(response({ jobs: [newJob, oldJob] }))
|
|
767
|
+
.mockReturnValueOnce(newResult.promise)
|
|
768
|
+
const scope = effectScope()
|
|
769
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
770
|
+
baseUrl: '/api/demo',
|
|
771
|
+
transport,
|
|
772
|
+
storage,
|
|
773
|
+
createId: () => 'tab-1',
|
|
774
|
+
stream: false,
|
|
775
|
+
}))!
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
await generated.initialize()
|
|
779
|
+
await generated.refreshJobs()
|
|
780
|
+
const oldLoad = generated.loadResult(oldJob)
|
|
781
|
+
const duplicateOldLoad = generated.loadResult(oldJob)
|
|
782
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(6))
|
|
783
|
+
await generated.refreshJobs()
|
|
784
|
+
const newLoad = generated.loadResult(newJob)
|
|
785
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(8))
|
|
786
|
+
|
|
787
|
+
newResult.resolve(response({ kind: 'text', text: 'new' }))
|
|
788
|
+
await newLoad
|
|
789
|
+
oldResult.resolve(response({ kind: 'text', text: 'old' }))
|
|
790
|
+
await oldLoad
|
|
791
|
+
await duplicateOldLoad
|
|
792
|
+
|
|
793
|
+
expect({
|
|
794
|
+
latest: generated.latestResult.value,
|
|
795
|
+
resultRequests: transport.mock.calls
|
|
796
|
+
.map(call => String(call[0]))
|
|
797
|
+
.filter(url => url.endsWith('/result')),
|
|
798
|
+
}).toEqual({
|
|
799
|
+
latest: { kind: 'text', text: 'new' },
|
|
800
|
+
resultRequests: [
|
|
801
|
+
'/api/demo/jobs/job-old/result',
|
|
802
|
+
'/api/demo/jobs/job-new/result',
|
|
803
|
+
],
|
|
804
|
+
})
|
|
805
|
+
} finally {
|
|
806
|
+
scope.stop()
|
|
807
|
+
}
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('should refresh a cached managed download before its capability expires', async () => {
|
|
811
|
+
vi.useFakeTimers()
|
|
812
|
+
vi.setSystemTime(new Date('2026-07-21T10:00:00Z'))
|
|
813
|
+
const completed = completedJob('managed-job')
|
|
814
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
815
|
+
.mockResolvedValueOnce(response(manifest))
|
|
816
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
817
|
+
.mockResolvedValueOnce(response({
|
|
818
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
819
|
+
}))
|
|
820
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
821
|
+
.mockResolvedValueOnce(response({
|
|
822
|
+
kind: 'artifact',
|
|
823
|
+
id: 'artifact-1',
|
|
824
|
+
name: 'result.bin',
|
|
825
|
+
media_type: 'application/octet-stream',
|
|
826
|
+
size: 6,
|
|
827
|
+
managed: true,
|
|
828
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-1',
|
|
829
|
+
download_expires_at: '2026-07-21T10:05:00Z',
|
|
830
|
+
}))
|
|
831
|
+
.mockResolvedValueOnce(response({
|
|
832
|
+
kind: 'artifact',
|
|
833
|
+
id: 'artifact-1',
|
|
834
|
+
name: 'result.bin',
|
|
835
|
+
media_type: 'application/octet-stream',
|
|
836
|
+
size: 6,
|
|
837
|
+
managed: true,
|
|
838
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-2',
|
|
839
|
+
download_expires_at: '2026-07-21T10:09:30Z',
|
|
840
|
+
}))
|
|
841
|
+
const scope = effectScope()
|
|
842
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
843
|
+
baseUrl: '/api/demo',
|
|
844
|
+
transport,
|
|
845
|
+
storage: memoryStorage(),
|
|
846
|
+
createId: () => 'tab-1',
|
|
847
|
+
stream: false,
|
|
848
|
+
}))!
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
await generated.initialize()
|
|
852
|
+
await generated.loadResult(completed)
|
|
853
|
+
await vi.advanceTimersByTimeAsync(270_000)
|
|
854
|
+
|
|
855
|
+
expect({
|
|
856
|
+
result: generated.resultForJob('managed-job'),
|
|
857
|
+
resultRequests: transport.mock.calls
|
|
858
|
+
.filter(call => String(call[0]).endsWith('/result')).length,
|
|
859
|
+
}).toMatchObject({
|
|
860
|
+
result: {
|
|
861
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-2',
|
|
862
|
+
},
|
|
863
|
+
resultRequests: 2,
|
|
864
|
+
})
|
|
865
|
+
} finally {
|
|
866
|
+
scope.stop()
|
|
867
|
+
vi.useRealTimers()
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('should stop renewing a managed download after the result expires', async () => {
|
|
872
|
+
vi.useFakeTimers()
|
|
873
|
+
vi.setSystemTime(new Date('2026-07-21T10:00:00Z'))
|
|
874
|
+
const completed = completedJob('managed-job')
|
|
875
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
876
|
+
.mockResolvedValueOnce(response(manifest))
|
|
877
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
878
|
+
.mockResolvedValueOnce(response({
|
|
879
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
880
|
+
}))
|
|
881
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
882
|
+
.mockResolvedValueOnce(response({
|
|
883
|
+
kind: 'artifact',
|
|
884
|
+
id: 'artifact-1',
|
|
885
|
+
name: 'result.bin',
|
|
886
|
+
media_type: 'application/octet-stream',
|
|
887
|
+
size: 6,
|
|
888
|
+
managed: true,
|
|
889
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-1',
|
|
890
|
+
download_expires_at: '2026-07-21T10:05:00Z',
|
|
891
|
+
}))
|
|
892
|
+
.mockResolvedValueOnce(response({ detail: 'Job result expired' }, 410))
|
|
893
|
+
const scope = effectScope()
|
|
894
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
895
|
+
baseUrl: '/api/demo',
|
|
896
|
+
transport,
|
|
897
|
+
storage: memoryStorage(),
|
|
898
|
+
createId: () => 'tab-1',
|
|
899
|
+
stream: false,
|
|
900
|
+
}))!
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
await generated.initialize()
|
|
904
|
+
await generated.loadResult(completed)
|
|
905
|
+
await vi.advanceTimersByTimeAsync(270_000)
|
|
906
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
907
|
+
|
|
908
|
+
expect({
|
|
909
|
+
result: generated.resultForJob('managed-job'),
|
|
910
|
+
resultStatus: generated.jobs.value[0]?.result_status,
|
|
911
|
+
resultRequests: transport.mock.calls
|
|
912
|
+
.filter(call => String(call[0]).endsWith('/result')).length,
|
|
913
|
+
}).toEqual({
|
|
914
|
+
result: null,
|
|
915
|
+
resultStatus: 'expired',
|
|
916
|
+
resultRequests: 2,
|
|
917
|
+
})
|
|
918
|
+
} finally {
|
|
919
|
+
scope.stop()
|
|
920
|
+
vi.useRealTimers()
|
|
921
|
+
}
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
it('should wait for a near-result-TTL capability to expire instead of tight-looping', async () => {
|
|
925
|
+
vi.useFakeTimers()
|
|
926
|
+
vi.setSystemTime(new Date('2026-07-21T10:00:00Z'))
|
|
927
|
+
const completed = completedJob('managed-job')
|
|
928
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
929
|
+
.mockResolvedValueOnce(response(manifest))
|
|
930
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
931
|
+
.mockResolvedValueOnce(response({
|
|
932
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
933
|
+
}))
|
|
934
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
935
|
+
.mockResolvedValueOnce(response({
|
|
936
|
+
kind: 'artifact',
|
|
937
|
+
id: 'artifact-1',
|
|
938
|
+
name: 'result.bin',
|
|
939
|
+
media_type: 'application/octet-stream',
|
|
940
|
+
size: 6,
|
|
941
|
+
managed: true,
|
|
942
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-1',
|
|
943
|
+
download_expires_at: '2026-07-21T10:00:20Z',
|
|
944
|
+
}))
|
|
945
|
+
.mockResolvedValueOnce(response({ detail: 'Job result expired' }, 410))
|
|
946
|
+
const scope = effectScope()
|
|
947
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
948
|
+
baseUrl: '/api/demo',
|
|
949
|
+
transport,
|
|
950
|
+
storage: memoryStorage(),
|
|
951
|
+
createId: () => 'tab-1',
|
|
952
|
+
stream: false,
|
|
953
|
+
}))!
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
await generated.initialize()
|
|
957
|
+
await generated.loadResult(completed)
|
|
958
|
+
await vi.advanceTimersByTimeAsync(19_999)
|
|
959
|
+
expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(1)
|
|
960
|
+
|
|
961
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
962
|
+
expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(2)
|
|
963
|
+
} finally {
|
|
964
|
+
scope.stop()
|
|
965
|
+
vi.useRealTimers()
|
|
966
|
+
}
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
it('should pause managed download renewal and refresh it after resume', async () => {
|
|
970
|
+
vi.useFakeTimers()
|
|
971
|
+
vi.setSystemTime(new Date('2026-07-21T10:00:00Z'))
|
|
972
|
+
const completed = completedJob('managed-job')
|
|
973
|
+
const managedResult = (token: string, expires: string) => ({
|
|
974
|
+
kind: 'artifact' as const,
|
|
975
|
+
id: 'artifact-1',
|
|
976
|
+
name: 'result.bin',
|
|
977
|
+
media_type: 'application/octet-stream',
|
|
978
|
+
size: 6,
|
|
979
|
+
managed: true,
|
|
980
|
+
download_url: `/api/demo/jobs/managed-job/result/file/${token}`,
|
|
981
|
+
download_expires_at: expires,
|
|
982
|
+
})
|
|
983
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
984
|
+
.mockResolvedValueOnce(response(manifest))
|
|
985
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
986
|
+
.mockResolvedValueOnce(response({
|
|
987
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
988
|
+
}))
|
|
989
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
990
|
+
.mockResolvedValueOnce(response(managedResult('token-1', '2026-07-21T10:05:00Z')))
|
|
991
|
+
.mockResolvedValueOnce(response(managedResult('token-2', '2026-07-21T10:10:01Z')))
|
|
992
|
+
const scope = effectScope()
|
|
993
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
994
|
+
baseUrl: '/api/demo',
|
|
995
|
+
transport,
|
|
996
|
+
storage: memoryStorage(),
|
|
997
|
+
createId: () => 'tab-1',
|
|
998
|
+
stream: false,
|
|
999
|
+
}))!
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
await generated.initialize()
|
|
1003
|
+
await generated.loadResult(completed)
|
|
1004
|
+
generated.pause()
|
|
1005
|
+
await vi.advanceTimersByTimeAsync(301_000)
|
|
1006
|
+
expect(transport.mock.calls.filter(call => String(call[0]).endsWith('/result'))).toHaveLength(1)
|
|
1007
|
+
|
|
1008
|
+
generated.resume()
|
|
1009
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
1010
|
+
|
|
1011
|
+
expect(generated.resultForJob('managed-job')).toMatchObject({
|
|
1012
|
+
download_url: '/api/demo/jobs/managed-job/result/file/token-2',
|
|
1013
|
+
})
|
|
1014
|
+
} finally {
|
|
1015
|
+
scope.stop()
|
|
1016
|
+
vi.useRealTimers()
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('should retry a transient result failure and surface the loading error', async () => {
|
|
1021
|
+
vi.useFakeTimers()
|
|
1022
|
+
const storage = memoryStorage()
|
|
1023
|
+
const completed = completedJob('job-1')
|
|
1024
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1025
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1026
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1027
|
+
.mockResolvedValueOnce(response({
|
|
1028
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1029
|
+
}))
|
|
1030
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1031
|
+
.mockRejectedValueOnce(new TypeError('temporary result failure'))
|
|
1032
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'recovered' }))
|
|
1033
|
+
const scope = effectScope()
|
|
1034
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1035
|
+
baseUrl: '/api/demo',
|
|
1036
|
+
transport,
|
|
1037
|
+
storage,
|
|
1038
|
+
createId: () => 'tab-1',
|
|
1039
|
+
pollInterval: 10,
|
|
1040
|
+
stream: false,
|
|
1041
|
+
}))!
|
|
1042
|
+
|
|
1043
|
+
try {
|
|
1044
|
+
await generated.initialize()
|
|
1045
|
+
await generated.loadResult(completed)
|
|
1046
|
+
|
|
1047
|
+
expect(generated.error.value).toBe('temporary result failure')
|
|
1048
|
+
|
|
1049
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
1050
|
+
|
|
1051
|
+
expect({
|
|
1052
|
+
result: generated.resultForJob('job-1'),
|
|
1053
|
+
error: generated.error.value,
|
|
1054
|
+
resultRequests: transport.mock.calls.filter(call => String(call[0]).endsWith('/result')).length,
|
|
1055
|
+
}).toEqual({
|
|
1056
|
+
result: { kind: 'text', text: 'recovered' },
|
|
1057
|
+
error: null,
|
|
1058
|
+
resultRequests: 2,
|
|
1059
|
+
})
|
|
1060
|
+
} finally {
|
|
1061
|
+
scope.stop()
|
|
1062
|
+
vi.useRealTimers()
|
|
1063
|
+
}
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
it('should load a visible completed result from another session', async () => {
|
|
1067
|
+
const storage = memoryStorage()
|
|
1068
|
+
const sharedJob = {
|
|
1069
|
+
...completedJob('shared-job'),
|
|
1070
|
+
session_id: 'another-session',
|
|
1071
|
+
}
|
|
1072
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1073
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1074
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1075
|
+
.mockResolvedValueOnce(response({
|
|
1076
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1077
|
+
}))
|
|
1078
|
+
.mockResolvedValueOnce(response({ jobs: [sharedJob] }))
|
|
1079
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'shared' }))
|
|
1080
|
+
const scope = effectScope()
|
|
1081
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1082
|
+
baseUrl: '/api/demo',
|
|
1083
|
+
transport,
|
|
1084
|
+
storage,
|
|
1085
|
+
createId: () => 'tab-1',
|
|
1086
|
+
stream: false,
|
|
1087
|
+
}))!
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
await generated.initialize()
|
|
1091
|
+
const result = await generated.loadResult(sharedJob)
|
|
1092
|
+
|
|
1093
|
+
expect(result).toEqual({ kind: 'text', text: 'shared' })
|
|
1094
|
+
} finally {
|
|
1095
|
+
scope.stop()
|
|
1096
|
+
}
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it('should clear exhausted retry state after a job leaves the visible list', async () => {
|
|
1100
|
+
vi.useFakeTimers()
|
|
1101
|
+
const storage = memoryStorage()
|
|
1102
|
+
const completed = completedJob('retry-job')
|
|
1103
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1104
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1105
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1106
|
+
.mockResolvedValueOnce(response({
|
|
1107
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1108
|
+
}))
|
|
1109
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1110
|
+
.mockRejectedValueOnce(new TypeError('failure-1'))
|
|
1111
|
+
.mockRejectedValueOnce(new TypeError('failure-2'))
|
|
1112
|
+
.mockRejectedValueOnce(new TypeError('failure-3'))
|
|
1113
|
+
.mockRejectedValueOnce(new TypeError('failure-4'))
|
|
1114
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
1115
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1116
|
+
.mockRejectedValueOnce(new TypeError('failure-after-return'))
|
|
1117
|
+
.mockResolvedValueOnce(response({ kind: 'text', text: 'recovered' }))
|
|
1118
|
+
const scope = effectScope()
|
|
1119
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1120
|
+
baseUrl: '/api/demo',
|
|
1121
|
+
transport,
|
|
1122
|
+
storage,
|
|
1123
|
+
createId: () => 'tab-1',
|
|
1124
|
+
pollInterval: 10,
|
|
1125
|
+
stream: false,
|
|
1126
|
+
}))!
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
await generated.initialize()
|
|
1130
|
+
await generated.loadResult(completed)
|
|
1131
|
+
await vi.advanceTimersByTimeAsync(70)
|
|
1132
|
+
await generated.refreshJobs()
|
|
1133
|
+
await generated.refreshJobs()
|
|
1134
|
+
await generated.loadResult(completed)
|
|
1135
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
1136
|
+
|
|
1137
|
+
expect(generated.resultForJob('retry-job')).toEqual({
|
|
1138
|
+
kind: 'text',
|
|
1139
|
+
text: 'recovered',
|
|
1140
|
+
})
|
|
1141
|
+
} finally {
|
|
1142
|
+
scope.stop()
|
|
1143
|
+
vi.useRealTimers()
|
|
1144
|
+
}
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
it('should retain at most thirty-two lazily loaded results', async () => {
|
|
1148
|
+
const storage = memoryStorage()
|
|
1149
|
+
const completedJobs = Array.from(
|
|
1150
|
+
{ length: 33 },
|
|
1151
|
+
(_, index) => completedJob(`job-${index}`),
|
|
1152
|
+
)
|
|
1153
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1154
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1155
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1156
|
+
.mockResolvedValueOnce(response({
|
|
1157
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1158
|
+
}))
|
|
1159
|
+
.mockResolvedValueOnce(response({ jobs: completedJobs }))
|
|
1160
|
+
.mockImplementation(async input => response({ kind: 'text', text: String(input) }))
|
|
1161
|
+
const scope = effectScope()
|
|
1162
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1163
|
+
baseUrl: '/api/demo',
|
|
1164
|
+
transport,
|
|
1165
|
+
storage,
|
|
1166
|
+
createId: () => 'tab-1',
|
|
1167
|
+
stream: false,
|
|
1168
|
+
}))!
|
|
1169
|
+
|
|
1170
|
+
try {
|
|
1171
|
+
await generated.initialize()
|
|
1172
|
+
for (const job of completedJobs) await generated.loadResult(job)
|
|
1173
|
+
|
|
1174
|
+
expect({
|
|
1175
|
+
evicted: generated.resultForJob('job-0'),
|
|
1176
|
+
retained: generated.resultForJob('job-32'),
|
|
1177
|
+
}).toEqual({
|
|
1178
|
+
evicted: null,
|
|
1179
|
+
retained: {
|
|
1180
|
+
kind: 'text',
|
|
1181
|
+
text: '/api/demo/jobs/job-32/result',
|
|
1182
|
+
},
|
|
1183
|
+
})
|
|
1184
|
+
} finally {
|
|
1185
|
+
scope.stop()
|
|
1186
|
+
}
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
it('waits for a terminal refresh and supports immediate terminal results', async () => {
|
|
1190
|
+
const queued = activeJob('wait-job')
|
|
1191
|
+
const running = activeJob('wait-job', 'running')
|
|
1192
|
+
const completed = completedJob('wait-job')
|
|
1193
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1194
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1195
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1196
|
+
.mockResolvedValueOnce(response({
|
|
1197
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1198
|
+
}))
|
|
1199
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1200
|
+
.mockResolvedValueOnce(response(running))
|
|
1201
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1202
|
+
const scope = effectScope()
|
|
1203
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1204
|
+
baseUrl: '/api/demo',
|
|
1205
|
+
transport,
|
|
1206
|
+
storage: memoryStorage(),
|
|
1207
|
+
createId: () => 'tab-1',
|
|
1208
|
+
pollInterval: 60_000,
|
|
1209
|
+
stream: false,
|
|
1210
|
+
}))!
|
|
1211
|
+
|
|
1212
|
+
try {
|
|
1213
|
+
await generated.initialize()
|
|
1214
|
+
const waiting = generated.waitForJob('wait-job')
|
|
1215
|
+
await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('running'))
|
|
1216
|
+
await generated.refreshJobs()
|
|
1217
|
+
|
|
1218
|
+
await expect(waiting).resolves.toMatchObject({
|
|
1219
|
+
job_id: 'wait-job',
|
|
1220
|
+
status: 'completed',
|
|
1221
|
+
})
|
|
1222
|
+
await expect(generated.waitForJob(completed)).resolves.toEqual(completed)
|
|
1223
|
+
expect(transport).toHaveBeenCalledTimes(6)
|
|
1224
|
+
} finally {
|
|
1225
|
+
scope.stop()
|
|
1226
|
+
}
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it('can reject failed jobs and pre-aborted waits without requesting the job', async () => {
|
|
1230
|
+
const failed = {
|
|
1231
|
+
...activeJob('failed-job'),
|
|
1232
|
+
status: 'failed' as const,
|
|
1233
|
+
error: 'analysis exploded',
|
|
1234
|
+
completed_at: '2026-07-21T00:00:00Z',
|
|
1235
|
+
can_cancel: false,
|
|
1236
|
+
can_delete: true,
|
|
1237
|
+
}
|
|
1238
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1239
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1240
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1241
|
+
.mockResolvedValueOnce(response({
|
|
1242
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1243
|
+
}))
|
|
1244
|
+
.mockResolvedValueOnce(response({ jobs: [failed] }))
|
|
1245
|
+
const scope = effectScope()
|
|
1246
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1247
|
+
baseUrl: '/api/demo',
|
|
1248
|
+
transport,
|
|
1249
|
+
storage: memoryStorage(),
|
|
1250
|
+
createId: () => 'tab-1',
|
|
1251
|
+
stream: false,
|
|
1252
|
+
}))!
|
|
1253
|
+
|
|
1254
|
+
try {
|
|
1255
|
+
await generated.initialize()
|
|
1256
|
+
await expect(generated.waitForJob(failed)).resolves.toMatchObject({ status: 'failed' })
|
|
1257
|
+
await expect(generated.waitForJob(failed, { rejectOnFailure: true }))
|
|
1258
|
+
.rejects.toThrow('analysis exploded')
|
|
1259
|
+
|
|
1260
|
+
const controller = new AbortController()
|
|
1261
|
+
controller.abort()
|
|
1262
|
+
await expect(generated.waitForJob(failed, { signal: controller.signal }))
|
|
1263
|
+
.rejects.toMatchObject({ name: 'AbortError' })
|
|
1264
|
+
expect(transport).toHaveBeenCalledTimes(4)
|
|
1265
|
+
} finally {
|
|
1266
|
+
scope.stop()
|
|
1267
|
+
}
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
it('removes an aborted waiter before a late job response arrives', async () => {
|
|
1271
|
+
const queued = activeJob('abort-job')
|
|
1272
|
+
const jobRequest = deferred<Response>()
|
|
1273
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1274
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1275
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1276
|
+
.mockResolvedValueOnce(response({
|
|
1277
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1278
|
+
}))
|
|
1279
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1280
|
+
.mockReturnValueOnce(jobRequest.promise)
|
|
1281
|
+
const scope = effectScope()
|
|
1282
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1283
|
+
baseUrl: '/api/demo',
|
|
1284
|
+
transport,
|
|
1285
|
+
storage: memoryStorage(),
|
|
1286
|
+
createId: () => 'tab-1',
|
|
1287
|
+
pollInterval: 60_000,
|
|
1288
|
+
stream: false,
|
|
1289
|
+
}))!
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
await generated.initialize()
|
|
1293
|
+
const controller = new AbortController()
|
|
1294
|
+
const waiting = generated.waitForJob('abort-job', { signal: controller.signal })
|
|
1295
|
+
expect(transport.mock.calls[4]?.[1]?.signal).toBe(controller.signal)
|
|
1296
|
+
controller.abort()
|
|
1297
|
+
|
|
1298
|
+
await expect(waiting).rejects.toMatchObject({ name: 'AbortError' })
|
|
1299
|
+
jobRequest.resolve(response(completedJob('abort-job')))
|
|
1300
|
+
await vi.waitFor(() => expect(generated.jobs.value[0]?.status).toBe('completed'))
|
|
1301
|
+
} finally {
|
|
1302
|
+
scope.stop()
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1305
|
+
|
|
1306
|
+
it('keeps a known active waiter through a transient lookup failure', async () => {
|
|
1307
|
+
const queued = activeJob('retry-wait-job')
|
|
1308
|
+
const completed = completedJob('retry-wait-job')
|
|
1309
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1310
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1311
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1312
|
+
.mockResolvedValueOnce(response({
|
|
1313
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1314
|
+
}))
|
|
1315
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1316
|
+
.mockResolvedValueOnce(response({ detail: 'temporarily unavailable' }, 503))
|
|
1317
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1318
|
+
const scope = effectScope()
|
|
1319
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1320
|
+
baseUrl: '/api/demo',
|
|
1321
|
+
transport,
|
|
1322
|
+
storage: memoryStorage(),
|
|
1323
|
+
createId: () => 'tab-1',
|
|
1324
|
+
pollInterval: 60_000,
|
|
1325
|
+
stream: false,
|
|
1326
|
+
}))!
|
|
1327
|
+
|
|
1328
|
+
try {
|
|
1329
|
+
await generated.initialize()
|
|
1330
|
+
const waiting = generated.waitForJob('retry-wait-job')
|
|
1331
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5))
|
|
1332
|
+
await generated.refreshJobs()
|
|
1333
|
+
await expect(waiting).resolves.toMatchObject({
|
|
1334
|
+
job_id: 'retry-wait-job',
|
|
1335
|
+
status: 'completed',
|
|
1336
|
+
})
|
|
1337
|
+
} finally {
|
|
1338
|
+
scope.stop()
|
|
1339
|
+
}
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
it('rejects an active wait immediately while background updates are paused', async () => {
|
|
1343
|
+
const queued = activeJob('paused-wait-job')
|
|
1344
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1345
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1346
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1347
|
+
.mockResolvedValueOnce(response({
|
|
1348
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1349
|
+
}))
|
|
1350
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1351
|
+
const scope = effectScope()
|
|
1352
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1353
|
+
baseUrl: '/api/demo',
|
|
1354
|
+
transport,
|
|
1355
|
+
storage: memoryStorage(),
|
|
1356
|
+
createId: () => 'tab-1',
|
|
1357
|
+
stream: false,
|
|
1358
|
+
}))!
|
|
1359
|
+
|
|
1360
|
+
try {
|
|
1361
|
+
await generated.initialize()
|
|
1362
|
+
generated.pause()
|
|
1363
|
+
await expect(generated.waitForJob('paused-wait-job')).rejects.toThrow(
|
|
1364
|
+
'Plugin jobs runtime is paused',
|
|
1365
|
+
)
|
|
1366
|
+
expect(transport).toHaveBeenCalledTimes(4)
|
|
1367
|
+
} finally {
|
|
1368
|
+
scope.stop()
|
|
1369
|
+
}
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
it('rejects pending waiters when their job is deleted or the runtime is disposed', async () => {
|
|
1373
|
+
const queued = activeJob('delete-wait-job')
|
|
1374
|
+
const disposeJobRequest = deferred<Response>()
|
|
1375
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1376
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1377
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1378
|
+
.mockResolvedValueOnce(response({
|
|
1379
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1380
|
+
}))
|
|
1381
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1382
|
+
.mockResolvedValueOnce(response(queued))
|
|
1383
|
+
.mockResolvedValueOnce(response({ status: 'deleted' }))
|
|
1384
|
+
.mockReturnValueOnce(disposeJobRequest.promise)
|
|
1385
|
+
const scope = effectScope()
|
|
1386
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1387
|
+
baseUrl: '/api/demo',
|
|
1388
|
+
transport,
|
|
1389
|
+
storage: memoryStorage(),
|
|
1390
|
+
createId: () => 'tab-1',
|
|
1391
|
+
pollInterval: 60_000,
|
|
1392
|
+
stream: false,
|
|
1393
|
+
}))!
|
|
1394
|
+
|
|
1395
|
+
await generated.initialize()
|
|
1396
|
+
const deletedWait = generated.waitForJob('delete-wait-job')
|
|
1397
|
+
await vi.waitFor(() => expect(transport).toHaveBeenCalledTimes(5))
|
|
1398
|
+
await generated.deleteJob('delete-wait-job')
|
|
1399
|
+
await expect(deletedWait).rejects.toThrow('was deleted')
|
|
1400
|
+
|
|
1401
|
+
const disposeWait = generated.waitForJob(activeJob('dispose-wait-job'))
|
|
1402
|
+
scope.stop()
|
|
1403
|
+
await expect(disposeWait).rejects.toThrow('runtime is disposed')
|
|
1404
|
+
disposeJobRequest.resolve(response(activeJob('dispose-wait-job')))
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
it('coalesces initialization and refreshes, then disposes idempotently', async () => {
|
|
1408
|
+
const manifestRequest = deferred<Response>()
|
|
1409
|
+
const refreshRequest = deferred<Response>()
|
|
1410
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1411
|
+
.mockReturnValueOnce(manifestRequest.promise)
|
|
1412
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1413
|
+
.mockResolvedValueOnce(response({
|
|
1414
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1415
|
+
}))
|
|
1416
|
+
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
1417
|
+
.mockReturnValueOnce(refreshRequest.promise)
|
|
1418
|
+
const scope = effectScope()
|
|
1419
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1420
|
+
baseUrl: '/api/demo',
|
|
1421
|
+
transport,
|
|
1422
|
+
storage: memoryStorage(),
|
|
1423
|
+
createId: () => 'tab-1',
|
|
1424
|
+
stream: false,
|
|
1425
|
+
}))!
|
|
1426
|
+
|
|
1427
|
+
const firstInitialize = generated.initialize()
|
|
1428
|
+
const secondInitialize = generated.ready()
|
|
1429
|
+
expect(firstInitialize).toBe(secondInitialize)
|
|
1430
|
+
manifestRequest.resolve(response(manifest))
|
|
1431
|
+
await firstInitialize
|
|
1432
|
+
|
|
1433
|
+
const firstRefresh = generated.refreshJobs()
|
|
1434
|
+
const secondRefresh = generated.refresh()
|
|
1435
|
+
expect(firstRefresh).toBe(secondRefresh)
|
|
1436
|
+
refreshRequest.resolve(response({ jobs: [completedJob('coalesced')] }))
|
|
1437
|
+
await firstRefresh
|
|
1438
|
+
|
|
1439
|
+
generated.dispose()
|
|
1440
|
+
generated.dispose()
|
|
1441
|
+
scope.stop()
|
|
1442
|
+
|
|
1443
|
+
expect({
|
|
1444
|
+
status: generated.status.value,
|
|
1445
|
+
requests: transport.mock.calls.length,
|
|
1446
|
+
filtered: generated.jobsFor('run').map(job => job.job_id),
|
|
1447
|
+
}).toEqual({
|
|
1448
|
+
status: 'disposed',
|
|
1449
|
+
requests: 5,
|
|
1450
|
+
filtered: ['coalesced'],
|
|
1451
|
+
})
|
|
1452
|
+
await expect(generated.initialize()).rejects.toThrow('disposed')
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
it('does not let a late list response overwrite a newer canonical job update', async () => {
|
|
1456
|
+
const staleRefresh = deferred<Response>()
|
|
1457
|
+
const queued = activeJob('live-job')
|
|
1458
|
+
const completed = completedJob('live-job')
|
|
1459
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1460
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1461
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1462
|
+
.mockResolvedValueOnce(response({
|
|
1463
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1464
|
+
}))
|
|
1465
|
+
.mockResolvedValueOnce(response({ jobs: [queued] }))
|
|
1466
|
+
.mockReturnValueOnce(staleRefresh.promise)
|
|
1467
|
+
.mockResolvedValueOnce(response(completed))
|
|
1468
|
+
const scope = effectScope()
|
|
1469
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1470
|
+
baseUrl: '/api/demo',
|
|
1471
|
+
transport,
|
|
1472
|
+
storage: memoryStorage(),
|
|
1473
|
+
createId: () => 'tab-1',
|
|
1474
|
+
stream: false,
|
|
1475
|
+
}))!
|
|
1476
|
+
|
|
1477
|
+
try {
|
|
1478
|
+
await generated.initialize()
|
|
1479
|
+
const refresh = generated.refreshJobs()
|
|
1480
|
+
await generated.getJob('live-job')
|
|
1481
|
+
staleRefresh.resolve(response({ jobs: [queued] }))
|
|
1482
|
+
await refresh
|
|
1483
|
+
|
|
1484
|
+
expect(generated.jobs.value[0]?.status).toBe('completed')
|
|
1485
|
+
} finally {
|
|
1486
|
+
scope.stop()
|
|
1487
|
+
}
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
it('does not regress a terminal job when a stale snapshot arrives without a concurrent update', async () => {
|
|
1491
|
+
const completed = completedJob('terminal-job')
|
|
1492
|
+
const staleQueued = activeJob('terminal-job')
|
|
1493
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1494
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1495
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1496
|
+
.mockResolvedValueOnce(response({
|
|
1497
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1498
|
+
}))
|
|
1499
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1500
|
+
.mockResolvedValueOnce(response({ jobs: [staleQueued] }))
|
|
1501
|
+
const scope = effectScope()
|
|
1502
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1503
|
+
baseUrl: '/api/demo',
|
|
1504
|
+
transport,
|
|
1505
|
+
storage: memoryStorage(),
|
|
1506
|
+
createId: () => 'tab-1',
|
|
1507
|
+
stream: false,
|
|
1508
|
+
}))!
|
|
1509
|
+
|
|
1510
|
+
try {
|
|
1511
|
+
await generated.initialize()
|
|
1512
|
+
await generated.refreshJobs()
|
|
1513
|
+
|
|
1514
|
+
expect(generated.jobs.value[0]?.status).toBe('completed')
|
|
1515
|
+
} finally {
|
|
1516
|
+
scope.stop()
|
|
1517
|
+
}
|
|
1518
|
+
})
|
|
1519
|
+
|
|
1520
|
+
it('initializes and lists jobs only once when refresh is the first operation', async () => {
|
|
1521
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1522
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1523
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1524
|
+
.mockResolvedValueOnce(response({
|
|
1525
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1526
|
+
}))
|
|
1527
|
+
.mockResolvedValueOnce(response({ jobs: [completedJob('initial-refresh')] }))
|
|
1528
|
+
const scope = effectScope()
|
|
1529
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1530
|
+
baseUrl: '/api/demo',
|
|
1531
|
+
transport,
|
|
1532
|
+
storage: memoryStorage(),
|
|
1533
|
+
createId: () => 'tab-1',
|
|
1534
|
+
stream: false,
|
|
1535
|
+
}))!
|
|
1536
|
+
|
|
1537
|
+
try {
|
|
1538
|
+
const firstRefresh = generated.refresh()
|
|
1539
|
+
const secondRefresh = generated.refresh()
|
|
1540
|
+
expect(firstRefresh).toBe(secondRefresh)
|
|
1541
|
+
const jobs = await firstRefresh
|
|
1542
|
+
|
|
1543
|
+
expect(jobs.map(job => job.job_id)).toEqual(['initial-refresh'])
|
|
1544
|
+
expect(transport).toHaveBeenCalledTimes(4)
|
|
1545
|
+
expect(transport.mock.calls.filter(([url]) => url === '/api/demo/jobs')).toHaveLength(1)
|
|
1546
|
+
} finally {
|
|
1547
|
+
scope.stop()
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('cancels one active job through the generated runtime', async () => {
|
|
1552
|
+
const running = {
|
|
1553
|
+
...activeJob('cancel-me', 'running'),
|
|
1554
|
+
}
|
|
1555
|
+
const cancelled = {
|
|
1556
|
+
...running,
|
|
1557
|
+
status: 'cancelled' as const,
|
|
1558
|
+
completed_at: '2026-07-21T00:00:00Z',
|
|
1559
|
+
can_cancel: false,
|
|
1560
|
+
can_delete: true,
|
|
1561
|
+
}
|
|
1562
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1563
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1564
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1565
|
+
.mockResolvedValueOnce(response({
|
|
1566
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1567
|
+
}))
|
|
1568
|
+
.mockResolvedValueOnce(response({ jobs: [running] }))
|
|
1569
|
+
.mockResolvedValueOnce(response(cancelled))
|
|
1570
|
+
const scope = effectScope()
|
|
1571
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1572
|
+
baseUrl: '/api/demo',
|
|
1573
|
+
transport,
|
|
1574
|
+
storage: memoryStorage(),
|
|
1575
|
+
createId: () => 'tab-1',
|
|
1576
|
+
stream: false,
|
|
1577
|
+
}))!
|
|
1578
|
+
|
|
1579
|
+
try {
|
|
1580
|
+
await generated.initialize()
|
|
1581
|
+
const updated = await generated.cancelJob('cancel-me')
|
|
1582
|
+
|
|
1583
|
+
expect({
|
|
1584
|
+
updated,
|
|
1585
|
+
jobs: generated.jobs.value,
|
|
1586
|
+
request: transport.mock.calls[4]?.slice(0, 2),
|
|
1587
|
+
}).toEqual({
|
|
1588
|
+
updated: cancelled,
|
|
1589
|
+
jobs: [cancelled],
|
|
1590
|
+
request: [
|
|
1591
|
+
'/api/demo/jobs/cancel-me/cancel',
|
|
1592
|
+
{ method: 'POST', headers: { Accept: 'application/json' } },
|
|
1593
|
+
],
|
|
1594
|
+
})
|
|
1595
|
+
} finally {
|
|
1596
|
+
scope.stop()
|
|
1597
|
+
}
|
|
1598
|
+
})
|
|
1599
|
+
|
|
1600
|
+
it('canonicalizes legacy job payloads and fetches one job without refreshing the list', async () => {
|
|
1601
|
+
const {
|
|
1602
|
+
definition_id: _activeDefinitionId,
|
|
1603
|
+
can_cancel: _activeCanCancel,
|
|
1604
|
+
can_delete: _activeCanDelete,
|
|
1605
|
+
result_status: _activeResultStatus,
|
|
1606
|
+
result_expires_at: _activeResultExpiresAt,
|
|
1607
|
+
...legacyActive
|
|
1608
|
+
} = activeJob('legacy-active', 'running')
|
|
1609
|
+
legacyActive.progress = { ...legacyActive.progress, percent: 140 }
|
|
1610
|
+
const {
|
|
1611
|
+
definition_id: _completedDefinitionId,
|
|
1612
|
+
can_cancel: _completedCanCancel,
|
|
1613
|
+
can_delete: _completedCanDelete,
|
|
1614
|
+
result_status: _completedResultStatus,
|
|
1615
|
+
result_expires_at: _completedResultExpiresAt,
|
|
1616
|
+
...legacyCompleted
|
|
1617
|
+
} = completedJob('legacy-completed')
|
|
1618
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1619
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1620
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1621
|
+
.mockResolvedValueOnce(response({
|
|
1622
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1623
|
+
}))
|
|
1624
|
+
.mockResolvedValueOnce(response({ jobs: [legacyActive] }))
|
|
1625
|
+
.mockResolvedValueOnce(response(legacyCompleted))
|
|
1626
|
+
const scope = effectScope()
|
|
1627
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1628
|
+
baseUrl: '/api/demo',
|
|
1629
|
+
transport,
|
|
1630
|
+
storage: memoryStorage(),
|
|
1631
|
+
createId: () => 'tab-1',
|
|
1632
|
+
stream: false,
|
|
1633
|
+
}))!
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
await generated.initialize()
|
|
1637
|
+
const fetched = await generated.getJob('legacy-completed')
|
|
1638
|
+
|
|
1639
|
+
expect(generated.jobs.value[1]).toMatchObject({
|
|
1640
|
+
job_id: 'legacy-active',
|
|
1641
|
+
definition_id: 'run',
|
|
1642
|
+
can_cancel: true,
|
|
1643
|
+
can_delete: false,
|
|
1644
|
+
result_status: 'none',
|
|
1645
|
+
progress: { percent: 100 },
|
|
1646
|
+
})
|
|
1647
|
+
expect(fetched).toMatchObject({
|
|
1648
|
+
job_id: 'legacy-completed',
|
|
1649
|
+
definition_id: 'run',
|
|
1650
|
+
can_cancel: false,
|
|
1651
|
+
can_delete: true,
|
|
1652
|
+
result_status: 'available',
|
|
1653
|
+
})
|
|
1654
|
+
expect(generated.jobs.value[0]).toBe(fetched)
|
|
1655
|
+
expect(transport.mock.calls[4]?.slice(0, 2)).toEqual([
|
|
1656
|
+
'/api/demo/jobs/legacy-completed',
|
|
1657
|
+
{ method: 'GET', headers: { Accept: 'application/json' } },
|
|
1658
|
+
])
|
|
1659
|
+
} finally {
|
|
1660
|
+
scope.stop()
|
|
1661
|
+
}
|
|
1662
|
+
})
|
|
1663
|
+
|
|
1664
|
+
it('deletes one finished job through the generated runtime', async () => {
|
|
1665
|
+
const completed = completedJob('delete-me')
|
|
1666
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1667
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1668
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1669
|
+
.mockResolvedValueOnce(response({
|
|
1670
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1671
|
+
}))
|
|
1672
|
+
.mockResolvedValueOnce(response({ jobs: [completed] }))
|
|
1673
|
+
.mockResolvedValueOnce(response({ status: 'deleted' }))
|
|
1674
|
+
const scope = effectScope()
|
|
1675
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1676
|
+
baseUrl: '/api/demo',
|
|
1677
|
+
transport,
|
|
1678
|
+
storage: memoryStorage(),
|
|
1679
|
+
createId: () => 'tab-1',
|
|
1680
|
+
stream: false,
|
|
1681
|
+
}))!
|
|
1682
|
+
|
|
1683
|
+
try {
|
|
1684
|
+
await generated.initialize()
|
|
1685
|
+
const deleted = await generated.deleteJob(completed)
|
|
1686
|
+
|
|
1687
|
+
expect({
|
|
1688
|
+
deleted,
|
|
1689
|
+
jobs: generated.jobs.value,
|
|
1690
|
+
request: transport.mock.calls[4]?.slice(0, 2),
|
|
1691
|
+
}).toEqual({
|
|
1692
|
+
deleted: true,
|
|
1693
|
+
jobs: [],
|
|
1694
|
+
request: ['/api/demo/jobs/delete-me', { method: 'DELETE', headers: { Accept: 'application/json' } }],
|
|
1695
|
+
})
|
|
1696
|
+
} finally {
|
|
1697
|
+
scope.stop()
|
|
1698
|
+
}
|
|
1699
|
+
})
|
|
1700
|
+
|
|
1701
|
+
it('clears finished jobs for one definition without clearing other definitions', async () => {
|
|
1702
|
+
const runJob = completedJob('clear-run')
|
|
1703
|
+
const otherJob = {
|
|
1704
|
+
...completedJob('keep-other'),
|
|
1705
|
+
definition_id: 'other',
|
|
1706
|
+
metadata: { definition_id: 'other', has_result: true },
|
|
1707
|
+
}
|
|
1708
|
+
const transport = vi.fn<GeneratedAnalysisTransport>()
|
|
1709
|
+
.mockResolvedValueOnce(response(manifest))
|
|
1710
|
+
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
1711
|
+
.mockResolvedValueOnce(response({
|
|
1712
|
+
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
1713
|
+
}))
|
|
1714
|
+
.mockResolvedValueOnce(response({ jobs: [runJob, otherJob] }))
|
|
1715
|
+
.mockResolvedValueOnce(response({ deleted: 1 }))
|
|
1716
|
+
.mockResolvedValueOnce(response({ jobs: [otherJob] }))
|
|
1717
|
+
const scope = effectScope()
|
|
1718
|
+
const generated = scope.run(() => useGeneratedAnalysis({
|
|
1719
|
+
baseUrl: '/api/demo',
|
|
1720
|
+
transport,
|
|
1721
|
+
storage: memoryStorage(),
|
|
1722
|
+
createId: () => 'tab-1',
|
|
1723
|
+
stream: false,
|
|
1724
|
+
}))!
|
|
1725
|
+
|
|
1726
|
+
try {
|
|
1727
|
+
await generated.initialize()
|
|
1728
|
+
const deleted = await generated.clearFinishedJobs('run')
|
|
1729
|
+
|
|
1730
|
+
expect(deleted).toBe(1)
|
|
1731
|
+
expect(generated.jobs.value.map(job => job.job_id)).toEqual(['keep-other'])
|
|
1732
|
+
expect(transport.mock.calls[4]?.slice(0, 2)).toEqual([
|
|
1733
|
+
'/api/demo/jobs?definition_id=run',
|
|
1734
|
+
{ method: 'DELETE', headers: { Accept: 'application/json' } },
|
|
1735
|
+
])
|
|
1736
|
+
} finally {
|
|
1737
|
+
scope.stop()
|
|
1738
|
+
}
|
|
1739
|
+
})
|
|
1740
|
+
})
|