@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
@@ -2,7 +2,12 @@ import { mount } from '@vue/test-utils'
2
2
  import { defineComponent, nextTick, ref, shallowRef } from 'vue'
3
3
  import { describe, expect, it, vi } from 'vitest'
4
4
 
5
- import { useJobsStatusTray, type UseJobsStatusTrayOptions } from '../../composables/useJobsStatusTray'
5
+ import {
6
+ combinePluginJobSources,
7
+ useJobsStatusTray,
8
+ usePluginJobCenter,
9
+ type UseJobsStatusTrayOptions,
10
+ } from '../../composables/useJobsStatusTray'
6
11
  import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
7
12
 
8
13
  function job(status: JobStatus, id: string = status): JobStateInput {
@@ -62,6 +67,179 @@ function deferred<T>(): {
62
67
  }
63
68
 
64
69
  describe('useJobsStatusTray', () => {
70
+ it('combines live source jobs and fans out or routes source actions', async () => {
71
+ const firstJobs = shallowRef([job('running', 'first')])
72
+ const secondJobs = shallowRef([job('completed', 'second')])
73
+ const firstRefresh = vi.fn(async () => firstJobs.value)
74
+ const secondRefresh = vi.fn(async () => secondJobs.value)
75
+ const secondCancel = vi.fn(async (jobId: string) => job('cancelled', jobId))
76
+ const firstClear = vi.fn(async () => 1)
77
+ const secondClear = vi.fn(async () => 2)
78
+ const firstDispose = vi.fn()
79
+ const secondDispose = vi.fn()
80
+ const combined = combinePluginJobSources([
81
+ {
82
+ jobs: firstJobs,
83
+ refreshJobs: firstRefresh,
84
+ clearFinishedJobs: firstClear,
85
+ dispose: firstDispose,
86
+ },
87
+ {
88
+ jobs: secondJobs,
89
+ refreshJobs: secondRefresh,
90
+ cancelJob: secondCancel,
91
+ clearFinishedJobs: secondClear,
92
+ dispose: secondDispose,
93
+ },
94
+ ])
95
+
96
+ expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'second'])
97
+ secondJobs.value = [job('running', 'replacement')]
98
+ await nextTick()
99
+ expect(combined.jobs.value.map(item => item.job_id)).toEqual(['first', 'replacement'])
100
+
101
+ await combined.refreshJobs?.()
102
+ await combined.cancelJob?.('replacement')
103
+ await expect(combined.clearFinishedJobs?.()).resolves.toBe(3)
104
+ combined.dispose?.()
105
+
106
+ expect({
107
+ refreshes: [firstRefresh.mock.calls.length, secondRefresh.mock.calls.length],
108
+ cancelled: secondCancel.mock.calls[0]?.[0],
109
+ disposed: [firstDispose.mock.calls.length, secondDispose.mock.calls.length],
110
+ }).toEqual({
111
+ refreshes: [1, 1],
112
+ cancelled: 'replacement',
113
+ disposed: [1, 1],
114
+ })
115
+ })
116
+
117
+ it('retains every failure when disposing combined sources', () => {
118
+ const firstFailure = new Error('first dispose failed')
119
+ const secondFailure = { source: 'second', reason: 'dispose failed' }
120
+ const finalDispose = vi.fn()
121
+ const combined = combinePluginJobSources([
122
+ {
123
+ jobs: [],
124
+ dispose: () => {
125
+ throw firstFailure
126
+ },
127
+ },
128
+ {
129
+ jobs: [],
130
+ dispose: () => {
131
+ throw secondFailure
132
+ },
133
+ },
134
+ { jobs: [], dispose: finalDispose },
135
+ ])
136
+
137
+ let failure: unknown
138
+ try {
139
+ combined.dispose?.()
140
+ } catch (cause) {
141
+ failure = cause
142
+ }
143
+
144
+ expect(failure).toMatchObject({
145
+ name: 'PluginJobSourceDisposeError',
146
+ message: 'Failed to dispose plugin job sources',
147
+ cause: firstFailure,
148
+ errors: [firstFailure, secondFailure],
149
+ })
150
+ expect(finalDispose).toHaveBeenCalledOnce()
151
+ })
152
+
153
+ it('rejects duplicate ids instead of routing to an arbitrary combined source', async () => {
154
+ const firstCancel = vi.fn(async () => undefined)
155
+ const secondCancel = vi.fn(async () => undefined)
156
+ const combined = combinePluginJobSources([
157
+ { jobs: [job('running', 'duplicate')], cancelJob: firstCancel },
158
+ { jobs: [job('queued', 'duplicate')], cancelJob: secondCancel },
159
+ ])
160
+
161
+ expect(() => combined.jobs.value).toThrow('Duplicate plugin job id')
162
+ await expect(combined.cancelJob?.('duplicate')).rejects.toThrow('Duplicate plugin job id')
163
+ expect([firstCancel.mock.calls.length, secondCancel.mock.calls.length]).toEqual([0, 0])
164
+ })
165
+
166
+ it('does not expose a partial bulk clear across combined sources', () => {
167
+ const combined = combinePluginJobSources([
168
+ { jobs: [job('completed', 'first')], clearFinishedJobs: async () => 1 },
169
+ { jobs: [job('completed', 'second')] },
170
+ ])
171
+
172
+ expect(combined.clearFinishedJobs).toBeUndefined()
173
+ })
174
+
175
+ it('does not partially delete finished jobs when one combined source cannot delete', async () => {
176
+ const firstDelete = vi.fn(async () => true)
177
+ const combined = combinePluginJobSources([
178
+ { jobs: [job('completed', 'first')], deleteJob: firstDelete },
179
+ { jobs: [job('completed', 'second')] },
180
+ ])
181
+ let center!: ReturnType<typeof usePluginJobCenter>
182
+ const wrapper = mount(defineComponent({
183
+ setup() {
184
+ center = usePluginJobCenter({ source: combined, loadOnMount: false })
185
+ return () => null
186
+ },
187
+ }))
188
+
189
+ expect(combined.deleteJob).toBeUndefined()
190
+ expect(center.canClearFinished.value).toBe(false)
191
+ await expect(center.clearFinished()).resolves.toBe(false)
192
+ expect(firstDelete).not.toHaveBeenCalled()
193
+
194
+ wrapper.unmount()
195
+ })
196
+
197
+ it('derives jobs directly from a canonical source ref', async () => {
198
+ const sourceJobs = shallowRef([job('queued', 'first')])
199
+ let center!: ReturnType<typeof usePluginJobCenter>
200
+ const wrapper = mount(defineComponent({
201
+ setup() {
202
+ center = usePluginJobCenter({
203
+ source: { jobs: sourceJobs },
204
+ loadOnMount: false,
205
+ })
206
+ return () => null
207
+ },
208
+ }))
209
+
210
+ sourceJobs.value = [job('running', 'replacement')]
211
+ await nextTick()
212
+
213
+ expect(center.jobs.value.map(item => item.job_id)).toEqual(['replacement'])
214
+ wrapper.unmount()
215
+ })
216
+
217
+ it('delegates actions by id and leaves source mutation with the source', async () => {
218
+ const sourceJobs = shallowRef([job('running', 'fit')])
219
+ const cancelJob = vi.fn(async (jobId: string) => {
220
+ sourceJobs.value = [job('cancelled', jobId)]
221
+ return sourceJobs.value[0]
222
+ })
223
+ let center!: ReturnType<typeof usePluginJobCenter>
224
+ const wrapper = mount(defineComponent({
225
+ setup() {
226
+ center = usePluginJobCenter({
227
+ source: { jobs: sourceJobs, cancelJob },
228
+ loadOnMount: false,
229
+ })
230
+ return () => null
231
+ },
232
+ }))
233
+
234
+ await center.cancelJob(center.jobs.value[0]!)
235
+
236
+ expect([cancelJob.mock.calls[0]?.[0], center.jobs.value[0]?.status]).toEqual([
237
+ 'fit',
238
+ 'cancelled',
239
+ ])
240
+ wrapper.unmount()
241
+ })
242
+
65
243
  it('keeps awaiting input jobs in the active group', () => {
66
244
  const { tray } = mountComposable({
67
245
  jobs: [job('awaiting_input')],
@@ -17,6 +17,11 @@ vi.mock('vue', async () => {
17
17
  })
18
18
 
19
19
  import { usePlatformContext } from '../../composables/usePlatformContext'
20
+ import type { PlatformContext } from '../../types'
21
+
22
+ type InjectedPlatformWindow = Window & {
23
+ __MINT_PLATFORM__?: PlatformContext
24
+ }
20
25
 
21
26
  function sendThemeMessage(origin: string, theme: 'light' | 'dark' | 'system'): void {
22
27
  if (!messageHandler) {
@@ -37,6 +42,7 @@ describe('usePlatformContext', () => {
37
42
  beforeEach(() => {
38
43
  unmountedCallbacks.length = 0
39
44
  messageHandler = null
45
+ delete (window as InjectedPlatformWindow).__MINT_PLATFORM__
40
46
 
41
47
  vi.spyOn(window, 'addEventListener').mockImplementation((type, listener) => {
42
48
  if (type === 'message') {
@@ -51,9 +57,31 @@ describe('usePlatformContext', () => {
51
57
  const callback = unmountedCallbacks.pop()
52
58
  callback?.()
53
59
  }
60
+ delete (window as InjectedPlatformWindow).__MINT_PLATFORM__
54
61
  vi.restoreAllMocks()
55
62
  })
56
63
 
64
+ it('preserves the injected plugin settings API capability', () => {
65
+ const injectedContext: PlatformContext = {
66
+ isIntegrated: true,
67
+ theme: 'light',
68
+ platformApiUrl: '/api',
69
+ plugin: {
70
+ id: 'test-plugin',
71
+ name: 'test-plugin',
72
+ version: '1.0.0',
73
+ route_prefix: '/test-plugin',
74
+ api_prefix: '/api/test-plugin',
75
+ settings_api: 'plugin',
76
+ },
77
+ }
78
+ ;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = injectedContext
79
+
80
+ const context = usePlatformContext()
81
+
82
+ expect(context.plugin.value?.settings_api).toBe('plugin')
83
+ })
84
+
57
85
  it('clears allowAnyOrigin policy after last consumer unmounts', () => {
58
86
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
59
87
 
@@ -156,6 +156,21 @@ describe('usePluginClient', () => {
156
156
  expect(requestConfigs[0]!.url).toBe('/health')
157
157
  })
158
158
 
159
+ it('omits MINT auth transport for a public generated endpoint', async () => {
160
+ const client = createPluginClient(contract, {
161
+ endpoints: {
162
+ health: { method: 'get', path: '/health', requiresAuth: false },
163
+ },
164
+ })
165
+
166
+ await client.health()
167
+
168
+ expect(
169
+ (requestConfigs[0] as AxiosRequestConfig & { _mintSkipAuth?: boolean })
170
+ ._mintSkipAuth,
171
+ ).toBe(true)
172
+ })
173
+
159
174
  it('VITE_API_PREFIX overrides contract apiPrefix', async () => {
160
175
  vi.stubEnv('VITE_API_PREFIX', '/api/override')
161
176
  const client = createPluginClient(contract, {
@@ -672,7 +687,79 @@ describe('usePluginClient', () => {
672
687
  expect(requestConfigs).toHaveLength(0)
673
688
  })
674
689
 
675
- it('loads and saves plugin settings through the integrated platform config endpoint', async () => {
690
+ it.each([
691
+ 'platform',
692
+ undefined,
693
+ ] as const)(
694
+ 'should use the integrated platform config facade when settings_api is %s',
695
+ async (settingsApi) => {
696
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
697
+ isIntegrated: true,
698
+ theme: 'light',
699
+ platformApiUrl: '/api',
700
+ plugin: {
701
+ id: 'drp',
702
+ name: 'drp',
703
+ version: '1.0.0',
704
+ route_prefix: '/drp',
705
+ api_prefix: '/api/drp',
706
+ ...(settingsApi ? { settings_api: settingsApi } : {}),
707
+ },
708
+ }
709
+ nextGetData = {
710
+ plugin_name: 'drp',
711
+ config: {
712
+ nasBasePath: '/data',
713
+ allowed_experiment_types: ['metabolomics'],
714
+ },
715
+ revision: 'revision-1',
716
+ }
717
+ nextPatchData = {
718
+ plugin_name: 'drp',
719
+ config: {
720
+ nasBasePath: '/mnt',
721
+ allowed_experiment_types: ['metabolomics'],
722
+ },
723
+ revision: 'revision-2',
724
+ }
725
+
726
+ const settings = usePluginSettings<{ nasBasePath?: string }>({ pluginName: 'drp' })
727
+ await settings.load()
728
+
729
+ expect(requestConfigs[0]!.baseURL).toBe('/api')
730
+ expect(requestConfigs[0]!.url).toBe('/plugins/drp/config')
731
+ expect(settings.values.value).toEqual({
732
+ nasBasePath: '/data',
733
+ allowed_experiment_types: ['metabolomics'],
734
+ })
735
+ expect(settings.settings.value).toEqual({
736
+ nasBasePath: '/data',
737
+ allowed_experiment_types: ['metabolomics'],
738
+ })
739
+ expect(settings.isDirty.value).toBe(false)
740
+
741
+ settings.setValues({
742
+ nasBasePath: '/mnt',
743
+ allowed_experiment_types: ['metabolomics'],
744
+ })
745
+ expect(settings.isDirty.value).toBe(true)
746
+ const ok = await settings.save()
747
+
748
+ expect(ok).toBe(true)
749
+ expect(requestConfigs[1]!.baseURL).toBe('/api')
750
+ expect(requestConfigs[1]!.url).toBe('/plugins/drp/config')
751
+ expect(requestMethods[1]).toBe('patch')
752
+ expect(requestConfigs[1]!.headers).toBeUndefined()
753
+ expect(requestBodies[0]).toEqual({ config: { nasBasePath: '/mnt' } })
754
+ expect(settings.values.value).toEqual({
755
+ nasBasePath: '/mnt',
756
+ allowed_experiment_types: ['metabolomics'],
757
+ })
758
+ expect(settings.isDirty.value).toBe(false)
759
+ },
760
+ )
761
+
762
+ it('should use the plugin settings router for an integrated plugin capability', async () => {
676
763
  ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
677
764
  isIntegrated: true,
678
765
  theme: 'light',
@@ -683,35 +770,271 @@ describe('usePluginClient', () => {
683
770
  version: '1.0.0',
684
771
  route_prefix: '/drp',
685
772
  api_prefix: '/api/drp',
773
+ settings_api: 'plugin',
686
774
  },
687
775
  }
688
- nextGetData = { plugin_name: 'drp', config: { nasBasePath: '/data' } }
689
- nextPatchData = { plugin_name: 'drp', config: { nasBasePath: '/mnt' } }
776
+ nextGetData = {
777
+ settings: { threshold: 1 },
778
+ mode: 'integrated',
779
+ revision: 'revision-1',
780
+ }
781
+ nextPutData = {
782
+ settings: { threshold: 2 },
783
+ mode: 'integrated',
784
+ revision: 'revision-2',
785
+ }
690
786
 
691
- const settings = usePluginSettings<{ nasBasePath?: string }>({ pluginName: 'drp' })
787
+ const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' })
692
788
  await settings.load()
789
+ const saved = await settings.save({ threshold: 2 })
693
790
 
694
- expect(requestConfigs[0]!.baseURL).toBe('/api')
695
- expect(requestConfigs[0]!.url).toBe('/plugins/drp/config')
696
- expect(settings.values.value).toEqual({ nasBasePath: '/data' })
697
- expect(settings.settings.value).toEqual({ nasBasePath: '/data' })
698
- expect(settings.isDirty.value).toBe(false)
791
+ expect({
792
+ saved,
793
+ methods: requestMethods,
794
+ requests: requestConfigs.map(({ baseURL, url, headers }) => ({
795
+ baseURL,
796
+ url,
797
+ headers,
798
+ })),
799
+ bodies: requestBodies,
800
+ values: settings.values.value,
801
+ }).toEqual({
802
+ saved: true,
803
+ methods: ['get', 'put'],
804
+ requests: [
805
+ {
806
+ baseURL: '/api/drp',
807
+ url: '/settings',
808
+ headers: undefined,
809
+ },
810
+ {
811
+ baseURL: '/api/drp',
812
+ url: '/settings',
813
+ headers: { 'If-Match': '"revision-1"' },
814
+ },
815
+ ],
816
+ bodies: [{ threshold: 2 }],
817
+ values: { threshold: 2 },
818
+ })
819
+ })
699
820
 
700
- settings.setValues({ nasBasePath: '/mnt' })
701
- expect(settings.isDirty.value).toBe(true)
702
- const ok = await settings.save()
821
+ it('should use the platform facade for an explicit different plugin name', async () => {
822
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
823
+ isIntegrated: true,
824
+ theme: 'light',
825
+ platformApiUrl: '/api',
826
+ plugin: {
827
+ id: 'current-plugin',
828
+ name: 'current-plugin',
829
+ version: '1.0.0',
830
+ route_prefix: '/current-plugin',
831
+ api_prefix: '/api/current-plugin',
832
+ settings_api: 'plugin',
833
+ },
834
+ }
835
+ nextGetData = {
836
+ plugin_name: 'other-plugin',
837
+ config: { threshold: 1 },
838
+ revision: 'revision-1',
839
+ }
703
840
 
704
- expect(ok).toBe(true)
705
- expect(requestConfigs[1]!.baseURL).toBe('/api')
706
- expect(requestConfigs[1]!.url).toBe('/plugins/drp/config')
707
- expect(requestBodies[0]).toEqual({ config: { nasBasePath: '/mnt' } })
708
- expect(settings.values.value).toEqual({ nasBasePath: '/mnt' })
709
- expect(settings.isDirty.value).toBe(false)
841
+ const settings = usePluginSettings<{ threshold?: number }>({
842
+ pluginName: 'other-plugin',
843
+ })
844
+ await settings.load()
845
+
846
+ expect({
847
+ method: requestMethods[0],
848
+ baseURL: requestConfigs[0]!.baseURL,
849
+ url: requestConfigs[0]!.url,
850
+ values: settings.values.value,
851
+ }).toEqual({
852
+ method: 'get',
853
+ baseURL: '/api',
854
+ url: '/plugins/other-plugin/config',
855
+ values: { threshold: 1 },
856
+ })
857
+ })
858
+
859
+ it('should split changed platform-owned settings from a versioned typed save', async () => {
860
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
861
+ isIntegrated: true,
862
+ theme: 'light',
863
+ platformApiUrl: '/api',
864
+ plugin: {
865
+ id: 'drp',
866
+ name: 'drp',
867
+ version: '1.0.0',
868
+ route_prefix: '/drp',
869
+ api_prefix: '/api/drp',
870
+ },
871
+ }
872
+ nextGetData = {
873
+ plugin_name: 'drp',
874
+ config: {
875
+ threshold: 1,
876
+ allowed_experiment_types: ['metabolomics'],
877
+ },
878
+ revision: 'revision-1',
879
+ }
880
+ nextPatchData = {
881
+ plugin_name: 'drp',
882
+ config: {
883
+ threshold: 2,
884
+ allowed_experiment_types: ['proteomics'],
885
+ },
886
+ revision: 'untrusted-platform-revision',
887
+ }
888
+ const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' })
889
+ await settings.load()
890
+ settings.setValues({
891
+ threshold: 2,
892
+ allowed_experiment_types: ['proteomics'],
893
+ })
894
+
895
+ const saved = await settings.save()
896
+ nextPatchData = {
897
+ plugin_name: 'drp',
898
+ config: {
899
+ threshold: 3,
900
+ allowed_experiment_types: ['proteomics'],
901
+ },
902
+ revision: 'revision-3',
903
+ }
904
+ settings.setValues({
905
+ threshold: 3,
906
+ allowed_experiment_types: ['proteomics'],
907
+ })
908
+ const savedAgain = await settings.save()
909
+
910
+ expect({
911
+ saved,
912
+ savedAgain,
913
+ methods: requestMethods,
914
+ bodies: requestBodies,
915
+ writeHeaders: requestConfigs.slice(1).map(config => config.headers),
916
+ values: settings.values.value,
917
+ isDirty: settings.isDirty.value,
918
+ }).toEqual({
919
+ saved: true,
920
+ savedAgain: true,
921
+ methods: ['get', 'patch', 'patch', 'patch'],
922
+ bodies: [
923
+ { config: { threshold: 2 } },
924
+ { config: { allowed_experiment_types: ['proteomics'] } },
925
+ { config: { threshold: 3 } },
926
+ ],
927
+ writeHeaders: [undefined, undefined, undefined],
928
+ values: {
929
+ threshold: 3,
930
+ allowed_experiment_types: ['proteomics'],
931
+ },
932
+ isDirty: false,
933
+ })
934
+ })
935
+
936
+ it('should avoid a typed PUT when only the platform-owned setting changes', async () => {
937
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
938
+ isIntegrated: true,
939
+ theme: 'light',
940
+ platformApiUrl: '/api',
941
+ plugin: {
942
+ id: 'drp',
943
+ name: 'drp',
944
+ version: '1.0.0',
945
+ route_prefix: '/drp',
946
+ api_prefix: '/api/drp',
947
+ },
948
+ }
949
+ nextGetData = {
950
+ plugin_name: 'drp',
951
+ config: {
952
+ threshold: 1,
953
+ allowed_experiment_types: ['metabolomics'],
954
+ },
955
+ revision: 'revision-1',
956
+ }
957
+ nextPatchData = {
958
+ plugin_name: 'drp',
959
+ config: {
960
+ threshold: 1,
961
+ allowed_experiment_types: ['proteomics'],
962
+ },
963
+ }
964
+
965
+ const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' })
966
+ await settings.load()
967
+ settings.setValues({
968
+ threshold: 1,
969
+ allowed_experiment_types: ['proteomics'],
970
+ })
971
+
972
+ const saved = await settings.save()
973
+
974
+ expect({
975
+ saved,
976
+ methods: requestMethods,
977
+ bodies: requestBodies,
978
+ values: settings.values.value,
979
+ }).toEqual({
980
+ saved: true,
981
+ methods: ['get', 'patch'],
982
+ bodies: [{ config: { allowed_experiment_types: ['proteomics'] } }],
983
+ values: {
984
+ threshold: 1,
985
+ allowed_experiment_types: ['proteomics'],
986
+ },
987
+ })
988
+ })
989
+
990
+ it('should detect nested settings edits and restore the saved JSON snapshot', async () => {
991
+ nextGetData = {
992
+ settings: {
993
+ rules: {
994
+ thresholds: [1, 2],
995
+ },
996
+ },
997
+ mode: 'standalone',
998
+ }
999
+
1000
+ const settings = usePluginSettings<{
1001
+ rules?: { thresholds: number[] }
1002
+ }>({
1003
+ apiBaseUrl: '/api/drp',
1004
+ loadOnMount: false,
1005
+ })
1006
+ await settings.load()
1007
+ settings.values.value.rules!.thresholds[0] = 9
1008
+
1009
+ const dirtyAfterMutation = settings.isDirty.value
1010
+ settings.reset()
1011
+
1012
+ expect({
1013
+ dirtyAfterMutation,
1014
+ values: settings.values.value,
1015
+ isDirty: settings.isDirty.value,
1016
+ }).toEqual({
1017
+ dirtyAfterMutation: true,
1018
+ values: {
1019
+ rules: {
1020
+ thresholds: [1, 2],
1021
+ },
1022
+ },
1023
+ isDirty: false,
1024
+ })
710
1025
  })
711
1026
 
712
1027
  it('loads and saves plugin settings through the standalone settings router', async () => {
713
- nextGetData = { settings: { nas_base_path: '/data' }, mode: 'standalone' }
714
- nextPutData = { settings: { nas_base_path: '/mnt' }, mode: 'standalone' }
1028
+ nextGetData = {
1029
+ settings: { nas_base_path: '/data' },
1030
+ mode: 'standalone',
1031
+ revision: 'revision-1',
1032
+ }
1033
+ nextPutData = {
1034
+ settings: { nas_base_path: '/mnt' },
1035
+ mode: 'standalone',
1036
+ revision: 'revision-2',
1037
+ }
715
1038
 
716
1039
  const settings = usePluginSettings<{ nas_base_path?: string }>({
717
1040
  apiBaseUrl: '/api/drp',
@@ -725,6 +1048,7 @@ describe('usePluginClient', () => {
725
1048
  expect(requestConfigs[0]!.url).toBe('/settings')
726
1049
  expect(requestConfigs[1]!.baseURL).toBe('/api/drp')
727
1050
  expect(requestConfigs[1]!.url).toBe('/settings')
1051
+ expect(requestConfigs[1]!.headers).toEqual({ 'If-Match': '"revision-1"' })
728
1052
  expect(requestBodies[0]).toEqual({ nas_base_path: '/mnt' })
729
1053
  expect(settings.settingsConfig.value.values).toEqual({ nas_base_path: '/mnt' })
730
1054
  })
@@ -774,6 +1098,28 @@ describe('usePluginClient', () => {
774
1098
  expect(stream.lastEventId.value).toBe('7')
775
1099
  })
776
1100
 
1101
+ it('omits MINT auth from a public plugin event stream', async () => {
1102
+ const authStore = useAuthStore()
1103
+ authStore.setToken('stream-token', 3600)
1104
+ const fetchMock = vi.fn<typeof fetch>(async () =>
1105
+ new Response(new ReadableStream({ start: controller => controller.close() }), {
1106
+ status: 200,
1107
+ }),
1108
+ )
1109
+ vi.stubGlobal('fetch', fetchMock)
1110
+
1111
+ usePluginEventStream(
1112
+ contract,
1113
+ { method: 'get', path: '/public/events', requiresAuth: false },
1114
+ { reconnect: false },
1115
+ )
1116
+
1117
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce())
1118
+ const [, init] = fetchMock.mock.calls[0]!
1119
+
1120
+ expect((init?.headers as Headers).get('Authorization')).toBeNull()
1121
+ })
1122
+
777
1123
  it('normalizes stream auth failures and does not reconnect rejected requests', async () => {
778
1124
  const authStore = useAuthStore()
779
1125
  authStore.setToken('expired-stream-token', 3600)