import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import axios, { type AxiosRequestConfig } from 'axios' import { watch } from 'vue' import { buildPluginEndpointUrl, createPluginClient, downloadBlob, downloadPluginEndpoint, getPluginPageSelectorItems, pluginFormDataFromPayload, resolvePluginBaseUrl, uploadPluginEndpoint, useCurrentExperiment, usePluginEventStream, usePluginSettings, type PluginContract, } from '../../composables/usePluginClient' import { AuthenticationRequiredError } from '../../composables/useApi' import { useAuthStore } from '../../stores/auth' const contract: PluginContract = { schemaVersion: 2, plugin: { routesPrefix: '/drp', apiPrefix: '/api/drp', type: 'analysis', }, settings: { modelName: 'PluginSettings', valueSchema: { type: 'object', properties: { threshold: { type: 'number', default: 0.05 }, }, }, formSchema: { groups: [ { id: 'settings', label: 'Settings', fields: [ { name: 'threshold', label: 'Threshold', type: 'number', defaultValue: 0.05, }, ], }, ], }, }, endpoints: [], hash: 'sha256:test', } function deferred(): { promise: Promise resolve: (value: T) => void } { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } function pendingEventStreamResponse(): { response: Response close: () => void } { let streamController!: ReadableStreamDefaultController const body = new ReadableStream({ start(controller) { streamController = controller }, }) return { response: new Response(body, { status: 200 }), close: () => { try { streamController.close() } catch { // stop() may already have cancelled the reader. } }, } } describe('usePluginClient', () => { let requestConfigs: AxiosRequestConfig[] = [] let requestBodies: unknown[] = [] let requestMethods: string[] = [] let nextGetError: Error | null = null let nextGetData: unknown let nextPostData: unknown let nextPatchData: unknown let nextPutData: unknown beforeEach(() => { setActivePinia(createPinia()) requestConfigs = [] requestBodies = [] requestMethods = [] nextGetError = null nextGetData = undefined nextPostData = undefined nextPatchData = undefined nextPutData = undefined vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function ( this: unknown, url: string, config?: AxiosRequestConfig, ) { requestMethods.push('get') requestConfigs.push({ url, ...config }) if (nextGetError) throw nextGetError return { data: nextGetData ?? { called: url } } }) vi.spyOn(axios.Axios.prototype, 'post').mockImplementation(async function ( this: unknown, url: string, data?: unknown, config?: AxiosRequestConfig, ) { requestMethods.push('post') requestConfigs.push({ url, ...config }) requestBodies.push(data) return { data: nextPostData ?? { called: url } } }) vi.spyOn(axios.Axios.prototype, 'patch').mockImplementation(async function ( this: unknown, url: string, data?: unknown, config?: AxiosRequestConfig, ) { requestMethods.push('patch') requestConfigs.push({ url, ...config }) requestBodies.push(data) return { data: nextPatchData ?? { called: url } } }) vi.spyOn(axios.Axios.prototype, 'put').mockImplementation(async function ( this: unknown, url: string, data?: unknown, config?: AxiosRequestConfig, ) { requestMethods.push('put') requestConfigs.push({ url, ...config }) requestBodies.push(data) return { data: nextPutData ?? { called: url } } }) vi.spyOn(axios.Axios.prototype, 'delete').mockImplementation(async function ( this: unknown, url: string, config?: AxiosRequestConfig, ) { requestMethods.push('delete') requestConfigs.push({ url, ...config }) requestBodies.push(config?.data) return { data: { called: url } } }) }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() vi.unstubAllEnvs() window.history.replaceState({}, '', '/') delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ }) it('uses contract apiPrefix by default', async () => { const client = createPluginClient(contract, { endpoints: { health: { method: 'get', path: '/health' }, }, }) await client.health() expect(requestConfigs[0]!.baseURL).toBe('/api/drp') expect(requestConfigs[0]!.url).toBe('/health') expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true }) }) it('omits MINT auth transport for a public generated endpoint', async () => { const client = createPluginClient(contract, { endpoints: { health: { method: 'get', path: '/health', requiresAuth: false }, }, }) await client.health() expect( (requestConfigs[0] as AxiosRequestConfig & { _mintSkipAuth?: boolean }) ._mintSkipAuth, ).toBe(true) }) it('VITE_API_PREFIX overrides contract apiPrefix', async () => { vi.stubEnv('VITE_API_PREFIX', '/api/override') const client = createPluginClient(contract, { endpoints: { health: { method: 'get', path: '/health' }, }, }) await client.health() expect(requestConfigs[0]!.baseURL).toBe('/api/override') }) it('platform injected api_prefix overrides contract fallback', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', plugin: { id: 'drp', name: 'DRP', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/platform-drp', }, } const client = createPluginClient(contract, { endpoints: { health: { method: 'get', path: '/health' }, }, }) await client.health() expect(requestConfigs[0]!.baseURL).toBe('/api/platform-drp') }) it('resolves plugin base URLs without making requests', () => { expect(resolvePluginBaseUrl(contract)).toBe('/api/drp') expect(resolvePluginBaseUrl( { ...contract, plugin: { ...contract.plugin, apiPrefix: '/api/drp/' } }, )).toBe('/api/drp') expect(resolvePluginBaseUrl(contract, '/api/explicit/')).toBe('/api/explicit') vi.stubEnv('VITE_API_PREFIX', '/api/env/') expect(resolvePluginBaseUrl(contract, '/api/explicit')).toBe('/api/env') }) it('normalizes platform-injected plugin base URLs', () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', plugin: { id: 'drp', name: 'DRP', version: '1.0.0', route_prefix: '/ignored/', api_prefix: '/api/platform-drp/', }, } expect(resolvePluginBaseUrl(contract)).toBe('/api/platform-drp') ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', plugin: { id: 'drp', name: 'DRP', version: '1.0.0', route_prefix: '/platform-drp/', }, } expect(resolvePluginBaseUrl(contract)).toBe('/api/platform-drp') }) it('builds concrete endpoint URLs without making requests', () => { const url = buildPluginEndpointUrl( contract, { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], queryParams: [ { name: 'dry_run', fieldName: 'dryRun', type: 'boolean', required: false }, ], hasBody: true, }, { experimentId: 42, dryRun: true, body: { threshold: 0.5 } }, ) expect(url).toBe('/api/drp/analyze/42?dry_run=true') expect(requestConfigs).toHaveLength(0) }) it('builds concrete endpoint URLs from structured generated payloads', () => { const url = buildPluginEndpointUrl( contract, { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], queryParams: [ { name: 'dry_run', fieldName: 'dryRun', type: 'boolean', required: false }, ], hasBody: true, }, { pathParams: { experimentId: 42 }, query: { dryRun: true }, body: { threshold: 0.5 }, }, ) expect(url).toBe('/api/drp/analyze/42?dry_run=true') expect(requestConfigs).toHaveLength(0) }) it('builds URLs from rich generated path parameter contracts', () => { const url = buildPluginEndpointUrl( contract, { method: 'get', path: '/templates/{template_type}', pathParams: [ { name: 'template_type', fieldName: 'templateType', type: 'string' }, ], }, { templateType: 'plate map' }, { includeBaseUrl: false }, ) expect(url).toBe('/templates/plate%20map') }) it('builds URLs from FastAPI converted path parameters', () => { const url = buildPluginEndpointUrl( contract, { method: 'get', path: '/raw/{folder}/{filename:path}', pathParams: ['folder', 'filename'], }, { folder: 'batch 1', filename: 'sample.mzML' }, { includeBaseUrl: false }, ) expect(url).toBe('/raw/batch%201/sample.mzML') }) it('preserves flat scalar query fields when no structured query object is used', () => { const url = buildPluginEndpointUrl( contract, { method: 'get', path: '/search', }, { query: 'glucose' }, { includeBaseUrl: false }, ) expect(url).toBe('/search?query=glucose') }) it('encodes generated plugin upload endpoint payloads as multipart form data', async () => { const response = await uploadPluginEndpoint<{ ok: boolean }>( contract, { method: 'post', path: '/schedule/import', queryParams: [ { name: 'instrument_id', fieldName: 'instrumentId', type: 'string', required: true }, { name: 'mode', fieldName: 'mode', type: 'string', required: false }, ], hasBody: true, }, { query: { instrumentId: 'orbitrap', mode: 'replace', }, body: { file: new Blob(['csv']), metadata: { source: 'test' }, dryRun: false, }, }, ) expect(response).toEqual({ called: '/schedule/import' }) expect(requestConfigs[0]!.baseURL).toBe('/api/drp') expect(requestConfigs[0]!.url).toBe('/schedule/import') expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', mode: 'replace' }) expect(requestConfigs[0]!.headers).toEqual({ 'Content-Type': undefined }) expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true }) expect(requestBodies[0]).toBeInstanceOf(FormData) const form = requestBodies[0] as FormData expect(form.get('file')).toBeInstanceOf(Blob) expect(form.get('metadata')).toBe('{"source":"test"}') expect(form.get('dryRun')).toBe('false') }) it('uploads generated octet-stream endpoint bodies without multipart conversion', async () => { const body = new Uint8Array([1, 2, 3]) await uploadPluginEndpoint( contract, { method: 'put', path: '/chunks', hasBody: true, requestContentType: 'application/octet-stream', }, body, ) expect({ body: requestBodies[0], contentType: (requestConfigs[0]!.headers as Record)['Content-Type'], }).toEqual({ body, contentType: 'application/octet-stream', }) }) it('builds multipart form data from repeated scalar values', () => { const form = pluginFormDataFromPayload({ tags: ['qc', 'plate'], threshold: 0.5, enabled: true, omitted: undefined, }) expect(form.getAll('tags')).toEqual(['qc', 'plate']) expect(form.get('threshold')).toBe('0.5') expect(form.get('enabled')).toBe('true') expect(form.has('omitted')).toBe(false) }) it('downloads generated plugin endpoint blobs with contract query handling', async () => { const expectedBlob = new Blob(['a,b']) nextGetData = expectedBlob const blob = await downloadPluginEndpoint( contract, { method: 'get', path: '/schedule/export', queryParams: [ { name: 'instrument_id', fieldName: 'instrumentId', type: 'string', required: true }, { name: 'start', fieldName: 'start', type: 'string', required: true }, ], }, { query: { instrumentId: 'orbitrap', start: '2026-05-27' } }, ) expect(blob).toBe(expectedBlob) expect(requestConfigs[0]!.baseURL).toBe('/api/drp') expect(requestConfigs[0]!.url).toBe('/schedule/export') expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', start: '2026-05-27' }) expect(requestConfigs[0]!.responseType).toBe('blob') expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true }) }) it('downloads generated POST endpoint blobs with the contract body and request options', async () => { const expectedBlob = new Blob(['zip']) const controller = new AbortController() nextPostData = expectedBlob const blob = await downloadPluginEndpoint( contract, { method: 'post', path: '/export/{sessionId}/results', pathParams: ['sessionId'], queryParams: [ { name: 'revision', fieldName: 'revision', type: 'number', required: true }, ], hasBody: true, requestContentType: 'application/json', }, { sessionId: 'session-1', revision: 4, body: { format: 'wide', correction: false }, }, { signal: controller.signal }, ) expect({ blob, body: requestBodies[0], url: requestConfigs[0]!.url, params: requestConfigs[0]!.params, signal: requestConfigs[0]!.signal, responseType: requestConfigs[0]!.responseType, method: requestMethods[0], }).toEqual({ blob: expectedBlob, body: { format: 'wide', correction: false }, url: '/export/session-1/results', params: { revision: 4 }, signal: controller.signal, responseType: 'blob', method: 'post', }) }) it.each(['put', 'patch', 'delete'] as const)( 'downloads generated %s endpoint blobs with the contract body', async (method) => { await downloadPluginEndpoint( contract, { method, path: '/exports/{exportId}', pathParams: ['exportId'], hasBody: true, }, { exportId: 'export-1', body: { format: 'wide' } }, ) expect({ body: requestBodies[0], method: requestMethods[0], responseType: requestConfigs[0]!.responseType, url: requestConfigs[0]!.url, }).toEqual({ body: { format: 'wide' }, method, responseType: 'blob', url: '/exports/export-1', }) }, ) it('rejects multipart download request bodies', async () => { await expect(downloadPluginEndpoint( contract, { method: 'post', path: '/exports', hasBody: true, requestContentType: 'multipart/form-data', }, { body: { format: 'wide' } }, )).rejects.toThrow('Plugin download endpoints do not support multipart request bodies') }) it.each([ new Blob(['chunk']), new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6]).buffer, ])('sends generated octet-stream request bodies without encoding them', async (body) => { const client = createPluginClient(contract, { endpoints: { uploadChunk: { method: 'post', path: '/chunks', hasBody: true, requestContentType: 'application/octet-stream', }, }, }) await client.uploadChunk(body) expect({ body: requestBodies[0], contentType: (requestConfigs[0]!.headers as Record)['Content-Type'], }).toEqual({ body, contentType: 'application/octet-stream', }) }) it('returns generated octet-stream responses as blobs', async () => { const expectedBlob = new Blob(['result']) nextGetData = expectedBlob const client = createPluginClient(contract, { endpoints: { downloadResult: { method: 'get', path: '/result', responseContentType: 'application/octet-stream', }, }, }) const result = await client.downloadResult() expect({ result, responseType: requestConfigs[0]!.responseType }).toEqual({ result: expectedBlob, responseType: 'blob', }) }) it('passes AbortSignal through generated endpoint calls', async () => { const controller = new AbortController() const client = createPluginClient(contract, { endpoints: { health: { method: 'get', path: '/health' }, }, }) await client.health({ signal: controller.signal }) expect(requestConfigs[0]!.signal).toBe(controller.signal) }) it('encodes generated multipart endpoint bodies as FormData', async () => { const file = new Blob(['csv']) const client = createPluginClient(contract, { endpoints: { uploadFile: { method: 'post', path: '/files', hasBody: true, requestContentType: 'multipart/form-data', }, }, }) await client.uploadFile({ file, label: 'sample' }) const body = requestBodies[0] as FormData expect({ isFormData: body instanceof FormData, fileIsBlob: body.get('file') instanceof Blob, label: body.get('label'), contentType: (requestConfigs[0]!.headers as Record)['Content-Type'], }).toEqual({ isFormData: true, fileIsBlob: true, label: 'sample', contentType: undefined, }) }) it('triggers browser downloads for existing blobs', () => { vi.useFakeTimers() const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test') const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) downloadBlob(new Blob(['csv']), 'export.csv', { revokeObjectUrlDelayMs: 0 }) expect(createObjectURL).toHaveBeenCalledOnce() expect(click).toHaveBeenCalledOnce() expect(revokeObjectURL).not.toHaveBeenCalled() vi.runOnlyPendingTimers() expect(revokeObjectURL).toHaveBeenCalledWith('blob:test') vi.useRealTimers() }) it('maps plugin contract nav items to AppTopBar page selector metadata', () => { const pageSelectorItems = getPluginPageSelectorItems({ ...contract, plugin: { ...contract.plugin, name: 'Dose Designer', description: 'Build dose designs', icon: 'PLUGIN_ICON', navItems: [ { path: '/', label: 'Dashboard', icon: 'DASHBOARD_ICON', description: 'Overview' }, { id: 'dose-design', path: 'dose-design', label: 'Dose Design' }, { path: '/qc/review/', label: 'QC Review', icon: 'QC_ICON' }, ], }, }) expect(pageSelectorItems).toEqual([ { id: 'dashboard', label: 'Dashboard', to: '/', icon: 'DASHBOARD_ICON', }, { id: 'dose-design', label: 'Dose Design', to: '/dose-design', icon: 'PLUGIN_ICON', }, { id: 'qc-review', label: 'QC Review', to: '/qc/review', icon: 'QC_ICON', }, ]) expect(requestConfigs).toHaveLength(0) }) it('does not synthesize page selector items when contract nav items are absent', () => { expect(getPluginPageSelectorItems({ ...contract, plugin: { ...contract.plugin, name: 'Dose Designer', description: 'Build dose designs', icon: 'PLUGIN_ICON', navItems: [], }, })).toEqual([]) expect(requestConfigs).toHaveLength(0) }) it('does not synthesize page selector items when contract nav items are omitted', () => { const { navItems: _navItems, ...pluginWithoutNavItems } = contract.plugin expect(getPluginPageSelectorItems({ ...contract, plugin: pluginWithoutNavItems, })).toEqual([]) expect(requestConfigs).toHaveLength(0) }) it.each([ 'platform', undefined, ] as const)( 'should use the integrated platform config facade when settings_api is %s', async (settingsApi) => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', ...(settingsApi ? { settings_api: settingsApi } : {}), }, } nextGetData = { plugin_name: 'drp', config: { nasBasePath: '/data', allowed_experiment_types: ['metabolomics'], }, revision: 'revision-1', } nextPatchData = { plugin_name: 'drp', config: { nasBasePath: '/mnt', allowed_experiment_types: ['metabolomics'], }, revision: 'revision-2', } const settings = usePluginSettings<{ nasBasePath?: string }>({ pluginName: 'drp' }) await settings.load() expect(requestConfigs[0]!.baseURL).toBe('/api') expect(requestConfigs[0]!.url).toBe('/plugins/drp/config') expect(settings.values.value).toEqual({ nasBasePath: '/data', allowed_experiment_types: ['metabolomics'], }) expect(settings.settings.value).toEqual({ nasBasePath: '/data', allowed_experiment_types: ['metabolomics'], }) expect(settings.isDirty.value).toBe(false) settings.setValues({ nasBasePath: '/mnt', allowed_experiment_types: ['metabolomics'], }) expect(settings.isDirty.value).toBe(true) const ok = await settings.save() expect(ok).toBe(true) expect(requestConfigs[1]!.baseURL).toBe('/api') expect(requestConfigs[1]!.url).toBe('/plugins/drp/config') expect(requestMethods[1]).toBe('patch') expect(requestConfigs[1]!.headers).toBeUndefined() expect(requestBodies[0]).toEqual({ config: { nasBasePath: '/mnt' } }) expect(settings.values.value).toEqual({ nasBasePath: '/mnt', allowed_experiment_types: ['metabolomics'], }) expect(settings.isDirty.value).toBe(false) }, ) it('should use the plugin settings router for an integrated plugin capability', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', settings_api: 'plugin', }, } nextGetData = { settings: { threshold: 1 }, mode: 'integrated', revision: 'revision-1', } nextPutData = { settings: { threshold: 2 }, mode: 'integrated', revision: 'revision-2', } const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' }) await settings.load() const saved = await settings.save({ threshold: 2 }) expect({ saved, methods: requestMethods, requests: requestConfigs.map(({ baseURL, url, headers }) => ({ baseURL, url, headers, })), bodies: requestBodies, values: settings.values.value, }).toEqual({ saved: true, methods: ['get', 'put'], requests: [ { baseURL: '/api/drp', url: '/settings', headers: undefined, }, { baseURL: '/api/drp', url: '/settings', headers: { 'If-Match': '"revision-1"' }, }, ], bodies: [{ threshold: 2 }], values: { threshold: 2 }, }) }) describe('secret settings references', () => { const secretRef = { $secret: 'sec_0f2a4c6e8a0c2e4a6c8e0a2c4e6a8c0e', set: true } function integratedPluginSettingsHost(): void { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', settings_api: 'plugin', }, } nextGetData = { settings: { apiKey: secretRef, method: 'first' }, secret_format: 'ref', mode: 'integrated', revision: 'revision-1', } nextPutData = { settings: { apiKey: secretRef, method: 'second' }, secret_format: 'ref', mode: 'integrated', revision: 'revision-2', } } it('should round-trip an untouched secret reference unchanged', async () => { integratedPluginSettingsHost() const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({ pluginName: 'drp', }) await settings.load() const saved = await settings.save({ ...settings.values.value, method: 'second' }) expect({ saved, body: requestBodies[0] }).toEqual({ saved: true, body: { apiKey: secretRef, method: 'second', secret_format: 'ref' }, }) }) it('should submit a typed string when the user enters a new secret', async () => { integratedPluginSettingsHost() const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({ pluginName: 'drp', }) await settings.load() await settings.save({ apiKey: 'typed-secret', method: 'first' }) expect(requestBodies[0]).toEqual({ apiKey: 'typed-secret', method: 'first', secret_format: 'ref', }) }) it('should submit null when the user clears a secret', async () => { integratedPluginSettingsHost() const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({ pluginName: 'drp', }) await settings.load() await settings.save({ apiKey: null, method: 'first' }) expect(requestBodies[0]).toEqual({ apiKey: null, method: 'first', secret_format: 'ref', }) }) it('should declare the reference format on platform config patches', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', }, } nextGetData = { plugin_name: 'drp', config: { apiKey: secretRef, method: 'first' }, secret_format: 'ref', revision: 'revision-1', } nextPatchData = { plugin_name: 'drp', config: { apiKey: secretRef, method: 'second' }, secret_format: 'ref', revision: 'revision-2', } const settings = usePluginSettings<{ apiKey?: unknown; method?: string }>({ pluginName: 'drp', }) await settings.load() await settings.save({ apiKey: secretRef, method: 'second' }) expect(requestBodies[0]).toEqual({ config: { method: 'second', secret_format: 'ref' }, }) }) it('should omit the format marker for a host that does not publish one', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', settings_api: 'plugin', }, } nextGetData = { settings: { method: 'first' }, mode: 'integrated', revision: 'revision-1' } nextPutData = { settings: { method: 'second' }, mode: 'integrated', revision: 'revision-2' } const settings = usePluginSettings<{ method?: string }>({ pluginName: 'drp' }) await settings.load() await settings.save({ method: 'second' }) expect(requestBodies[0]).toEqual({ method: 'second' }) }) }) it('should preserve the contract v2 form schema in settingsConfig', () => { const settings = usePluginSettings({ apiBaseUrl: '/api/drp', loadOnMount: false, schema: contract.settings.formSchema, }) expect(settings.settingsConfig.value.schema).toBe(contract.settings.formSchema) }) it('should use the platform facade for an explicit different plugin name', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'current-plugin', name: 'current-plugin', version: '1.0.0', route_prefix: '/current-plugin', api_prefix: '/api/current-plugin', settings_api: 'plugin', }, } nextGetData = { plugin_name: 'other-plugin', config: { threshold: 1 }, revision: 'revision-1', } const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'other-plugin', }) await settings.load() expect({ method: requestMethods[0], baseURL: requestConfigs[0]!.baseURL, url: requestConfigs[0]!.url, values: settings.values.value, }).toEqual({ method: 'get', baseURL: '/api', url: '/plugins/other-plugin/config', values: { threshold: 1 }, }) }) it('should split changed platform-owned settings from a versioned typed save', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', }, } nextGetData = { plugin_name: 'drp', config: { threshold: 1, allowed_experiment_types: ['metabolomics'], }, revision: 'revision-1', } nextPatchData = { plugin_name: 'drp', config: { threshold: 2, allowed_experiment_types: ['proteomics'], }, revision: 'untrusted-platform-revision', } const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' }) await settings.load() settings.setValues({ threshold: 2, allowed_experiment_types: ['proteomics'], }) const saved = await settings.save() nextPatchData = { plugin_name: 'drp', config: { threshold: 3, allowed_experiment_types: ['proteomics'], }, revision: 'revision-3', } settings.setValues({ threshold: 3, allowed_experiment_types: ['proteomics'], }) const savedAgain = await settings.save() expect({ saved, savedAgain, methods: requestMethods, bodies: requestBodies, writeHeaders: requestConfigs.slice(1).map(config => config.headers), values: settings.values.value, isDirty: settings.isDirty.value, }).toEqual({ saved: true, savedAgain: true, methods: ['get', 'patch', 'patch', 'patch'], bodies: [ { config: { threshold: 2 } }, { config: { allowed_experiment_types: ['proteomics'] } }, { config: { threshold: 3 } }, ], writeHeaders: [undefined, undefined, undefined], values: { threshold: 3, allowed_experiment_types: ['proteomics'], }, isDirty: false, }) }) it('should avoid a typed PUT when only the platform-owned setting changes', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api', plugin: { id: 'drp', name: 'drp', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/drp', }, } nextGetData = { plugin_name: 'drp', config: { threshold: 1, allowed_experiment_types: ['metabolomics'], }, revision: 'revision-1', } nextPatchData = { plugin_name: 'drp', config: { threshold: 1, allowed_experiment_types: ['proteomics'], }, } const settings = usePluginSettings<{ threshold?: number }>({ pluginName: 'drp' }) await settings.load() settings.setValues({ threshold: 1, allowed_experiment_types: ['proteomics'], }) const saved = await settings.save() expect({ saved, methods: requestMethods, bodies: requestBodies, values: settings.values.value, }).toEqual({ saved: true, methods: ['get', 'patch'], bodies: [{ config: { allowed_experiment_types: ['proteomics'] } }], values: { threshold: 1, allowed_experiment_types: ['proteomics'], }, }) }) it('should detect nested settings edits and restore the saved JSON snapshot', async () => { nextGetData = { settings: { rules: { thresholds: [1, 2], }, }, mode: 'standalone', } const settings = usePluginSettings<{ rules?: { thresholds: number[] } }>({ apiBaseUrl: '/api/drp', loadOnMount: false, }) await settings.load() settings.values.value.rules!.thresholds[0] = 9 const dirtyAfterMutation = settings.isDirty.value settings.reset() expect({ dirtyAfterMutation, values: settings.values.value, isDirty: settings.isDirty.value, }).toEqual({ dirtyAfterMutation: true, values: { rules: { thresholds: [1, 2], }, }, isDirty: false, }) }) it('loads and saves plugin settings through the standalone settings router', async () => { nextGetData = { settings: { nas_base_path: '/data' }, mode: 'standalone', revision: 'revision-1', } nextPutData = { settings: { nas_base_path: '/mnt' }, mode: 'standalone', revision: 'revision-2', } const settings = usePluginSettings<{ nas_base_path?: string }>({ apiBaseUrl: '/api/drp', loadOnMount: false, }) await settings.load() const ok = await settings.save({ nas_base_path: '/mnt' }) expect(ok).toBe(true) expect(requestConfigs[0]!.baseURL).toBe('/api/drp') expect(requestConfigs[0]!.url).toBe('/settings') expect(requestConfigs[1]!.baseURL).toBe('/api/drp') expect(requestConfigs[1]!.url).toBe('/settings') expect(requestConfigs[1]!.headers).toEqual({ 'If-Match': '"revision-1"' }) expect(requestBodies[0]).toEqual({ nas_base_path: '/mnt' }) expect(settings.settingsConfig.value.values).toEqual({ nas_base_path: '/mnt' }) }) it('streams plugin events with auth headers and SSE parsing', async () => { const authStore = useAuthStore() authStore.setToken('stream-token', 3600) const onMessage = vi.fn() const streamBody = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('event: progress\nid: 7\ndata: {"pct":50}\n\n')) controller.close() }, }) const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(streamBody, { status: 200 }), ) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream<{ pct: number }>( contract, { method: 'get', path: '/downloads/events' }, { parseJson: true, reconnect: false, onMessage, }, ) await vi.waitFor(() => { expect(onMessage).toHaveBeenCalledOnce() }) expect(fetchMock).toHaveBeenCalledOnce() const [url, init] = fetchMock.mock.calls[0]! expect(url).toBe('/api/drp/downloads/events') expect((init?.headers as Headers).get('Authorization')).toBe('Bearer stream-token') expect((init?.headers as Headers).get('Accept')).toBe('text/event-stream') expect(onMessage).toHaveBeenCalledWith({ event: 'progress', data: { pct: 50 }, id: '7', retry: undefined, raw: 'event: progress\nid: 7\ndata: {"pct":50}', }) expect(stream.lastMessage.value?.data).toEqual({ pct: 50 }) expect(stream.lastEventId.value).toBe('7') }) it('omits MINT auth from a public plugin event stream', async () => { const authStore = useAuthStore() authStore.setToken('stream-token', 3600) const fetchMock = vi.fn(async () => new Response(new ReadableStream({ start: controller => controller.close() }), { status: 200, }), ) vi.stubGlobal('fetch', fetchMock) usePluginEventStream( contract, { method: 'get', path: '/public/events', requiresAuth: false }, { reconnect: false }, ) await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) const [, init] = fetchMock.mock.calls[0]! expect((init?.headers as Headers).get('Authorization')).toBeNull() }) it('normalizes stream auth failures and does not reconnect rejected requests', async () => { const authStore = useAuthStore() authStore.setToken('expired-stream-token', 3600) const onError = vi.fn() const fetchMock = vi.fn(async () => new Response(null, { status: 401 })) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { reconnectDelayMs: 1, onError }, ) await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) await new Promise((resolve) => setTimeout(resolve, 10)) expect({ attempts: fetchMock.mock.calls.length, errorType: onError.mock.calls[0]![0]?.constructor, message: stream.error.value, token: authStore.token, }).toEqual({ attempts: 1, errorType: AuthenticationRequiredError, message: 'Authentication required. Please sign in again.', token: null, }) }) it('parses generated typed event streams as JSON by default', async () => { const onMessage = vi.fn() const streamBody = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('event: job\ndata: {"job_id":"fit"}\n\n')) controller.close() }, }) vi.stubGlobal('fetch', vi.fn(async () => new Response(streamBody, { status: 200 }))) usePluginEventStream<{ job_id: string }>( contract, { method: 'get', path: '/jobs/events', eventStream: true, responseType: 'JobStatePayload | JobSnapshotPayload', }, { reconnect: false, onMessage }, ) await vi.waitFor(() => { expect(onMessage).toHaveBeenCalledOnce() }) expect(onMessage.mock.calls[0]![0].data).toEqual({ job_id: 'fit' }) }) it('should deliver terminal enrichment on the original generated stream', async () => { type JobStreamData = { jobs?: Array> job_id?: string can_delete?: boolean output_path?: string | null } const onMessage = vi.fn() const streamBody = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode( 'event: snapshot\ndata: {"jobs":[{"job_id":"fit","can_delete":false,"output_path":null}]}\n\n' + 'event: terminal\ndata: {"job_id":"fit","can_delete":true,"output_path":"/results/final.csv"}\n\n', )) controller.close() }, }) const fetchMock = vi.fn(async () => new Response(streamBody, { status: 200 })) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/jobs/events', eventStream: true, responseType: 'JobStatePayload | JobSnapshotPayload', }, { reconnect: false, onMessage }, ) await vi.waitFor(() => { expect({ fetches: fetchMock.mock.calls.length, events: onMessage.mock.calls.map(([message]) => message.event), last: stream.lastMessage.value?.data, }).toEqual({ fetches: 1, events: ['snapshot', 'terminal'], last: { job_id: 'fit', can_delete: true, output_path: '/results/final.csv', }, }) }) }) it('keeps generated string event streams as text by default', async () => { const onMessage = vi.fn() const streamBody = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('event: ready\ndata: ready\n\n')) controller.close() }, }) vi.stubGlobal('fetch', vi.fn(async () => new Response(streamBody, { status: 200 }))) usePluginEventStream( contract, { method: 'get', path: '/jobs/events', eventStream: true, responseType: 'string', }, { reconnect: false, onMessage }, ) await vi.waitFor(() => { expect(onMessage).toHaveBeenCalledOnce() }) expect(onMessage.mock.calls[0]![0].data).toBe('ready') }) it('should keep start idempotent when an event stream is active', async () => { const active = pendingEventStreamResponse() const fetchMock = vi.fn(async () => active.response) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { immediate: false }, ) try { stream.start() stream.start() await Promise.resolve() expect(fetchMock).toHaveBeenCalledOnce() } finally { stream.stop() active.close() } }) it('should keep replacement stream connected when an old connection finishes', async () => { const oldFetch = deferred() const replacement = pendingEventStreamResponse() const fetchMock = vi.fn() .mockReturnValueOnce(oldFetch.promise) .mockResolvedValueOnce(replacement.response) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { immediate: false }, ) try { stream.start() stream.stop() stream.start() await Promise.resolve() await Promise.resolve() oldFetch.resolve(new Response('', { status: 200 })) await Promise.resolve() await Promise.resolve() expect(stream.isConnected.value).toBe(true) } finally { stream.stop() replacement.close() } }) it('should not reconnect after a stale event stream finishes', async () => { vi.useFakeTimers() const oldFetch = deferred() const replacement = pendingEventStreamResponse() const fetchMock = vi.fn() .mockReturnValueOnce(oldFetch.promise) .mockResolvedValueOnce(replacement.response) .mockResolvedValue(new Response('', { status: 200 })) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { immediate: false, reconnectDelayMs: 10 }, ) try { stream.start() stream.stop() stream.start() await Promise.resolve() oldFetch.resolve(new Response('', { status: 200 })) await Promise.resolve() await vi.advanceTimersByTimeAsync(10) expect(fetchMock).toHaveBeenCalledTimes(2) } finally { stream.stop() replacement.close() } }) it('should not call onOpen after a synchronous restart invalidates the stream', async () => { const oldConnection = pendingEventStreamResponse() const replacement = pendingEventStreamResponse() const fetchMock = vi.fn() .mockResolvedValueOnce(oldConnection.response) .mockResolvedValueOnce(replacement.response) vi.stubGlobal('fetch', fetchMock) const onOpen = vi.fn() const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { immediate: false, onOpen }, ) let restarted = false const stopWatching = watch( stream.isConnected, (connected) => { if (!connected || restarted) return restarted = true stream.stop() stream.start() }, { flush: 'sync' }, ) try { stream.start() await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce()) } finally { stopWatching() stream.stop() oldConnection.close() replacement.close() } }) it('should not fetch after a synchronous setup restart invalidates the stream', async () => { const replacement = pendingEventStreamResponse() const stale = pendingEventStreamResponse() const fetchMock = vi.fn() .mockResolvedValueOnce(replacement.response) .mockResolvedValueOnce(stale.response) vi.stubGlobal('fetch', fetchMock) const stream = usePluginEventStream( contract, { method: 'get', path: '/downloads/events' }, { immediate: false }, ) let restarted = false const stopWatching = watch( stream.isConnecting, (connecting) => { if (!connecting || restarted) return restarted = true stream.stop() stream.start() }, { flush: 'sync' }, ) try { stream.start() await Promise.resolve() expect(fetchMock).toHaveBeenCalledOnce() } finally { stopWatching() stream.stop() replacement.close() stale.close() } }) it('can build path-only endpoint URLs with inferred experiment ids', () => { window.history.replaceState({}, '', '/experiments/42/plugins/drp') const url = buildPluginEndpointUrl( contract, { method: 'get', path: '/experiments/{experimentId}/summary', pathParams: ['experimentId'], queryParams: [ { name: 'include_qc', fieldName: 'includeQc', type: 'boolean', required: false }, ], }, { includeQc: true }, { includeBaseUrl: false }, ) expect(url).toBe('/experiments/42/summary?include_qc=true') }) it('validates required query params when building endpoint URLs', () => { expect(() => buildPluginEndpointUrl( contract, { method: 'get', path: '/search', queryParams: [ { name: 'q', fieldName: 'q', type: 'string', required: true }, ], }, {}, )).toThrow("[MINT SDK] Missing query parameter 'q' for plugin endpoint /search") }) it('replaces path params and sends request body', async () => { type Client = { analyze(params: { experimentId: number; body: { threshold: number } }): Promise } const client = createPluginClient(contract, { endpoints: { analyze: { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], hasBody: true, }, }, }) await client.analyze({ experimentId: 42, body: { threshold: 0.5 } }) expect(requestConfigs[0]!.url).toBe('/analyze/42') expect(requestConfigs[0]!.params).toBeUndefined() expect(requestBodies[0]).toEqual({ threshold: 0.5 }) }) it('infers experimentId path params from the current platform route', async () => { window.history.replaceState({}, '', '/experiments/42/plugins/drp') type Client = { analyze(params: { body: { threshold: number } }): Promise } const client = createPluginClient(contract, { endpoints: { analyze: { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], hasBody: true, }, }, }) await client.analyze({ body: { threshold: 0.5 } }) expect(requestConfigs[0]!.url).toBe('/analyze/42') expect(requestConfigs[0]!.params).toBeUndefined() expect(requestBodies[0]).toEqual({ threshold: 0.5 }) }) it('sends query params for non-GET endpoints with request bodies', async () => { type Client = { analyze(params: { experimentId: number dryRun?: boolean body: { threshold: number } }): Promise } const client = createPluginClient(contract, { endpoints: { analyze: { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], queryParams: [ { name: 'dry_run', fieldName: 'dryRun', type: 'boolean', required: false }, ], hasBody: true, }, }, }) await client.analyze({ experimentId: 42, dryRun: true, body: { threshold: 0.5 }, }) expect(requestConfigs[0]!.url).toBe('/analyze/42') expect(requestConfigs[0]!.params).toEqual({ dry_run: true }) expect(requestBodies[0]).toEqual({ threshold: 0.5 }) }) it('sends structured generated payloads with path params, query params, and bodies', async () => { type Client = { analyze(params: { pathParams: { experimentId: number } query?: { dryRun?: boolean } body: { threshold: number } }): Promise } const client = createPluginClient(contract, { endpoints: { analyze: { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], queryParams: [ { name: 'dry_run', fieldName: 'dryRun', type: 'boolean', required: false }, ], hasBody: true, }, }, }) await client.analyze({ pathParams: { experimentId: 42 }, query: { dryRun: true }, body: { threshold: 0.5 }, }) expect(requestConfigs[0]!.url).toBe('/analyze/42') expect(requestConfigs[0]!.params).toEqual({ dry_run: true }) expect(requestBodies[0]).toEqual({ threshold: 0.5 }) }) it('throws a clear error before requests with missing request bodies', async () => { type Client = { analyze(params: { experimentId: number; body?: { threshold: number } }): Promise } const client = createPluginClient(contract, { endpoints: { analyze: { method: 'post', path: '/analyze/{experimentId}', pathParams: ['experimentId'], hasBody: true, }, }, }) await expect(client.analyze({ experimentId: 42 })).rejects.toThrow( '[MINT SDK] Missing request body for plugin endpoint /analyze/{experimentId}', ) expect(requestConfigs).toHaveLength(0) }) it('sends request bodies and query params for DELETE endpoints', async () => { type Client = { purge(params: { dryRun?: boolean; body: { reason: string } }): Promise } const client = createPluginClient(contract, { endpoints: { purge: { method: 'delete', path: '/purge', queryParams: [ { name: 'dry_run', fieldName: 'dryRun', type: 'boolean', required: false }, ], hasBody: true, }, }, }) await client.purge({ dryRun: true, body: { reason: 'duplicate' } }) expect(requestConfigs[0]!.url).toBe('/purge') expect(requestConfigs[0]!.params).toEqual({ dry_run: true }) expect(requestBodies[0]).toEqual({ reason: 'duplicate' }) }) it('sends non-path GET params as query params', async () => { type Client = { search(params: { experimentId: number; q?: string; limit?: number; includeArchived?: boolean }): Promise } const client = createPluginClient(contract, { endpoints: { search: { method: 'get', path: '/experiments/{experimentId}/search', pathParams: ['experimentId'], queryParams: [ { name: 'q', fieldName: 'q', type: 'string', required: false }, { name: 'limit', fieldName: 'limit', type: 'number', required: false }, { name: 'include_archived', fieldName: 'includeArchived', type: 'boolean', required: false }, ], }, }, }) await client.search({ experimentId: 42, q: 'glucose', limit: 20, includeArchived: true }) expect(requestConfigs[0]!.url).toBe('/experiments/42/search') expect(requestConfigs[0]!.params).toEqual({ q: 'glucose', limit: 20, include_archived: true }) }) it('throws a clear error before requests with missing required query params', async () => { type Client = { search(params: { experimentId: number; q?: string }): Promise } const client = createPluginClient(contract, { endpoints: { search: { method: 'get', path: '/experiments/{experimentId}/search', pathParams: ['experimentId'], queryParams: [ { name: 'q', fieldName: 'q', type: 'string', required: true }, ], }, }, }) await expect(client.search({ experimentId: 42 })).rejects.toThrow( "[MINT SDK] Missing query parameter 'q' for plugin endpoint /experiments/{experimentId}/search", ) expect(requestConfigs).toHaveLength(0) }) it('loads the current experiment from URL conventions', async () => { window.history.replaceState({}, '', '/analysis?experimentId=42') const current = useCurrentExperiment<{ called: string }>({ immediate: false }) expect(current.experimentId.value).toBe(42) expect(current.hasExperiment.value).toBe(true) expect(current.requireExperimentId()).toBe(42) expect(current.lastLoadedAt.value).toBeNull() const pending = current.refresh() expect(current.isLoading.value).toBe(true) await pending expect(requestConfigs[0]!.url).toBe('/experiments/42') expect(current.experiment.value).toEqual({ called: '/experiments/42' }) expect(current.error.value).toBeNull() expect(current.isLoading.value).toBe(false) expect(current.lastLoadedAt.value).toBeInstanceOf(Date) }) it('throws a clear error when a current experiment is required but missing', () => { const current = useCurrentExperiment({ immediate: false }) expect(current.experimentId.value).toBeUndefined() expect(current.hasExperiment.value).toBe(false) expect(() => current.requireExperimentId()).toThrow('[MINT SDK] No current experiment is selected.') }) it('loads the current experiment from platform route paths', async () => { window.history.replaceState({}, '', '/projects/3/experiments/42/plugins/drp') const current = useCurrentExperiment<{ called: string }>({ immediate: false }) expect(current.experimentId.value).toBe(42) await current.refresh() expect(requestConfigs[0]!.url).toBe('/experiments/42') }) it('loads the current experiment from hash route paths', async () => { window.history.replaceState({}, '', '/plugins/drp#/experiments/43/analysis') const current = useCurrentExperiment<{ called: string }>({ immediate: false }) expect(current.experimentId.value).toBe(43) }) it('returns undefined and preserves sync state on current experiment load failure', async () => { window.history.replaceState({}, '', '/analysis?experimentId=42') nextGetError = new Error('network down') const current = useCurrentExperiment<{ called: string }>({ immediate: false }) const result = await current.refresh() expect(result).toBeUndefined() expect(requestConfigs[0]!.url).toBe('/experiments/42') expect(current.experiment.value).toBeUndefined() expect(current.error.value).toBe('network down') expect(current.isLoading.value).toBe(false) expect(current.lastLoadedAt.value).toBeNull() }) it('prefers platform-injected experiment context and API base', async () => { ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = { isIntegrated: true, theme: 'light', platformApiUrl: '/api/platform', currentExperimentId: '7', plugin: { id: 'drp', name: 'DRP', version: '1.0.0', route_prefix: '/drp', api_prefix: '/api/platform-drp', }, } const current = useCurrentExperiment<{ called: string }>({ immediate: false }) expect(current.experimentId.value).toBe(7) await current.fetch() expect(requestConfigs[0]!.baseURL).toBe('/api/platform') expect(requestConfigs[0]!.url).toBe('/experiments/7') }) })