@morscherlab/mint-sdk 1.5.0-beta.1 → 1.5.0-beta.10

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 (174) hide show
  1. package/dist/{AlertBox-CIhqsWbN.js → AlertBox-Cai0w7gF.js} +3 -3
  2. package/dist/{AlertBox-CIhqsWbN.js.map → AlertBox-Cai0w7gF.js.map} +1 -1
  3. package/dist/{AppSidebar-BDrd7Kwn.js → AppSidebar-C-Vq5x4I.js} +410 -127
  4. package/dist/AppSidebar-C-Vq5x4I.js.map +1 -0
  5. package/dist/{ConfirmDialog-CjWoxAD1.js → ConfirmDialog-BjFsOpBe.js} +3 -3
  6. package/dist/ConfirmDialog-BjFsOpBe.js.map +1 -0
  7. package/dist/{ExperimentPopover-BM92LlrQ.js → ExperimentPopover-DYy_s0kI.js} +25 -37
  8. package/dist/ExperimentPopover-DYy_s0kI.js.map +1 -0
  9. package/dist/{ExperimentSelectorModal-LsB1ABmh.js → ExperimentSelectorModal-Bfh5bdkv.js} +37 -8
  10. package/dist/ExperimentSelectorModal-Bfh5bdkv.js.map +1 -0
  11. package/dist/{SegmentedControl-Dz41vAFM.js → SegmentedControl-B8KgyOjd.js} +2 -2
  12. package/dist/{SegmentedControl-Dz41vAFM.js.map → SegmentedControl-B8KgyOjd.js.map} +1 -1
  13. package/dist/{SettingsModal-C-MSR-bk.js → SettingsModal-DwfrD1ER.js} +7 -7
  14. package/dist/{SettingsModal-C-MSR-bk.js.map → SettingsModal-DwfrD1ER.js.map} +1 -1
  15. package/dist/__tests__/components/AppTopBarCompact.test.d.ts +1 -0
  16. package/dist/__tests__/components/AppTopBarCompactReset.test.d.ts +1 -0
  17. package/dist/__tests__/components/AutoGroupModal.experimentLoad.test.d.ts +1 -0
  18. package/dist/components/AppTopBar.compact.d.ts +5 -0
  19. package/dist/components/AppTopBar.vue.d.ts +17 -17
  20. package/dist/components/FormBuilder.vue.d.ts +1 -1
  21. package/dist/components/PluginIcon.vue.d.ts +2 -2
  22. package/dist/components/PluginWorkspaceView.vue.d.ts +4 -0
  23. package/dist/components/SectionCard.vue.d.ts +13 -1
  24. package/dist/components/SettingsModal.vue.d.ts +1 -1
  25. package/dist/components/internal/AppTopBarPageSelectorInternal.vue.d.ts +2 -2
  26. package/dist/components/internal/FormSchemaInternal.vue.d.ts +9 -9
  27. package/dist/components/internal/FormSectionRenderer.vue.d.ts +4 -4
  28. package/dist/components/internal/LucideIconInternal.vue.d.ts +17 -0
  29. package/dist/composables/appLayoutSidebar.d.ts +9 -0
  30. package/dist/composables/useExperimentSelector.d.ts +2 -0
  31. package/dist/composables/useOnboarding.d.ts +1 -1
  32. package/dist/composables/usePluginWorkspace.d.ts +2 -0
  33. package/dist/composables/useVersionedSave.d.ts +5 -2
  34. package/dist/{controlSchemaModel-BbXbMgc6.js → controlSchemaModel-BtFNZj8t.js} +2 -2
  35. package/dist/{controlSchemaModel-BbXbMgc6.js.map → controlSchemaModel-BtFNZj8t.js.map} +1 -1
  36. package/dist/{data-LzdkEZJ-.js → data-CJztFns8.js} +31 -15
  37. package/dist/{data-LzdkEZJ-.js.map → data-CJztFns8.js.map} +1 -1
  38. package/dist/data.js +8 -8
  39. package/dist/{experimentStatus-DCn2QKG7.js → experimentStatus-Deokk7Br.js} +2 -2
  40. package/dist/{experimentStatus-DCn2QKG7.js.map → experimentStatus-Deokk7Br.js.map} +1 -1
  41. package/dist/index.d.ts +0 -1
  42. package/dist/index.js +15 -20
  43. package/dist/index.js.map +1 -1
  44. package/dist/{install-UTqrAgHe.js → install-Bk02tJ5M.js} +15 -40
  45. package/dist/install-Bk02tJ5M.js.map +1 -0
  46. package/dist/install.js +1 -1
  47. package/dist/{lab-CbdDLAVr.js → lab-BYhb_cJI.js} +47 -117
  48. package/dist/lab-BYhb_cJI.js.map +1 -0
  49. package/dist/lab.js +4 -4
  50. package/dist/{records-CWRztE5M.js → records-GtiVMlTQ.js} +207 -171
  51. package/dist/records-GtiVMlTQ.js.map +1 -0
  52. package/dist/{runtime-DrL6lKAb.js → runtime-aZPUPxoB.js} +112 -54
  53. package/dist/runtime-aZPUPxoB.js.map +1 -0
  54. package/dist/runtime.js +6 -6
  55. package/dist/{shell-Ch7cJnjo.js → shell-Bk9mD4Lc.js} +5 -5
  56. package/dist/{shell-Ch7cJnjo.js.map → shell-Bk9mD4Lc.js.map} +1 -1
  57. package/dist/shell.js +5 -5
  58. package/dist/styles.css +315 -134
  59. package/dist/styles.d.ts +1 -0
  60. package/dist/types/components.d.ts +22 -2
  61. package/dist/types/generated-ui.d.ts +2 -0
  62. package/dist/types/index.d.ts +2 -2
  63. package/dist/types/instrument.d.ts +5 -17
  64. package/dist/{useDropdownState-uFIz6Yv8.js → useDropdownState-CcBzUYZ6.js} +14 -5
  65. package/dist/useDropdownState-CcBzUYZ6.js.map +1 -0
  66. package/dist/{useExperimentSamples-Cz2v3V_q.js → useExperimentSamples-CpNXvpUn.js} +3 -3
  67. package/dist/{useExperimentSamples-Cz2v3V_q.js.map → useExperimentSamples-CpNXvpUn.js.map} +1 -1
  68. package/dist/{useListReorder-QqJc1dpX.js → useListReorder-Bp4EAI2w.js} +16 -4
  69. package/dist/useListReorder-Bp4EAI2w.js.map +1 -0
  70. package/dist/{useRackEditor-BHGM1Ngz.js → useRackEditor-i2bdPUFf.js} +156 -27
  71. package/dist/useRackEditor-i2bdPUFf.js.map +1 -0
  72. package/dist/{useRealtime-CNQbrS01.js → useRealtime-BLQEjbuq.js} +3 -3
  73. package/dist/{useRealtime-CNQbrS01.js.map → useRealtime-BLQEjbuq.js.map} +1 -1
  74. package/dist/useRequest-CXyPw3eh.js +76 -0
  75. package/dist/useRequest-CXyPw3eh.js.map +1 -0
  76. package/dist/utils/deprecation.d.ts +2 -0
  77. package/dist/utils/lucideIcon.d.ts +4 -0
  78. package/package.json +7 -4
  79. package/src/__tests__/components/AppTopBar.test.ts +102 -20
  80. package/src/__tests__/components/AppTopBarCompact.test.ts +65 -0
  81. package/src/__tests__/components/AppTopBarCompactReset.test.ts +24 -0
  82. package/src/__tests__/components/AppTopBarOnboarding.test.ts +10 -0
  83. package/src/__tests__/components/AutoGroupModal.experimentLoad.test.ts +58 -0
  84. package/src/__tests__/components/BaseModal.test.ts +33 -2
  85. package/src/__tests__/components/ConfirmDialog.test.ts +2 -2
  86. package/src/__tests__/components/ExperimentPopover.test.ts +32 -0
  87. package/src/__tests__/components/ExperimentSelectorModal.test.ts +16 -0
  88. package/src/__tests__/components/PluginWorkspaceView.test.ts +20 -1
  89. package/src/__tests__/components/SectionCard.test.ts +33 -0
  90. package/src/__tests__/components/deprecationTags.test.ts +4 -18
  91. package/src/__tests__/composables/useJobCenter.test.ts +35 -0
  92. package/src/__tests__/composables/useJobsEndpoint.test.ts +50 -0
  93. package/src/__tests__/composables/useOnboarding.test.ts +2 -2
  94. package/src/__tests__/composables/useVersionedSave.test.ts +71 -6
  95. package/src/__tests__/docs/frontendDocsCatalog.test.ts +3 -3
  96. package/src/__tests__/styles/menusShellRefresh.test.ts +10 -0
  97. package/src/components/AppLayout.story.vue +5 -5
  98. package/src/components/AppLayout.vue +13 -1
  99. package/src/components/AppSidebar.vue +2 -2
  100. package/src/components/AppTopBar.compact.ts +60 -0
  101. package/src/components/AppTopBar.story.vue +39 -14
  102. package/src/components/AppTopBar.vue +222 -89
  103. package/src/components/BaseModal.vue +16 -5
  104. package/src/components/CollapsibleCard.vue +1 -1
  105. package/src/components/ConfirmDialog.vue +1 -1
  106. package/src/components/ExperimentPopover.story.vue +2 -2
  107. package/src/components/ExperimentPopover.vue +6 -22
  108. package/src/components/ExperimentSelectorModal.vue +35 -0
  109. package/src/components/FileUploader.vue +3 -35
  110. package/src/components/GeneratedPluginView.vue +34 -81
  111. package/src/components/GeneratedResultRenderer.vue +2 -1
  112. package/src/components/InstrumentStatusPanels.vue +3 -3
  113. package/src/components/PlotlyChart.vue +1 -1
  114. package/src/components/PluginIcon.story.vue +2 -2
  115. package/src/components/PluginIcon.vue +2 -2
  116. package/src/components/PluginWorkspaceView.vue +10 -1
  117. package/src/components/SectionCard.vue +42 -6
  118. package/src/components/internal/AppTopBarOnboardingInternal.vue +30 -14
  119. package/src/components/internal/AppTopBarPageSelectorInternal.vue +12 -0
  120. package/src/components/internal/AutoGroupModalInternal.vue +14 -3
  121. package/src/components/internal/LucideIconInternal.vue +23 -0
  122. package/src/composables/appLayoutSidebar.ts +11 -0
  123. package/src/composables/index.ts +1 -2
  124. package/src/composables/useExperimentSelector.ts +4 -0
  125. package/src/composables/useJobCenter.ts +21 -1
  126. package/src/composables/useJobsEndpoint.ts +15 -4
  127. package/src/composables/useOnboarding.ts +3 -3
  128. package/src/composables/usePluginWorkspace.ts +15 -4
  129. package/src/composables/useVersionedSave.ts +32 -7
  130. package/src/generated-ui/main.ts +3 -1
  131. package/src/index.ts +0 -3
  132. package/src/styles/components/alert-box.css +1 -1
  133. package/src/styles/components/app-layout.css +5 -0
  134. package/src/styles/components/app-sidebar.css +5 -5
  135. package/src/styles/components/app-top-bar.css +130 -9
  136. package/src/styles/components/button.css +3 -3
  137. package/src/styles/components/checkbox.css +1 -1
  138. package/src/styles/components/collapsible-card.css +1 -1
  139. package/src/styles/components/confirm-dialog.css +2 -2
  140. package/src/styles/components/file-uploader.css +3 -3
  141. package/src/styles/components/icon-button.css +3 -3
  142. package/src/styles/components/loading-spinner.css +1 -1
  143. package/src/styles/components/onboarding.css +39 -0
  144. package/src/styles/components/pill.css +1 -1
  145. package/src/styles/components/plugin-icon.css +2 -2
  146. package/src/styles/components/presence-avatars.css +4 -4
  147. package/src/styles/components/progress-bar.css +1 -1
  148. package/src/styles/components/section-card.css +65 -6
  149. package/src/styles/components/sequence-input.css +1 -1
  150. package/src/styles/components/slider.css +1 -1
  151. package/src/styles/components/toggle.css +1 -1
  152. package/src/styles/variables.css +10 -4
  153. package/src/types/components.ts +23 -2
  154. package/src/types/generated-ui.ts +2 -0
  155. package/src/types/index.ts +1 -1
  156. package/src/types/instrument.ts +5 -18
  157. package/src/utils/deprecation.ts +8 -0
  158. package/src/utils/lucideIcon.ts +25 -0
  159. package/src/utils/lucideIconIndex.d.ts +2 -0
  160. package/dist/AppSidebar-BDrd7Kwn.js.map +0 -1
  161. package/dist/ConfirmDialog-CjWoxAD1.js.map +0 -1
  162. package/dist/ExperimentPopover-BM92LlrQ.js.map +0 -1
  163. package/dist/ExperimentSelectorModal-LsB1ABmh.js.map +0 -1
  164. package/dist/deprecatedRootExports.d.ts +0 -191
  165. package/dist/install-UTqrAgHe.js.map +0 -1
  166. package/dist/lab-CbdDLAVr.js.map +0 -1
  167. package/dist/records-CWRztE5M.js.map +0 -1
  168. package/dist/runtime-DrL6lKAb.js.map +0 -1
  169. package/dist/useDropdownState-uFIz6Yv8.js.map +0 -1
  170. package/dist/useListReorder-QqJc1dpX.js.map +0 -1
  171. package/dist/useRackEditor-BHGM1Ngz.js.map +0 -1
  172. package/dist/useRequest-Bfsh7gIm.js +0 -215
  173. package/dist/useRequest-Bfsh7gIm.js.map +0 -1
  174. package/src/deprecatedRootExports.ts +0 -226
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { trackTopBarCompact } from '../../components/AppTopBar.compact'
4
+
5
+ let resize: () => void = () => {}
6
+ vi.stubGlobal('ResizeObserver', class {
7
+ constructor(callback: () => void) { resize = callback }
8
+ observe() {}
9
+ disconnect() {}
10
+ })
11
+
12
+ function size(el: Element, box: { width?: number; client?: number; scroll?: number }) {
13
+ if (box.width !== undefined) el.getBoundingClientRect = () => ({ width: box.width }) as DOMRect
14
+ if (box.client !== undefined) Object.defineProperty(el, 'clientWidth', { configurable: true, value: box.client })
15
+ if (box.scroll !== undefined) Object.defineProperty(el, 'scrollWidth', { configurable: true, value: box.scroll })
16
+ }
17
+
18
+ function topBar() {
19
+ const header = document.createElement('header')
20
+ header.innerHTML = `
21
+ <button class="mint-experiment-popover__trigger"><span class="mint-experiment-popover__trigger-text"></span></button>
22
+ <span class="mint-experiment-popover__save-label">Save</span>`
23
+ const [chip, text, label] = ['__trigger', '__trigger-text', '__save-label']
24
+ .map(part => header.querySelector(`.mint-experiment-popover${part}`)!)
25
+ size(label, { width: 40 })
26
+ return { header, chip, text }
27
+ }
28
+
29
+ afterEach(() => { resize = () => {} })
30
+
31
+ describe('trackTopBarCompact', () => {
32
+ it('should compact when the chip is squeezed and expand once the bar has grown by the label width', () => {
33
+ const { header, chip, text } = topBar()
34
+ const compact = ref(false)
35
+ trackTopBarCompact(header, compact)
36
+
37
+ size(header, { client: 800 })
38
+ size(chip, { width: 150 })
39
+ size(text, { client: 60, scroll: 120 })
40
+ resize()
41
+ const squeezed = compact.value
42
+
43
+ size(header, { client: 830 })
44
+ resize()
45
+ const belowLabelWidth = compact.value
46
+
47
+ size(header, { client: 840 })
48
+ resize()
49
+
50
+ expect([squeezed, belowLabelWidth, compact.value]).toEqual([true, true, false])
51
+ })
52
+
53
+ it('should keep the labels when a short name leaves the chip narrow', () => {
54
+ const { header, chip, text } = topBar()
55
+ const compact = ref(false)
56
+ trackTopBarCompact(header, compact)
57
+
58
+ size(header, { client: 800 })
59
+ size(chip, { width: 150 })
60
+ size(text, { client: 60, scroll: 60 })
61
+ resize()
62
+
63
+ expect(compact.value).toBe(false)
64
+ })
65
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { createPinia } from 'pinia'
4
+ import AppTopBar from '../../components/AppTopBar.vue'
5
+
6
+ const reset = vi.fn()
7
+ vi.mock('../../components/AppTopBar.compact', () => ({
8
+ trackTopBarCompact: () => ({ reset, stop: () => {} }),
9
+ }))
10
+
11
+ describe('AppTopBar compact reset', () => {
12
+ it('should re-measure when a job appears but not when its progress label changes', async () => {
13
+ const wrapper = mount(AppTopBar, {
14
+ props: { title: 'Test App', job: null },
15
+ global: { plugins: [createPinia()] },
16
+ })
17
+
18
+ await wrapper.setProps({ job: { status: 'running', label: 'Extracting 10 files' } })
19
+ const afterAppear = reset.mock.calls.length
20
+ await wrapper.setProps({ job: { status: 'running', label: 'Extracting 4 of 10 files' } })
21
+
22
+ expect([afterAppear, reset.mock.calls.length]).toEqual([1, 1])
23
+ })
24
+ })
@@ -56,6 +56,16 @@ describe('AppTopBar onboarding', () => {
56
56
  if (shown) expect(wrapper.find('.mint-onboarding-help__chip').text()).toContain('0/1')
57
57
  })
58
58
 
59
+ it('should hide the checklist chip when its close button is clicked', async () => {
60
+ const wrapper = mountTopBar(definition)
61
+
62
+ await wrapper.get('[aria-label="Hide checklist"]').trigger('click')
63
+
64
+ expect(wrapper.find('.mint-onboarding-help__chip').exists()).toBe(false)
65
+ expect(JSON.parse(localStorage.getItem('mint.onboarding.test.1')!)).toContain('hide:checklist')
66
+ expect(document.activeElement).toBe(wrapper.get('[data-onboarding="topbar-help"]').element)
67
+ })
68
+
59
69
  it('should run a tour picked in the Help panel and end it with Escape', async () => {
60
70
  const wrapper = mountTopBar(definition)
61
71
  await wrapper.get('[data-onboarding="topbar-help"]').trigger('click')
@@ -0,0 +1,58 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue'
5
+
6
+ const pending = vi.hoisted(() => new Map<string, (data: unknown) => void>())
7
+
8
+ vi.mock('../../composables/useApi', () => ({
9
+ useApi: () => ({
10
+ get: (url: string) => new Promise(resolve => pending.set(url, data => resolve({ data }))),
11
+ }),
12
+ }))
13
+
14
+ const { default: AutoGroupModal } = await import('../../components/internal/AutoGroupModalInternal.vue')
15
+
16
+ const design = (names: string[]) => ({
17
+ samples: names.map(sample_name => ({ sample_name, sample_type: 'sample', conditions: { Genotype: sample_name } })),
18
+ })
19
+
20
+ describe('AutoGroupModal experiment load', () => {
21
+ it('should ignore the response of an earlier open when the modal reopens on another experiment', async () => {
22
+ const wrapper = mount(AutoGroupModal, {
23
+ props: { open: true, experimentId: 1 },
24
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
25
+ })
26
+ await wrapper.setProps({ open: false })
27
+ await wrapper.setProps({ open: true, experimentId: 2 })
28
+ const recipe = () => wrapper.findComponent(SmartGroupFieldRecipe)
29
+
30
+ pending.get('/experiments/1/data')!(design(['O1', 'O2', 'O3']))
31
+ await flushPromises()
32
+ const afterOld = [recipe().props('loading'), recipe().props('headerCount')]
33
+ pending.get('/experiments/2/data')!(design(['N1', 'N2']))
34
+ await flushPromises()
35
+
36
+ expect({
37
+ afterOld,
38
+ afterNew: [recipe().props('loading'), recipe().props('headerCount')],
39
+ }).toEqual({ afterOld: [true, 0], afterNew: [false, 2] })
40
+ })
41
+
42
+ it('should stop loading and keep the pasted samples when the modal reopens without an experiment during a fetch', async () => {
43
+ const wrapper = mount(AutoGroupModal, {
44
+ props: { open: true, experimentId: 3 },
45
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
46
+ })
47
+ await wrapper.setProps({ open: false })
48
+ await wrapper.setProps({ open: true, experimentId: undefined, samples: ['WT_1', 'WT_2', 'KO_1', 'KO_2'] })
49
+
50
+ pending.get('/experiments/3/data')!(design(['O1', 'O2', 'O3']))
51
+ await flushPromises()
52
+ const recipe = wrapper.findComponent(SmartGroupFieldRecipe)
53
+
54
+ const anatomy = (recipe.props('anatomy') as { text: string }[]).map(token => token.text)
55
+
56
+ expect([recipe.props('loading'), anatomy]).toEqual([false, ['WT', '1']])
57
+ })
58
+ })
@@ -1,6 +1,6 @@
1
- import { mount } from '@vue/test-utils'
1
+ import { enableAutoUnmount, mount } from '@vue/test-utils'
2
2
  import { nextTick } from 'vue'
3
- import { describe, expect, it } from 'vitest'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
4
  import BaseModal from '../../components/BaseModal.vue'
5
5
  import type { ModalTab } from '../../types'
6
6
 
@@ -36,6 +36,8 @@ function mountModal(props: Record<string, unknown> = {}, slots: Record<string, s
36
36
  })
37
37
  }
38
38
 
39
+ enableAutoUnmount(afterEach)
40
+
39
41
  describe('BaseModal', () => {
40
42
  describe('plain layout', () => {
41
43
  it('should not render a tab strip when layout is omitted', () => {
@@ -233,4 +235,33 @@ describe('BaseModal', () => {
233
235
  attached.unmount()
234
236
  })
235
237
  })
238
+
239
+ describe('scroll lock', () => {
240
+ afterEach(() => {
241
+ document.body.style.overflow = ''
242
+ })
243
+
244
+ it('should keep body scroll locked until the last nested modal closes when modals stack', async () => {
245
+ document.body.style.overflow = 'scroll'
246
+ const outer = mountModal()
247
+ const inner = mountModal({ title: 'Nested' })
248
+ await inner.setProps({ open: false })
249
+ expect(document.body.style.overflow).toBe('hidden')
250
+ await outer.setProps({ open: false })
251
+ expect(document.body.style.overflow).toBe('scroll')
252
+ })
253
+
254
+ it('should keep body scroll locked when a closed modal unmounts while another is open', async () => {
255
+ const open = mountModal({ open: false })
256
+ await open.setProps({ open: true })
257
+ const closed = mountModal({ open: false })
258
+ closed.unmount()
259
+ expect(document.body.style.overflow).toBe('hidden')
260
+ })
261
+
262
+ it('should lock body scroll when the modal mounts open', () => {
263
+ mountModal()
264
+ expect(document.body.style.overflow).toBe('hidden')
265
+ })
266
+ })
236
267
  })
@@ -7,8 +7,8 @@ import type { ButtonVariant, Tone } from '../../types'
7
7
  describe('ConfirmDialog', () => {
8
8
  it.each<[Tone, ButtonVariant]>([
9
9
  ['error', 'danger'],
10
- ['warning', 'cta'],
11
- ['cta', 'cta'],
10
+ ['warning', 'primary'],
11
+ ['cta', 'primary'],
12
12
  ['success', 'success'],
13
13
  ['info', 'primary'],
14
14
  ['neutral', 'primary'],
@@ -1,5 +1,6 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import ConfirmDialog from '../../components/ConfirmDialog.vue'
3
4
  import ExperimentPopover from '../../components/ExperimentPopover.vue'
4
5
 
5
6
  function mountPopover(props = {}) {
@@ -279,4 +280,35 @@ describe('ExperimentPopover', () => {
279
280
 
280
281
  wrapper.unmount()
281
282
  })
283
+
284
+ it('should drop the success indicator when the parent clears saveSuccessMessage', async () => {
285
+ const wrapper = mountPopover({ experimentName: 'Dose response run', showSave: true })
286
+ const save = () => wrapper.find('.mint-experiment-popover__save')
287
+
288
+ await wrapper.setProps({ saveSuccessMessage: 'Saved' })
289
+ expect(save().classes()).toContain('mint-experiment-popover__save--success')
290
+ expect(save().attributes('title')).toBe('Saved')
291
+
292
+ await wrapper.setProps({ saveSuccessMessage: undefined, saveDisabled: true })
293
+ expect(save().classes()).not.toContain('mint-experiment-popover__save--success')
294
+ expect(save().classes()).toContain('mint-experiment-popover__save--disabled')
295
+ expect(save().attributes('disabled')).toBeDefined()
296
+
297
+ wrapper.unmount()
298
+ })
299
+
300
+ it.each([
301
+ { saveDisabled: true },
302
+ { saveLoading: true },
303
+ ])('should not emit save from the confirm dialog when %o', async (blocked) => {
304
+ const wrapper = mountPopover({ experimentName: 'Dose response run', showSave: true })
305
+
306
+ await wrapper.find('.mint-experiment-popover__save').trigger('click')
307
+ await wrapper.setProps(blocked)
308
+ wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
309
+
310
+ expect(wrapper.emitted('save')).toBeUndefined()
311
+
312
+ wrapper.unmount()
313
+ })
282
314
  })
@@ -96,6 +96,7 @@ describe('ExperimentSelectorModal', () => {
96
96
  projects.value = []
97
97
  statusCounts.value = {}
98
98
  filters = newFilters()
99
+ localStorage.clear()
99
100
  fetchExperiments.mockClear()
100
101
  fetchFilterOptions.mockClear()
101
102
  fetchStatusCounts.mockClear()
@@ -124,6 +125,7 @@ describe('ExperimentSelectorModal', () => {
124
125
  experimentType: undefined,
125
126
  allowedExperimentTypes: ['mass_spectrometry'],
126
127
  withStatusCounts: true,
128
+ initialFilters: {},
127
129
  })
128
130
  })
129
131
 
@@ -293,6 +295,20 @@ describe('ExperimentSelectorModal', () => {
293
295
  expect(mine.attributes('aria-pressed')).toBe('true')
294
296
  })
295
297
 
298
+ it('should reopen with the status, project and mine filters of the last visit but not the search or type', async () => {
299
+ const first = mountModal({ open: true })
300
+ Object.assign(filters, { search: 'glu', status: 'ongoing', project: 'Screen', experimentType: 'screen', mine: true })
301
+ await first.vm.$nextTick()
302
+ first.unmount()
303
+
304
+ vi.mocked(useExperimentSelector).mockClear()
305
+ filters = newFilters()
306
+ mountModal({ open: true })
307
+
308
+ expect(vi.mocked(useExperimentSelector).mock.calls[0][0]?.initialFilters)
309
+ .toEqual({ status: 'ongoing', project: 'Screen', mine: true })
310
+ })
311
+
296
312
  it('should clear status, project and mine but keep the search when Clear N is clicked', async () => {
297
313
  const wrapper = mountModal({ open: true })
298
314
  expect(wrapper.find('.mint-experiment-selector__clear-filters').exists()).toBe(false)
@@ -174,7 +174,7 @@ describe('PluginWorkspaceView', () => {
174
174
  expect(wrapper.findComponent(AppTopBar).props()).toMatchObject({
175
175
  title: 'MS Uploader',
176
176
  subtitle: 'Import and visualize',
177
- spec: { pills: pillNav, themeToggle: true, settings: {}, standaloneLabel: false },
177
+ spec: { pills: pillNav, settings: {}, standaloneLabel: false },
178
178
  pill: 'data',
179
179
  })
180
180
  expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({
@@ -392,6 +392,25 @@ describe('PluginWorkspaceView', () => {
392
392
  expect(wrapper.get('[data-test="sidebar-footer"]').text()).toBe('Run')
393
393
  })
394
394
 
395
+ it.each([
396
+ { name: 'bound v-model', sidebarCollapsed: true, collapsedWidth: undefined, expected: '3rem' },
397
+ { name: 'its own state', sidebarCollapsed: undefined, collapsedWidth: '3.5rem', expected: '3.5rem' },
398
+ ])('should give AppLayout the collapsed width when the sidebar collapses through $name', async ({ sidebarCollapsed, collapsedWidth, expected }) => {
399
+ const wrapper = mount(PluginWorkspaceView, {
400
+ props: {
401
+ sidebarCollapsed,
402
+ spec: { title: 'Tools', sidebar: { width: '20rem', collapsible: true, collapsedWidth, whenEmpty: true } },
403
+ },
404
+ global: globalOptions,
405
+ })
406
+ expect(wrapper.findComponent(AppLayout).props('sidebarWidth')).toBe(sidebarCollapsed ? expected : '20rem')
407
+
408
+ wrapper.findComponent(AppSidebar).vm.$emit('update:collapsed', true)
409
+ await nextTick()
410
+
411
+ expect(wrapper.findComponent(AppLayout).props('sidebarWidth')).toBe(expected)
412
+ })
413
+
395
414
  it('can provide AppTopBar experiment selection and save wiring from the shared store', async () => {
396
415
  vi.mocked(usePlatform).mockReturnValueOnce({
397
416
  integrated: computed(() => true),
@@ -178,4 +178,37 @@ describe('SectionCard', () => {
178
178
  expect(withoutFooter.find('.mint-section-card__footer').exists()).toBe(false)
179
179
  })
180
180
  })
181
+
182
+ describe('collapsible', () => {
183
+ it('should hide the body and emit the collapsed state when a collapsible header is clicked', async () => {
184
+ const wrapper = mount(SectionCard, {
185
+ props: { title: 'Alerts', count: 3, collapsible: true },
186
+ slots: { default: '<p class="body-probe">rows</p>', actions: '<button class="action-probe">Export</button>' },
187
+ attachTo: document.body,
188
+ })
189
+ const toggle = wrapper.find('button.mint-section-card__toggle')
190
+ expect(toggle.attributes('aria-expanded')).toBe('true')
191
+ expect(wrapper.find('h2.mint-section-card__title > button').element).toBe(toggle.element)
192
+ expect(toggle.text()).toContain('Alerts')
193
+ expect(toggle.find('.action-probe').exists()).toBe(false)
194
+
195
+ await toggle.trigger('click')
196
+
197
+ expect(toggle.attributes('aria-expanded')).toBe('false')
198
+ expect(wrapper.find('.mint-section-card__body').isVisible()).toBe(false)
199
+ expect(wrapper.emitted('update:collapsed')).toEqual([[true]])
200
+ wrapper.unmount()
201
+ })
202
+
203
+ it('should keep the body visible with no toggle when collapsed is set without collapsible', () => {
204
+ const wrapper = mount(SectionCard, {
205
+ props: { title: 'Alerts', collapsed: true },
206
+ slots: { default: '<p class="body-probe">rows</p>' },
207
+ attachTo: document.body,
208
+ })
209
+ expect(wrapper.find('.mint-section-card__toggle').exists()).toBe(false)
210
+ expect(wrapper.find('.mint-section-card__body').isVisible()).toBe(true)
211
+ wrapper.unmount()
212
+ })
213
+ })
181
214
  })
@@ -52,13 +52,11 @@ describe('deprecation tags', () => {
52
52
  describe('root exports outside L1 and L2', () => {
53
53
  const ROOT_MODULES = ['composables/index.ts', 'components/index.ts', 'stores/index.ts', 'types/index.ts', 'apiErrors.ts', 'install.ts']
54
54
  .map(file => resolve(SRC, file))
55
- const SUBPATHS = ['lab', 'shell', 'data', 'runtime']
56
- const DEPRECATION = /^Import from '@morscherlab\/mint-sdk\/(\w+)'\. Removed in MINT 1\.5\.$/
57
55
 
58
- function deprecatedRootNames(): ts.Symbol[] {
56
+ function otherRootNames(): string[] {
59
57
  const kept = new Set(ROOT_MODULES.flatMap(file => exportsOf(file).map(symbol => symbol.getName())))
60
58
  kept.add('MINTSdk').add('default')
61
- return exportsOf(ROOT_ENTRY).filter(symbol => !kept.has(symbol.getName()))
59
+ return exportsOf(ROOT_ENTRY).map(symbol => symbol.getName()).filter(name => !kept.has(name))
62
60
  }
63
61
 
64
62
  it('should keep only the spec 3.3 L1 and L2 composable values in the composables barrel', () => {
@@ -72,19 +70,7 @@ describe('root exports outside L1 and L2', () => {
72
70
  ])
73
71
  })
74
72
 
75
- it('should tag each other root name with the subpath that exports it untagged', () => {
76
- const symbols = deprecatedRootNames()
77
- const problems = symbols.flatMap((symbol) => {
78
- const name = symbol.getName()
79
- const tag = symbol.getJsDocTags(checker).find(item => item.name === 'deprecated')
80
- const subpath = ts.displayPartsToString(tag?.text).match(DEPRECATION)?.[1]
81
- if (!subpath || !SUBPATHS.includes(subpath)) return [`${name}: no deprecation tag naming a subpath`]
82
- const target = exportsOf(resolve(SRC, `${subpath}.ts`)).find(item => item.getName() === name)
83
- if (!target) return [`${name}: not exported from ./${subpath}`]
84
- return isDeprecated(target) ? [`${name}: deprecated in ./${subpath}`] : []
85
- })
86
-
87
- expect(symbols.length).toBeGreaterThan(0)
88
- expect(problems).toEqual([])
73
+ it('should export no root name outside the L1 and L2 modules', () => {
74
+ expect(otherRootNames()).toEqual([])
89
75
  })
90
76
  })
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  combinePluginJobSources,
7
7
  useJobCenter,
8
+ type PluginJobCenterSource,
8
9
  type UseJobCenterOptions,
9
10
  } from '../../composables/useJobCenter'
10
11
  import type { JobStateInput, JobStatus, TopBarJob } from '../../types/jobs'
@@ -228,6 +229,26 @@ describe('useJobCenter', () => {
228
229
  wrapper.unmount()
229
230
  })
230
231
 
232
+ it('should refuse a second cancel when the source still reports the job running after the first cancel', async () => {
233
+ const sourceJobs = shallowRef([job('running', 'fit')])
234
+ const cancelJob = vi.fn(async () => {})
235
+ const { tray } = mountComposable({ source: { jobs: sourceJobs, cancel: cancelJob }, loadOnMount: false })
236
+
237
+ const first = await tray.cancelJob(tray.jobs.value[0]!)
238
+ const second = await tray.cancelJob(tray.jobs.value[0]!)
239
+ const pendingWhileRunning = tray.isPending(tray.jobs.value[0]!)
240
+ sourceJobs.value = [job('cancelled', 'fit')]
241
+ await nextTick()
242
+
243
+ expect({
244
+ first,
245
+ second,
246
+ calls: cancelJob.mock.calls.length,
247
+ pendingWhileRunning,
248
+ pendingAfter: tray.isPending(tray.jobs.value[0]!),
249
+ }).toEqual({ first: true, second: false, calls: 1, pendingWhileRunning: true, pendingAfter: false })
250
+ })
251
+
231
252
  it('keeps awaiting input jobs in the active group', () => {
232
253
  const { tray } = mountComposable({
233
254
  source: { jobs: [job('awaiting_input')] },
@@ -406,6 +427,20 @@ describe('useJobCenter top-bar chip', () => {
406
427
  expect(tray.topBarJob.value).toBeNull()
407
428
  })
408
429
 
430
+ it('should alert only later failures when a replacement source already lists failures', async () => {
431
+ const source = shallowRef<PluginJobCenterSource>({ jobs: [job('completed', 'c')] })
432
+ const { tray } = mountComposable({ source, loadOnMount: false })
433
+ const replacementJobs = shallowRef([job('failed', 'old')])
434
+
435
+ source.value = { jobs: replacementJobs }
436
+ await nextTick()
437
+ const replaced = tray.topBarJob.value
438
+ replacementJobs.value = [job('failed', 'old'), job('failed', 'new')]
439
+ await nextTick()
440
+
441
+ expect([replaced, tray.topBarJob.value]).toEqual([null, { status: 'failed', label: '1 failed' }])
442
+ })
443
+
409
444
  it('should alert only failures after the first job list when nothing was stored', async () => {
410
445
  const jobs = shallowRef<JobStateInput[]>([])
411
446
  const { tray } = mountComposable({ source: { jobs }, loadOnMount: false })
@@ -1595,6 +1595,56 @@ describe('useJobsEndpoint', () => {
1595
1595
  }
1596
1596
  })
1597
1597
 
1598
+ it.each([
1599
+ { name: 'resolves when the server still has the job', answers: [() => response(completedJob('submitted-job'))], outcome: 'completed' },
1600
+ { name: 'rejects when the server no longer has the job', answers: [() => response({ detail: 'Job not found' }, 404)], outcome: 'Job submitted-job is no longer available' },
1601
+ { name: 'checks again on the next snapshot after a retryable error', answers: [() => response({ detail: 'Busy' }, 503), () => response(completedJob('submitted-job'))], outcome: 'completed' },
1602
+ ])('should check a waited job that a stale stream snapshot omits and $name', async ({ answers, outcome }) => {
1603
+ const encoder = new TextEncoder()
1604
+ let pushFrame!: (frame: string) => void
1605
+ const stream = new ReadableStream<Uint8Array>({
1606
+ start(controller) { pushFrame = frame => controller.enqueue(encoder.encode(frame)) },
1607
+ })
1608
+ let lookups = 0
1609
+ const transport = vi.fn<GeneratedAnalysisTransport>(async (input, init) => {
1610
+ const url = String(input)
1611
+ if (url.endsWith('/jobs/sessions') && init?.method === 'GET') return response({ sessions: [] })
1612
+ if (url.endsWith('/jobs/sessions')) {
1613
+ return response({ id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [] })
1614
+ }
1615
+ if (url.endsWith('/jobs/events')) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } })
1616
+ if (url.endsWith('/jobs') && init?.method === 'POST') return response(activeJob('submitted-job'))
1617
+ if (url.endsWith('/jobs')) return response({ jobs: [] })
1618
+ if (url.endsWith('/jobs/submitted-job')) {
1619
+ lookups += 1
1620
+ return lookups === 1 ? response(activeJob('submitted-job', 'running')) : answers[lookups - 2]!()
1621
+ }
1622
+ throw new Error(`Unexpected request: ${url}`)
1623
+ })
1624
+ const scope = effectScope()
1625
+ const generated = scope.run(() => useJobsEndpoint({
1626
+ baseUrl: '/api/demo',
1627
+ transport,
1628
+ storage: memoryStorage(),
1629
+ createId: () => 'tab-1',
1630
+ streamReconnectDelay: 60_000,
1631
+ }))!
1632
+
1633
+ try {
1634
+ const waiting = generated.wait(await generated.submit('run', {}))
1635
+ .then(job => job.status, (cause: Error) => cause.message)
1636
+ // Each snapshot was built before the submit reached the server.
1637
+ for (const [index] of answers.entries()) {
1638
+ await vi.waitFor(() => expect(lookups).toBe(index + 1))
1639
+ pushFrame(`event: snapshot\ndata: ${JSON.stringify({ jobs: [] })}\n\n`)
1640
+ }
1641
+
1642
+ expect(await waiting).toBe(outcome)
1643
+ } finally {
1644
+ scope.stop()
1645
+ }
1646
+ })
1647
+
1598
1648
  it('rejects pending waiters when their job is deleted or the runtime is disposed', async () => {
1599
1649
  const queued = activeJob('delete-wait-job')
1600
1650
  const disposeJobRequest = deferred<Response>()
@@ -98,7 +98,7 @@ describe('provideOnboarding', () => {
98
98
  expect(state.active.value).toMatchObject({ step: { id: 'b' }, index: 0, count: 1 })
99
99
  })
100
100
 
101
- it('should mark every step seen on dismiss and not start the tour again', async () => {
101
+ it('should mark every step of every tour seen on dismiss and not start a tour again', async () => {
102
102
  const state = setup()
103
103
  await flush()
104
104
  state.dismiss()
@@ -107,7 +107,7 @@ describe('provideOnboarding', () => {
107
107
  const again = setup()
108
108
  await flush()
109
109
 
110
- expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b']))
110
+ expect(stored()).toEqual(expect.arrayContaining(['step:intro/a', 'step:intro/b', 'step:list/new']))
111
111
  expect(again.active.value).toBeNull()
112
112
  })
113
113
 
@@ -1,3 +1,4 @@
1
+ import { flushPromises } from '@vue/test-utils'
1
2
  import { effectScope, ref } from 'vue'
2
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
4
  import { MintApiError } from '../../apiErrors'
@@ -95,18 +96,47 @@ describe('useVersionedSave', () => {
95
96
  scope.stop()
96
97
  })
97
98
 
98
- it('should keep the conflict and the stale change when the reload brings no fresh record', async () => {
99
+ it.each([
100
+ ['the conflict', 'conflict', { version: 17 }],
101
+ ['the stale change', 'stale', { actors: ['Alice Chen'], fields: ['Notes'], at: '2026-10-23T10:00:00Z' }],
102
+ ] as const)('should keep %s when the reload brings no fresh record', async (_label, banner, expected) => {
99
103
  const { versioned, save, reload, scope } = setup()
100
- save.mockRejectedValueOnce(conflictError({ version: 17 }))
101
- await versioned.save()
102
- emit({ actor_id: '7', fields: ['notes'] })
104
+ if (banner === 'conflict') {
105
+ save.mockRejectedValueOnce(conflictError({ version: 17 }))
106
+ await versioned.save()
107
+ } else {
108
+ emit({ actor_id: '7', fields: ['notes'], at: '2026-10-23T10:00:00Z' })
109
+ }
103
110
  const failure = new Error('Could not reload')
104
111
  reload.mockRejectedValueOnce(failure)
105
112
 
106
113
  await expect(versioned.reload()).rejects.toBe(failure)
107
114
 
108
- expect(versioned.conflict.value).toEqual({ version: 17 })
109
- expect(versioned.stale.value?.actors).toEqual(['Alice Chen'])
115
+ expect(versioned[banner].value).toEqual(expected)
116
+ scope.stop()
117
+ })
118
+
119
+ it('should show only the conflict when a save is refused after a write by someone else', async () => {
120
+ const { versioned, save, scope } = setup()
121
+ emit({ actor_id: '7', fields: ['notes'] })
122
+ save.mockRejectedValueOnce(conflictError({ version: 17 }))
123
+
124
+ await versioned.save()
125
+ emit({ actor_id: '9', fields: ['status'] })
126
+
127
+ expect([versioned.conflict.value, versioned.stale.value]).toEqual([{ version: 17 }, null])
128
+ scope.stop()
129
+ })
130
+
131
+ it('should send one request when save is called again while a save runs', async () => {
132
+ const pending: ((value: string) => void)[] = []
133
+ const { versioned, save, scope } = setup({ save: () => new Promise(resolve => { pending.push(resolve) }) })
134
+
135
+ const first = versioned.save()
136
+ const second = await Promise.race([versioned.save(), Promise.resolve('sent')])
137
+ pending.forEach(resolve => resolve('saved'))
138
+
139
+ expect([await first, second, save.mock.calls.length]).toEqual(['saved', undefined, 1])
110
140
  scope.stop()
111
141
  })
112
142
 
@@ -136,6 +166,41 @@ describe('useVersionedSave', () => {
136
166
  scope.stop()
137
167
  })
138
168
 
169
+ it('should ask for an automatic reload and show the stale change when the user starts editing during the refetch', async () => {
170
+ const { versioned, reload, dirty, scope } = setup()
171
+ dirty.value = false
172
+ let finish: () => void = () => {}
173
+ reload.mockImplementationOnce(() => new Promise<void>(resolve => { finish = resolve }))
174
+
175
+ emit({ actor_id: '7', fields: ['notes'], at: '2026-10-23T10:00:00Z' })
176
+ dirty.value = true
177
+ finish()
178
+ await flushPromises()
179
+
180
+ expect([reload.mock.calls[0], versioned.stale.value]).toEqual([
181
+ [true],
182
+ { actors: ['Alice Chen'], fields: ['Notes'], at: '2026-10-23T10:00:00Z' },
183
+ ])
184
+ scope.stop()
185
+ })
186
+
187
+ it('should not show the stale change when the topic changes during an automatic reload', async () => {
188
+ const { versioned, reload, dirty, topic, scope } = setup()
189
+ dirty.value = false
190
+ let finish: () => void = () => {}
191
+ reload.mockImplementationOnce(() => new Promise<void>(resolve => { finish = resolve }))
192
+
193
+ emit({ actor_id: '7', fields: ['notes'] })
194
+ dirty.value = true
195
+ topic.value = 'experiment:43'
196
+ await Promise.resolve()
197
+ finish()
198
+ await flushPromises()
199
+
200
+ expect(versioned.stale.value).toBeNull()
201
+ scope.stop()
202
+ })
203
+
139
204
  // A failed background refetch only keeps the clean form as it is; the host reports it.
140
205
  it('should not reject when the refetch for an event on a clean form fails', async () => {
141
206
  const { versioned, reload, dirty, scope } = setup()