@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.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 (215) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
  3. package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DHE9k8TP.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DHE9k8TP.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DOcCf0Vo.js} +41 -18
  7. package/dist/SettingsModal-DOcCf0Vo.js.map +1 -0
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-jhF9wUkU.js} +19 -9
  9. package/dist/Skeleton-jhF9wUkU.js.map +1 -0
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  12. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  13. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  14. package/dist/__tests__/components/FormField.test.d.ts +1 -0
  15. package/dist/__tests__/components/NumberInput.test.d.ts +1 -0
  16. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  17. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  18. package/dist/__tests__/composables/localFiles.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  20. package/dist/__tests__/composables/usePickerSelection.test.d.ts +1 -0
  21. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  22. package/dist/__tests__/styles/tokenOnlyColors.test.d.ts +1 -0
  23. package/dist/apiErrors.d.ts +26 -0
  24. package/dist/components/AppSidebar.vue.d.ts +7 -7
  25. package/dist/components/BaseInput.vue.d.ts +1 -1
  26. package/dist/components/BasePill.vue.d.ts +3 -0
  27. package/dist/components/BaseSelect.vue.d.ts +1 -1
  28. package/dist/components/BaseTabs.vue.d.ts +1 -4
  29. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  30. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  31. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  32. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  33. package/dist/components/DatePicker.vue.d.ts +1 -1
  34. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  35. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  36. package/dist/components/FilePicker.vue.d.ts +32 -0
  37. package/dist/components/FileUploader.vue.d.ts +8 -2
  38. package/dist/components/FormField.vue.d.ts +8 -1
  39. package/dist/components/FormulaInput.vue.d.ts +1 -1
  40. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  41. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  42. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  43. package/dist/components/NumberInput.vue.d.ts +1 -1
  44. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  45. package/dist/components/PluginWorkspaceView.props.d.ts +3 -3
  46. package/dist/components/ResourceCard.vue.d.ts +1 -1
  47. package/dist/components/SecretInput.vue.d.ts +1 -1
  48. package/dist/components/SequenceInput.vue.d.ts +1 -1
  49. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  50. package/dist/components/TagsInput.vue.d.ts +1 -1
  51. package/dist/components/TimePicker.vue.d.ts +1 -1
  52. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  53. package/dist/components/UnitInput.vue.d.ts +1 -1
  54. package/dist/components/WellPlate.vue.d.ts +1 -1
  55. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  56. package/dist/components/index.d.ts +4 -0
  57. package/dist/components/index.js +8 -8
  58. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  59. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  60. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  61. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  62. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  63. package/dist/{components-CgAUzF8F.js → components-Cp8DcNrU.js} +4363 -1730
  64. package/dist/components-Cp8DcNrU.js.map +1 -0
  65. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  66. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  67. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  68. package/dist/composables/filePicker/validation.d.ts +5 -0
  69. package/dist/composables/index.d.ts +2 -0
  70. package/dist/composables/index.js +10 -10
  71. package/dist/composables/pluginWorkspaceModel.d.ts +0 -3
  72. package/dist/composables/useApi.d.ts +14 -7
  73. package/dist/composables/useFileBrowser.d.ts +55 -0
  74. package/dist/composables/useFilePicker.d.ts +189 -0
  75. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  76. package/dist/composables/useToast.d.ts +22 -1
  77. package/dist/{composables-B_pia3hO.js → composables-D_4qqOlp.js} +300 -11
  78. package/dist/composables-D_4qqOlp.js.map +1 -0
  79. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  80. package/dist/errors-BLuqMee5.js.map +1 -0
  81. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  82. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  83. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  84. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  85. package/dist/index.d.ts +1 -0
  86. package/dist/index.js +15 -15
  87. package/dist/install.js +2 -2
  88. package/dist/stores/index.js +2 -2
  89. package/dist/styles.css +2957 -1708
  90. package/dist/templates/index.js +2 -2
  91. package/dist/{templates-Qq113pyZ.js → templates-Dd9bBSs6.js} +2 -2
  92. package/dist/{templates-Qq113pyZ.js.map → templates-Dd9bBSs6.js.map} +1 -1
  93. package/dist/types/components.d.ts +21 -0
  94. package/dist/types/fileBrowserTypes.d.ts +77 -0
  95. package/dist/types/filePicker.d.ts +77 -0
  96. package/dist/types/form-builder.d.ts +2 -0
  97. package/dist/types/generated-ui.d.ts +2 -0
  98. package/dist/types/index.d.ts +3 -1
  99. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BB2GRPhW.js} +190 -105
  100. package/dist/useControlSchema-BB2GRPhW.js.map +1 -0
  101. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  102. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  103. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-G_CAOFTX.js} +4 -4
  104. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-G_CAOFTX.js.map} +1 -1
  105. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-BnGSdSRG.js} +2 -2
  106. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-BnGSdSRG.js.map} +1 -1
  107. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-CTxuzLIX.js} +70 -13
  108. package/dist/validation-CTxuzLIX.js.map +1 -0
  109. package/package.json +1 -1
  110. package/src/__tests__/apiErrors.test.ts +81 -0
  111. package/src/__tests__/components/AppSidebar.test.ts +84 -94
  112. package/src/__tests__/components/AppToastContainer.test.ts +55 -1
  113. package/src/__tests__/components/BioTemplatePresetWorkspaceView.test.ts +0 -21
  114. package/src/__tests__/components/ControlWorkspaceView.test.ts +0 -2
  115. package/src/__tests__/components/DoseDesignWorkspaceView.test.ts +0 -2
  116. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  117. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  118. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  119. package/src/__tests__/components/FilePicker.test.ts +487 -0
  120. package/src/__tests__/components/FormBuilder.test.ts +26 -0
  121. package/src/__tests__/components/FormField.test.ts +26 -0
  122. package/src/__tests__/components/GeneratedPathInput.test.ts +18 -0
  123. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  124. package/src/__tests__/components/NumberInput.test.ts +105 -0
  125. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  126. package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +1 -2
  127. package/src/__tests__/components/PluginWorkspaceView.test.ts +1 -2
  128. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  129. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  130. package/src/__tests__/composables/localFiles.test.ts +57 -0
  131. package/src/__tests__/composables/useApi.test.ts +92 -9
  132. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  133. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  134. package/src/__tests__/composables/usePickerSelection.test.ts +98 -0
  135. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  136. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  137. package/src/__tests__/generatedUi.test.ts +111 -0
  138. package/src/__tests__/styles/tokenOnlyColors.test.ts +16 -0
  139. package/src/apiErrors.ts +85 -0
  140. package/src/components/AppLayout.story.vue +1 -1
  141. package/src/components/AppSidebar.story.vue +7 -10
  142. package/src/components/AppSidebar.vue +178 -58
  143. package/src/components/AppToastContainer.vue +65 -23
  144. package/src/components/BasePill.story.vue +2 -0
  145. package/src/components/BasePill.vue +4 -0
  146. package/src/components/BaseTabs.story.vue +0 -42
  147. package/src/components/BaseTabs.vue +3 -6
  148. package/src/components/ExperimentPopover.vue +10 -2
  149. package/src/components/FileBrowserModal.story.vue +203 -0
  150. package/src/components/FileBrowserModal.vue +384 -0
  151. package/src/components/FilePicker.story.vue +44 -0
  152. package/src/components/FilePicker.vue +284 -0
  153. package/src/components/FileUploader.vue +42 -15
  154. package/src/components/FormField.vue +10 -3
  155. package/src/components/GeneratedPathInput.vue +11 -7
  156. package/src/components/GeneratedPlotlyResult.vue +34 -69
  157. package/src/components/GeneratedResultRenderer.vue +0 -5
  158. package/src/components/NumberInput.story.vue +32 -13
  159. package/src/components/NumberInput.vue +116 -55
  160. package/src/components/PlotlyChart.story.vue +61 -0
  161. package/src/components/PlotlyChart.vue +233 -0
  162. package/src/components/PluginWorkspaceView.props.ts +3 -3
  163. package/src/components/SegmentedControl.vue +2 -2
  164. package/src/components/fileBrowserPresentation.ts +39 -0
  165. package/src/components/index.ts +5 -0
  166. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  167. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  168. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  169. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  170. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  171. package/src/components/internal/FormFieldRendererInternal.vue +17 -0
  172. package/src/components/internal/WorkspaceSidebarInternal.vue +1 -2
  173. package/src/composables/filePicker/localFiles.ts +65 -0
  174. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  175. package/src/composables/filePicker/usePickerSelection.ts +270 -0
  176. package/src/composables/filePicker/validation.ts +41 -0
  177. package/src/composables/index.ts +7 -0
  178. package/src/composables/pluginWorkspaceModel.ts +0 -3
  179. package/src/composables/useApi.ts +57 -13
  180. package/src/composables/useFileBrowser.ts +314 -0
  181. package/src/composables/useFilePicker.ts +366 -0
  182. package/src/composables/useGeneratedAnalysis.ts +10 -17
  183. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  184. package/src/composables/usePluginClient.ts +3 -0
  185. package/src/composables/usePluginWorkspace.ts +1 -2
  186. package/src/composables/useToast.ts +21 -5
  187. package/src/generated/schema.ts +58 -18
  188. package/src/index.ts +3 -0
  189. package/src/styles/components/app-sidebar.css +282 -121
  190. package/src/styles/components/file-browser-modal.css +489 -0
  191. package/src/styles/components/file-picker.css +502 -0
  192. package/src/styles/components/form-builder.css +1 -1
  193. package/src/styles/components/form-field.css +28 -0
  194. package/src/styles/components/icon-button.css +9 -0
  195. package/src/styles/components/number-input.css +73 -91
  196. package/src/styles/components/pill.css +49 -51
  197. package/src/styles/components/segmented-control.css +16 -11
  198. package/src/styles/components/tabs.css +18 -34
  199. package/src/styles/components/toast.css +136 -27
  200. package/src/styles/index.css +3 -0
  201. package/src/styles/variables.css +25 -5
  202. package/src/types/components.ts +23 -0
  203. package/src/types/fileBrowserTypes.ts +85 -0
  204. package/src/types/filePicker.ts +72 -0
  205. package/src/types/form-builder.ts +2 -0
  206. package/src/types/generated-ui.ts +2 -0
  207. package/src/types/index.ts +16 -0
  208. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  209. package/dist/SettingsModal-Dphh3RrI.js.map +0 -1
  210. package/dist/Skeleton-DxtwJ5gt.js.map +0 -1
  211. package/dist/components-CgAUzF8F.js.map +0 -1
  212. package/dist/composables-B_pia3hO.js.map +0 -1
  213. package/dist/errors-_W0-LbA6.js.map +0 -1
  214. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  215. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
3
3
  import { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
4
4
 
5
5
  import { AuthenticationRequiredError, useApi } from '../../composables/useApi'
6
+ import { MintApiError } from '../../apiErrors'
6
7
  import { useAuthStore } from '../../stores/auth'
7
8
 
8
9
  function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
@@ -32,6 +33,7 @@ function unauthorizedAdapter(config: AxiosRequestConfig): Promise<never> {
32
33
  describe('useApi URL helpers', () => {
33
34
  beforeEach(() => {
34
35
  setActivePinia(createPinia())
36
+ useAuthStore().clearToken()
35
37
  })
36
38
 
37
39
  afterEach(() => {
@@ -98,25 +100,35 @@ describe('useApi URL helpers', () => {
98
100
  expect(authorization).toBeUndefined()
99
101
  })
100
102
 
101
- it('normalizes authenticated 401 responses and clears the rejected token', async () => {
103
+ it.each([false, true])('normalizes authenticated 401 responses and clears the rejected token (typedErrors=%s)', async (typedErrors) => {
102
104
  const authStore = useAuthStore()
103
105
  authStore.setToken('expired-token', 3600)
104
- const api = useApi({ baseUrl: '/api' })
106
+ const api = useApi({ baseUrl: '/api', typedErrors })
105
107
 
106
108
  const adapter = vi.fn(unauthorizedAdapter)
107
109
  const error = await api.get('/protected', { adapter }).catch((reason: unknown) => reason)
108
110
 
109
- expect({ attempts: adapter.mock.calls.length, errorType: error?.constructor, token: authStore.token }).toEqual({
111
+ expect({
112
+ attempts: adapter.mock.calls.length,
113
+ errorType: error?.constructor,
114
+ typed: error instanceof MintApiError,
115
+ status: (error as MintApiError).status,
116
+ code: (error as MintApiError).code,
117
+ token: authStore.token,
118
+ }).toEqual({
110
119
  attempts: 1,
111
120
  errorType: AuthenticationRequiredError,
121
+ typed: true,
122
+ status: 401,
123
+ code: 'AUTHENTICATION_REQUIRED',
112
124
  token: null,
113
125
  })
114
126
  })
115
127
 
116
- it('does not clear auth state for public requests that return 401', async () => {
128
+ it.each([false, true])('does not clear auth state for public requests that return 401 (typedErrors=%s)', async (typedErrors) => {
117
129
  const authStore = useAuthStore()
118
130
  authStore.setToken('still-valid-token', 3600)
119
- const api = useApi({ baseUrl: '/api', withAuth: false })
131
+ const api = useApi({ baseUrl: '/api', typedErrors, withAuth: false })
120
132
 
121
133
  const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason)
122
134
 
@@ -126,10 +138,10 @@ describe('useApi URL helpers', () => {
126
138
  })
127
139
  })
128
140
 
129
- it('does not clear a replacement token after an older request returns 401', async () => {
141
+ it.each([false, true])('does not clear a replacement token after an older request returns 401 (typedErrors=%s)', async (typedErrors) => {
130
142
  const authStore = useAuthStore()
131
143
  authStore.setToken('rejected-token', 3600)
132
- const api = useApi({ baseUrl: '/api' })
144
+ const api = useApi({ baseUrl: '/api', typedErrors })
133
145
 
134
146
  await api.get('/protected', {
135
147
  adapter: async (config) => {
@@ -141,9 +153,10 @@ describe('useApi URL helpers', () => {
141
153
  expect(authStore.token).toBe('replacement-token')
142
154
  })
143
155
 
144
- it('does not clear a token created while an unauthenticated request is in flight', async () => {
156
+ it.each([false, true])('does not clear a token created while an unauthenticated request is in flight (typedErrors=%s)', async (typedErrors) => {
145
157
  const authStore = useAuthStore()
146
- const api = useApi({ baseUrl: '/api' })
158
+ const api = useApi({ baseUrl: '/api', typedErrors })
159
+ expect(authStore.token).toBeNull()
147
160
 
148
161
  await api.get('/protected', {
149
162
  adapter: async (config) => {
@@ -154,4 +167,74 @@ describe('useApi URL helpers', () => {
154
167
 
155
168
  expect(authStore.token).toBe('new-login-token')
156
169
  })
170
+
171
+ it('normalizes a typed response when typed errors are enabled', async () => {
172
+ const api = useApi({ baseUrl: '/api', typedErrors: true })
173
+ const adapter = vi.fn(async (config: AxiosRequestConfig) => Promise.reject(new AxiosError(
174
+ 'Not Found',
175
+ AxiosError.ERR_BAD_REQUEST,
176
+ config as InternalAxiosRequestConfig,
177
+ undefined,
178
+ {
179
+ data: {
180
+ code: 'leaf.ARTIFACT_NOT_FOUND',
181
+ message: 'Artifact is not available',
182
+ status: 404,
183
+ request_id: 'req-generated',
184
+ details: { artifact_id: 67 },
185
+ detail: 'Artifact is not available',
186
+ },
187
+ status: 404,
188
+ statusText: 'Not Found',
189
+ headers: { 'x-request-id': 'req-generated' },
190
+ config: config as InternalAxiosRequestConfig,
191
+ },
192
+ )))
193
+
194
+ const error = await api.get('/artifacts/67', { adapter }).catch((reason: unknown) => reason)
195
+
196
+ const typedError = error as MintApiError
197
+ expect({ attempts: adapter.mock.calls.length, type: typedError.constructor, code: typedError.code }).toEqual({
198
+ attempts: 1,
199
+ type: MintApiError,
200
+ code: 'leaf.ARTIFACT_NOT_FOUND',
201
+ })
202
+ })
203
+
204
+ it.each([true, false])('normalizes Blob download errors (JSON=%s)', async (json) => {
205
+ const api = useApi({ typedErrors: true })
206
+ const body = { code: 'artifact.unavailable', message: 'No artifact', details: { id: 67 } }
207
+ const blob = new Blob([json ? JSON.stringify(body) : 'Bad gateway'], {
208
+ type: json ? 'application/json' : 'text/plain',
209
+ })
210
+ let cause: AxiosError | undefined
211
+ const error = await api.get('/download', {
212
+ responseType: 'blob',
213
+ adapter: async (config) => {
214
+ cause = new AxiosError('Bad gateway', AxiosError.ERR_BAD_RESPONSE, config, undefined, {
215
+ data: blob, status: 502, statusText: 'Bad gateway',
216
+ headers: { 'x-request-id': 'blob-request' }, config,
217
+ })
218
+ throw cause
219
+ },
220
+ }).catch((reason: unknown) => reason)
221
+
222
+ expect(error).toBeInstanceOf(MintApiError)
223
+ expect(error).toMatchObject({
224
+ code: json ? body.code : 'unknown',
225
+ message: json ? body.message : 'Bad gateway',
226
+ status: 502, requestId: 'blob-request', cause,
227
+ })
228
+ expect((error as MintApiError).body).toEqual(json ? body : blob)
229
+ })
230
+
231
+ it('leaves cancellations and network failures unchanged in typed mode', async () => {
232
+ const api = useApi({ typedErrors: true })
233
+ const cause = new AxiosError('Offline', AxiosError.ERR_NETWORK)
234
+ await expect(api.get('/offline', { adapter: async () => { throw cause } })).rejects.toBe(cause)
235
+ const controller = new AbortController()
236
+ controller.abort()
237
+ await expect(api.get('/cancelled', { signal: controller.signal })).rejects.toMatchObject({ code: 'ERR_CANCELED' })
238
+ })
239
+
157
240
  })
@@ -0,0 +1,305 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
+
3
+ const mockGet = vi.fn()
4
+ vi.mock('../../composables/useApi', () => ({
5
+ useApi: () => ({ get: mockGet }),
6
+ }))
7
+
8
+ import { effectScope, nextTick, ref } from 'vue'
9
+
10
+ import { useFileBrowser } from '../../composables/useFileBrowser'
11
+
12
+ const MOUNTS = {
13
+ mounts: [
14
+ { id: 'seq', label: '/mnt/sequencer-01', available: false },
15
+ { id: 'qe', label: '/mnt/instrument-qe', available: true },
16
+ ],
17
+ }
18
+
19
+ const LISTING = {
20
+ mount_id: 'qe',
21
+ path: 'raw',
22
+ parent: '',
23
+ breadcrumbs: [{ label: '/mnt/instrument-qe', path: '' }, { label: 'raw', path: 'raw' }],
24
+ entries: [
25
+ { name: 'blanks', path: 'raw/blanks', kind: 'directory', child_count: 6 },
26
+ { name: 'a.raw', path: 'raw/a.raw', kind: 'file', size_bytes: 10, extension: '.raw' },
27
+ ],
28
+ total_count: 2,
29
+ match_count: 1,
30
+ }
31
+
32
+ beforeEach(() => {
33
+ mockGet.mockReset()
34
+ })
35
+
36
+ describe('useFileBrowser', () => {
37
+ it('should load the configured mounts', async () => {
38
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
39
+ const browser = useFileBrowser()
40
+ await browser.init()
41
+
42
+ expect(browser.mounts.value.map(m => m.id)).toEqual(['seq', 'qe'])
43
+ })
44
+
45
+ // An offline mount must not be the one we open on.
46
+ it('should open the first available mount', async () => {
47
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
48
+ const browser = useFileBrowser()
49
+ await browser.init()
50
+
51
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe')
52
+ })
53
+
54
+ // 'nas' is listed after 'qe', so choosing it proves the preference is honoured
55
+ // rather than the first-available fallback happening to win.
56
+ it('should honour an explicit initial mount when it is reachable', async () => {
57
+ mockGet
58
+ .mockResolvedValueOnce({ mounts: [...MOUNTS.mounts, { id: 'nas', label: '/mnt/nas', available: true }] })
59
+ .mockResolvedValueOnce(LISTING)
60
+ const browser = useFileBrowser({ initialMountId: 'nas' })
61
+ await browser.init()
62
+
63
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=nas')
64
+ })
65
+
66
+ it('should expose the listing entries', async () => {
67
+ mockGet.mockResolvedValue(LISTING)
68
+ const browser = useFileBrowser()
69
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
70
+
71
+ expect(browser.entries.value.map(e => e.name)).toEqual(['blanks', 'a.raw'])
72
+ })
73
+
74
+ it('should expose the breadcrumb trail', async () => {
75
+ mockGet.mockResolvedValue(LISTING)
76
+ const browser = useFileBrowser()
77
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
78
+
79
+ expect(browser.breadcrumbs.value.map(c => c.label)).toEqual(['/mnt/instrument-qe', 'raw'])
80
+ })
81
+
82
+ it('should send the configured type rules', async () => {
83
+ mockGet.mockResolvedValue(LISTING)
84
+ const browser = useFileBrowser({ typeRules: ['.raw', '.mzML'] })
85
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
86
+
87
+ expect(mockGet.mock.calls[0][0]).toContain('type_rules=.raw&type_rules=.mzML')
88
+ })
89
+
90
+ it('should surface a listing failure', async () => {
91
+ mockGet.mockRejectedValue(new Error('Mount is offline'))
92
+ const browser = useFileBrowser()
93
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
94
+
95
+ expect(browser.error.value).toContain('Mount is offline')
96
+ })
97
+
98
+ it('should empty the listing when a request fails', async () => {
99
+ mockGet.mockRejectedValue(new Error('nope'))
100
+ const browser = useFileBrowser()
101
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
102
+
103
+ expect(browser.entries.value).toEqual([])
104
+ })
105
+
106
+ // A slow response for the folder you left must not overwrite the one you are in.
107
+ it('should ignore a superseded listing response', async () => {
108
+ let resolveSlow: (value: unknown) => void = () => {}
109
+ const slow = new Promise(resolve => { resolveSlow = resolve })
110
+ mockGet.mockReturnValueOnce(slow).mockResolvedValueOnce(LISTING)
111
+
112
+ const browser = useFileBrowser()
113
+ const first = browser.navigate({ mount_id: 'qe', path: 'stale' })
114
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
115
+ resolveSlow({ ...LISTING, path: 'stale', entries: [] })
116
+ await first
117
+
118
+ expect(browser.path.value).toBe('raw')
119
+ })
120
+
121
+ it('should take the current folder into the selection', async () => {
122
+ mockGet.mockResolvedValue(LISTING)
123
+ const browser = useFileBrowser()
124
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
125
+ browser.selectCurrentFolder()
126
+
127
+ expect(browser.selected.value).toMatchObject([{ mount_id: 'qe', path: 'raw', kind: 'directory' }])
128
+ })
129
+
130
+ it('should not take the same folder twice', async () => {
131
+ mockGet.mockResolvedValue(LISTING)
132
+ const browser = useFileBrowser()
133
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
134
+ browser.selectCurrentFolder()
135
+ browser.selectCurrentFolder()
136
+
137
+ expect(browser.selected.value).toHaveLength(1)
138
+ })
139
+
140
+ it('should clear the selection', async () => {
141
+ mockGet.mockResolvedValue(LISTING)
142
+ const browser = useFileBrowser()
143
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
144
+ browser.selectCurrentFolder()
145
+ browser.clear()
146
+
147
+ expect(browser.selected.value).toEqual([])
148
+ })
149
+
150
+ it('should report the unfiltered total count', async () => {
151
+ mockGet.mockResolvedValue(LISTING)
152
+ const browser = useFileBrowser()
153
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
154
+
155
+ expect(browser.totalCount.value).toBe(2)
156
+ })
157
+
158
+ it('should not be loading once a request settles', async () => {
159
+ mockGet.mockResolvedValue(LISTING)
160
+ const browser = useFileBrowser()
161
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
162
+
163
+ expect(browser.isLoading.value).toBe(false)
164
+ })
165
+ })
166
+
167
+ describe('useFileBrowser review fixes', () => {
168
+ it('should skip an offline mount even when named as the initial one', async () => {
169
+ mockGet.mockResolvedValueOnce(MOUNTS).mockResolvedValueOnce(LISTING)
170
+ const browser = useFileBrowser({ initialMountId: 'seq' })
171
+ await browser.init()
172
+
173
+ expect(mockGet.mock.calls[1][0]).toContain('mount_id=qe')
174
+ })
175
+
176
+ // No configured mounts means the routes are absent, not that browsing failed.
177
+ it('should treat a 404 mount list as no mounts configured', async () => {
178
+ mockGet.mockRejectedValue({ response: { status: 404 } })
179
+ const browser = useFileBrowser()
180
+ await browser.init()
181
+
182
+ expect(browser.error.value).toBeNull()
183
+ })
184
+
185
+ it('should leave the mount list empty when none are configured', async () => {
186
+ mockGet.mockRejectedValue({ response: { status: 404 } })
187
+ const browser = useFileBrowser()
188
+ await browser.init()
189
+
190
+ expect(browser.mounts.value).toEqual([])
191
+ })
192
+
193
+ it('should surface the API error detail rather than the status code', async () => {
194
+ mockGet.mockRejectedValue({
195
+ response: { status: 404, data: { detail: "Mount 'nas' is not available" } },
196
+ message: 'Request failed with status code 404',
197
+ })
198
+ const browser = useFileBrowser()
199
+ await browser.navigate({ mount_id: 'nas', path: '' })
200
+
201
+ expect(browser.error.value).toBe("Mount 'nas' is not available")
202
+ })
203
+
204
+ it('should refetch when the sort changes', async () => {
205
+ mockGet.mockResolvedValue(LISTING)
206
+ const browser = useFileBrowser()
207
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
208
+ const before = mockGet.mock.calls.length
209
+ browser.sort.value = 'size'
210
+ await new Promise(resolve => setTimeout(resolve, 0))
211
+
212
+ expect(mockGet.mock.calls.length).toBeGreaterThan(before)
213
+ })
214
+
215
+ it('should refetch when the search changes', async () => {
216
+ mockGet.mockResolvedValue(LISTING)
217
+ const browser = useFileBrowser()
218
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
219
+ const before = mockGet.mock.calls.length
220
+ browser.search.value = 'hela'
221
+ await new Promise(resolve => setTimeout(resolve, 400))
222
+
223
+ expect(mockGet.mock.calls.length).toBeGreaterThan(before)
224
+ })
225
+
226
+ it('should expose how many rows the listing carries', async () => {
227
+ mockGet.mockResolvedValue({ ...LISTING, shown_count: 2 })
228
+ const browser = useFileBrowser()
229
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
230
+
231
+ expect(browser.shownCount.value).toBe(2)
232
+ })
233
+
234
+ it('should expose a truncated listing', async () => {
235
+ mockGet.mockResolvedValue({ ...LISTING, truncated: true })
236
+ const browser = useFileBrowser()
237
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
238
+
239
+ expect(browser.truncated.value).toBe(true)
240
+ })
241
+ })
242
+
243
+
244
+ describe('useFileBrowser selection and request lifecycle', () => {
245
+ it('never selects an uninitialized or failed directory', async () => {
246
+ const browser = useFileBrowser()
247
+ browser.selectCurrentFolder()
248
+ expect(browser.selected.value).toEqual([])
249
+ mockGet.mockRejectedValueOnce(new Error('offline'))
250
+ await browser.navigate({ mount_id: 'nas', path: 'runs' })
251
+ browser.selectCurrentFolder()
252
+ expect(browser.selected.value).toEqual([])
253
+ })
254
+
255
+ it('retries the failed destination rather than returning to the previous folder', async () => {
256
+ mockGet.mockResolvedValueOnce(LISTING).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ ...LISTING, path: 'new' })
257
+ const browser = useFileBrowser()
258
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
259
+ await browser.navigate({ mount_id: 'qe', path: 'new' })
260
+ await browser.refresh()
261
+ expect(mockGet.mock.calls.at(-1)?.[0]).toContain('path=new')
262
+ })
263
+
264
+ it('does not let obsolete requests keep the current directory loading', async () => {
265
+ let finish!: (value: unknown) => void
266
+ mockGet.mockReturnValueOnce(new Promise(resolve => { finish = resolve })).mockResolvedValueOnce(LISTING)
267
+ const browser = useFileBrowser()
268
+ const old = browser.navigate({ mount_id: 'qe', path: 'old' })
269
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
270
+ expect(browser.isLoading.value).toBe(false)
271
+ finish(LISTING)
272
+ await old
273
+ })
274
+
275
+ it('refreshes when reactive type rules change', async () => {
276
+ mockGet.mockResolvedValue(LISTING)
277
+ const typeRules = ref(['.raw'])
278
+ const scope = effectScope()
279
+ const browser = scope.run(() => useFileBrowser({ typeRules }))!
280
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
281
+ typeRules.value = ['.mzML']
282
+ await nextTick()
283
+ expect(mockGet.mock.calls.at(-1)?.[0]).toContain('type_rules=.mzML')
284
+ scope.stop()
285
+ })
286
+
287
+ it('disposes a pending search without issuing another request', async () => {
288
+ vi.useFakeTimers()
289
+ const scope = effectScope()
290
+ try {
291
+ mockGet.mockResolvedValue(LISTING)
292
+ const browser = scope.run(() => useFileBrowser())!
293
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
294
+ browser.search.value = 'abc'
295
+ await nextTick()
296
+ const calls = mockGet.mock.calls.length
297
+ scope.stop()
298
+ await vi.advanceTimersByTimeAsync(300)
299
+ expect(mockGet).toHaveBeenCalledTimes(calls)
300
+ } finally {
301
+ scope.stop()
302
+ vi.useRealTimers()
303
+ }
304
+ })
305
+ })
@@ -2,6 +2,7 @@ import { effectScope } from 'vue'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import { useGeneratedAnalysis } from '../../composables/useGeneratedAnalysis'
5
+ import { MintApiError } from '../../apiErrors'
5
6
  import {
6
7
  createGeneratedAnalysisTransport,
7
8
  type GeneratedAnalysisTransport,
@@ -701,7 +702,10 @@ describe('useGeneratedAnalysis', () => {
701
702
  ])
702
703
  })
703
704
 
704
- it('should recreate an expired session and retry one job with the same request id', async () => {
705
+ it.each([
706
+ { detail: 'Session not found' },
707
+ { code: 'resource.not_found', message: 'Session not found', request_id: 'session-request' },
708
+ ])('should recreate an expired session and retry one job with the same request id (%j)', async (errorBody) => {
705
709
  const storage = memoryStorage()
706
710
  const createId = vi.fn()
707
711
  .mockReturnValueOnce('tab-1')
@@ -713,7 +717,7 @@ describe('useGeneratedAnalysis', () => {
713
717
  id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
714
718
  }))
715
719
  .mockResolvedValueOnce(response({ jobs: [] }))
716
- .mockResolvedValueOnce(response({ detail: 'Session not found' }, 404))
720
+ .mockResolvedValueOnce(response(errorBody, 404))
717
721
  .mockResolvedValueOnce(response({ sessions: [] }))
718
722
  .mockResolvedValueOnce(response({
719
723
  id: 'session-2', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
@@ -796,6 +800,7 @@ describe('useGeneratedAnalysis', () => {
796
800
  scope.stop()
797
801
  }
798
802
 
803
+ expect(caught).toBeInstanceOf(MintApiError)
799
804
  expect({
800
805
  status: (caught as { status?: number } | undefined)?.status,
801
806
  message: caught instanceof Error ? caught.message : null,
@@ -0,0 +1,98 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { effectScope, ref, type EffectScope } from 'vue'
3
+ import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation'
4
+ import { usePickerSelection } from '../../composables/filePicker/usePickerSelection'
5
+ import type { PickerCapability, PickerNode, PickerSelectionMode } from '../../types/filePicker'
6
+
7
+ const scopes: EffectScope[] = []
8
+ afterEach(() => scopes.splice(0).forEach(scope => scope.stop()))
9
+
10
+ function setup(nodes: PickerNode[]) {
11
+ const scope = effectScope()
12
+ scopes.push(scope)
13
+ return scope.run(() => {
14
+ const capability = ref<PickerCapability>({ formats: ['raw'] })
15
+ const hidden = ref('')
16
+ const mode = ref<PickerSelectionMode>('folder+files')
17
+ const nav = usePickerNavigation(() => ({
18
+ listRoot: async () => [], listChildren: async () => [], search: async () => [],
19
+ }), () => ({}), node => node.path !== hidden.value)
20
+ nav.register(nodes)
21
+ const selection = usePickerSelection(nav, () => mode.value, () => capability.value, () => false)
22
+ const progress = (path: string) => selection.folderProgress(nav.nodes.value.get(path)!)
23
+ return { nav, selection, capability, hidden, mode, progress }
24
+ })!
25
+ }
26
+
27
+ describe('file picker folder progress', () => {
28
+ it('tracks exclusions, selection modes, replaced paths and changing ancestors', () => {
29
+ const { nav, selection, capability, hidden, mode, progress } = setup([
30
+ { kind: 'folder', path: 'root', name: 'root', fileCount: 2 },
31
+ { kind: 'folder', path: 'a', name: 'a', parentPath: 'root', fileCount: 2 },
32
+ { kind: 'folder', path: 'b', name: 'b', parentPath: 'root', fileCount: 0 },
33
+ { kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'a' },
34
+ { kind: 'file', path: 'g', name: 'g.txt', format: 'txt', parentPath: 'a' },
35
+ ])
36
+ selection.keys.value.add('root')
37
+ expect(progress('root')).toBe('1 / 2')
38
+ expect(progress('a')).toBe('1 / 2')
39
+ expect(progress('b')).toBe('0 / 0')
40
+ selection.dropped.value.add('a')
41
+ expect(progress('root')).toBe('0 / 2')
42
+ expect(progress('a')).toBe('0 / 2')
43
+ selection.dropped.value.clear()
44
+ capability.value.formats.push('txt')
45
+ expect(progress('a')).toBe('2 / 2')
46
+ nav.register([{ kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'b' }])
47
+ expect(progress('a')).toBe('1 / 2')
48
+ expect(progress('b')).toBe('1 / 0')
49
+ expect(progress('root')).toBe('2 / 2')
50
+ nav.nodes.value.get('b')!.parentPath = null
51
+ expect(progress('root')).toBe('1 / 2')
52
+ selection.keys.value.add('b')
53
+ expect(progress('b')).toBe('1 / 0')
54
+ hidden.value = 'b'
55
+ expect(progress('b')).toBe('0 / 0')
56
+ mode.value = 'folder'
57
+ expect(progress('a')).toBe('0 / 2')
58
+ expect(progress('root')).toBe('1 / 2')
59
+ nav.nodes.value.delete('g')
60
+ expect(progress('root')).toBe('0 / 2')
61
+ expect(selection.folderProgress({ kind: 'folder', path: 'unknown', name: 'unknown' })).toBe('')
62
+ expect(selection.folderProgress(nav.nodes.value.get('f')!)).toBe('')
63
+ })
64
+
65
+ it('counts each file once for cyclic or missing ancestors', () => {
66
+ const { nav, selection, progress } = setup([
67
+ { kind: 'folder', path: 'a', name: 'a', parentPath: 'b', fileCount: 1 },
68
+ { kind: 'folder', path: 'b', name: 'b', parentPath: 'a', fileCount: 1 },
69
+ { kind: 'file', path: 'f', name: 'f.raw', format: 'raw', parentPath: 'a' },
70
+ ])
71
+ selection.keys.value.add('a')
72
+ expect(progress('a')).toBe('1 / 1')
73
+ expect(progress('b')).toBe('1 / 1')
74
+ nav.nodes.value.delete('a')
75
+ expect(selection.folderProgress({ kind: 'folder', path: 'a', name: 'a', fileCount: 1 })).toBe('1 / 1')
76
+ expect(progress('b')).toBe('0 / 1')
77
+ })
78
+
79
+ it('does not rescan every selected file for each folder row', () => {
80
+ const count = 200
81
+ const { nav, selection, progress } = setup([
82
+ { kind: 'folder', path: 'root', name: 'root', fileCount: count },
83
+ ])
84
+ let parentReads = 0
85
+ for (let i = 0; i < count; i++) {
86
+ nav.register([{ kind: 'folder', path: `d${i}`, name: `d${i}`, parentPath: 'root', fileCount: 1 }])
87
+ nav.nodes.value.set(`f${i}`, {
88
+ kind: 'file', path: `f${i}`, name: `${i}.raw`, format: 'raw',
89
+ get parentPath() { parentReads++; return `d${i}` },
90
+ })
91
+ }
92
+ selection.keys.value.add('root')
93
+ expect(selection.chosen.value.files).toHaveLength(count)
94
+ parentReads = 0
95
+ for (let i = 0; i < count; i++) expect(progress(`d${i}`)).toBe('1 / 1')
96
+ expect(parentReads).toBeLessThanOrEqual(count * 2)
97
+ })
98
+ })
@@ -0,0 +1,67 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest'
2
+ const { get } = vi.hoisted(() => ({ get: vi.fn() }))
3
+ vi.mock('../../composables/useApi', () => ({ useApi: () => ({ get }) }))
4
+ import {
5
+ decodePlatformPickerPath,
6
+ encodePlatformPickerPath,
7
+ usePlatformFilePickerAdapter,
8
+ } from '../../composables/usePlatformFilePickerAdapter'
9
+
10
+ beforeEach(() => get.mockReset())
11
+ it('preserves mount and relative path identities with punctuation and Unicode', () => {
12
+ const path = '测试/one, [2] # ?.raw'
13
+ expect(decodePlatformPickerPath(encodePlatformPickerPath('instrument', path))).toEqual({
14
+ mountId: 'instrument',
15
+ path,
16
+ })
17
+ expect(() => decodePlatformPickerPath('["mount"]')).toThrow()
18
+ expect(() => decodePlatformPickerPath('["", "file"]')).toThrow()
19
+ })
20
+ it('passes cancellation, marks offline mounts, and maps compound extensions', async () => {
21
+ get
22
+ .mockResolvedValueOnce({
23
+ mounts: [
24
+ { id: 'online', label: 'Instrument', available: true },
25
+ { id: 'offline', label: 'NAS', available: false },
26
+ ],
27
+ })
28
+ .mockResolvedValueOnce({
29
+ entries: [{ kind: 'file', path: 'run.mzML.gz', name: 'run.mzML.gz', size_bytes: 7 }],
30
+ })
31
+ const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
32
+ const signal = new AbortController().signal
33
+ const roots = await adapter.listRoot({ signal })
34
+ expect(roots[1]?.unavailableReason).toContain('offline')
35
+ const files = await adapter.listChildren(roots[0]!.path, { signal })
36
+ expect(files[0]).toMatchObject({
37
+ kind: 'file',
38
+ format: 'mzML.gz',
39
+ bytes: 7,
40
+ parentPath: roots[0]!.path,
41
+ })
42
+ expect(get.mock.calls.every((call) => call[1].signal === signal)).toBe(true)
43
+ })
44
+ it('refuses truncated listings rather than returning a partial folder selection', async () => {
45
+ get.mockResolvedValue({ truncated: true, entries: [] })
46
+ await expect(
47
+ usePlatformFilePickerAdapter({ apiBaseUrl: '/api' }).listChildren(
48
+ encodePlatformPickerPath('a'),
49
+ ),
50
+ ).rejects.toThrow('listing limit')
51
+ })
52
+ it('scopes shallow search to one level and recursive search skips offline mounts', async () => {
53
+ get.mockImplementation(async (url: string) =>
54
+ url === '/filesystem/mounts'
55
+ ? {
56
+ mounts: [
57
+ { id: 'a', label: 'Instrument', available: true },
58
+ { id: 'b', label: 'NAS', available: false },
59
+ ],
60
+ }
61
+ : { entries: [{ kind: 'file', path: 'run.raw', name: 'run.raw', size_bytes: 1 }] },
62
+ )
63
+ const adapter = usePlatformFilePickerAdapter({ apiBaseUrl: '/api' })
64
+ expect(await adapter.search('run', { path: '', recursive: false })).toEqual([])
65
+ expect(await adapter.search('run', { path: '', recursive: true })).toHaveLength(1)
66
+ expect(get.mock.calls.filter((call) => call[0].includes('/browse'))).toHaveLength(1)
67
+ })