@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.0

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 (152) 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-DCXmwKNS.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
  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/PlotlyChart.test.d.ts +1 -0
  15. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  17. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  18. package/dist/apiErrors.d.ts +26 -0
  19. package/dist/components/BaseInput.vue.d.ts +1 -1
  20. package/dist/components/BaseSelect.vue.d.ts +1 -1
  21. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  22. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  23. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  25. package/dist/components/DatePicker.vue.d.ts +1 -1
  26. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  27. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  28. package/dist/components/FilePicker.vue.d.ts +32 -0
  29. package/dist/components/FileUploader.vue.d.ts +5 -2
  30. package/dist/components/FormulaInput.vue.d.ts +1 -1
  31. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  32. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  33. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  34. package/dist/components/NumberInput.vue.d.ts +1 -1
  35. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  36. package/dist/components/ResourceCard.vue.d.ts +1 -1
  37. package/dist/components/SecretInput.vue.d.ts +1 -1
  38. package/dist/components/SequenceInput.vue.d.ts +1 -1
  39. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  40. package/dist/components/TagsInput.vue.d.ts +1 -1
  41. package/dist/components/TimePicker.vue.d.ts +1 -1
  42. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  43. package/dist/components/UnitInput.vue.d.ts +1 -1
  44. package/dist/components/WellPlate.vue.d.ts +1 -1
  45. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  46. package/dist/components/index.d.ts +4 -0
  47. package/dist/components/index.js +8 -8
  48. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  49. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  50. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  51. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  52. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  53. package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
  54. package/dist/components-DIum5hkx.js.map +1 -0
  55. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  56. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  57. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  58. package/dist/composables/filePicker/validation.d.ts +5 -0
  59. package/dist/composables/index.d.ts +2 -0
  60. package/dist/composables/index.js +10 -10
  61. package/dist/composables/useApi.d.ts +14 -7
  62. package/dist/composables/useFileBrowser.d.ts +55 -0
  63. package/dist/composables/useFilePicker.d.ts +189 -0
  64. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  65. package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
  66. package/dist/composables-mFXsTA4g.js.map +1 -0
  67. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  68. package/dist/errors-BLuqMee5.js.map +1 -0
  69. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  70. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  71. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  72. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.js +15 -15
  75. package/dist/install.js +2 -2
  76. package/dist/stores/index.js +2 -2
  77. package/dist/styles.css +2369 -1377
  78. package/dist/templates/index.js +2 -2
  79. package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
  80. package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
  81. package/dist/types/fileBrowserTypes.d.ts +77 -0
  82. package/dist/types/filePicker.d.ts +77 -0
  83. package/dist/types/index.d.ts +2 -0
  84. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
  85. package/dist/useControlSchema-DldpxNOP.js.map +1 -0
  86. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  87. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  88. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
  89. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
  90. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
  91. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
  92. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
  93. package/dist/validation-uwoJGEb6.js.map +1 -0
  94. package/package.json +1 -1
  95. package/src/__tests__/apiErrors.test.ts +81 -0
  96. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  97. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  98. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  99. package/src/__tests__/components/FilePicker.test.ts +487 -0
  100. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  101. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  102. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  103. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  104. package/src/__tests__/composables/useApi.test.ts +92 -9
  105. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  106. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  107. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  108. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  109. package/src/apiErrors.ts +85 -0
  110. package/src/components/ExperimentPopover.vue +10 -2
  111. package/src/components/FileBrowserModal.story.vue +203 -0
  112. package/src/components/FileBrowserModal.vue +384 -0
  113. package/src/components/FilePicker.story.vue +44 -0
  114. package/src/components/FilePicker.vue +284 -0
  115. package/src/components/FileUploader.vue +32 -14
  116. package/src/components/GeneratedPlotlyResult.vue +34 -69
  117. package/src/components/GeneratedResultRenderer.vue +0 -5
  118. package/src/components/PlotlyChart.story.vue +61 -0
  119. package/src/components/PlotlyChart.vue +233 -0
  120. package/src/components/SegmentedControl.vue +2 -2
  121. package/src/components/fileBrowserPresentation.ts +39 -0
  122. package/src/components/index.ts +5 -0
  123. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  124. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  125. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  126. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  127. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  128. package/src/composables/filePicker/localFiles.ts +58 -0
  129. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  130. package/src/composables/filePicker/usePickerSelection.ts +259 -0
  131. package/src/composables/filePicker/validation.ts +41 -0
  132. package/src/composables/index.ts +7 -0
  133. package/src/composables/useApi.ts +57 -13
  134. package/src/composables/useFileBrowser.ts +314 -0
  135. package/src/composables/useFilePicker.ts +366 -0
  136. package/src/composables/useGeneratedAnalysis.ts +10 -17
  137. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  138. package/src/composables/usePluginClient.ts +3 -0
  139. package/src/index.ts +3 -0
  140. package/src/styles/components/file-browser-modal.css +489 -0
  141. package/src/styles/components/file-picker.css +502 -0
  142. package/src/styles/components/icon-button.css +9 -0
  143. package/src/styles/index.css +3 -0
  144. package/src/types/fileBrowserTypes.ts +85 -0
  145. package/src/types/filePicker.ts +72 -0
  146. package/src/types/index.ts +14 -0
  147. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  148. package/dist/components-CgAUzF8F.js.map +0 -1
  149. package/dist/composables-B_pia3hO.js.map +0 -1
  150. package/dist/errors-_W0-LbA6.js.map +0 -1
  151. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  152. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -0,0 +1,305 @@
1
+ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
2
+ import { reactive } from 'vue'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import PlotlyChart from '../../components/PlotlyChart.vue'
6
+
7
+ const plotly = vi.hoisted(() => ({
8
+ Plots: { resize: vi.fn() },
9
+ purge: vi.fn(),
10
+ react: vi.fn(),
11
+ }))
12
+
13
+ vi.mock('plotly.js-dist-min', () => ({ default: plotly }))
14
+ enableAutoUnmount(afterEach)
15
+
16
+ describe('PlotlyChart', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks()
19
+ plotly.react.mockReset().mockResolvedValue(undefined)
20
+ plotly.Plots.resize.mockReset().mockResolvedValue(undefined)
21
+ document.documentElement.classList.remove('dark')
22
+ })
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals()
26
+ document.documentElement.classList.remove('dark')
27
+ document.documentElement.removeAttribute('style')
28
+ })
29
+
30
+ it('should merge MINT defaults with native Plotly props', async () => {
31
+ const trace = { type: 'scatter' as const, x: [1], y: [2] }
32
+ mount(PlotlyChart, {
33
+ props: {
34
+ data: [trace],
35
+ layout: { title: { text: 'QC' } },
36
+ config: { toImageButtonOptions: { filename: 'qc-export' } },
37
+ },
38
+ })
39
+ await flushPromises()
40
+
41
+ expect(plotly.react).toHaveBeenCalledWith(
42
+ expect.any(HTMLElement),
43
+ [trace],
44
+ expect.objectContaining({
45
+ autosize: true,
46
+ title: { text: 'QC' },
47
+ paper_bgcolor: 'rgba(0,0,0,0)',
48
+ plot_bgcolor: 'rgba(0,0,0,0)',
49
+ }),
50
+ expect.objectContaining({
51
+ responsive: true,
52
+ displaylogo: false,
53
+ toImageButtonOptions: { filename: 'qc-export' },
54
+ }),
55
+ )
56
+ })
57
+
58
+ it('should render the shared chart empty state when explicitly empty', async () => {
59
+ const wrapper = mount(PlotlyChart, {
60
+ props: { data: [], empty: true, emptyMessage: 'No peaks found' },
61
+ })
62
+ await flushPromises()
63
+
64
+ expect(wrapper.text()).toContain('No peaks found')
65
+ })
66
+
67
+ it('should show a safe error when Plotly rejects rendering', async () => {
68
+ plotly.react.mockRejectedValue(new Error('<script>unsafe details</script>'))
69
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
70
+ await flushPromises()
71
+
72
+ expect(wrapper.get('[role="alert"]').text()).toBe('Unable to display this chart.')
73
+ })
74
+
75
+ it('should rerender when immutable native props change', async () => {
76
+ const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
77
+ await flushPromises()
78
+
79
+ await wrapper.setProps({ data: [{ x: [3], y: [4] }] })
80
+ await flushPromises()
81
+
82
+ expect(plotly.react).toHaveBeenCalledTimes(2)
83
+ })
84
+
85
+ it('should rerender when the MINT dark theme changes', async () => {
86
+ let themeChanged: MutationCallback | undefined
87
+ vi.stubGlobal('MutationObserver', class {
88
+ constructor(callback: MutationCallback) {
89
+ themeChanged = callback
90
+ }
91
+
92
+ observe(): void {}
93
+ disconnect(): void {}
94
+ takeRecords(): MutationRecord[] { return [] }
95
+ })
96
+ mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
97
+ await flushPromises()
98
+
99
+ document.documentElement.classList.add('dark')
100
+ themeChanged?.([], {} as MutationObserver)
101
+ await flushPromises()
102
+
103
+ expect(plotly.react).toHaveBeenCalledTimes(2)
104
+ })
105
+
106
+ it('should resize the Plotly canvas when its container changes size', async () => {
107
+ let resize: ResizeObserverCallback | undefined
108
+ vi.stubGlobal('ResizeObserver', class {
109
+ constructor(callback: ResizeObserverCallback) {
110
+ resize = callback
111
+ }
112
+
113
+ observe(): void {}
114
+ disconnect(): void {}
115
+ })
116
+ mount(PlotlyChart, { props: { data: [{ x: [1], y: [2] }] } })
117
+ await flushPromises()
118
+
119
+ resize?.([], {} as ResizeObserver)
120
+
121
+ expect(plotly.Plots.resize).toHaveBeenCalledTimes(1)
122
+ })
123
+
124
+ it('should observe the Plotly canvas after leaving the empty state', async () => {
125
+ const observe = vi.fn()
126
+ vi.stubGlobal('ResizeObserver', class {
127
+ observe = observe
128
+ disconnect(): void {}
129
+ })
130
+ const wrapper = mount(PlotlyChart, {
131
+ props: { data: [], empty: true },
132
+ })
133
+ await flushPromises()
134
+
135
+ await wrapper.setProps({ empty: false })
136
+ await flushPromises()
137
+
138
+ expect(observe).toHaveBeenCalledWith(expect.any(HTMLElement))
139
+ })
140
+
141
+ it('should render once when data arrives as loading completes', async () => {
142
+ const wrapper = mount(PlotlyChart, {
143
+ props: { data: [], loading: true },
144
+ })
145
+ await flushPromises()
146
+
147
+ await wrapper.setProps({ data: [{ x: [1], y: [2] }], loading: false })
148
+ await flushPromises()
149
+
150
+ expect(plotly.react).toHaveBeenCalledTimes(1)
151
+ })
152
+
153
+ it('should serialize renders and use the latest props after an in-flight render', async () => {
154
+ let finish!: () => void
155
+ plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
156
+ const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
157
+ await flushPromises()
158
+ await wrapper.setProps({ data: [{ x: [2] }] })
159
+ await wrapper.setProps({ data: [{ x: [3] }] })
160
+ await flushPromises()
161
+ expect(plotly.react).toHaveBeenCalledTimes(1)
162
+
163
+ finish()
164
+ await flushPromises()
165
+ expect(plotly.react).toHaveBeenCalledTimes(2)
166
+ expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [3] }])
167
+ })
168
+
169
+ it('should skip rendering when unmounted before the initial DOM update', async () => {
170
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
171
+ wrapper.unmount()
172
+ await flushPromises()
173
+ expect(plotly.react).not.toHaveBeenCalled()
174
+ })
175
+
176
+ it('should recover from a stale failure without overwriting the newer render', async () => {
177
+ let reject!: (error: Error) => void
178
+ plotly.react.mockImplementationOnce(() => new Promise<void>((_resolve, fail) => { reject = fail }))
179
+ const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
180
+ await flushPromises()
181
+ await wrapper.setProps({ data: [{ x: [2] }] })
182
+ reject(new Error('Old render failed'))
183
+ await flushPromises()
184
+ expect(plotly.react).toHaveBeenCalledTimes(2)
185
+ expect(plotly.react.mock.lastCall?.[1]).toEqual([{ x: [2] }])
186
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
187
+ })
188
+
189
+ it('should purge an in-flight canvas removed by loading and then render its replacement', async () => {
190
+ let finish!: () => void
191
+ plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
192
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
193
+ await flushPromises()
194
+ const element = wrapper.get('.mint-plotly-chart__plot').element
195
+ await wrapper.setProps({ loading: true })
196
+ await wrapper.setProps({ loading: false, data: [{ x: [3] }] })
197
+ finish()
198
+ await flushPromises()
199
+ expect(plotly.purge).toHaveBeenCalledWith(element)
200
+ expect(plotly.react).toHaveBeenCalledTimes(2)
201
+ expect(plotly.react.mock.lastCall?.[0]).toBe(wrapper.get('.mint-plotly-chart__plot').element)
202
+ expect(plotly.react.mock.lastCall?.[0]).not.toBe(element)
203
+ })
204
+
205
+ it('should clean up a render that completes after unmount', async () => {
206
+ let finish!: () => void
207
+ plotly.react.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
208
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
209
+ await flushPromises()
210
+ const element = wrapper.get('.mint-plotly-chart__plot').element
211
+ wrapper.unmount()
212
+ plotly.purge.mockClear()
213
+ finish()
214
+ await flushPromises()
215
+ expect(plotly.purge).toHaveBeenCalledWith(element)
216
+ expect(plotly.react).toHaveBeenCalledTimes(1)
217
+ })
218
+
219
+ it('should purge the removed canvas and render new data after loading or empty states', async () => {
220
+ const disconnect = vi.fn()
221
+ vi.stubGlobal('ResizeObserver', class {
222
+ observe = vi.fn()
223
+ unobserve = vi.fn()
224
+ disconnect = disconnect
225
+ })
226
+ const wrapper = mount(PlotlyChart, { props: { data: [{ x: [1] }] } })
227
+ await flushPromises()
228
+ for (const state of ['loading', 'empty'] as const) {
229
+ const element = wrapper.get('.mint-plotly-chart__plot').element
230
+ await wrapper.setProps({ [state]: true })
231
+ await flushPromises()
232
+ expect(plotly.purge).toHaveBeenCalledWith(element)
233
+ expect(disconnect).toHaveBeenCalled()
234
+ await wrapper.setProps({ [state]: false, data: [{ x: [2] }] })
235
+ await flushPromises()
236
+ expect(wrapper.get('.mint-plotly-chart__plot').element).not.toBe(element)
237
+ }
238
+ expect(plotly.react).toHaveBeenCalledTimes(3)
239
+ })
240
+
241
+ it('should use MINT colors and protect reactive native data and nested layout from Plotly mutations', async () => {
242
+ document.documentElement.style.setProperty('--text-primary', '#f8fafc')
243
+ document.documentElement.style.setProperty('--border-color', '#334155')
244
+ const data = reactive([{ x: [1], y: [2] }])
245
+ const layout = reactive({ title: { text: 'Original' }, xaxis: { title: { text: 'Sample' } } })
246
+ plotly.react.mockImplementationOnce((_element, traces, options) => {
247
+ traces[0].x.push(9)
248
+ options.title.text = 'Changed'
249
+ options.xaxis.title.text = 'Changed'
250
+ return Promise.resolve()
251
+ })
252
+ mount(PlotlyChart, { attachTo: document.body, props: { data, layout } })
253
+ await flushPromises()
254
+ expect(plotly.react.mock.lastCall?.[2].font.color).toBe('#f8fafc')
255
+ expect(plotly.react.mock.lastCall?.[2].xaxis.gridcolor).toBe('#334155')
256
+ expect(data[0].x).toEqual([1])
257
+ expect(layout.title.text).toBe('Original')
258
+ expect(layout.xaxis.title.text).toBe('Sample')
259
+ })
260
+
261
+ it('should accept nested reactive arrays, typed arrays, dates, and native export callbacks', async () => {
262
+ const x = reactive([new Date('2026-01-01')])
263
+ const y = new Float64Array([2])
264
+ const click = vi.fn()
265
+ mount(PlotlyChart, {
266
+ props: {
267
+ data: [{ x, y }],
268
+ config: { modeBarButtonsToAdd: [{ name: 'Export', title: 'Export', icon: 'camera', click }] },
269
+ },
270
+ })
271
+ await flushPromises()
272
+ expect(plotly.react).toHaveBeenCalledOnce()
273
+ const [, traces, , config] = plotly.react.mock.lastCall!
274
+ expect(traces[0].x).toEqual(x)
275
+ expect(traces[0].x).not.toBe(x)
276
+ expect(traces[0].x[0]).toBeInstanceOf(Date)
277
+ expect(traces[0].y).toEqual(y)
278
+ expect(traces[0].y).not.toBe(y)
279
+ expect(config.modeBarButtonsToAdd[0].click).toBe(click)
280
+ })
281
+
282
+ it('should ignore resize rejection when the canvas becomes hidden', async () => {
283
+ let resize!: ResizeObserverCallback
284
+ vi.stubGlobal('ResizeObserver', class {
285
+ constructor(callback: ResizeObserverCallback) { resize = callback }
286
+ observe(): void {}
287
+ disconnect(): void {}
288
+ })
289
+ plotly.Plots.resize.mockRejectedValueOnce(new Error('Resize must be passed a displayed plot div'))
290
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
291
+ await flushPromises()
292
+ resize([], {} as ResizeObserver)
293
+ await flushPromises()
294
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
295
+ })
296
+
297
+ it('should purge the Plotly canvas when unmounted', async () => {
298
+ const wrapper = mount(PlotlyChart, { props: { data: [] } })
299
+ await flushPromises()
300
+
301
+ wrapper.unmount()
302
+
303
+ expect(plotly.purge).toHaveBeenCalledTimes(1)
304
+ })
305
+ })
@@ -3,6 +3,25 @@ import { describe, expect, it } from 'vitest'
3
3
  import SegmentedControl from '../../components/SegmentedControl.vue'
4
4
 
5
5
  describe('SegmentedControl', () => {
6
+ it('defaults to card while allowing an explicit simple variant', async () => {
7
+ const wrapper = mount(SegmentedControl, {
8
+ props: {
9
+ modelValue: 'table',
10
+ options: [{ value: 'table', label: 'Table', description: 'View rows' }],
11
+ },
12
+ })
13
+
14
+ expect(wrapper.classes()).toContain('mint-segmented-control--card')
15
+ expect(wrapper.get('button').classes()).toContain('mint-segmented-control__option--card')
16
+ expect(wrapper.get('.mint-segmented-control__description').text()).toBe('View rows')
17
+
18
+ await wrapper.setProps({ variant: 'simple' })
19
+
20
+ expect(wrapper.classes()).toContain('mint-segmented-control--simple')
21
+ expect(wrapper.get('button').classes()).toContain('mint-segmented-control__option--simple')
22
+ expect(wrapper.find('.mint-segmented-control__description').exists()).toBe(false)
23
+ })
24
+
6
25
  it('accepts string and number shorthand options', async () => {
7
26
  const wrapper = mount(SegmentedControl, {
8
27
  props: {
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // jsdom has no layout, so this pins the stylesheet contract instead: a bare
6
+ // <svg> passed to IconButton's slot must be sized by the icon box, otherwise it
7
+ // collapses to zero width as a flex item (FilePicker source toggles, beta.14).
8
+ describe('IconButton icon sizing', () => {
9
+ it('should size a bare slot svg to the icon box when the caller passes no dimensions', () => {
10
+ const css = readFileSync(
11
+ resolve(__dirname, '../../styles/components/icon-button.css'),
12
+ 'utf8',
13
+ )
14
+ expect(css).toMatch(
15
+ /\.mint-icon-button__icon--sm > svg,\s*\.mint-icon-button__icon--md > svg,\s*\.mint-icon-button__icon--lg > svg \{\s*width: 100%;\s*height: 100%;\s*\}/,
16
+ )
17
+ })
18
+ })
@@ -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
  })