// @vitest-environment jsdom import { act, useState, type ComponentType } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, expect, it, vi } from 'vitest' import { ApiError, caller, usePoll } from '../src/client/api.ts' import { apply } from '../src/client/index.tsx' import { zh } from '../src/client/locales.ts' import { SettingsSchema } from '../src/policy.ts' declare global { var IS_REACT_ACT_ENVIRONMENT: boolean } globalThis.IS_REACT_ACT_ENVIRONMENT = true type Entry = { options: { name: string; id?: string; key?: string; order?: number; locale?: string; label?: () => string }; component: ComponentType> } const t = ((key: keyof typeof zh) => zh[key] ?? key) as never function fixture(listed: string[]) { const entries: Entry[] = [] const dictionaries: Record = {} const ctx = { slots: { inject: (_name: string, register: () => void) => register(), register: (options: Entry['options'], component: Entry['component']) => { entries.push({ options, component }); return () => {} } }, locale: { register: (ns: string, value: unknown) => { dictionaries[ns] = value; return () => {} }, bind: () => t }, effect: (effect: () => unknown) => { effect() }, sessions: { list: { getSnapshot: () => ({ phase: 'ready', byId: Object.fromEntries(listed.map(id => [id, {}])) }) }, refresh: vi.fn(async () => {}) }, uiWorkspace: { openSession: vi.fn() }, // Omit config form service so Deployment renders read-only. get: () => undefined, } apply(ctx as never) const entry = (name: string) => entries.find(item => item.options.name === name)! return { ctx, entries, dictionaries, main: entry('main'), nav: entry('sidebar.panellist') } } async function render(entry: Entry, props: Record) { const container = document.createElement('div'); document.body.append(container) const root = createRoot(container) await act(async () => { root.render() }) for (let i = 0; i < 5; i++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) return { container, dispose: () => { act(() => { root.unmount() }); container.remove() } } } const openMaster = async (container: HTMLElement) => { const button = [...container.querySelectorAll('button')].find(item => item.textContent === zh.openMaster)! await act(async () => { button.click() }) for (let i = 0; i < 5; i++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) } const serve = () => vi.stubGlobal('fetch', vi.fn(async (url: string) => { const path = new URL(url, 'http://127.0.0.1').pathname.replace('/orchestrator/api/', '') if (path === 'overview') return Response.json({ problems: [], liveness: { startedAt: null, uptimeMs: null, lastPollAt: null, pollAgeMs: null, polling: false }, person: [], executor: { running: 0, queued: 0, maxWorkers: 3, idle: 0 }, flow: { triggers: 0, filtered: 0, judged: 0, accepted: 0, evaluating: 0 }, policyVersion: 'v' }) if (path === 'state') return Response.json({ masterId: 'master', settings: SettingsSchema.parse({}), revisions: { policy: 0, eval: 0, executor: 0 }, sources: [], policy_version: 'v', health: {}, outgoing: [], load: { running: 0, queued: 0 }, setup: { machine: { stateDir: '/srv/state', worktreeRoot: '/srv/state/worktrees', blockedCommands: [], promptMaxChars: 100000, admissionContextMaxPages: 10, admissionContextTimeoutMs: 15000 }, problem: null, feishuProblem: null, accounts: null } }) return Response.json({ error: 'unavailable' }, { status: 503 }) })) afterEach(() => { vi.unstubAllGlobals() }) it('registers a localized main panel, sidebar entry and Plugin Manager row page, and no settings section', () => { const { entries, dictionaries, main, nav } = fixture([]) expect(entries.map(entry => entry.options.name)).toEqual(['main', 'sidebar.panellist', 'plugins.row.config']) expect(entries[2]!.options).toMatchObject({ key: 'dsh-superagent#dsh-superagent', locale: 'dshSuperagent' }) expect(Object.keys(dictionaries)).toEqual(['dshSuperagent']) expect(main.options).toMatchObject({ key: 'dsh-superagent', locale: 'dshSuperagent' }) expect(nav.options).toMatchObject({ id: 'dsh-superagent', order: 10, locale: 'dshSuperagent' }) expect(nav.options.label?.()).toBe('Superagent') }) it('opens a listed Session through the workspace from the main panel', async () => { serve() const { ctx, main } = fixture(['master']) const view = await render(main, { t }) await openMaster(view.container) expect(ctx.uiWorkspace.openSession).toHaveBeenCalledExactlyOnceWith('master') view.dispose() }) it('reports a Session missing from the catalog in the interface language', async () => { serve() const { ctx, main } = fixture([]) const view = await render(main, { t }) await openMaster(view.container) expect(ctx.uiWorkspace.openSession).not.toHaveBeenCalled() expect(document.body.textContent).toContain(zh.sessionNotListed) view.dispose() }) it('polls one request at a time, keeps the data while new dependencies load, and polls less while hidden', async () => { vi.useFakeTimers() const calls: { limit: number; done: (value: string[]) => void }[] = [] let shown: { data?: string[]; limit: number } = { limit: 0 } let setLimit = (_limit: number) => {} function Harness() { const [limit, update] = useState(1) setLimit = update const { data } = usePoll(() => new Promise(done => { calls.push({ limit, done }) }), [limit], 1000) shown = { ...data ? { data } : {}, limit } return null } const container = document.createElement('div') const root = createRoot(container) await act(async () => { root.render() }) expect(calls).toHaveLength(1) // A slow response holds the next poll back instead of overlapping. await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) expect(calls).toHaveLength(1) await act(async () => { calls[0]!.done(['a']) }) expect(shown.data).toEqual(['a']) await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) expect(calls).toHaveLength(2) await act(async () => { setLimit(2) }) expect(shown).toEqual({ data: ['a'], limit: 2 }) await act(async () => { calls[2]!.done(['a', 'b']) }) expect(shown.data).toEqual(['a', 'b']) // A late response to the superseded request must not replace the newer result. await act(async () => { calls[1]!.done(['stale']) }) expect(shown.data).toEqual(['a', 'b']) const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true) await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) await act(async () => { calls[3]!.done(['a', 'b']) }) await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) expect(calls).toHaveLength(4) hidden.mockRestore() await act(async () => { document.dispatchEvent(new Event('visibilitychange')) }) expect(calls).toHaveLength(5) act(() => { root.unmount() }) vi.useRealTimers() }) it('tells a dsh-superagent that does not answer from a record it does not have, and words each failure by what failed', async () => { const messages = { offline: 'offline', notRunning: 'not running', serverError: (status: number) => `server ${status}`, requestFailed: (status: number) => `request ${status}`, setup: () => 'setup', user: ({ code }: { code: string }) => `user ${code}` } const call = caller(() => messages) const failure = async (answer: () => Promise) => { vi.stubGlobal('fetch', vi.fn(answer)) const error = await call('GET tickets/:id', { id: 't1' }).then(() => undefined, (thrown: unknown) => thrown) expect(error).toBeInstanceOf(ApiError) const { message, status, missing, details } = error as ApiError return { message, status, missing, unreachable: !!details.unreachable } } expect(await failure(async () => { throw new TypeError('Failed to fetch') })).toEqual({ message: 'offline', status: 0, missing: false, unreachable: true }) // DSH's web server answers an empty 404 while dsh-superagent's routes are unregistered. expect(await failure(async () => new Response(null, { status: 404 }))).toEqual({ message: 'not running', status: 404, missing: false, unreachable: true }) expect(await failure(async () => new Response(null, { status: 502 }))).toEqual({ message: 'server 502', status: 502, missing: false, unreachable: true }) expect(await failure(async () => new Response('Bad request', { status: 400 }))).toEqual({ message: 'request 400', status: 400, missing: false, unreachable: false }) expect(await failure(async () => Response.json({ error: 'Unknown ticket', code: 'unknownTicket' }, { status: 404 }))).toEqual({ message: 'user unknownTicket', status: 404, missing: true, unreachable: false }) })