@morscherlab/mint-sdk 1.0.63 → 1.1.0-beta.2

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 (165) hide show
  1. package/README.md +101 -2
  2. package/dist/EmptyState-D1c4RdC7.js +638 -0
  3. package/dist/EmptyState-D1c4RdC7.js.map +1 -0
  4. package/dist/{ExperimentPopover-BbPkIFsI.js → ExperimentPopover-BFw3QX8L.js} +10 -9
  5. package/dist/{ExperimentPopover-BbPkIFsI.js.map → ExperimentPopover-BFw3QX8L.js.map} +1 -1
  6. package/dist/{ExperimentSelectorModal-DZeSd-yr.js → ExperimentSelectorModal-B7w6k3Y8.js} +12 -216
  7. package/dist/ExperimentSelectorModal-B7w6k3Y8.js.map +1 -0
  8. package/dist/{SettingsModal-CJgLadsN.js → SettingsModal-DPDwCbXK.js} +12 -9
  9. package/dist/{SettingsModal-CJgLadsN.js.map → SettingsModal-DPDwCbXK.js.map} +1 -1
  10. package/dist/__tests__/components/GeneratedPlotlyResult.test.d.ts +1 -0
  11. package/dist/__tests__/components/GeneratedPluginView.test.d.ts +1 -0
  12. package/dist/__tests__/components/GeneratedResultRenderer.test.d.ts +1 -0
  13. package/dist/__tests__/components/InstrumentAlertLog.test.d.ts +1 -0
  14. package/dist/__tests__/composables/useGeneratedAnalysis.test.d.ts +1 -0
  15. package/dist/__tests__/generatedUi.test.d.ts +1 -0
  16. package/dist/{analysisArtifactTypes-Cu40dzSG.js → analysisArtifactTypes-ejqR8MX3.js} +1 -1
  17. package/dist/analysisArtifactTypes-ejqR8MX3.js.map +1 -0
  18. package/dist/{auth-DZAwEeis.js → auth-B87xM--s.js} +1 -1
  19. package/dist/{auth-DZAwEeis.js.map → auth-B87xM--s.js.map} +1 -1
  20. package/dist/chunk-BoAXSpZd.js +33 -0
  21. package/dist/components/AppSidebar.vue.d.ts +1 -1
  22. package/dist/components/AutoGroupModal.vue.d.ts +2 -2
  23. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/CollapsibleCard.vue.d.ts +1 -1
  25. package/dist/components/ComponentBindingRenderer.vue.d.ts +1 -1
  26. package/dist/components/ConfirmDialog.vue.d.ts +1 -1
  27. package/dist/components/ExperimentDataViewer.vue.d.ts +1 -1
  28. package/dist/components/ExperimentTimeline.vue.d.ts +1 -1
  29. package/dist/components/FileUploader.vue.d.ts +4 -2
  30. package/dist/components/FitPanel.vue.d.ts +1 -1
  31. package/dist/components/FormActions.vue.d.ts +1 -1
  32. package/dist/components/FormBuilder.vue.d.ts +1 -1
  33. package/dist/components/GeneratedPathInput.vue.d.ts +12 -0
  34. package/dist/components/GeneratedPlotlyResult.vue.d.ts +11 -0
  35. package/dist/components/GeneratedPluginView.vue.d.ts +10 -0
  36. package/dist/components/GeneratedResultRenderer.vue.d.ts +6 -0
  37. package/dist/components/JobsStatusTray.vue.d.ts +5 -1
  38. package/dist/components/LcmsSequenceTable.vue.d.ts +1 -1
  39. package/dist/components/SampleLegend.vue.d.ts +1 -1
  40. package/dist/components/SampleSelector.vue.d.ts +2 -2
  41. package/dist/components/SettingsModal.vue.d.ts +1 -1
  42. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  43. package/dist/components/index.d.ts +4 -0
  44. package/dist/components/index.js +8 -9
  45. package/dist/components/internal/PlateMapEditorToolbarInternal.vue.d.ts +2 -2
  46. package/dist/{components-DdzHh3a_.js → components-UfkPN1bK.js} +2255 -2303
  47. package/dist/components-UfkPN1bK.js.map +1 -0
  48. package/dist/composables/index.d.ts +2 -3
  49. package/dist/composables/index.js +10 -11
  50. package/dist/composables/platformContextHelpers.d.ts +0 -2
  51. package/dist/composables/useAnalysisArtifacts.d.ts +1 -3
  52. package/dist/composables/useGeneratedAnalysis.d.ts +62 -0
  53. package/dist/composables/useJobsStatusTray.d.ts +48 -4
  54. package/dist/composables/usePlatformContext.d.ts +3 -0
  55. package/dist/composables/usePluginClient.d.ts +6 -3
  56. package/dist/{composables-BUUphBqg.js → composables-B512bWJP.js} +155 -74
  57. package/dist/composables-B512bWJP.js.map +1 -0
  58. package/dist/experiment-utils-CxkruZ3G.js +392 -0
  59. package/dist/experiment-utils-CxkruZ3G.js.map +1 -0
  60. package/dist/generated/schema.d.ts +7 -0
  61. package/dist/generated-ui/main.d.ts +0 -0
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.js +15 -18
  64. package/dist/index.js.map +1 -1
  65. package/dist/install.js +7 -8
  66. package/dist/install.js.map +1 -1
  67. package/dist/plotly.min-BR53hja4.js +180634 -0
  68. package/dist/plotly.min-BR53hja4.js.map +1 -0
  69. package/dist/stores/auth.d.ts +2 -2
  70. package/dist/stores/index.js +1 -1
  71. package/dist/styles.css +10543 -10404
  72. package/dist/templates/index.js +4 -3
  73. package/dist/templates/packs.d.ts +0 -1
  74. package/dist/{templates-CNbPQNID.js → templates-Cttbjbyc.js} +2 -4
  75. package/dist/templates-Cttbjbyc.js.map +1 -0
  76. package/dist/types/analysisArtifactTypes.d.ts +0 -8
  77. package/dist/types/form-builder.d.ts +1 -1
  78. package/dist/types/generated-ui.d.ts +170 -0
  79. package/dist/types/index.d.ts +2 -1
  80. package/dist/types/index.js +1 -1
  81. package/dist/types/instrument.d.ts +1 -0
  82. package/dist/types/jobs.d.ts +7 -1
  83. package/dist/types/platform.d.ts +2 -0
  84. package/dist/{useControlSchema-BZNdalmL.js → useControlSchema-Dallso-L.js} +1852 -171
  85. package/dist/useControlSchema-Dallso-L.js.map +1 -0
  86. package/dist/{useDropdownState-Bb-c_PeE.js → useDropdownState-gVaMXtwW.js} +2 -2
  87. package/dist/{useDropdownState-Bb-c_PeE.js.map → useDropdownState-gVaMXtwW.js.map} +1 -1
  88. package/dist/{useExperimentSelector-DRbj6vse.js → useExperimentSelector-BMjzNcf2.js} +4 -255
  89. package/dist/useExperimentSelector-BMjzNcf2.js.map +1 -0
  90. package/dist/{useFormBuilder-DKekvXRs.js → useFormBuilder-BXaaDI41.js} +2 -2
  91. package/dist/{useFormBuilder-DKekvXRs.js.map → useFormBuilder-BXaaDI41.js.map} +1 -1
  92. package/dist/{useAnalysisArtifacts-C3l0a0vu.js → useProtocolTemplates-BP7_4RGQ.js} +307 -475
  93. package/dist/useProtocolTemplates-BP7_4RGQ.js.map +1 -0
  94. package/package.json +7 -3
  95. package/src/__tests__/components/GeneratedPathInput.test.ts +132 -0
  96. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +117 -0
  97. package/src/__tests__/components/GeneratedPluginView.test.ts +230 -0
  98. package/src/__tests__/components/GeneratedResultRenderer.test.ts +93 -0
  99. package/src/__tests__/components/InstrumentAlertLog.test.ts +59 -0
  100. package/src/__tests__/composables/formBuilderRegistry.test.ts +5 -1
  101. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +1 -74
  102. package/src/__tests__/composables/useApi.test.ts +8 -0
  103. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +1740 -0
  104. package/src/__tests__/composables/useJobsStatusTray.test.ts +179 -1
  105. package/src/__tests__/composables/usePlatformContext.test.ts +28 -0
  106. package/src/__tests__/composables/usePluginClient.test.ts +366 -20
  107. package/src/__tests__/composables/usePluginConfig.test.ts +323 -7
  108. package/src/__tests__/docs/extractDocsTemplates.test.ts +0 -1
  109. package/src/__tests__/generatedUi.test.ts +119 -0
  110. package/src/__tests__/templates/templates.test.ts +1 -1
  111. package/src/components/FileUploader.vue +49 -11
  112. package/src/components/GeneratedPathInput.vue +218 -0
  113. package/src/components/GeneratedPlotlyResult.vue +87 -0
  114. package/src/components/GeneratedPluginView.vue +279 -0
  115. package/src/components/GeneratedResultRenderer.vue +137 -0
  116. package/src/components/InstrumentAlertLog.vue +2 -2
  117. package/src/components/JobsStatusTray.vue +14 -8
  118. package/src/components/index.ts +4 -0
  119. package/src/composables/formBuilderRegistry.ts +2 -0
  120. package/src/composables/index.ts +18 -5
  121. package/src/composables/platformContextHelpers.ts +3 -10
  122. package/src/composables/useAnalysisArtifacts.ts +0 -48
  123. package/src/composables/useApi.ts +1 -0
  124. package/src/composables/useGeneratedAnalysis.ts +1252 -0
  125. package/src/composables/useJobsStatusTray.ts +234 -45
  126. package/src/composables/usePluginClient.ts +152 -25
  127. package/src/composables/usePluginConfig.ts +156 -20
  128. package/src/generated/schema.ts +130 -0
  129. package/src/generated-ui/main.ts +21 -0
  130. package/src/index.ts +3 -0
  131. package/src/jobs.ts +5 -0
  132. package/src/plotly-dist.d.ts +3 -0
  133. package/src/templates/packs.ts +0 -3
  134. package/src/types/analysisArtifactTypes.ts +0 -9
  135. package/src/types/form-builder.ts +1 -0
  136. package/src/types/generated-ui.ts +176 -0
  137. package/src/types/index.ts +2 -1
  138. package/src/types/instrument.ts +1 -0
  139. package/src/types/jobs.ts +10 -1
  140. package/src/types/platform.ts +2 -0
  141. package/dist/BaseButton-Dgqrze41.js +0 -74
  142. package/dist/BaseButton-Dgqrze41.js.map +0 -1
  143. package/dist/BaseModal-B9UA8Y_I.js +0 -165
  144. package/dist/BaseModal-B9UA8Y_I.js.map +0 -1
  145. package/dist/BaseSelect-ekgr9fDo.js +0 -179
  146. package/dist/BaseSelect-ekgr9fDo.js.map +0 -1
  147. package/dist/ExperimentPopover-Ryusjrhv.js +0 -4
  148. package/dist/ExperimentSelectorModal-Cg4-LXBD.js +0 -5
  149. package/dist/ExperimentSelectorModal-DZeSd-yr.js.map +0 -1
  150. package/dist/SettingsModal-Bl_9w2Y3.js +0 -5
  151. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +0 -1
  152. package/dist/components-DdzHh3a_.js.map +0 -1
  153. package/dist/composables/analysisArtifactTarget.d.ts +0 -5
  154. package/dist/composables-BUUphBqg.js.map +0 -1
  155. package/dist/experiment-utils-Bfa7CwPU.js +0 -108
  156. package/dist/experiment-utils-Bfa7CwPU.js.map +0 -1
  157. package/dist/templates-CNbPQNID.js.map +0 -1
  158. package/dist/useAnalysisArtifacts-C3l0a0vu.js.map +0 -1
  159. package/dist/useControlSchema-BZNdalmL.js.map +0 -1
  160. package/dist/useEventListener-CfVkP9Xz.js +0 -57
  161. package/dist/useEventListener-CfVkP9Xz.js.map +0 -1
  162. package/dist/useExperimentSelector-DRbj6vse.js.map +0 -1
  163. package/src/__tests__/composables/analysisArtifactTarget.test.ts +0 -77
  164. package/src/composables/analysisArtifactTarget.ts +0 -53
  165. /package/dist/__tests__/{composables/analysisArtifactTarget.test.d.ts → components/GeneratedPathInput.test.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.63",
3
+ "version": "1.1.0-beta.2",
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",
@@ -54,7 +54,9 @@
54
54
  "src"
55
55
  ],
56
56
  "scripts": {
57
- "build": "vue-tsc --noEmit && vite build && node scripts/check-package-exports.mjs",
57
+ "build": "vue-tsc --noEmit && bun run build:library && bun run build:generated-ui && node scripts/check-package-exports.mjs",
58
+ "build:library": "vite build",
59
+ "build:generated-ui": "vite build --config vite.generated-ui.config.ts",
58
60
  "check:exports": "node scripts/check-package-exports.mjs",
59
61
  "dev": "vite build --watch",
60
62
  "typecheck": "vue-tsc --noEmit",
@@ -81,13 +83,15 @@
81
83
  },
82
84
  "dependencies": {
83
85
  "@floating-ui/dom": "^1.7.6",
84
- "axios": "1.13.6"
86
+ "axios": "1.13.6",
87
+ "plotly.js-dist-min": "^3.7.0"
85
88
  },
86
89
  "devDependencies": {
87
90
  "@histoire/plugin-vue": "1.0.0-beta.1",
88
91
  "@simplewebauthn/browser": "10.0.0",
89
92
  "@simplewebauthn/types": "10.0.0",
90
93
  "@types/node": "22.19.13",
94
+ "@types/plotly.js": "^3.0.10",
91
95
  "@vitejs/plugin-vue": "6.0.5",
92
96
  "@vue/test-utils": "2.4.6",
93
97
  "happy-dom": "15.11.7",
@@ -0,0 +1,132 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import ArtifactSelectorModal from '../../components/ArtifactSelectorModal.vue'
6
+ import FileUploader from '../../components/FileUploader.vue'
7
+ import GeneratedPathInput from '../../components/GeneratedPathInput.vue'
8
+ import type { AnalysisArtifactDetail } from '../../types/analysisArtifactTypes'
9
+
10
+ describe('GeneratedPathInput', () => {
11
+ beforeEach(() => {
12
+ Object.assign(window, {
13
+ __MINT_PLATFORM__: {
14
+ isIntegrated: true,
15
+ platformApiUrl: '/api',
16
+ theme: 'system',
17
+ currentExperimentId: 42,
18
+ },
19
+ })
20
+ })
21
+
22
+ afterEach(() => {
23
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
24
+ vi.unstubAllGlobals()
25
+ })
26
+
27
+ it('should preserve selected directory paths for SDK staging', async () => {
28
+ const wrapper = mount(GeneratedPathInput, {
29
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
30
+ })
31
+ const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]
32
+
33
+ await wrapper.findComponent(FileUploader).vm.$emit(
34
+ 'upload',
35
+ files,
36
+ 'dataset',
37
+ ['dataset/a.txt', 'dataset/sub/b.txt'],
38
+ )
39
+
40
+ expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({
41
+ kind: 'path',
42
+ files,
43
+ rootName: 'dataset',
44
+ relativePaths: ['dataset/a.txt', 'dataset/sub/b.txt'],
45
+ })
46
+ })
47
+
48
+ it('should emit one path selection per file when multiple paths are requested', async () => {
49
+ const wrapper = mount(GeneratedPathInput, {
50
+ props: { multiple: true },
51
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
52
+ })
53
+ const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]
54
+
55
+ await wrapper.findComponent(FileUploader).vm.$emit(
56
+ 'upload',
57
+ files,
58
+ undefined,
59
+ ['a.txt', 'b.txt'],
60
+ )
61
+
62
+ expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual([
63
+ { kind: 'path', files: [files[0]], relativePaths: ['a.txt'] },
64
+ { kind: 'path', files: [files[1]], relativePaths: ['b.txt'] },
65
+ ])
66
+ })
67
+
68
+ it('should remove a file from the submitted value when the uploader removes it', async () => {
69
+ const wrapper = mount(GeneratedPathInput, {
70
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
71
+ })
72
+ const uploader = wrapper.findComponent(FileUploader)
73
+ const file = new File(['a'], 'a.txt')
74
+
75
+ const input = uploader.get('input[type="file"]')
76
+ Object.defineProperty(input.element, 'files', { value: [file] })
77
+ await input.trigger('change')
78
+ await wrapper.setProps({
79
+ modelValue: { kind: 'path', files: [file], relativePaths: ['a.txt'] },
80
+ })
81
+ await wrapper.get('button[aria-label="Remove a.txt"]').trigger('click')
82
+
83
+ expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeNull()
84
+ })
85
+
86
+ it('should download a selected platform file artifact for staging', async () => {
87
+ const fetcher = vi.fn().mockResolvedValue(new Response('artifact-data', {
88
+ headers: { 'Content-Type': 'text/csv' },
89
+ }))
90
+ vi.stubGlobal('fetch', fetcher)
91
+ const wrapper = mount(GeneratedPathInput, {
92
+ global: { plugins: [createPinia()], stubs: { Teleport: true } },
93
+ })
94
+ const detail = {
95
+ id: 7,
96
+ experiment_id: 42,
97
+ plugin_id: 'leaf',
98
+ artifact_key: 'matrix',
99
+ display_name: 'Matrix',
100
+ status: 'active',
101
+ result_keys: ['file'],
102
+ tree: [],
103
+ summary: null,
104
+ result: {
105
+ schema_version: 'mint.analysis_file.v1',
106
+ filename: 'matrix.csv',
107
+ file: {
108
+ key: 'analysis-artifacts/matrix/v1/matrix.csv',
109
+ plugin_id: 'leaf',
110
+ experiment_id: 42,
111
+ },
112
+ },
113
+ } satisfies AnalysisArtifactDetail
114
+
115
+ wrapper.findComponent(ArtifactSelectorModal).vm.$emit('selectDetail', detail)
116
+ await flushPromises()
117
+
118
+ const selection = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as {
119
+ files: File[]
120
+ relativePaths: string[]
121
+ }
122
+ expect({
123
+ url: fetcher.mock.calls[0]?.[0],
124
+ filename: selection.files[0]?.name,
125
+ relativePaths: selection.relativePaths,
126
+ }).toEqual({
127
+ url: '/api/experiments/42/objects/analysis-artifacts/matrix/v1/matrix.csv?plugin_id=leaf&format=raw',
128
+ filename: 'matrix.csv',
129
+ relativePaths: ['matrix.csv'],
130
+ })
131
+ })
132
+ })
@@ -0,0 +1,117 @@
1
+ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
2
+ import { nextTick } from 'vue'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import GeneratedPlotlyResult from '../../components/GeneratedPlotlyResult.vue'
6
+
7
+ const plotly = vi.hoisted(() => ({
8
+ purge: vi.fn(),
9
+ react: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('plotly.js-dist-min', () => ({ default: plotly }))
13
+
14
+ async function mountAndRecover(
15
+ fallback?: { media_type: string; data: string },
16
+ ): Promise<VueWrapper> {
17
+ const wrapper = mount(GeneratedPlotlyResult, {
18
+ props: {
19
+ spec: { data: [] },
20
+ ...(fallback ? { fallback } : {}),
21
+ },
22
+ })
23
+ await flushPromises()
24
+
25
+ plotly.react.mockResolvedValue(undefined)
26
+ await wrapper.setProps({
27
+ spec: { data: [{ type: 'scatter', x: [1], y: [2] }] },
28
+ })
29
+ await flushPromises()
30
+
31
+ return wrapper
32
+ }
33
+
34
+ describe('GeneratedPlotlyResult', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks()
37
+ plotly.react.mockRejectedValue(new Error('<script>unsafe details</script>'))
38
+ })
39
+
40
+ it('should render a safe error when Plotly fails without a fallback', async () => {
41
+ const wrapper = mount(GeneratedPlotlyResult, {
42
+ props: { spec: { data: [] } },
43
+ })
44
+ await flushPromises()
45
+
46
+ expect(wrapper.get('[role="alert"]').text()).toBe('Unable to display this chart.')
47
+ })
48
+
49
+ it('should render the static image when Plotly fails with a fallback', async () => {
50
+ const wrapper = mount(GeneratedPlotlyResult, {
51
+ props: {
52
+ spec: { data: [] },
53
+ fallback: { media_type: 'image/png', data: 'cG5n' },
54
+ },
55
+ })
56
+ await flushPromises()
57
+
58
+ expect(wrapper.get('img').attributes('src')).toBe('data:image/png;base64,cG5n')
59
+ })
60
+
61
+ it('should not redraw an immutable result for nested spec mutations', async () => {
62
+ plotly.react.mockResolvedValue(undefined)
63
+ const wrapper = mount(GeneratedPlotlyResult, {
64
+ props: { spec: { data: [{ x: [1] }] } },
65
+ })
66
+ await flushPromises()
67
+
68
+ const spec = wrapper.props('spec') as { data: Array<{ x: number[] }> }
69
+ spec.data[0]!.x.push(2)
70
+ await nextTick()
71
+ await flushPromises()
72
+
73
+ expect(plotly.react).toHaveBeenCalledTimes(1)
74
+ })
75
+
76
+ it.each([
77
+ ['Heatmap', { type: 'heatmap', z: [[1, 2], [3, 4]] }],
78
+ ['Scatter3d', { type: 'scatter3d', x: [1], y: [2], z: [3] }],
79
+ ])('should pass a %s trace to the Plotly renderer', async (_name, trace) => {
80
+ plotly.react.mockResolvedValue(undefined)
81
+ mount(GeneratedPlotlyResult, {
82
+ props: { spec: { data: [trace] } },
83
+ })
84
+ await flushPromises()
85
+
86
+ expect(plotly.react).toHaveBeenCalledWith(
87
+ expect.any(HTMLElement),
88
+ [trace],
89
+ {},
90
+ { responsive: true, displaylogo: false },
91
+ )
92
+ })
93
+
94
+ it('should retry Plotly when a new immutable spec follows a render failure', async () => {
95
+ await mountAndRecover()
96
+
97
+ expect(plotly.react).toHaveBeenCalledTimes(2)
98
+ })
99
+
100
+ it('should remove the fallback after a new immutable spec renders successfully', async () => {
101
+ const wrapper = await mountAndRecover({ media_type: 'image/png', data: 'cG5n' })
102
+
103
+ expect(wrapper.find('img').exists()).toBe(false)
104
+ })
105
+
106
+ it('should remove the error after a new immutable spec renders successfully', async () => {
107
+ const wrapper = await mountAndRecover()
108
+
109
+ expect(wrapper.find('[role="alert"]').exists()).toBe(false)
110
+ })
111
+
112
+ it('should restore the interactive chart after a new immutable spec renders successfully', async () => {
113
+ const wrapper = await mountAndRecover()
114
+
115
+ expect(wrapper.get('.mint-generated-result__plotly').isVisible()).toBe(true)
116
+ })
117
+ })
@@ -0,0 +1,230 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { createPinia } from 'pinia'
3
+ import { computed, ref } from 'vue'
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import GeneratedPluginView from '../../components/GeneratedPluginView.vue'
7
+ import GeneratedResultRenderer from '../../components/GeneratedResultRenderer.vue'
8
+ import JobsStatusTray from '../../components/JobsStatusTray.vue'
9
+ import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
10
+
11
+ const generated = vi.hoisted(() => ({
12
+ initialize: vi.fn(),
13
+ submit: vi.fn(),
14
+ loadResult: vi.fn(),
15
+ resultForJob: vi.fn(),
16
+ options: undefined as unknown,
17
+ jobs: [] as Array<Record<string, unknown>>,
18
+ }))
19
+
20
+ vi.mock('../../composables/useGeneratedAnalysis', async () => {
21
+ const { computed, ref, shallowRef } = await import('vue')
22
+ return {
23
+ useGeneratedAnalysis: (options: unknown) => {
24
+ generated.options = options
25
+ const jobs = shallowRef(generated.jobs)
26
+ return ({
27
+ manifest: shallowRef({
28
+ schema_version: '1',
29
+ plugin: {
30
+ name: 'Demo', version: '1.0.0', description: 'Demo plugin', routes_prefix: '/demo',
31
+ },
32
+ ui: { title: 'Demo analysis', description: 'Backend-only workspace' },
33
+ jobs: [
34
+ {
35
+ id: 'run',
36
+ title: 'Run analysis',
37
+ description: 'Analyze one dataset',
38
+ cpu: 2,
39
+ input_schema: {
40
+ type: 'object',
41
+ properties: { threshold: { type: 'number', title: 'Threshold', default: 2.5 } },
42
+ },
43
+ output_schema: {},
44
+ },
45
+ {
46
+ id: 'sync',
47
+ title: 'Sync service',
48
+ description: 'Synchronize remote metadata',
49
+ cpu: 0,
50
+ lifecycle: 'service',
51
+ profile: { kind: 'service', timeoutSeconds: 60, maxConcurrency: 2 },
52
+ input_schema: { type: 'object', properties: {} },
53
+ output_schema: {},
54
+ },
55
+ ],
56
+ }),
57
+ session: shallowRef({ id: 'session-1' }),
58
+ jobs,
59
+ latestResult: computed(() => ({ kind: 'text', text: 'complete' })),
60
+ isLoading: ref(false),
61
+ isSubmitting: ref(false),
62
+ error: shallowRef(null),
63
+ initialize: generated.initialize,
64
+ submit: generated.submit,
65
+ loadResult: generated.loadResult,
66
+ resultForJob: generated.resultForJob,
67
+ refreshJobs: vi.fn(),
68
+ jobsAdapter: {},
69
+ })
70
+ },
71
+ }
72
+ })
73
+
74
+ vi.mock('../../composables/usePlatformContext', () => ({
75
+ usePlatformContext: vi.fn(() => ({
76
+ isIntegrated: computed(() => false),
77
+ context: ref({ isIntegrated: false, theme: 'system' }),
78
+ plugin: computed(() => undefined),
79
+ user: computed(() => undefined),
80
+ theme: computed(() => 'system'),
81
+ features: computed(() => undefined),
82
+ navigate: vi.fn(),
83
+ notify: vi.fn(),
84
+ sendToPlatform: vi.fn(),
85
+ })),
86
+ }))
87
+
88
+ vi.mock('../../composables/useTheme', () => ({
89
+ useTheme: vi.fn(() => ({ isDark: ref(false), theme: ref('light'), toggleTheme: vi.fn() })),
90
+ }))
91
+
92
+ describe('GeneratedPluginView', () => {
93
+ beforeEach(() => {
94
+ vi.clearAllMocks()
95
+ const baseJob = {
96
+ kind: 'analysis',
97
+ name: 'Run analysis',
98
+ status: 'completed',
99
+ progress: {
100
+ current_file: 0, total_files: 0, current_filename: null, percent: 100, message: '', stage: 'completed',
101
+ },
102
+ queue_position: 0,
103
+ warnings: [],
104
+ error: null,
105
+ output_path: null,
106
+ save_path: null,
107
+ session_id: 'session-1',
108
+ folders: [],
109
+ folder_path: null,
110
+ user_id: 'user-1',
111
+ owner_user_id: 'user-1',
112
+ experiment_id: null,
113
+ extraction_result_id: null,
114
+ metadata: { definition_id: 'run', has_result: true },
115
+ created_at: '',
116
+ started_at: '',
117
+ completed_at: '',
118
+ can_cancel: false,
119
+ can_delete: true,
120
+ }
121
+ generated.jobs = [
122
+ { ...baseJob, id: 'job-1', job_id: 'job-1' },
123
+ { ...baseJob, id: 'job-2', job_id: 'job-2', session_id: 'shared-session' },
124
+ ]
125
+ generated.initialize.mockResolvedValue(undefined)
126
+ generated.submit.mockResolvedValue(undefined)
127
+ generated.loadResult.mockResolvedValue({ kind: 'text', text: 'complete' })
128
+ generated.resultForJob.mockReturnValue({ kind: 'text', text: 'complete' })
129
+ })
130
+
131
+ it('composes the selected B workspace from SDK-owned layout, form, jobs, and results', async () => {
132
+ const wrapper = mount(GeneratedPluginView, {
133
+ props: { baseUrl: '/api/demo' },
134
+ global: {
135
+ plugins: [createPinia()],
136
+ stubs: {
137
+ 'router-link': { template: '<a><slot /></a>' },
138
+ Teleport: true,
139
+ },
140
+ },
141
+ })
142
+ await flushPromises()
143
+
144
+ expect({
145
+ workspace: wrapper.findComponent(PluginWorkspaceView).props(),
146
+ result: wrapper.findComponent(GeneratedResultRenderer).text(),
147
+ jobs: wrapper.findComponent(JobsStatusTray).exists(),
148
+ jobSource: wrapper.findComponent(JobsStatusTray).props('source'),
149
+ legacyJobs: wrapper.findComponent(JobsStatusTray).props('jobs'),
150
+ legacyAdapter: wrapper.findComponent(JobsStatusTray).props('adapter'),
151
+ }).toMatchObject({
152
+ workspace: {
153
+ title: 'Demo analysis',
154
+ subtitle: 'Backend-only workspace',
155
+ sidebarPosition: 'left',
156
+ showFormActions: true,
157
+ values: { threshold: 2.5 },
158
+ },
159
+ result: 'complete',
160
+ jobs: true,
161
+ jobSource: expect.any(Object),
162
+ legacyJobs: undefined,
163
+ legacyAdapter: undefined,
164
+ })
165
+ })
166
+
167
+ it('submits normalized form values through the current job definition', async () => {
168
+ const wrapper = mount(GeneratedPluginView, {
169
+ props: { baseUrl: '/api/demo' },
170
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
171
+ })
172
+ await flushPromises()
173
+
174
+ wrapper.findComponent(PluginWorkspaceView).vm.$emit('form-submit', 'run', { threshold: 4 })
175
+ await flushPromises()
176
+
177
+ expect(generated.submit).toHaveBeenCalledWith('run', { threshold: 4 })
178
+ })
179
+
180
+ it('describes host service jobs without a misleading CPU reservation', async () => {
181
+ const wrapper = mount(GeneratedPluginView, {
182
+ props: { baseUrl: '/api/demo' },
183
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
184
+ })
185
+ await flushPromises()
186
+
187
+ wrapper.findComponent(PluginWorkspaceView).vm.$emit('update:activeView', 'sync')
188
+ await flushPromises()
189
+
190
+ expect(wrapper.find('[aria-label="Job resource requirements"]').text()).toBe('Host service')
191
+ })
192
+
193
+ it('should pass the host transport to the generated analysis composable', async () => {
194
+ const transport = vi.fn()
195
+ mount(GeneratedPluginView, {
196
+ props: { baseUrl: '/api/demo', transport },
197
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
198
+ })
199
+ await flushPromises()
200
+
201
+ expect(generated.options).toMatchObject({ baseUrl: '/api/demo', transport })
202
+ })
203
+
204
+ it('should lazily load the current job result by job id', async () => {
205
+ mount(GeneratedPluginView, {
206
+ props: { baseUrl: '/api/demo' },
207
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
208
+ })
209
+ await flushPromises()
210
+
211
+ expect(generated.loadResult).toHaveBeenCalledWith(expect.objectContaining({ job_id: 'job-1' }))
212
+ })
213
+
214
+ it('should display the exact historical job selected from the jobs tray', async () => {
215
+ generated.resultForJob.mockImplementation(jobId => ({ kind: 'text', text: String(jobId) }))
216
+ const wrapper = mount(GeneratedPluginView, {
217
+ props: { baseUrl: '/api/demo' },
218
+ global: { plugins: [createPinia()], stubs: { 'router-link': true, Teleport: true } },
219
+ })
220
+ await flushPromises()
221
+
222
+ wrapper.findComponent(JobsStatusTray).vm.$emit('select', generated.jobs[1])
223
+ await flushPromises()
224
+
225
+ expect({
226
+ result: wrapper.findComponent(GeneratedResultRenderer).text(),
227
+ loadedJob: generated.loadResult.mock.calls.at(-1)?.[0]?.job_id,
228
+ }).toEqual({ result: 'job-2', loadedJob: 'job-2' })
229
+ })
230
+ })
@@ -0,0 +1,93 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import ChartContainer from '../../components/ChartContainer.vue'
5
+ import DataFrame from '../../components/DataFrame.vue'
6
+ import GeneratedResultRenderer from '../../components/GeneratedResultRenderer.vue'
7
+
8
+ describe('GeneratedResultRenderer', () => {
9
+ it('renders tabular results with the standard DataFrame component', () => {
10
+ const wrapper = mount(GeneratedResultRenderer, {
11
+ props: {
12
+ result: {
13
+ kind: 'table',
14
+ columns: ['sample', 'value'],
15
+ rows: [{ sample: 'A', value: 1.5 }],
16
+ },
17
+ },
18
+ })
19
+
20
+ expect(wrapper.findComponent(DataFrame).props()).toMatchObject({
21
+ data: [{ sample: 'A', value: 1.5 }],
22
+ columns: [
23
+ { key: 'sample', label: 'Sample', sortable: true },
24
+ { key: 'value', label: 'Value', sortable: true },
25
+ ],
26
+ })
27
+ })
28
+
29
+ it('renders Matplotlib images inside the standard chart container', () => {
30
+ const wrapper = mount(GeneratedResultRenderer, {
31
+ props: {
32
+ result: { kind: 'image', media_type: 'image/png', data: 'cG5n', alt: 'PCA' },
33
+ },
34
+ })
35
+
36
+ expect({
37
+ chart: wrapper.findComponent(ChartContainer).exists(),
38
+ source: wrapper.get('img').attributes('src'),
39
+ alt: wrapper.get('img').attributes('alt'),
40
+ }).toEqual({ chart: true, source: 'data:image/png;base64,cG5n', alt: 'PCA' })
41
+ })
42
+
43
+ it('never interprets JSON results as HTML', () => {
44
+ const wrapper = mount(GeneratedResultRenderer, {
45
+ props: {
46
+ result: { kind: 'json', value: { unsafe: '<script>window.bad = true</script>' } },
47
+ },
48
+ })
49
+
50
+ expect({ scripts: wrapper.findAll('script').length, text: wrapper.text() }).toEqual({
51
+ scripts: 0,
52
+ text: expect.stringContaining('<script>window.bad = true</script>'),
53
+ })
54
+ })
55
+
56
+ it('should preserve the artifact filename when downloading a result', () => {
57
+ const wrapper = mount(GeneratedResultRenderer, {
58
+ props: {
59
+ result: {
60
+ kind: 'artifact',
61
+ id: 'artifact-1',
62
+ name: 'analysis.csv',
63
+ media_type: 'text/csv',
64
+ size: 3,
65
+ data: 'YSxi',
66
+ },
67
+ },
68
+ })
69
+
70
+ expect(wrapper.get('a').attributes('download')).toBe('analysis.csv')
71
+ })
72
+
73
+ it('uses the host download route for a managed file result', () => {
74
+ const wrapper = mount(GeneratedResultRenderer, {
75
+ props: {
76
+ result: {
77
+ kind: 'artifact',
78
+ id: 'artifact-2',
79
+ name: 'large.msd',
80
+ media_type: 'application/octet-stream',
81
+ size: 512_000_000,
82
+ download_url: '/api/leaf/jobs/job-1/result/file/scoped-token',
83
+ managed: true,
84
+ },
85
+ },
86
+ })
87
+
88
+ expect(wrapper.get('a').attributes()).toMatchObject({
89
+ href: '/api/leaf/jobs/job-1/result/file/scoped-token',
90
+ download: 'large.msd',
91
+ })
92
+ })
93
+ })
@@ -0,0 +1,59 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, expectTypeOf, it } from 'vitest'
3
+ import type { VNode } from 'vue'
4
+ import InstrumentAlertLog from '../../components/InstrumentAlertLog.vue'
5
+ import type { InstrumentAlert } from '../../types'
6
+
7
+ type EventKeyIsOptional = {} extends Pick<InstrumentAlert, 'event_key'> ? true : false
8
+
9
+ function alert(message: string): InstrumentAlert {
10
+ return {
11
+ event_key: 'orbitrap-01:acquisition-stopped:42',
12
+ instrument_id: 'orbitrap-01',
13
+ level: 'critical',
14
+ rule: 'acquisition-stopped',
15
+ message,
16
+ timestamp: '2026-07-22T08:30:00Z',
17
+ }
18
+ }
19
+
20
+ function findAlertVNodeKey(vnode: VNode): VNode['key'] {
21
+ const classes = vnode.props?.class
22
+ if (typeof classes === 'string' && classes.split(' ').includes('mint-instrument-alert')) {
23
+ return vnode.key
24
+ }
25
+ if (Array.isArray(vnode.children)) {
26
+ for (const child of vnode.children) {
27
+ if (typeof child !== 'object' || child === null) continue
28
+ const key = findAlertVNodeKey(child as VNode)
29
+ if (key !== null) return key
30
+ }
31
+ }
32
+ if (vnode.component?.subTree) return findAlertVNodeKey(vnode.component.subTree)
33
+ return null
34
+ }
35
+
36
+ describe('InstrumentAlertLog', () => {
37
+ it('should require a stable event_key in the public alert type', () => {
38
+ expectTypeOf<EventKeyIsOptional>().toEqualTypeOf<false>()
39
+ })
40
+
41
+ it('should retain the rendered alert when mutable content changes for the same event_key', async () => {
42
+ const wrapper = mount(InstrumentAlertLog, {
43
+ props: { alerts: [alert('Acquisition stopped')] },
44
+ })
45
+ const renderedAlert = wrapper.get('.mint-instrument-alert').element
46
+
47
+ await wrapper.setProps({ alerts: [alert('Acquisition remains stopped')] })
48
+
49
+ expect(wrapper.get('.mint-instrument-alert').element).toBe(renderedAlert)
50
+ })
51
+
52
+ it('should key each rendered alert by event_key', () => {
53
+ const wrapper = mount(InstrumentAlertLog, {
54
+ props: { alerts: [alert('Acquisition stopped')] },
55
+ })
56
+
57
+ expect(findAlertVNodeKey(wrapper.vm.$.subTree)).toBe('orbitrap-01:acquisition-stopped:42')
58
+ })
59
+ })
@@ -9,7 +9,7 @@ describe('formBuilderRegistry', () => {
9
9
  'text', 'email', 'password', 'tel', 'url', 'search',
10
10
  'number', 'textarea', 'select', 'multiselect',
11
11
  'checkbox', 'toggle', 'radio', 'slider', 'tags',
12
- 'date', 'time', 'datetime', 'file',
12
+ 'date', 'time', 'datetime', 'file', 'path',
13
13
  'formula', 'sequence', 'molecule', 'concentration', 'unit',
14
14
  ]
15
15
 
@@ -59,6 +59,10 @@ describe('formBuilderRegistry', () => {
59
59
  expect(entry.vModel).toBe(false)
60
60
  })
61
61
 
62
+ it('should bind generated path inputs with v-model', () => {
63
+ expect(getFieldRegistryEntry('path').vModel).toBe(true)
64
+ })
65
+
62
66
  it('should have vModel true for all non-file types', () => {
63
67
  const vModelTypes: FormFieldType[] = [
64
68
  'text', 'email', 'password', 'tel', 'url', 'search',