@morscherlab/mint-sdk 1.2.0-beta.14 → 1.2.0-beta.16

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 (111) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-BIZKoRTT.js → ExperimentPopover-CJdNlCZb.js} +3 -3
  3. package/dist/{ExperimentPopover-BIZKoRTT.js.map → ExperimentPopover-CJdNlCZb.js.map} +1 -1
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-CZ1f_K9I.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-CZ1f_K9I.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DQUcpT4T.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-DQUcpT4T.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-JaDtC8-9.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-JaDtC8-9.js.map} +1 -1
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  12. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  13. package/dist/apiErrors.d.ts +26 -0
  14. package/dist/components/BaseInput.vue.d.ts +1 -1
  15. package/dist/components/BaseSelect.vue.d.ts +1 -1
  16. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  17. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  18. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  19. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  20. package/dist/components/DatePicker.vue.d.ts +1 -1
  21. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  22. package/dist/components/FileBrowserModal.vue.d.ts +1 -1
  23. package/dist/components/FilePicker.vue.d.ts +3 -3
  24. package/dist/components/FileUploader.vue.d.ts +5 -2
  25. package/dist/components/FormulaInput.vue.d.ts +1 -1
  26. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  27. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  28. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  29. package/dist/components/NumberInput.vue.d.ts +1 -1
  30. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  31. package/dist/components/ResourceCard.vue.d.ts +1 -1
  32. package/dist/components/SecretInput.vue.d.ts +1 -1
  33. package/dist/components/SequenceInput.vue.d.ts +1 -1
  34. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  35. package/dist/components/TagsInput.vue.d.ts +1 -1
  36. package/dist/components/TimePicker.vue.d.ts +1 -1
  37. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  38. package/dist/components/UnitInput.vue.d.ts +1 -1
  39. package/dist/components/WellPlate.vue.d.ts +1 -1
  40. package/dist/components/index.d.ts +1 -0
  41. package/dist/components/index.js +8 -8
  42. package/dist/{components-6IIxCSI8.js → components-DT6ggNrt.js} +580 -366
  43. package/dist/components-DT6ggNrt.js.map +1 -0
  44. package/dist/composables/filePicker/localFiles.d.ts +1 -1
  45. package/dist/composables/filePicker/usePickerNavigation.d.ts +4 -3
  46. package/dist/composables/index.js +9 -9
  47. package/dist/composables/useApi.d.ts +2 -0
  48. package/dist/composables/useFilePicker.d.ts +5 -6
  49. package/dist/{composables-hY2Q2xPI.js → composables-DmF_8iyX.js} +13 -10
  50. package/dist/{composables-hY2Q2xPI.js.map → composables-DmF_8iyX.js.map} +1 -1
  51. package/dist/{errors-_W0-LbA6.js → errors-DZDb1cIb.js} +66 -5
  52. package/dist/errors-DZDb1cIb.js.map +1 -0
  53. package/dist/{experiment-Cwl9AYxw.js → experiment-CHm_TwJy.js} +2 -2
  54. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CHm_TwJy.js.map} +1 -1
  55. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  56. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +15 -15
  59. package/dist/install.js +2 -2
  60. package/dist/stores/index.js +2 -2
  61. package/dist/styles.css +37 -18
  62. package/dist/templates/index.js +2 -2
  63. package/dist/{templates-Qq113pyZ.js → templates-CXARYr6_.js} +2 -2
  64. package/dist/{templates-Qq113pyZ.js.map → templates-CXARYr6_.js.map} +1 -1
  65. package/dist/types/filePicker.d.ts +9 -1
  66. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BAzmP1_y.js} +42 -37
  67. package/dist/useControlSchema-BAzmP1_y.js.map +1 -0
  68. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  69. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  70. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-D_vobhnW.js} +4 -4
  71. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-D_vobhnW.js.map} +1 -1
  72. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-DEHejpiL.js} +2 -2
  73. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-DEHejpiL.js.map} +1 -1
  74. package/dist/{validation-nBbSn1TI.js → validation-OuEr0iAS.js} +9 -8
  75. package/dist/{validation-nBbSn1TI.js.map → validation-OuEr0iAS.js.map} +1 -1
  76. package/package.json +1 -1
  77. package/src/__tests__/apiErrors.test.ts +81 -0
  78. package/src/__tests__/components/FilePicker.state.test.ts +128 -1
  79. package/src/__tests__/components/FilePicker.test.ts +146 -1
  80. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  81. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  82. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  83. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  84. package/src/__tests__/composables/useApi.test.ts +82 -9
  85. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  86. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  87. package/src/apiErrors.ts +85 -0
  88. package/src/components/FilePicker.story.vue +44 -0
  89. package/src/components/FilePicker.vue +23 -40
  90. package/src/components/FileUploader.vue +32 -14
  91. package/src/components/GeneratedPlotlyResult.vue +34 -69
  92. package/src/components/GeneratedResultRenderer.vue +0 -5
  93. package/src/components/PlotlyChart.story.vue +61 -0
  94. package/src/components/PlotlyChart.vue +233 -0
  95. package/src/components/SegmentedControl.vue +2 -2
  96. package/src/components/index.ts +1 -0
  97. package/src/composables/filePicker/localFiles.ts +6 -3
  98. package/src/composables/filePicker/usePickerNavigation.ts +29 -8
  99. package/src/composables/filePicker/usePickerSelection.ts +17 -4
  100. package/src/composables/filePicker/validation.ts +1 -0
  101. package/src/composables/useApi.ts +33 -4
  102. package/src/composables/useFilePicker.ts +41 -22
  103. package/src/composables/useGeneratedAnalysis.ts +10 -17
  104. package/src/composables/usePluginClient.ts +3 -0
  105. package/src/index.ts +3 -0
  106. package/src/styles/components/file-picker.css +6 -5
  107. package/src/styles/components/icon-button.css +9 -0
  108. package/src/types/filePicker.ts +9 -2
  109. package/dist/components-6IIxCSI8.js.map +0 -1
  110. package/dist/errors-_W0-LbA6.js.map +0 -1
  111. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.2.0-beta.14",
3
+ "version": "1.2.0-beta.16",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { MintApiError, mintApiErrorFromResponse } from '../apiErrors'
4
+
5
+ describe('MintApiError', () => {
6
+ it('should expose a typed server envelope', () => {
7
+ const error = mintApiErrorFromResponse(404, {
8
+ code: 'leaf.ARTIFACT_NOT_FOUND',
9
+ message: 'Artifact is not available',
10
+ status: 404,
11
+ request_id: 'req-typed',
12
+ details: { artifact_id: 67 },
13
+ detail: 'Artifact is not available',
14
+ })
15
+
16
+ expect({
17
+ type: error.constructor,
18
+ code: error.code,
19
+ status: error.status,
20
+ requestId: error.requestId,
21
+ details: error.details,
22
+ }).toEqual({
23
+ type: MintApiError,
24
+ code: 'leaf.ARTIFACT_NOT_FOUND',
25
+ status: 404,
26
+ requestId: 'req-typed',
27
+ details: { artifact_id: 67 },
28
+ })
29
+ })
30
+
31
+ it('should retain a legacy FastAPI validation detail list', () => {
32
+ const detail = [{ type: 'int_parsing', loc: ['body', 'count'] }]
33
+
34
+ const error = mintApiErrorFromResponse(422, { detail })
35
+
36
+ expect(error.detail).toBe(detail)
37
+ expect({ message: error.message, code: error.code, details: error.details }).toEqual({
38
+ message: 'Validation error',
39
+ code: 'unknown',
40
+ details: null,
41
+ })
42
+ })
43
+
44
+ it('preserves nested SDK errors and outer platform metadata', () => {
45
+ const detail = { error: 'NOT_FOUND', message: 'Missing artifact', details: { id: 67 }, request_id: 'inner' }
46
+ const body = { code: 'resource.not_found', message: 'Resource not found', request_id: 'outer', detail }
47
+ const error = mintApiErrorFromResponse(404, body)
48
+
49
+ expect(error).toMatchObject({
50
+ code: 'NOT_FOUND', message: 'Missing artifact', status: 404,
51
+ requestId: 'outer', details: { id: 67 }, detail, body,
52
+ })
53
+ expect(mintApiErrorFromResponse(404, { ...body, details: [] }).details).toEqual([])
54
+ expect(mintApiErrorFromResponse(404, detail).code).toBe('NOT_FOUND')
55
+ })
56
+
57
+ it('prefers canonical envelopes over legacy nested SDK error metadata', () => {
58
+ const body = {
59
+ status: 426, code: 'plugin.api_version_mismatch', message: 'Plugin API version mismatch',
60
+ detail: { error: 'PLATFORM_API_VERSION_MISMATCH', message: 'Legacy version mismatch' },
61
+ }
62
+ expect(mintApiErrorFromResponse(426, body)).toMatchObject({
63
+ status: 426, code: body.code, message: body.message, detail: body.detail,
64
+ })
65
+ expect(mintApiErrorFromResponse(502, body).status).toBe(502)
66
+ expect(mintApiErrorFromResponse(426, { ...body, status: '426' }).code)
67
+ .toBe('PLATFORM_API_VERSION_MISMATCH')
68
+ })
69
+
70
+ it('retains platform codes and falls back to legacy detail and header request IDs', () => {
71
+ expect(mintApiErrorFromResponse(403, {
72
+ code: 'auth.forbidden', message: '', detail: 'Restricted', request_id: '',
73
+ }, { requestId: 'header-id' })).toMatchObject({
74
+ code: 'auth.forbidden', message: 'Restricted', requestId: 'header-id',
75
+ })
76
+ expect(mintApiErrorFromResponse(502, '<html>Bad gateway</html>')).toMatchObject({
77
+ code: 'unknown', message: 'Bad gateway', body: '<html>Bad gateway</html>',
78
+ })
79
+ })
80
+
81
+ })
@@ -1,6 +1,8 @@
1
1
  import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
2
2
  import { afterEach, expect, it, vi } from 'vitest'
3
+ import { ref } from 'vue'
3
4
  import FilePicker from '../../components/FilePicker.vue'
5
+ import FileUploader from '../../components/FileUploader.vue'
4
6
  import type { PickerAdapter, PickerNode, PickerSelection } from '../../types/filePicker'
5
7
 
6
8
  enableAutoUnmount(afterEach)
@@ -83,6 +85,24 @@ it('refresh removes deleted descendants from a selected folder', async () => {
83
85
  expect(wrapper.get('.mint-picker__receipt').text()).not.toContain('7 B')
84
86
  })
85
87
 
88
+ it('marks only an explicit refresh so adapters can bypass their caches', async () => {
89
+ const data = adapter()
90
+ const wrapper = await setup(data)
91
+ await row(wrapper, folder.path).trigger('dblclick')
92
+ await flushPromises()
93
+ expect(vi.mocked(data.listRoot).mock.calls[0]![0]).not.toHaveProperty('refresh')
94
+ expect(vi.mocked(data.listChildren).mock.calls[0]![1]).not.toHaveProperty('refresh')
95
+ await wrapper.get('[aria-label="Refresh location"]').trigger('click')
96
+ await flushPromises()
97
+ expect(vi.mocked(data.listChildren).mock.calls.at(-1)![1]).toMatchObject({ refresh: true })
98
+ await wrapper.get('[aria-label="Breadcrumb"] button').trigger('click')
99
+ await flushPromises()
100
+ expect(vi.mocked(data.listRoot).mock.calls.at(-1)![0]).not.toHaveProperty('refresh')
101
+ await wrapper.get('[aria-label="Refresh location"]').trigger('click')
102
+ await flushPromises()
103
+ expect(vi.mocked(data.listRoot).mock.calls.at(-1)![0]).toMatchObject({ refresh: true })
104
+ })
105
+
86
106
  it('late server responses cannot overwrite browser source metadata', async () => {
87
107
  const data = adapter()
88
108
  let release!: (nodes: PickerNode[]) => void
@@ -97,7 +117,7 @@ it('late server responses cannot overwrite browser source metadata', async () =>
97
117
  release([folder])
98
118
  await flushPromises()
99
119
  expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(0)
100
- expect(wrapper.text()).toContain('No files chosen')
120
+ expect(wrapper.text()).toContain('No selection')
101
121
  })
102
122
 
103
123
  it('browser source confirms original File objects without reading or uploading them', async () => {
@@ -113,6 +133,26 @@ it('browser source confirms original File objects without reading or uploading t
113
133
  expect(read).not.toHaveBeenCalled()
114
134
  })
115
135
 
136
+ it('restores dropped folder paths after remounting with the confirmed local selection', async () => {
137
+ const options = { sources: ['localFile'], selectionMode: 'folder' }
138
+ const wrapper = await setup(adapter(), options)
139
+ const file = new File(['data'], 'run.raw')
140
+ wrapper.getComponent(FileUploader).vm.$emit('upload', [file], 'batch', ['batch/run.raw'])
141
+ await flushPromises()
142
+ await confirm(wrapper).trigger('click')
143
+ const result = wrapper.emitted('select')![0]![0] as PickerSelection
144
+ expect(result).toEqual({ source: 'localFile', files: [file], relativePaths: ['batch/run.raw'] })
145
+ expect(result.files[0]).toBe(file)
146
+ wrapper.unmount()
147
+
148
+ const restored = await setup(adapter(), { ...options, initialSelection: result })
149
+ expect(confirm(restored).attributes('disabled')).toBeUndefined()
150
+ await confirm(restored).trigger('click')
151
+ const confirmed = restored.emitted('select')![0]![0] as PickerSelection
152
+ expect(confirmed).toEqual(result)
153
+ expect(confirmed.files[0]).toBe(file)
154
+ })
155
+
116
156
  it('restores a nested selected folder display name without exposing its opaque identity', async () => {
117
157
  const wrapper = await setup()
118
158
  await row(wrapper, folder.path).trigger('dblclick')
@@ -158,3 +198,90 @@ it('does not confirm a folder after the host switches the same picker to single-
158
198
  await confirm(wrapper).trigger('click')
159
199
  expect(wrapper.emitted('select')).toBeUndefined()
160
200
  })
201
+
202
+ it('system filter hides denied folders and skips their contents during folder selection', async () => {
203
+ const allowed: PickerNode = { ...file, path: 'allowed', name: 'allowed.raw', parentPath: folder.path }
204
+ const data = adapter()
205
+ vi.mocked(data.listChildren).mockImplementation(async (path) =>
206
+ path === folder.path ? [child, allowed] : [file],
207
+ )
208
+ const filter = vi.fn((node: PickerNode) => node.path !== child.path)
209
+ const wrapper = await setup(data, { systemFilter: filter })
210
+ await row(wrapper, folder.path).trigger('click')
211
+ await flushPromises()
212
+ expect(data.listChildren).not.toHaveBeenCalledWith(child.path, expect.anything())
213
+ await row(wrapper, folder.path).trigger('dblclick')
214
+ await flushPromises()
215
+ expect(row(wrapper, child.path)).toBeUndefined()
216
+ await confirm(wrapper).trigger('click')
217
+ expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ files: [allowed], excluded: [] })
218
+ expect(filter).toHaveBeenCalledWith(expect.objectContaining({ path: child.path }), 'server')
219
+ })
220
+
221
+ it('system filter applies to search results and restored or already selected files', async () => {
222
+ const data = adapter()
223
+ vi.mocked(data.listRoot).mockResolvedValue([file])
224
+ vi.mocked(data.search).mockResolvedValue([file])
225
+ const wrapper = await setup(data, { selectionMode: 'multi-file' })
226
+ await row(wrapper, file.path).trigger('click')
227
+ await flushPromises()
228
+ const deny = (node: PickerNode) => node.path !== file.path
229
+ await wrapper.setProps({ systemFilter: deny })
230
+ expect(row(wrapper, file.path)).toBeUndefined()
231
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
232
+ await wrapper.get('input[aria-label="Search folders and files"]').setValue('run')
233
+ await new Promise((resolve) => setTimeout(resolve, 250))
234
+ await flushPromises()
235
+ expect(data.search).toHaveBeenCalled()
236
+ expect(row(wrapper, file.path)).toBeUndefined()
237
+ await wrapper.setProps({ open: false, initialSelection: {
238
+ source: 'server', folders: [], files: [file], excluded: [],
239
+ } })
240
+ await wrapper.setProps({ open: true })
241
+ await flushPromises()
242
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
243
+ await confirm(wrapper).trigger('click')
244
+ expect(wrapper.emitted('select')).toBeUndefined()
245
+ })
246
+
247
+ it('system filter receives local source and prevents denied browser files from being confirmed', async () => {
248
+ const filter = vi.fn((node: PickerNode) => node.name !== 'private.raw')
249
+ const wrapper = await setup(adapter(), {
250
+ sources: ['localFile'], selectionMode: 'multi-file', systemFilter: filter,
251
+ })
252
+ const allowed = new File(['public'], 'public.raw')
253
+ const denied = new File(['private'], 'private.raw')
254
+ const input = wrapper.get('input[type="file"]')
255
+ Object.defineProperty(input.element, 'files', { value: [allowed, denied], configurable: true })
256
+ await input.trigger('change')
257
+ await flushPromises()
258
+ expect(wrapper.get('.mint-picker__receipt').text()).toContain('1 file')
259
+ expect(wrapper.text()).not.toContain('private.raw')
260
+ await confirm(wrapper).trigger('click')
261
+ expect(wrapper.emitted('select')?.[0]?.[0]).toEqual({ source: 'localFile', files: [allowed] })
262
+ expect(filter).toHaveBeenCalledWith(expect.objectContaining({ name: denied.name }), 'localFile')
263
+ })
264
+
265
+ it('reactive permission changes restore visible rows and resolve newly allowed selected subfolders', async () => {
266
+ const allowed = ref(false)
267
+ const data = adapter()
268
+ const wrapper = await setup(data, {
269
+ systemFilter: (node: PickerNode) => node.path !== child.path || allowed.value,
270
+ })
271
+ await row(wrapper, folder.path).trigger('click')
272
+ await flushPromises()
273
+ await row(wrapper, folder.path).trigger('dblclick')
274
+ await flushPromises()
275
+ expect(row(wrapper, child.path)).toBeUndefined()
276
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
277
+ allowed.value = true
278
+ await flushPromises()
279
+ expect(row(wrapper, child.path)).toBeDefined()
280
+ expect(confirm(wrapper).attributes('disabled')).toBeUndefined()
281
+ await confirm(wrapper).trigger('click')
282
+ expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({ files: [file] })
283
+ allowed.value = false
284
+ await flushPromises()
285
+ expect(row(wrapper, child.path)).toBeUndefined()
286
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
287
+ })
@@ -1,6 +1,7 @@
1
1
  import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
  import FilePicker from '../../components/FilePicker.vue'
4
+ import FileUploader from '../../components/FileUploader.vue'
4
5
  import type { PickerAdapter, PickerNode, PickerSelection } from '../../types/filePicker'
5
6
  import { localFilesAdapter } from '../../composables/filePicker/localFiles'
6
7
  import { pickerExclusion } from '../../composables/filePicker/validation'
@@ -272,7 +273,151 @@ describe('FilePicker', () => {
272
273
  await flushPromises()
273
274
  expect(wrapper.get('[aria-label="Local files"]').attributes('aria-pressed')).toBe('true')
274
275
  expect(wrapper.find('[aria-label="Absolute path"]').exists()).toBe(false)
275
- expect(wrapper.text()).toContain('Choose files…')
276
+ expect(wrapper.text()).toContain('Drop files or a folder')
277
+ })
278
+
279
+ it.each(['single-file', 'multi-file', 'folder', 'folder+files'] as const)(
280
+ 'uses one upload panel without a lower workspace and preserves %s selection limits',
281
+ async (selectionMode) => {
282
+ const wrapper = await setup({ sources: ['localFile'], selectionMode })
283
+ const uploaders = wrapper.findAllComponents(FileUploader)
284
+ expect(uploaders.map((uploader) => uploader.props('mode'))).toEqual(
285
+ selectionMode === 'single-file'
286
+ ? ['file']
287
+ : selectionMode === 'folder'
288
+ ? ['folder']
289
+ : ['file+folder'],
290
+ )
291
+ expect(wrapper.find('.mint-picker__workspace').exists()).toBe(false)
292
+ expect(wrapper.find('.mint-file-uploader__actions').exists()).toBe(false)
293
+ expect(wrapper.text()).not.toContain('Files stay in this browser.')
294
+ expect(wrapper.find('.mint-picker__pathbar').exists()).toBe(false)
295
+ expect(wrapper.find('[aria-label="Search folders and files"]').exists()).toBe(false)
296
+ const files = [new File(['a'], 'one.raw'), new File(['b'], 'two.raw')]
297
+ const input = uploaders[0]!.get('input[type="file"]')
298
+ if (selectionMode === 'folder') {
299
+ files.forEach((file) =>
300
+ Object.defineProperty(file, 'webkitRelativePath', { value: `batch/${file.name}` }),
301
+ )
302
+ }
303
+ Object.defineProperty(input.element, 'files', { value: files })
304
+ await input.trigger('change')
305
+ await flushPromises()
306
+ expect(wrapper.find('.mint-file-uploader__list').exists()).toBe(false)
307
+ expect(wrapper.find('.mint-file-uploader__folder-summary').exists()).toBe(false)
308
+ await confirm(wrapper).trigger('click')
309
+ const result = wrapper.emitted('select')?.[0]?.[0] as Extract<
310
+ PickerSelection,
311
+ { source: 'localFile' }
312
+ >
313
+ expect(result.files).toEqual(selectionMode === 'single-file' ? [files[0]] : files)
314
+ expect(result.files[0]).toBe(files[0])
315
+ },
316
+ )
317
+
318
+ it.each(['folder+files', 'multi-file'] as const)('accepts dropped directories in %s mode and validates without a workspace', async (selectionMode) => {
319
+ const wrapper = await setup({
320
+ sources: ['localFile'],
321
+ selectionMode,
322
+ capabilities: { localFile: { formats: ['mzML.gz'], maxBytes: 10 } },
323
+ })
324
+ const valid = new File(['data'], 'run.mzML.gz')
325
+ const unsupported = new File(['notes'], 'notes.txt')
326
+ const oversized = new File(['too much data'], 'large.mzML.gz')
327
+ const directory = (name: string, entries: unknown[]) => ({
328
+ name,
329
+ isDirectory: true,
330
+ createReader: () => {
331
+ let pending = entries
332
+ return {
333
+ readEntries: (resolve: (entries: unknown[]) => void) => {
334
+ resolve(pending)
335
+ pending = []
336
+ },
337
+ }
338
+ },
339
+ })
340
+ const nested = directory('batch', [
341
+ directory('nested', [valid, unsupported, oversized].map((file) => ({
342
+ name: file.name,
343
+ isFile: true,
344
+ file: (resolve: (file: File) => void) => resolve(file),
345
+ }))),
346
+ ])
347
+ const uploader = wrapper.getComponent(FileUploader)
348
+ await uploader.get('[role="button"]').trigger('drop', {
349
+ dataTransfer: { items: [{ webkitGetAsEntry: () => nested }] },
350
+ })
351
+ await flushPromises()
352
+ expect(wrapper.get('.mint-picker__receipt').text()).toContain('1 file')
353
+ if (selectionMode === 'folder+files') expect(wrapper.text()).toContain('2 skipped')
354
+ expect(wrapper.find('.mint-picker__workspace').exists()).toBe(false)
355
+ await confirm(wrapper).trigger('click')
356
+ const result = wrapper.emitted('select')?.[0]?.[0] as Extract<
357
+ PickerSelection,
358
+ { source: 'localFile' }
359
+ >
360
+ expect(result.files).toEqual([valid])
361
+ expect(result.files[0]).toBe(valid)
362
+ await wrapper.setProps({ open: false })
363
+ await wrapper.setProps({ open: true })
364
+ await flushPromises()
365
+ expect(wrapper.get('.mint-picker__receipt').text()).toContain('1 file')
366
+ expect(confirm(wrapper).attributes('disabled')).toBeUndefined()
367
+ })
368
+
369
+ it.each(['entry', 'file'] as const)(
370
+ 'captures dropped %s handles before the drag data becomes protected',
371
+ async (secondHandle) => {
372
+ const wrapper = await setup({ sources: ['localFile'], selectionMode: 'multi-file' })
373
+ const files = [new File(['a'], 'one.raw'), new File(['b'], 'two.raw')]
374
+ let protectedMode = false
375
+ const secondEntry = {
376
+ isFile: true,
377
+ file: (resolve: (file: File) => void) => resolve(files[1]!),
378
+ }
379
+ await wrapper.getComponent(FileUploader).get('[role="button"]').trigger('drop', {
380
+ dataTransfer: {
381
+ items: [
382
+ { webkitGetAsEntry: () => ({
383
+ isFile: true,
384
+ file: (resolve: (file: File) => void) => queueMicrotask(() => {
385
+ protectedMode = true
386
+ resolve(files[0]!)
387
+ }),
388
+ }) },
389
+ {
390
+ webkitGetAsEntry: () =>
391
+ !protectedMode && secondHandle === 'entry' ? secondEntry : null,
392
+ getAsFile: () =>
393
+ !protectedMode && secondHandle === 'file' ? files[1] : null,
394
+ },
395
+ ],
396
+ },
397
+ })
398
+ await flushPromises()
399
+ await confirm(wrapper).trigger('click')
400
+ const result = wrapper.emitted('select')?.[0]?.[0] as Extract<
401
+ PickerSelection,
402
+ { source: 'localFile' }
403
+ >
404
+ expect(result.files).toHaveLength(2)
405
+ expect(result.files[0]).toBe(files[0])
406
+ expect(result.files[1]).toBe(files[1])
407
+ },
408
+ )
409
+
410
+ it('shows folder drop errors without a preview panel', async () => {
411
+ const wrapper = await setup({ sources: ['localFile'] })
412
+ await wrapper.getComponent(FileUploader).get('[role="button"]').trigger('drop', {
413
+ dataTransfer: { items: [{ webkitGetAsEntry: () => ({
414
+ name: 'unreadable', isDirectory: true,
415
+ createReader: () => ({ readEntries: (_resolve: unknown, reject: (error: Error) => void) => reject(new Error('Folder cannot be read')) }),
416
+ }) }] },
417
+ })
418
+ await flushPromises()
419
+ expect(wrapper.get('[role="alert"]').text()).toBe('Folder cannot be read')
420
+ expect(confirm(wrapper).attributes('disabled')).toBeDefined()
276
421
  })
277
422
 
278
423
  it('does not render sources the host did not supply', async () => {
@@ -1,15 +1,17 @@
1
- import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
2
- import { nextTick } from 'vue'
3
- import { beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
2
+ import { nextTick, reactive } from 'vue'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import GeneratedPlotlyResult from '../../components/GeneratedPlotlyResult.vue'
6
6
 
7
7
  const plotly = vi.hoisted(() => ({
8
+ Plots: { resize: vi.fn() },
8
9
  purge: vi.fn(),
9
10
  react: vi.fn(),
10
11
  }))
11
12
 
12
13
  vi.mock('plotly.js-dist-min', () => ({ default: plotly }))
14
+ enableAutoUnmount(afterEach)
13
15
 
14
16
  async function mountAndRecover(
15
17
  fallback?: { media_type: string; data: string },
@@ -58,6 +60,18 @@ describe('GeneratedPlotlyResult', () => {
58
60
  expect(wrapper.get('img').attributes('src')).toBe('data:image/png;base64,cG5n')
59
61
  })
60
62
 
63
+ it('should render a static fallback without the error presentation', async () => {
64
+ const wrapper = mount(GeneratedPlotlyResult, {
65
+ props: {
66
+ spec: { data: [] },
67
+ fallback: { media_type: 'image/png', data: 'cG5n' },
68
+ },
69
+ })
70
+ await flushPromises()
71
+
72
+ expect(wrapper.find('.mint-plotly-chart__error').exists()).toBe(false)
73
+ })
74
+
61
75
  it('should not redraw an immutable result for nested spec mutations', async () => {
62
76
  plotly.react.mockResolvedValue(undefined)
63
77
  const wrapper = mount(GeneratedPlotlyResult, {
@@ -73,6 +87,21 @@ describe('GeneratedPlotlyResult', () => {
73
87
  expect(plotly.react).toHaveBeenCalledTimes(1)
74
88
  })
75
89
 
90
+ it('should redraw a replaced spec even when its native arrays are reused', async () => {
91
+ plotly.react.mockResolvedValue(undefined)
92
+ const spec = reactive({ data: [{ x: [1], y: [2] }], layout: {}, config: { displayModeBar: true } })
93
+ const wrapper = mount(GeneratedPlotlyResult, { props: { spec } })
94
+ await flushPromises()
95
+
96
+ spec.data[0]!.x.push(3)
97
+ spec.data[0]!.y.push(4)
98
+ await wrapper.setProps({ spec: { ...spec } })
99
+ await flushPromises()
100
+
101
+ expect(plotly.react).toHaveBeenCalledTimes(2)
102
+ expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [1, 3], y: [2, 4] }])
103
+ })
104
+
76
105
  it.each([
77
106
  ['Heatmap', { type: 'heatmap', z: [[1, 2], [3, 4]] }],
78
107
  ['Scatter3d', { type: 'scatter3d', x: [1], y: [2], z: [3] }],
@@ -86,7 +115,7 @@ describe('GeneratedPlotlyResult', () => {
86
115
  expect(plotly.react).toHaveBeenCalledWith(
87
116
  expect.any(HTMLElement),
88
117
  [trace],
89
- {},
118
+ expect.objectContaining({ autosize: true }),
90
119
  { responsive: true, displaylogo: false },
91
120
  )
92
121
  })
@@ -112,6 +141,24 @@ describe('GeneratedPlotlyResult', () => {
112
141
  it('should restore the interactive chart after a new immutable spec renders successfully', async () => {
113
142
  const wrapper = await mountAndRecover()
114
143
 
115
- expect(wrapper.get('.mint-generated-result__plotly').isVisible()).toBe(true)
144
+ expect(wrapper.get('.mint-plotly-chart__plot').isVisible()).toBe(true)
145
+ })
146
+
147
+ it('should reveal the interactive canvas before retrying Plotly', async () => {
148
+ const wrapper = mount(GeneratedPlotlyResult, {
149
+ props: { spec: { data: [] } },
150
+ })
151
+ await flushPromises()
152
+
153
+ let displayDuringRetry: string | undefined
154
+ plotly.react.mockImplementation(async (element: HTMLElement) => {
155
+ displayDuringRetry = element.style.display
156
+ })
157
+ await wrapper.setProps({
158
+ spec: { data: [{ type: 'scatter', x: [1], y: [2] }] },
159
+ })
160
+ await flushPromises()
161
+
162
+ expect(displayDuringRetry).not.toBe('none')
116
163
  })
117
164
  })