import { flushPromises, mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import FileUploader from '../../components/FileUploader.vue' import GeneratedPathInput from '../../components/GeneratedPathInput.vue' import { GENERATED_JOBS_API_KEY } from '../../components/internal/generatedJobsApi' import type { GeneratedAnalysisTransport } from '../../composables/useGeneratedAnalysis' import type { PluginJobPathInput } from '../../types/generated-ui' describe('GeneratedPathInput', () => { beforeEach(() => { Object.assign(window, { __MINT_PLATFORM__: { isIntegrated: true, platformApiUrl: '/api', theme: 'system', currentExperimentId: 42, }, }) }) afterEach(() => { delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ vi.unstubAllGlobals() }) it('should preserve selected directory paths for SDK staging', async () => { const wrapper = mount(GeneratedPathInput, { global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')] await wrapper.findComponent(FileUploader).vm.$emit( 'upload', files, 'dataset', ['dataset/a.txt', 'dataset/sub/b.txt'], ) expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual({ kind: 'path', files, rootName: 'dataset', relativePaths: ['dataset/a.txt', 'dataset/sub/b.txt'], }) }) it('restores existing selections and keeps the visible files synchronized with the model', async () => { const file = new File(['a'], 'a.txt') const wrapper = mount(GeneratedPathInput, { props: { modelValue: { kind: 'path', files: [file], relativePaths: ['a.txt'] } }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) expect(wrapper.find('button[aria-label="Remove a.txt"]').exists()).toBe(true) await wrapper.setProps({ modelValue: { kind: 'path', files: [file], rootName: 'dataset', relativePaths: ['dataset/a.txt'] }, }) expect(wrapper.findComponent(FileUploader).props('mode')).toBe('folder') expect(wrapper.text()).toContain('dataset') await wrapper.get('button[aria-label="Clear folder"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeNull() await wrapper.setProps({ modelValue: null }) expect(wrapper.find('button[aria-label="Clear folder"]').exists()).toBe(false) }) it('should emit one path selection per file when multiple paths are requested', async () => { const wrapper = mount(GeneratedPathInput, { props: { multiple: true }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')] await wrapper.findComponent(FileUploader).vm.$emit( 'upload', files, undefined, ['a.txt', 'b.txt'], ) expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual([ { kind: 'path', files: [files[0]], relativePaths: ['a.txt'] }, { kind: 'path', files: [files[1]], relativePaths: ['b.txt'] }, ]) }) it('should remove a file from the submitted value when the uploader removes it', async () => { const wrapper = mount(GeneratedPathInput, { global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) const uploader = wrapper.findComponent(FileUploader) const file = new File(['a'], 'a.txt') const input = uploader.get('input[type="file"]') Object.defineProperty(input.element, 'files', { value: [file] }) await input.trigger('change') await wrapper.setProps({ modelValue: { kind: 'path', files: [file], relativePaths: ['a.txt'] }, }) await wrapper.get('button[aria-label="Remove a.txt"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeNull() }) const readableRow = { id: 7, experiment_id: 42, plugin_id: 'leaf', artifact_key: 'matrix', display_name: 'Matrix', status: 'active', result_keys: ['schema_version', 'file'], created_at: '2026-09-20T10:00:00Z', updated_at: '2026-09-20T10:00:00Z', filename: 'matrix.csv', } const artifactSelection = { kind: 'artifact-path', experimentId: 42, artifactId: 7, name: 'matrix.csv', } as const function listing(rows: unknown[] = [readableRow], status = 200) { return vi.fn().mockResolvedValue( new Response(JSON.stringify(status === 200 ? { artifacts: rows } : { detail: 'Forbidden' }), { status, headers: { 'Content-Type': 'application/json' }, }), ) } function mountWithJobsApi( transport: GeneratedAnalysisTransport, props: Record = {}, ) { return mount(GeneratedPathInput, { props, global: { plugins: [createPinia()], stubs: { Teleport: true }, provide: { [GENERATED_JOBS_API_KEY as symbol]: { baseUrl: '/api/demo', transport } }, }, }) } async function openPicker(wrapper: ReturnType): Promise { const button = wrapper.findAll('button').find(candidate => candidate.text() === 'Existing artifact') await button!.trigger('click') await flushPromises() } it('should list only the artifacts the plugin jobs API says it can read', async () => { const transport = listing() const wrapper = mountWithJobsApi(transport) await openPicker(wrapper) expect(transport.mock.calls[0]?.[0]).toBe('/api/demo/jobs/artifacts?experiment_id=42') }) it('should keep a picked artifact as a server reference', async () => { const wrapper = mountWithJobsApi(listing()) await openPicker(wrapper) await wrapper.get('[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual(artifactSelection) }) it('should not download artifact bytes when an artifact is picked', async () => { const transport = listing() const wrapper = mountWithJobsApi(transport) await openPicker(wrapper) await wrapper.get('[role="option"]').trigger('click') await flushPromises() expect(transport).toHaveBeenCalledTimes(1) }) it('should explain that no artifact is readable when the list is empty', async () => { const wrapper = mountWithJobsApi(listing([])) await openPicker(wrapper) expect(wrapper.text()).toContain('No file artifacts this plugin can read') }) it('should show why the artifact list could not be loaded', async () => { const wrapper = mountWithJobsApi(listing([], 403)) await openPicker(wrapper) expect(wrapper.text()).toContain('Forbidden') }) it('should ignore a slower listing from an earlier opening of the picker', async () => { let releaseFirst: (response: Response) => void = () => undefined const stale = new Promise((resolve) => { releaseFirst = resolve }) const transport = vi.fn() .mockReturnValueOnce(stale) .mockResolvedValueOnce(new Response(JSON.stringify({ artifacts: [readableRow] }))) const wrapper = mountWithJobsApi(transport) await openPicker(wrapper) await openPicker(wrapper) releaseFirst(new Response(JSON.stringify({ artifacts: [] }))) await flushPromises() expect(wrapper.findAll('[role="option"]')).toHaveLength(1) }) it('should list artifacts under a jobs API base given with a trailing slash', async () => { const transport = listing() const wrapper = mount(GeneratedPathInput, { global: { plugins: [createPinia()], stubs: { Teleport: true }, provide: { [GENERATED_JOBS_API_KEY as symbol]: { baseUrl: '/api/demo/', transport } }, }, }) await openPicker(wrapper as ReturnType) expect(transport.mock.calls[0]?.[0]).toBe('/api/demo/jobs/artifacts?experiment_id=42') }) it('should hide the artifact choice outside a generated jobs workspace', () => { const wrapper = mount(GeneratedPathInput, { global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) expect(wrapper.text()).not.toContain('Existing artifact') }) it('should show the selected artifact by name when the model holds one', () => { const wrapper = mount(GeneratedPathInput, { props: { modelValue: artifactSelection }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) expect(wrapper.get('.mint-generated-path-input__artifact').text()).toContain('matrix.csv') }) it('should clear the model when the selected artifact is removed', async () => { const wrapper = mount(GeneratedPathInput, { props: { modelValue: artifactSelection }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) await wrapper.get('button[aria-label="Remove matrix.csv"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toBeNull() }) it('should append a picked artifact to existing selections when multiple paths are requested', async () => { const file = new File(['a'], 'a.txt') const upload: PluginJobPathInput = { kind: 'path', files: [file], relativePaths: ['a.txt'] } const wrapper = mountWithJobsApi(listing(), { multiple: true, modelValue: [upload] }) await openPicker(wrapper) await wrapper.get('[role="option"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual([upload, artifactSelection]) }) it('should keep the selected artifact locked while the input is disabled', () => { const wrapper = mount(GeneratedPathInput, { props: { modelValue: artifactSelection, disabled: true }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) expect(wrapper.get('button[aria-label="Remove matrix.csv"]').attributes('disabled')).toBeDefined() }) })