import { test, expect, type Locator, type Page } from '@playwright/test'; import { build } from 'vite'; import { createServer, type Server } from 'node:http'; import { createRequire } from 'node:module'; import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudPendingOperation } from '../../shared/cloud-agents'; let server: Server; let fixtureDir: string; let baseUrl: string; const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-recovery.spec.ts')); const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); test.use({ headless: true, channel: undefined, video: 'off' }); // Exercise the real page and styles with Main IPC mocked. The static fixture // never starts Electron, a native window, a file picker, or a cloud service. test.beforeAll(async () => { fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-recovery-fixture-'))); await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); const html = join(fixtureDir, 'index.html'); const output = join(fixtureDir, 'dist'); const entry = resolve('tests/e2e/__cloud-agent-recovery-harness.tsx'); await writeFile(html, '
'); await build({ configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, css: { postcss: resolve('.') }, build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, plugins: [{ name: 'isolated-cloud-agent-recovery', enforce: 'pre', resolveId(id) { if (id === '/__cloud-agent-recovery-harness.tsx') return entry; }, load(id) { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; import ${JSON.stringify(resolve('src/styles/globals.css'))}; const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); createRoot(document.getElementById('root')).render(
); `; if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }'; }, }], }); const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; server = createServer(async (req, res) => { const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } try { res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(await readFile(file)); } catch { res.writeHead(404); res.end(); } }); await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); baseUrl = `http://127.0.0.1:${address.port}`; }); test.afterAll(async () => { try { if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } } finally { try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } } }); const agent: CloudAgentDraft = { slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '一起观察和记录。', system_prompt: '用简单的中文回答。', draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, published_version: 1, enabled: true, updated_at: '2026-10-13T00:00:00Z', }; const longName = ('观察小伙伴_' + 'SuperLongUnbrokenAgentName'.repeat(4)).slice(0, 100); const records: CloudPendingOperation[] = Array.from({ length: 8 }, (_, index) => ({ id: `e2e-create-record-${index + 1}`, operation: 'createAgent', input: { operation_id: `e2e-create-record-${index + 1}`, name: index === 0 ? longName : `我的观察伙伴 ${index + 1}`, purpose: '', icon: null }, created_at: `2026-10-13T0${8 - index}:30:00Z`, })); const retryError = '创建结果暂时无法确认,请稍后再试。'; type FixtureCall = { path: string; method: string; input: Record | null }; type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] }; type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture }; async function installFixture(page: Page, count = 2) { page.on('pageerror', error => { throw error; }); await page.addInitScript(({ agent, records, retryError }) => { const scope = window as unknown as FixtureWindow; const state: RecoveryFixture = { agents: [agent], pending: records, retry: 'fail', calls: [] }; scope.__recoveryFixture = state; const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); Object.defineProperty(window, 'electron', { value: { ipcRenderer: { invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { if (channel === 'hostapi:token') return 'fixture'; if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); const path = request?.path ?? ''; const method = request?.method ?? 'GET'; const input = request?.body ? JSON.parse(request.body) as Record : null; state.calls.push({ path, method, input }); if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: state.pending }); if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); if (path === '/api/cloud-agents/recovery/pending') { const record = state.pending.find(item => item.id === input?.id); if (!record) throw new Error('Recovery must keep the original pending id'); if (input?.discard) { state.pending = state.pending.filter(item => item.id !== record.id); return result({ discarded: true }); } if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError } } }; const created = { ...agent, slug: 'ml-' + 'b'.repeat(32), name: String(record.input.name), published_version: null }; state.agents.push(created); state.pending = state.pending.filter(item => item.id !== record.id); return result({ operation: 'createAgent', result: created }); } if (path.startsWith('/api/cloud-agents/agents')) { if (method !== 'GET') throw new Error('No new Agent may be created by reminder browsing'); return result(path === '/api/cloud-agents/agents' ? { agents: state.agents, next_cursor: null } : agent); } if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path ' + path); switch (input?.operation) { case 'access': return result({ published_version: 1, enabled: true, share_url: 'niancode://agents/' + agent.slug, api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [{ version: 1, draft_revision: 1 }] }); case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); case 'knowledge': return result({ databases: [], models: [] }); case 'threads': return result({ threads: [], next_offset: null }); default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); } }, } } }); }, { agent, records: records.slice(0, count), retryError }); } async function recoveryCalls(page: Page) { return page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls.filter(call => call.path === '/api/cloud-agents/recovery/pending')); } async function expectNoReminderOutsideDialog(page: Page) { await expect(page.getByRole('complementary', { name: '未确认的云端操作', exact: true })).toHaveCount(0); await expect(page.getByText('有 2 个操作的结果尚未确认', { exact: true })).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); } async function expectModalFits(dialog: Locator) { const geometry = await dialog.evaluate(element => { const box = element.getBoundingClientRect(); return { fits: box.left >= 0 && box.top >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, horizontalOverflow: element.scrollWidth > element.clientWidth }; }); expect(geometry).toEqual({ fits: true, horizontalOverflow: false }); } for (const width of [1440, 375]) { test(`creation records stay in the header and use a scrollable modal at ${width}px`, async ({ page }) => { const count = width === 375 ? 8 : 2; await page.setViewportSize({ width, height: 850 }); await installFixture(page, count); await page.goto(baseUrl); const home = page.getByTestId('cloud-agents-page'); const trigger = home.getByRole('button', { name: new RegExp(`^创建记录.*${count}`) }); await expect(trigger).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); expect(await trigger.evaluate(button => Boolean(button.closest('.agent-home-header')))).toBe(true); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); await expectNoReminderOutsideDialog(page); expect(await recoveryCalls(page)).toEqual([]); await page.screenshot({ path: test.info().outputPath(`recovery-home-${width}.png`), animations: 'disabled' }); await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); await expect(dialog).toBeVisible(); await expect(dialog).toContainText(longName); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); await expect(dialog.locator('.agent-recovery-time').first()).not.toHaveText(''); const close = dialog.getByRole('button', { name: '返回列表', exact: true }); await expect(close).toBeInViewport(); await expectModalFits(dialog); await dialog.locator('.agent-recovery-record').last().scrollIntoViewIfNeeded(); await expect(dialog.locator('.agent-recovery-record').last().getByRole('button', { name: '再试一次', exact: true })).toBeInViewport(); await expect(close).toBeInViewport(); await expectModalFits(dialog); expect(await recoveryCalls(page)).toEqual([]); await page.screenshot({ path: test.info().outputPath(`recovery-modal-${width}.png`), animations: 'disabled' }); await close.click(); await expect(dialog).toHaveCount(0); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); await close.click(); expect(await recoveryCalls(page)).toEqual([]); }); } test('retry keeps the same pending identity and errors remain inside the modal', async ({ page }) => { await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); const trigger = page.getByRole('button', { name: /^创建记录/ }); await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); for (let attempt = 0; attempt < 2; attempt++) { await dialog.getByRole('button', { name: '再试一次', exact: true }).first().click(); await expect(dialog.getByRole('alert')).toContainText(retryError); await expect(dialog.getByRole('button', { name: '再试一次', exact: true }).first()).toBeEnabled(); } expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: false }, { id: records[0].id, discard: false }]); await page.screenshot({ path: test.info().outputPath('recovery-retry-error-375.png'), animations: 'disabled' }); await dialog.getByRole('button', { name: '关闭记录', exact: true }).click(); await expect(dialog).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); await expectNoReminderOutsideDialog(page); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(2); expect(await recoveryCalls(page)).toHaveLength(2); }); test('discard is explicit and reminders do not follow into configuration, chat or creation', async ({ page }) => { await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); const trigger = page.getByRole('button', { name: /^创建记录/ }); await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); await dialog.getByRole('button', { name: '不再提醒', exact: true }).first().click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(1); expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: true }]); await dialog.getByRole('button', { name: '返回列表', exact: true }).click(); await expect(trigger).toContainText('1'); const card = page.getByRole('article', { name: agent.name, exact: true }); await card.getByRole('button', { name: '调整', exact: true }).click(); await expect(page.getByTestId('cloud-agent-editor')).toBeVisible(); await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); await page.getByRole('button', { name: '我的智能体', exact: true }).click(); await card.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); await page.getByRole('button', { name: '返回智能体列表', exact: true }).click(); await page.getByRole('button', { name: '创建智能体', exact: true }).click(); await expect(page.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); await page.getByRole('button', { name: '取消', exact: true }).click(); await expect(trigger).toContainText('1'); expect(await recoveryCalls(page)).toHaveLength(1); const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls); expect(calls.filter(call => call.path.startsWith('/api/cloud-agents/agents') && call.method !== 'GET')).toEqual([]); });