@morscherlab/mint-sdk 1.0.61 → 1.1.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.
Files changed (147) hide show
  1. package/README.md +101 -2
  2. package/dist/EmptyState-D1c4RdC7.js +638 -0
  3. package/dist/EmptyState-D1c4RdC7.js.map +1 -0
  4. package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
  5. package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
  7. package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
  8. package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
  9. package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
  10. package/dist/__tests__/components/GeneratedPathInput.test.d.ts +1 -0
  11. package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
  12. package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
  13. package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
  14. package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
  15. package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
  16. package/dist/__tests__/generatedUi.test.d.ts +1 -0
  17. package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
  18. package/dist/{analysisArtifactTypes-Cu40dzSG.js.map → analysisArtifactTypes-ejqR8MX3.js.map} +1 -1
  19. package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
  20. package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
  21. package/dist/chunk-BoAXSpZd.js +33 -0
  22. package/dist/components/AppSidebar.vue.d.ts +1 -1
  23. package/dist/components/AutoGroupModal.vue.d.ts +2 -2
  24. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  25. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  26. package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
  27. package/dist/components/ConfirmDialog.vue.d.ts +1 -1
  28. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  29. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  30. package/dist/components/FileUploader.vue.d.ts +4 -2
  31. package/dist/components/FitPanel.vue.d.ts +1 -1
  32. package/dist/components/FormActions.vue.d.ts +1 -1
  33. package/dist/components/FormBuilder.vue.d.ts +1 -1
  34. package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
  35. package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
  36. package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
  37. package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
  38. package/dist/components/JobsStatusTray.vue.d.ts +5 -1
  39. package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
  40. package/dist/components/SampleLegend.vue.d.ts +1 -1
  41. package/dist/components/SampleSelector.vue.d.ts +2 -2
  42. package/dist/components/SettingsModal.vue.d.ts +1 -1
  43. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  44. package/dist/components/index.d.ts +4 -0
  45. package/dist/components/index.js +8 -9
  46. package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
  47. package/dist/{components-CpnOaPbP.js → components-UfkPN1bK.js} +2255 -2303
  48. package/dist/components-UfkPN1bK.js.map +1 -0
  49. package/dist/composables/index.d.ts +2 -1
  50. package/dist/composables/index.js +10 -11
  51. package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
  52. package/dist/composables/useJobsStatusTray.d.ts +48 -4
  53. package/dist/composables/usePluginClient.d.ts +6 -3
  54. package/dist/{composables-OYTD0Y3r.js → composables-F4JFK9K1.js} +37 -15
  55. package/dist/{composables-OYTD0Y3r.js.map → composables-F4JFK9K1.js.map} +1 -1
  56. package/dist/experiment-utils-CxkruZ3G.js +392 -0
  57. package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
  58. package/dist/generated/schema.d.ts +7 -0
  59. package/dist/generated-ui/main.d.ts +0 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +15 -18
  62. package/dist/index.js.map +1 -1
  63. package/dist/install.js +7 -8
  64. package/dist/install.js.map +1 -1
  65. package/dist/plotly.min-BR53hja4.js +180634 -0
  66. package/dist/plotly.min-BR53hja4.js.map +1 -0
  67. package/dist/stores/auth.d.ts +2 -2
  68. package/dist/stores/index.js +1 -1
  69. package/dist/styles.css +10543 -10404
  70. package/dist/templates/index.js +4 -3
  71. package/dist/templates/packs.d.ts +0 -1
  72. package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
  73. package/dist/templates-Cttbjbyc.js.map +1 -0
  74. package/dist/types/form-builder.d.ts +1 -1
  75. package/dist/types/generated-ui.d.ts +170 -0
  76. package/dist/types/index.d.ts +1 -0
  77. package/dist/types/index.js +1 -1
  78. package/dist/types/instrument.d.ts +1 -0
  79. package/dist/types/jobs.d.ts +7 -1
  80. package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
  81. package/dist/useControlSchema-Dallso-L.js.map +1 -0
  82. package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
  83. package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
  84. package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
  85. package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
  86. package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
  87. package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
  88. package/dist/{useAnalysisArtifacts-Ob8UtVwl.js → useProtocolTemplates-BP7_4RGQ.js} +307 -449
  89. package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
  90. package/package.json +7 -3
  91. package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
  92. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
  93. package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
  94. package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
  95. package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
  96. package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
  97. package/src/__tests__/composables/useApi.test.ts +8 -0
  98. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
  99. package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
  100. package/src/__tests__/composables/usePluginClient.test.ts +132 -1
  101. package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
  102. package/src/__tests__/generatedUi.test.ts +119 -0
  103. package/src/__tests__/templates/templates.test.ts +1 -1
  104. package/src/components/FileUploader.vue +49 -11
  105. package/src/components/GeneratedPathInput.vue +218 -0
  106. package/src/components/GeneratedPlotlyResult.vue +87 -0
  107. package/src/components/GeneratedPluginView.vue +279 -0
  108. package/src/components/GeneratedResultRenderer.vue +137 -0
  109. package/src/components/InstrumentAlertLog.vue +2 -2
  110. package/src/components/JobsStatusTray.vue +14 -8
  111. package/src/components/index.ts +4 -0
  112. package/src/composables/formBuilderRegistry.ts +2 -0
  113. package/src/composables/index.ts +18 -0
  114. package/src/composables/useApi.ts +1 -0
  115. package/src/composables/useGeneratedAnalysis.ts +1252 -0
  116. package/src/composables/useJobsStatusTray.ts +234 -45
  117. package/src/composables/usePluginClient.ts +54 -14
  118. package/src/generated/schema.ts +130 -0
  119. package/src/generated-ui/main.ts +21 -0
  120. package/src/index.ts +3 -0
  121. package/src/jobs.ts +5 -0
  122. package/src/plotly-dist.d.ts +3 -0
  123. package/src/templates/packs.ts +0 -3
  124. package/src/types/form-builder.ts +1 -0
  125. package/src/types/generated-ui.ts +176 -0
  126. package/src/types/index.ts +2 -0
  127. package/src/types/instrument.ts +1 -0
  128. package/src/types/jobs.ts +10 -1
  129. package/dist/BaseButton-Dgqrze41.js +0 -74
  130. package/dist/BaseButton-Dgqrze41.js.map +0 -1
  131. package/dist/BaseModal-B9UA8Y_I.js +0 -165
  132. package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
  133. package/dist/BaseSelect-ekgr9fDo.js +0 -179
  134. package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
  135. package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
  136. package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
  137. package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
  138. package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
  139. package/dist/components-CpnOaPbP.js.map +0 -1
  140. package/dist/experiment-utils-Bfa7CwPU.js +0 -108
  141. package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
  142. package/dist/templates-CNbPQNID.js.map +0 -1
  143. package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +0 -1
  144. package/dist/useControlSchema-BZNdalmL.js.map +0 -1
  145. package/dist/useEventListener-CfVkP9Xz.js +0 -57
  146. package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
  147. package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
@@ -2,7 +2,12 @@ import { mount } from '@vue/test-utils'
2
2
  import { defineComponent, nextTick, ref, shallowRef } from 'vue'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { useJobsStatusTray, type UseJobsStatusTrayOptions } from '../../composables/useJobsStatusTray'
5
+ import {
6
+ combinePluginJobSources,
7
+ useJobsStatusTray,
8
+ usePluginJobCenter,
9
+ type UseJobsStatusTrayOptions,
10
+ } from '../../composables/useJobsStatusTray'
6
11
  import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
7
12
 
8
13
  function job(status: JobStatus, id: string = status): JobStateInput {
@@ -62,6 +67,179 @@ function deferred<T>(): {
62
67
  }
63
68
 
64
69
  describe('useJobsStatusTray', () => {
70
+ it('combines live source jobs and fans out or routes source actions', async () => {
71
+ const firstJobs = shallowRef([job('running', 'first')])
72
+ const secondJobs = shallowRef([job('completed', 'second')])
73
+ const firstRefresh = vi.fn(async () => firstJobs.value)
74
+ const secondRefresh = vi.fn(async () => secondJobs.value)
75
+ const secondCancel = vi.fn(async (jobId: string) => job('cancelled', jobId))
76
+ const firstClear = vi.fn(async () => 1)
77
+ const secondClear = vi.fn(async () => 2)
78
+ const firstDispose = vi.fn()
79
+ const secondDispose = vi.fn()
80
+ const combined = combinePluginJobSources([
81
+ {
82
+ jobs: firstJobs,
83
+ refreshJobs: firstRefresh,
84
+ clearFinishedJobs: firstClear,
85
+ dispose: firstDispose,
86
+ },
87
+ {
88
+ jobs: secondJobs,
89
+ refreshJobs: secondRefresh,
90
+ cancelJob: secondCancel,
91
+ clearFinishedJobs: secondClear,
92
+ dispose: secondDispose,
93
+ },
94
+ ])
95
+
96
+ expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'second'])
97
+ secondJobs.value = [job('running', 'replacement')]
98
+ await nextTick()
99
+ expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'replacement'])
100
+
101
+ await combined.refreshJobs?.()
102
+ await combined.cancelJob?.('replacement')
103
+ await expect(combined.clearFinishedJobs?.()).resolves.toBe(3)
104
+ combined.dispose?.()
105
+
106
+ expect({
107
+ refreshes: [firstRefresh.mock.calls.length, secondRefresh.mock.calls.length],
108
+ cancelled: secondCancel.mock.calls[0]?.[0],
109
+ disposed: [firstDispose.mock.calls.length, secondDispose.mock.calls.length],
110
+ }).toEqual({
111
+ refreshes: [1, 1],
112
+ cancelled: 'replacement',
113
+ disposed: [1, 1],
114
+ })
115
+ })
116
+
117
+ it('retains every failure when disposing combined sources', () => {
118
+ const firstFailure = new Error('first dispose failed')
119
+ const secondFailure = { source: 'second', reason: 'dispose failed' }
120
+ const finalDispose = vi.fn()
121
+ const combined = combinePluginJobSources([
122
+ {
123
+ jobs: [],
124
+ dispose: () => {
125
+ throw firstFailure
126
+ },
127
+ },
128
+ {
129
+ jobs: [],
130
+ dispose: () => {
131
+ throw secondFailure
132
+ },
133
+ },
134
+ { jobs: [], dispose: finalDispose },
135
+ ])
136
+
137
+ let failure: unknown
138
+ try {
139
+ combined.dispose?.()
140
+ } catch (cause) {
141
+ failure = cause
142
+ }
143
+
144
+ expect(failure).toMatchObject({
145
+ name: 'PluginJobSourceDisposeError',
146
+ message: 'Failed to dispose plugin job sources',
147
+ cause: firstFailure,
148
+ errors: [firstFailure, secondFailure],
149
+ })
150
+ expect(finalDispose).toHaveBeenCalledOnce()
151
+ })
152
+
153
+ it('rejects duplicate ids instead of routing to an arbitrary combined source', async () => {
154
+ const firstCancel = vi.fn(async () => undefined)
155
+ const secondCancel = vi.fn(async () => undefined)
156
+ const combined = combinePluginJobSources([
157
+ { jobs: [job('running', 'duplicate')], cancelJob: firstCancel },
158
+ { jobs: [job('queued', 'duplicate')], cancelJob: secondCancel },
159
+ ])
160
+
161
+ expect(() => combined.jobs.value).toThrow('Duplicate plugin job id')
162
+ await expect(combined.cancelJob?.('duplicate')).rejects.toThrow('Duplicate plugin job id')
163
+ expect([firstCancel.mock.calls.length, secondCancel.mock.calls.length]).toEqual([0, 0])
164
+ })
165
+
166
+ it('does not expose a partial bulk clear across combined sources', () => {
167
+ const combined = combinePluginJobSources([
168
+ { jobs: [job('completed', 'first')], clearFinishedJobs: async () => 1 },
169
+ { jobs: [job('completed', 'second')] },
170
+ ])
171
+
172
+ expect(combined.clearFinishedJobs).toBeUndefined()
173
+ })
174
+
175
+ it('does not partially delete finished jobs when one combined source cannot delete', async () => {
176
+ const firstDelete = vi.fn(async () => true)
177
+ const combined = combinePluginJobSources([
178
+ { jobs: [job('completed', 'first')], deleteJob: firstDelete },
179
+ { jobs: [job('completed', 'second')] },
180
+ ])
181
+ let center!: ReturnType<typeof usePluginJobCenter>
182
+ const wrapper = mount(defineComponent({
183
+ setup() {
184
+ center = usePluginJobCenter({ source: combined, loadOnMount: false })
185
+ return () => null
186
+ },
187
+ }))
188
+
189
+ expect(combined.deleteJob).toBeUndefined()
190
+ expect(center.canClearFinished.value).toBe(false)
191
+ await expect(center.clearFinished()).resolves.toBe(false)
192
+ expect(firstDelete).not.toHaveBeenCalled()
193
+
194
+ wrapper.unmount()
195
+ })
196
+
197
+ it('derives jobs directly from a canonical source ref', async () => {
198
+ const sourceJobs = shallowRef([job('queued', 'first')])
199
+ let center!: ReturnType<typeof usePluginJobCenter>
200
+ const wrapper = mount(defineComponent({
201
+ setup() {
202
+ center = usePluginJobCenter({
203
+ source: { jobs: sourceJobs },
204
+ loadOnMount: false,
205
+ })
206
+ return () => null
207
+ },
208
+ }))
209
+
210
+ sourceJobs.value = [job('running', 'replacement')]
211
+ await nextTick()
212
+
213
+ expect(center.jobs.value.map(item => item.job_id)).toEqual(['replacement'])
214
+ wrapper.unmount()
215
+ })
216
+
217
+ it('delegates actions by id and leaves source mutation with the source', async () => {
218
+ const sourceJobs = shallowRef([job('running', 'fit')])
219
+ const cancelJob = vi.fn(async (jobId: string) => {
220
+ sourceJobs.value = [job('cancelled', jobId)]
221
+ return sourceJobs.value[0]
222
+ })
223
+ let center!: ReturnType<typeof usePluginJobCenter>
224
+ const wrapper = mount(defineComponent({
225
+ setup() {
226
+ center = usePluginJobCenter({
227
+ source: { jobs: sourceJobs, cancelJob },
228
+ loadOnMount: false,
229
+ })
230
+ return () => null
231
+ },
232
+ }))
233
+
234
+ await center.cancelJob(center.jobs.value[0]!)
235
+
236
+ expect([cancelJob.mock.calls[0]?.[0], center.jobs.value[0]?.status]).toEqual([
237
+ 'fit',
238
+ 'cancelled',
239
+ ])
240
+ wrapper.unmount()
241
+ })
242
+
65
243
  it('keeps awaiting input jobs in the active group', () => {
66
244
  const { tray } = mountComposable({
67
245
  jobs: [job('awaiting_input')],
@@ -62,8 +62,10 @@ function pendingEventStreamResponse(): {
62
62
  describe('usePluginClient', () => {
63
63
  let requestConfigs: AxiosRequestConfig[] = []
64
64
  let requestBodies: unknown[] = []
65
+ let requestMethods: string[] = []
65
66
  let nextGetError: Error | null = null
66
67
  let nextGetData: unknown
68
+ let nextPostData: unknown
67
69
  let nextPatchData: unknown
68
70
  let nextPutData: unknown
69
71
 
@@ -71,8 +73,10 @@ describe('usePluginClient', () => {
71
73
  setActivePinia(createPinia())
72
74
  requestConfigs = []
73
75
  requestBodies = []
76
+ requestMethods = []
74
77
  nextGetError = null
75
78
  nextGetData = undefined
79
+ nextPostData = undefined
76
80
  nextPatchData = undefined
77
81
  nextPutData = undefined
78
82
  vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
@@ -80,6 +84,7 @@ describe('usePluginClient', () => {
80
84
  url: string,
81
85
  config?: AxiosRequestConfig,
82
86
  ) {
87
+ requestMethods.push('get')
83
88
  requestConfigs.push({ url, ...config })
84
89
  if (nextGetError) throw nextGetError
85
90
  return { data: nextGetData ?? { called: url } }
@@ -90,9 +95,10 @@ describe('usePluginClient', () => {
90
95
  data?: unknown,
91
96
  config?: AxiosRequestConfig,
92
97
  ) {
98
+ requestMethods.push('post')
93
99
  requestConfigs.push({ url, ...config })
94
100
  requestBodies.push(data)
95
- return { data: { called: url } }
101
+ return { data: nextPostData ?? { called: url } }
96
102
  })
97
103
  vi.spyOn(axios.Axios.prototype, 'patch').mockImplementation(async function (
98
104
  this: unknown,
@@ -100,6 +106,7 @@ describe('usePluginClient', () => {
100
106
  data?: unknown,
101
107
  config?: AxiosRequestConfig,
102
108
  ) {
109
+ requestMethods.push('patch')
103
110
  requestConfigs.push({ url, ...config })
104
111
  requestBodies.push(data)
105
112
  return { data: nextPatchData ?? { called: url } }
@@ -110,6 +117,7 @@ describe('usePluginClient', () => {
110
117
  data?: unknown,
111
118
  config?: AxiosRequestConfig,
112
119
  ) {
120
+ requestMethods.push('put')
113
121
  requestConfigs.push({ url, ...config })
114
122
  requestBodies.push(data)
115
123
  return { data: nextPutData ?? { called: url } }
@@ -119,6 +127,7 @@ describe('usePluginClient', () => {
119
127
  url: string,
120
128
  config?: AxiosRequestConfig,
121
129
  ) {
130
+ requestMethods.push('delete')
122
131
  requestConfigs.push({ url, ...config })
123
132
  requestBodies.push(config?.data)
124
133
  return { data: { called: url } }
@@ -147,6 +156,21 @@ describe('usePluginClient', () => {
147
156
  expect(requestConfigs[0]!.url).toBe('/health')
148
157
  })
149
158
 
159
+ it('omits MINT auth transport for a public generated endpoint', async () => {
160
+ const client = createPluginClient(contract, {
161
+ endpoints: {
162
+ health: { method: 'get', path: '/health', requiresAuth: false },
163
+ },
164
+ })
165
+
166
+ await client.health()
167
+
168
+ expect(
169
+ (requestConfigs[0] as AxiosRequestConfig & { _mintSkipAuth?: boolean })
170
+ ._mintSkipAuth,
171
+ ).toBe(true)
172
+ })
173
+
150
174
  it('VITE_API_PREFIX overrides contract apiPrefix', async () => {
151
175
  vi.stubEnv('VITE_API_PREFIX', '/api/override')
152
176
  const client = createPluginClient(contract, {
@@ -410,6 +434,91 @@ describe('usePluginClient', () => {
410
434
  expect(requestConfigs[0]!.responseType).toBe('blob')
411
435
  })
412
436
 
437
+ it('downloads generated POST endpoint blobs with the contract body and request options', async () => {
438
+ const expectedBlob = new Blob(['zip'])
439
+ const controller = new AbortController()
440
+ nextPostData = expectedBlob
441
+
442
+ const blob = await downloadPluginEndpoint(
443
+ contract,
444
+ {
445
+ method: 'post',
446
+ path: '/export/{sessionId}/results',
447
+ pathParams: ['sessionId'],
448
+ queryParams: [
449
+ { name: 'revision', fieldName: 'revision', type: 'number', required: true },
450
+ ],
451
+ hasBody: true,
452
+ requestContentType: 'application/json',
453
+ },
454
+ {
455
+ sessionId: 'session-1',
456
+ revision: 4,
457
+ body: { format: 'wide', correction: false },
458
+ },
459
+ { signal: controller.signal },
460
+ )
461
+
462
+ expect({
463
+ blob,
464
+ body: requestBodies[0],
465
+ url: requestConfigs[0]!.url,
466
+ params: requestConfigs[0]!.params,
467
+ signal: requestConfigs[0]!.signal,
468
+ responseType: requestConfigs[0]!.responseType,
469
+ method: requestMethods[0],
470
+ }).toEqual({
471
+ blob: expectedBlob,
472
+ body: { format: 'wide', correction: false },
473
+ url: '/export/session-1/results',
474
+ params: { revision: 4 },
475
+ signal: controller.signal,
476
+ responseType: 'blob',
477
+ method: 'post',
478
+ })
479
+ })
480
+
481
+ it.each(['put', 'patch', 'delete'] as const)(
482
+ 'downloads generated %s endpoint blobs with the contract body',
483
+ async (method) => {
484
+ await downloadPluginEndpoint(
485
+ contract,
486
+ {
487
+ method,
488
+ path: '/exports/{exportId}',
489
+ pathParams: ['exportId'],
490
+ hasBody: true,
491
+ },
492
+ { exportId: 'export-1', body: { format: 'wide' } },
493
+ )
494
+
495
+ expect({
496
+ body: requestBodies[0],
497
+ method: requestMethods[0],
498
+ responseType: requestConfigs[0]!.responseType,
499
+ url: requestConfigs[0]!.url,
500
+ }).toEqual({
501
+ body: { format: 'wide' },
502
+ method,
503
+ responseType: 'blob',
504
+ url: '/exports/export-1',
505
+ })
506
+ },
507
+ )
508
+
509
+ it('rejects multipart download request bodies', async () => {
510
+ await expect(downloadPluginEndpoint(
511
+ contract,
512
+ {
513
+ method: 'post',
514
+ path: '/exports',
515
+ hasBody: true,
516
+ requestContentType: 'multipart/form-data',
517
+ },
518
+ { body: { format: 'wide' } },
519
+ )).rejects.toThrow('Plugin download endpoints do not support multipart request bodies')
520
+ })
521
+
413
522
  it.each([
414
523
  new Blob(['chunk']),
415
524
  new Uint8Array([1, 2, 3]),
@@ -680,6 +789,28 @@ describe('usePluginClient', () => {
680
789
  expect(stream.lastEventId.value).toBe('7')
681
790
  })
682
791
 
792
+ it('omits MINT auth from a public plugin event stream', async () => {
793
+ const authStore = useAuthStore()
794
+ authStore.setToken('stream-token', 3600)
795
+ const fetchMock = vi.fn<typeof fetch>(async () =>
796
+ new Response(new ReadableStream({ start: controller => controller.close() }), {
797
+ status: 200,
798
+ }),
799
+ )
800
+ vi.stubGlobal('fetch', fetchMock)
801
+
802
+ usePluginEventStream(
803
+ contract,
804
+ { method: 'get', path: '/public/events', requiresAuth: false },
805
+ { reconnect: false },
806
+ )
807
+
808
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
809
+ const [, init] = fetchMock.mock.calls[0]!
810
+
811
+ expect((init?.headers as Headers).get('Authorization')).toBeNull()
812
+ })
813
+
683
814
  it('normalizes stream auth failures and does not reconnect rejected requests', async () => {
684
815
  const authStore = useAuthStore()
685
816
  authStore.setToken('expired-stream-token', 3600)
@@ -47,7 +47,6 @@ describe('extract docs template helpers', () => {
47
47
  group: packEntry.category,
48
48
  source: 'packages/sdk-frontend/src/templates/packs.ts',
49
49
  add_command: packEntry.add_command,
50
- init_command: packEntry.init_command,
51
50
  docs_command: packEntry.docs_command,
52
51
  })
53
52
  })
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ generatedJobDefaults,
5
+ generatedJobFormSchema,
6
+ normalizeGeneratedJobInput,
7
+ } from '../generated/schema'
8
+ import type { GeneratedJobDefinition } from '../types/generated-ui'
9
+
10
+ const definition: GeneratedJobDefinition = {
11
+ id: 'summarize',
12
+ title: 'Summarize',
13
+ description: 'Summarize a dataset',
14
+ cpu: 2,
15
+ input_schema: {
16
+ type: 'object',
17
+ properties: {
18
+ threshold: { type: 'number', title: 'Threshold', default: 2.5, minimum: 0, maximum: 10 },
19
+ method: { type: 'string', title: 'Method', enum: ['fast', 'robust'], default: 'fast' },
20
+ normalize: { type: 'boolean', title: 'Normalize', default: true },
21
+ source: {
22
+ anyOf: [{ type: 'string', format: 'path' }, { type: 'null' }],
23
+ default: null,
24
+ 'x-mint-component': 'path',
25
+ },
26
+ },
27
+ required: ['threshold'],
28
+ },
29
+ output_schema: {},
30
+ }
31
+
32
+ describe('generated UI schema adapter', () => {
33
+ it('maps the supported JSON Schema subset to standard FormBuilder fields', () => {
34
+ expect(generatedJobFormSchema(definition).sections[0]?.fields).toMatchObject([
35
+ { name: 'threshold', type: 'number', validation: { required: true, min: 0, max: 10 } },
36
+ { name: 'method', type: 'select', props: { options: ['fast', 'robust'] } },
37
+ { name: 'normalize', type: 'toggle' },
38
+ { name: 'source', type: 'path' },
39
+ ])
40
+ })
41
+
42
+ it('extracts defaults without sharing mutable input state', () => {
43
+ const defaults = generatedJobDefaults(definition)
44
+ const second = generatedJobDefaults(definition)
45
+ defaults.threshold = 9
46
+
47
+ expect(second).toEqual({ threshold: 2.5, method: 'fast', normalize: true, source: null })
48
+ })
49
+
50
+ it('preserves explicit null defaults without inventing values for missing defaults', () => {
51
+ const nullableDefinition: GeneratedJobDefinition = {
52
+ ...definition,
53
+ input_schema: {
54
+ type: 'object',
55
+ properties: {
56
+ optional_count: {
57
+ anyOf: [{ type: 'integer' }, { type: 'null' }],
58
+ default: null,
59
+ },
60
+ required_count: { type: 'integer' },
61
+ },
62
+ required: ['required_count'],
63
+ },
64
+ }
65
+
66
+ expect({
67
+ defaults: generatedJobDefaults(nullableDefinition),
68
+ fields: generatedJobFormSchema(nullableDefinition).sections[0]?.fields.map(field => ({
69
+ name: field.name,
70
+ defaultValue: field.defaultValue,
71
+ })),
72
+ }).toEqual({
73
+ defaults: { optional_count: null },
74
+ fields: [
75
+ { name: 'optional_count', defaultValue: null },
76
+ { name: 'required_count', defaultValue: undefined },
77
+ ],
78
+ })
79
+ })
80
+
81
+ it('keeps a selected path for SDK staging during submission', () => {
82
+ const selection = {
83
+ kind: 'path' as const,
84
+ files: [new File(['data'], 'data.csv')],
85
+ relativePaths: ['data.csv'],
86
+ }
87
+
88
+ expect(normalizeGeneratedJobInput(definition, {
89
+ threshold: 2.5,
90
+ source: selection,
91
+ })).toEqual({
92
+ threshold: 2.5,
93
+ source: selection,
94
+ })
95
+ })
96
+
97
+ it('marks path arrays as multiple path inputs', () => {
98
+ const listDefinition: GeneratedJobDefinition = {
99
+ ...definition,
100
+ input_schema: {
101
+ type: 'object',
102
+ properties: {
103
+ sources: {
104
+ type: 'array',
105
+ items: { type: 'string', format: 'path' },
106
+ 'x-mint-component': 'path',
107
+ },
108
+ },
109
+ required: ['sources'],
110
+ },
111
+ }
112
+
113
+ expect(generatedJobFormSchema(listDefinition).sections[0]?.fields[0]).toMatchObject({
114
+ name: 'sources',
115
+ type: 'path',
116
+ props: { multiple: true },
117
+ })
118
+ })
119
+ })
@@ -166,7 +166,7 @@ describe('bio data templates', () => {
166
166
  expect(getBioTemplatePackInfo('metabolism')?.name).toBe('omics-assay')
167
167
  expect(getBioTemplatePackInfo('metabolite profiling')?.name).toBe('omics-assay')
168
168
  expect(getBioTemplatePackInfo('metabolomics')?.components).toContain('ExperimentTimeline')
169
- expect(getBioTemplatePackInfo('longitudinal')?.init_command).toBe('mint init --template longitudinal-study')
169
+ expect(getBioTemplatePackInfo('longitudinal')?.add_command).toBe('mint add data-template-pack longitudinal-study --page')
170
170
  expect(getBioTemplatePackInfo('gene expression')?.templates.at(-1)).toBe('qpcr-plate')
171
171
  expect(getBioTemplatePackInfo('gene-expression')?.templates.at(-1)).toBe('qpcr-plate')
172
172
  expect(searchBioTemplatePacks('WellPlate').map(entry => entry.name)).toEqual([
@@ -19,7 +19,8 @@ const props = withDefaults(defineProps<Props>(), {
19
19
  })
20
20
 
21
21
  const emit = defineEmits<{
22
- upload: [files: File[]]
22
+ upload: [files: File[], rootName?: string, relativePaths?: string[]]
23
+ remove: [files: File[]]
23
24
  error: [message: string]
24
25
  }>()
25
26
 
@@ -116,20 +117,31 @@ function validateFiles(files: FileList | File[]): File[] {
116
117
  return validFiles
117
118
  }
118
119
 
119
- function handleFiles(files: FileList | File[], folder?: string) {
120
+ function handleFiles(
121
+ files: FileList | File[],
122
+ folder?: string,
123
+ explicitRelativePaths?: string[],
124
+ ) {
125
+ const candidates = Array.from(files)
120
126
  const validFiles = validateFiles(files)
121
127
  if (validFiles.length === 0) return
128
+ const relativePaths = validFiles.map((file) => {
129
+ const candidateIndex = candidates.indexOf(file)
130
+ return explicitRelativePaths?.[candidateIndex]
131
+ || file.webkitRelativePath
132
+ || (folder ? `${folder}/${file.name}` : file.name)
133
+ })
122
134
 
123
135
  if (isFolder.value) {
124
136
  folderName.value = folder || extractFolderName(validFiles)
125
137
  selectedFiles.value = validFiles
126
- emit('upload', validFiles)
138
+ emit('upload', validFiles, folderName.value ?? undefined, relativePaths)
127
139
  } else if (!props.multiple) {
128
140
  selectedFiles.value = [validFiles[0]]
129
- emit('upload', [validFiles[0]])
141
+ emit('upload', [validFiles[0]], undefined, [relativePaths[0]])
130
142
  } else {
131
143
  selectedFiles.value = [...selectedFiles.value, ...validFiles]
132
- emit('upload', validFiles)
144
+ emit('upload', validFiles, undefined, relativePaths)
133
145
  }
134
146
  }
135
147
 
@@ -156,25 +168,39 @@ function handleDrop(event: DragEvent) {
156
168
 
157
169
  async function handleFolderDrop(items: DataTransferItemList) {
158
170
  const files: File[] = []
171
+ const relativePaths: string[] = []
159
172
  let folderNameFromDrop: string | undefined
160
173
 
161
174
  for (const item of Array.from(items)) {
162
175
  const entry = item.webkitGetAsEntry?.()
163
176
  if (entry?.isDirectory) {
164
177
  folderNameFromDrop = entry.name
165
- await readDirectory(entry as FileSystemDirectoryEntry, files)
178
+ await readDirectory(
179
+ entry as FileSystemDirectoryEntry,
180
+ files,
181
+ relativePaths,
182
+ entry.name,
183
+ )
166
184
  } else if (entry?.isFile) {
167
185
  const file = await getFile(entry as FileSystemFileEntry)
168
- if (file) files.push(file)
186
+ if (file) {
187
+ files.push(file)
188
+ relativePaths.push(file.name)
189
+ }
169
190
  }
170
191
  }
171
192
 
172
193
  if (files.length > 0) {
173
- handleFiles(files, folderNameFromDrop)
194
+ handleFiles(files, folderNameFromDrop, relativePaths)
174
195
  }
175
196
  }
176
197
 
177
- async function readDirectory(directory: FileSystemDirectoryEntry, files: File[]): Promise<void> {
198
+ async function readDirectory(
199
+ directory: FileSystemDirectoryEntry,
200
+ files: File[],
201
+ relativePaths: string[],
202
+ prefix: string,
203
+ ): Promise<void> {
178
204
  const reader = directory.createReader()
179
205
  // readEntries returns at most 100 entries per call per spec — must loop
180
206
  let batch: FileSystemEntry[]
@@ -185,9 +211,17 @@ async function readDirectory(directory: FileSystemDirectoryEntry, files: File[])
185
211
  for (const entry of batch) {
186
212
  if (entry.isFile) {
187
213
  const file = await getFile(entry as FileSystemFileEntry)
188
- if (file) files.push(file)
214
+ if (file) {
215
+ files.push(file)
216
+ relativePaths.push(`${prefix}/${file.name}`)
217
+ }
189
218
  } else if (entry.isDirectory) {
190
- await readDirectory(entry as FileSystemDirectoryEntry, files)
219
+ await readDirectory(
220
+ entry as FileSystemDirectoryEntry,
221
+ files,
222
+ relativePaths,
223
+ `${prefix}/${entry.name}`,
224
+ )
191
225
  }
192
226
  }
193
227
  } while (batch.length > 0)
@@ -225,15 +259,19 @@ function openFilePicker() {
225
259
  }
226
260
 
227
261
  function removeFile(index: number) {
262
+ const removed = selectedFiles.value[index]
228
263
  selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
229
264
  if (isFolder.value && selectedFiles.value.length === 0) {
230
265
  folderName.value = null
231
266
  }
267
+ if (removed) emit('remove', [removed])
232
268
  }
233
269
 
234
270
  function clearAll() {
271
+ const removed = [...selectedFiles.value]
235
272
  selectedFiles.value = []
236
273
  folderName.value = null
274
+ if (removed.length) emit('remove', removed)
237
275
  }
238
276
 
239
277
  function formatFileSize(bytes: number): string {