import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CloudAgentsModule } from '@electron/services/cloud-agents'; import { clearWorksSquareSession, storeWorksSquareSession } from '@electron/services/works-square-session'; const draft = { slug: 'ml-' + 'a'.repeat(32), name: '写作搭档', purpose: '帮助写作', system_prompt: '简明表达', draft_revision: 1, updated_at: '2026-09-10T00:00:00Z', }; const input = { operation_id: '12345678-1234-1234-1234-123456789012', name: draft.name, purpose: draft.purpose }; const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); const session = () => json({ access_token: 'yuxi-secret', token_type: 'bearer', scope: 'makelore-agent-drafts', expires_at: Math.floor(Date.now() / 1000) + 120, api_base_url: 'https://agents.example.test', }); function login(key = 'a'.repeat(64)) { storeWorksSquareSession({ accessToken: 'ws-secret', expiresAt: Date.now() + 600000, accountPartitionKey: key }); } const instances: CloudAgentsModule[] = []; function moduleFor(fetchImpl: typeof fetch) { const module = new CloudAgentsModule(fetchImpl); instances.push(module); return module; } beforeEach(() => { clearWorksSquareSession(); login(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); describe('Main cloud Agents boundary', () => { it('keeps credentials in Main, projects the DTO and sends only creator-independent inputs', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()) .mockResolvedValueOnce(json({ ...draft, access_token: 'do-not-project', owner: 'private' })) .mockResolvedValueOnce(json({ agents: [draft], next_cursor: null })); const module = moduleFor(fetchImpl); expect(await module.create({ ...input, payer: 'other', account_id: 'other' })).toEqual(draft); expect(await module.list()).toEqual({ agents: [draft], next_cursor: null }); expect(fetchImpl).toHaveBeenCalledTimes(3); expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Bearer ws-secret'); expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer yuxi-secret'); expect(JSON.parse(fetchImpl.mock.calls[1][1].body)).toEqual(input); expect(fetchImpl.mock.calls[1][1].redirect).toBe('error'); }); it('rejects the old account response after switching accounts', async () => { let finish!: (response: Response) => void; const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); const result = moduleFor(fetchImpl).list(); const rejection = expect(result).rejects.toMatchObject({ code: 'account_changed' }); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); login('b'.repeat(64)); finish(json({ agents: [draft], next_cursor: null })); await rejection; }); it('does not automatically repeat a possibly committed create and preserves an explicit retry operation', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response ws-secret')) .mockResolvedValueOnce(json(draft)); const module = moduleFor(fetchImpl); await expect(module.create(input)).rejects.toMatchObject({ code: 'cloud_service_unavailable' }); expect(fetchImpl).toHaveBeenCalledTimes(2); expect(await module.create(input)).toEqual(draft); expect(fetchImpl.mock.calls[2][1].body).toBe(fetchImpl.mock.calls[1][1].body); }); it('passes revision conflicts without exposing upstream diagnostics', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code: 'draft_revision_conflict', message: 'raw yuxi-secret' }, }, 409)); await expect(moduleFor(fetchImpl).save(draft.slug, { expected_revision: 1, name: draft.name, purpose: draft.purpose, system_prompt: 'new', })).rejects.toMatchObject({ status: 409, code: 'draft_revision_conflict', message: '草稿已在其他设备更新,你的输入已保留' }); }); it('rejects malformed draft responses as service failures', async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, draft_revision: 'oops' })); await expect(moduleFor(fetchImpl).get(draft.slug)).rejects.toMatchObject({ status: 502 }); }); it('rejects missing login before any network request', async () => { clearWorksSquareSession(); const fetchImpl = vi.fn(); await expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ status: 401 }); expect(fetchImpl).not.toHaveBeenCalled(); }); it('times out a stalled response body and projects a safe error', async () => { vi.useFakeTimers(); const fetchImpl = vi.fn().mockResolvedValueOnce({ text: () => new Promise(() => {}) }); const result = expect(moduleFor(fetchImpl).list()).rejects.toMatchObject({ code: 'cloud_service_unavailable' }); await vi.advanceTimersByTimeAsync(30000); await result; }); });