221 lines
15 KiB
TypeScript
221 lines
15 KiB
TypeScript
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, '<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-recovery-harness.tsx"></script></body></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: <CloudAgents /> }], { initialEntries: ['/cloud-agents?view=mine'] });
|
|
createRoot(document.getElementById('root')).render(<div className="h-screen min-w-0"><RouterProvider router={router} /></div>);
|
|
`;
|
|
if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }';
|
|
},
|
|
}],
|
|
});
|
|
const mime: Record<string, string> = { 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<void>((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<void>((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<string, unknown> | 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<string, unknown> : 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([]);
|
|
});
|