diff --git a/apps/web/package.json b/apps/web/package.json index 2bab9e262..75a4ca003 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "@unocss/preset-wind3": "^66.5.10", "@unocss/reset": "^66.5.10", "@vitejs/plugin-vue": "^6.0.1", + "@vue/test-utils": "^2.4.11", "happy-dom": "^20.0.0", "unocss": "^66.5.10", "unocss-preset-animations": "^1.3.0", diff --git a/apps/web/src/api/test-fixtures.ts b/apps/web/src/api/test-fixtures.ts new file mode 100644 index 000000000..bfb3ef46a --- /dev/null +++ b/apps/web/src/api/test-fixtures.ts @@ -0,0 +1,36 @@ +// Test-only fixtures that satisfy the `@floway-dev/protocols/common` PublicModel +// shape. Production rows always carry every required field — see the gateway's +// `toPublicModel` and `toControlPlaneModel` — but tests want to fan out partials +// without retyping `object` / `type` / `display_name` / `limits` / `endpoints` +// every time. The factories merge `over` last so any field the test sets wins. + +import type { ControlPlaneModel } from './types.ts'; + +const baseFields = (): Omit => ({ + object: 'model', + type: 'model', + display_name: '', + limits: {}, + kind: 'chat', + endpoints: { chatCompletions: {} }, +}); + +export const buildRealModel = (over: Partial & { id: string }): ControlPlaneModel => ({ + ...baseFields(), + upstreams: [{ id: 'u1', name: 'U1', kind: 'custom' }], + ...over, +}); + +export const buildAliasModel = (over: Partial & { id: string }): ControlPlaneModel => ({ + ...baseFields(), + upstreams: [], + aliasedFrom: { name: over.id, kind: 'chat', selection: 'first-available', targets: [] }, + ...over, +}); + +export const buildUnlistedModel = (over: Partial & { id: string }): ControlPlaneModel => ({ + ...baseFields(), + upstreams: [{ id: 'u1', name: 'U1', kind: 'custom' }], + unlisted: true, + ...over, +}); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 088891e1b..80371f289 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -1,16 +1,30 @@ // Control-plane DTOs the SPA consumes — serialized shapes the gateway emits at /api. import type { + AliasKind, + AliasRules, + AliasSelection, + AliasTarget, + AnnouncedMetadata, BillingDimension, + ChatAliasRules, + ChatModelInfo, + ModelAlias, ModelEndpointKey, ModelEndpoints, ModelKind, ModelPricing, + PublicModel, + PublicModelLimits, } from '@floway-dev/protocols/common'; import type { AddressableForm, ModelPrefixConfig } from '@floway-dev/provider/model-prefix'; export type { BillingDimension, ModelEndpointKey, ModelEndpoints, ModelKind, ModelPricing }; export type { AddressableForm, ModelPrefixConfig }; +export type { + AliasKind, AliasRules, AliasSelection, AliasTarget, AnnouncedMetadata, ChatAliasRules, ChatModelInfo, ModelAlias, + PublicModel, PublicModelLimits, +}; export type UpstreamProviderKind = 'custom' | 'azure' | 'copilot' | 'codex' | 'claude-code' | 'ollama'; @@ -38,7 +52,7 @@ export interface UpstreamModelConfig { kind: ModelKind; endpoints: ModelEndpoints; display_name?: string; - limits?: ModelLimits; + limits?: PublicModelLimits; cost?: ModelPricing; flagOverrides?: { enabled: boolean; values: Record }; chat?: UpstreamChatConfig; @@ -57,7 +71,7 @@ export interface CustomRawModel { name?: string; created?: number; owned_by?: string; - limits?: ModelLimits; + limits?: PublicModelLimits; cost?: ModelPricing; kind?: ModelKind; } @@ -322,26 +336,6 @@ export interface ApiKey { dump_retention_seconds: number | null; } -export interface ModelEndpointInfo { - url: string; - doc?: string; -} - -export interface ModelLimits { - max_context_window_tokens?: number; - max_prompt_tokens?: number; - max_output_tokens?: number; -} - -export interface PublicModel { - id: string; - display_name?: string; - limits?: ModelLimits; - endpoints?: Record; - cost?: ModelPricing; - kind?: ModelKind; -} - export interface ControlPlaneModel extends PublicModel { upstreams: { kind: UpstreamProviderKind; id: string; name: string }[]; } diff --git a/apps/web/src/components/alias-edit/AliasEditDialog.vue b/apps/web/src/components/alias-edit/AliasEditDialog.vue new file mode 100644 index 000000000..3a586514a --- /dev/null +++ b/apps/web/src/components/alias-edit/AliasEditDialog.vue @@ -0,0 +1,363 @@ + + + diff --git a/apps/web/src/components/alias-edit/AliasEditDialog_test.ts b/apps/web/src/components/alias-edit/AliasEditDialog_test.ts new file mode 100644 index 000000000..7757d0475 --- /dev/null +++ b/apps/web/src/components/alias-edit/AliasEditDialog_test.ts @@ -0,0 +1,268 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, ref } from 'vue'; + +import { buildRealModel } from '../../api/test-fixtures.ts'; +import type { ControlPlaneModel, ModelAlias } from '../../api/types.ts'; + +// Mock the API client + composables so the dialog mounts without hitting the +// network. The composables expose `ref`-based state — return the same shape +// so the dialog reads the catalog and the alias list directly off these +// stubs. +const aliasesRef = ref([]); +const modelsRef = ref([]); +const postSpy = vi.fn(async (_arg: unknown) => new Response(JSON.stringify({}), { status: 201 })); +const putSpy = vi.fn(async (_arg: unknown) => new Response(JSON.stringify({}), { status: 200 })); + +vi.mock('../../composables/useModelAliases.ts', () => ({ + useModelAliases: () => ({ aliases: aliasesRef, loading: ref(false), error: ref(null), load: async () => {} }), +})); +vi.mock('../../composables/useModels.ts', () => ({ + useRawModelsStore: () => ({ models: modelsRef, loading: ref(false), error: ref(null), load: async () => {} }), +})); +vi.mock('../../api/client.ts', () => ({ + useApi: () => ({ + api: { + aliases: { + $post: (arg: unknown) => postSpy(arg), + ':name': { $put: (arg: unknown) => putSpy(arg) }, + }, + }, + }), + callApi: async (fn: () => Promise) => { + const res = await fn(); + if (!res.ok) return { error: { status: res.status, message: 'mock-error' } }; + return { data: (await res.json()) as T }; + }, + authFetch: vi.fn(), +})); + +// Import after mocks are registered. +const { default: AliasEditDialog } = await import('./AliasEditDialog.vue'); + +const realModel = (id: string, display?: string): ControlPlaneModel => + buildRealModel(display !== undefined ? { id, display_name: display } : { id }); + +const baseAlias = (over: Partial & { name: string }): ModelAlias => ({ + kind: 'chat', + selection: 'first-available', + display_name: null, + visible_in_models_list: true, + targets: [{ target_model_id: 'gpt-5', rules: {} }], + announced_metadata: null, + sort_order: 0, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...over, +}); + +// Reka-UI's DialogPortal teleports content out of the wrapper. Read the +// portal-rooted DOM by scanning document.body directly. +const portalText = () => document.body.textContent ?? ''; +const portalQuery = (selector: string): T | null => document.body.querySelector(selector); +const portalQueryAll = (selector: string): T[] => Array.from(document.body.querySelectorAll(selector)); + +beforeEach(() => { + aliasesRef.value = []; + modelsRef.value = [realModel('gpt-5', 'GPT 5'), realModel('claude')]; + postSpy.mockClear(); + putSpy.mockClear(); +}); + +afterEach(() => { + // Reka-UI portals append to document.body; clear them between tests so + // subsequent assertions don't see stale content. + document.body.innerHTML = ''; +}); + +describe('AliasEditDialog', () => { + it('starts create mode with one blank target row and seeds the form fields', async () => { + const w = mount(AliasEditDialog, { props: { open: true, record: null }, attachTo: document.body }); + await nextTick(); + expect(portalQueryAll('[aria-label="Toggle target row"]')).toHaveLength(1); + const inputs = portalQueryAll('input[type="text"]'); + expect(inputs[0].value).toBe(''); + w.unmount(); + }); + + it('"Add target" appends a row', async () => { + const w = mount(AliasEditDialog, { props: { open: true, record: null }, attachTo: document.body }); + await nextTick(); + expect(portalQueryAll('[aria-label="Toggle target row"]')).toHaveLength(1); + const addBtn = portalQueryAll('button').find(b => b.textContent?.trim() === 'Add target')!; + addBtn.click(); + await nextTick(); + expect(portalQueryAll('[aria-label="Toggle target row"]')).toHaveLength(2); + w.unmount(); + }); + + it('expands the chat rule body for chat aliases; the row toggle is disabled for non-chat aliases', async () => { + const chat = mount(AliasEditDialog, { + props: { open: true, record: baseAlias({ name: 'a', targets: [{ target_model_id: 'gpt-5', rules: { reasoning: { effort: 'low' } } }] }) }, + attachTo: document.body, + }); + await nextTick(); + portalQuery('button[aria-label="Toggle target row"]')!.click(); + await nextTick(); + expect(portalText()).toContain('Reasoning effort'); + chat.unmount(); + document.body.innerHTML = ''; + + const embed = mount(AliasEditDialog, { + props: { open: true, record: baseAlias({ name: 'e', kind: 'embedding', targets: [{ target_model_id: 'embed-1', rules: {} as never }] }) }, + attachTo: document.body, + }); + await nextTick(); + const toggle = portalQuery('button[aria-label="Toggle target row"]')!; + expect(toggle.disabled).toBe(true); + expect(portalText()).not.toContain('Reasoning effort'); + embed.unmount(); + }); + + it('Save is disabled on empty name and on collision with another alias; enabled once the name is unique', async () => { + aliasesRef.value = [baseAlias({ name: 'existing' })]; + // Seed the edit dialog with a valid target so the only validation knob + // under test is the alias name (the borderless combobox in the target + // row doesn't surface a plain HTMLInput we can drive from the test). + const w = mount(AliasEditDialog, { + props: { + open: true, + record: baseAlias({ name: '', targets: [{ target_model_id: 'gpt-5', rules: {} }] }), + }, + attachTo: document.body, + }); + await nextTick(); + + const saveBtn = portalQueryAll('button').find(b => b.textContent?.trim() === 'Save')!; + expect(saveBtn.disabled).toBe(true); + + const nameInput = portalQueryAll('input[type="text"]')[0]; + nameInput.value = 'existing'; + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + expect(saveBtn.disabled).toBe(true); + + nameInput.value = 'fresh'; + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + expect(saveBtn.disabled).toBe(false); + + w.unmount(); + }); + + it('renders the shadow warning card when the alias name collides with a real model and no target references it', async () => { + const w = mount(AliasEditDialog, { props: { open: true, record: null }, attachTo: document.body }); + await nextTick(); + + const nameInput = portalQueryAll('input[type="text"]')[0]; + nameInput.value = 'gpt-5'; + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + + expect(portalText()).toContain('shadows a real model id'); + expect(document.body.innerHTML).toContain('GPT 5'); + w.unmount(); + }); + + // ── Announced metadata section ──────────────────────────────────────── + + // The section header always renders for chat/embedding; the body only + // renders the editor when the "Manual" switch is on. Image + // aliases never see the section at all. + + const expandAnnouncedSection = async () => { + const header = portalQueryAll('button').find(b => (b.textContent ?? '').includes('Announced metadata'))!; + header.click(); + await nextTick(); + }; + + const announcedSwitch = (): HTMLButtonElement => { + // The override switch sits at the right end of the section header + // row. Reka-UI renders Switch as a