fix(home): align stacked module entries with creation title

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 13:18:47 +08:00
1 parent 45e6b91715
commit 383ce71faf
11 files changed
+719 -110

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
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([]);
});
+27 -31
View File
@@ -314,7 +314,7 @@ test.describe('Makelore module navigation without setup flow', () => {
}
});
test('lays out three module cards in a desktop row and a compact mobile stack', async ({ launchElectronApp }) => {
test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
@@ -343,9 +343,7 @@ test.describe('Makelore module navigation without setup flow', () => {
imageWidth: image?.width ?? 0,
imageHeight: image?.height ?? 0,
imageLeft: image?.left ?? 0,
imageTop: image?.top ?? 0,
contentLeft: content?.left ?? 0,
contentTop: content?.top ?? 0,
};
}));
@@ -355,47 +353,45 @@ test.describe('Makelore module navigation without setup flow', () => {
BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600);
});
for (const width of [1440, 1024, 768]) {
await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, 900), width);
for (const width of [1440, 1024, 768, 375]) {
await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width);
await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width);
const cards = await readCardMetrics();
expect(cards).toHaveLength(3);
expect(Math.max(...cards.map(card => card.top)) - Math.min(...cards.map(card => card.top))).toBeLessThan(1);
expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1);
expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1);
const gaps = cards.slice(1).map((card, index) => card.left - cards[index]!.left - cards[index]!.width);
expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1);
const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height);
expect(Math.min(...gaps)).toBeGreaterThan(0);
expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1);
for (const card of cards) {
expect(card.left).toBeGreaterThanOrEqual(0);
expect(card.left + card.width).toBeLessThanOrEqual(width);
expect(card.contentTop).toBeGreaterThanOrEqual(card.imageTop + card.imageHeight - 1);
expect(card.imageWidth).toBeLessThan(card.width / 2);
expect(card.imageHeight).toBeGreaterThan(card.height * 0.75);
expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1);
}
await expect(page.getByRole('heading', { name: '今天,想创作什么?' })).toHaveCSS('text-align', 'center');
expect(await moduleCards.evaluateAll(elements => elements.every(element => {
const bounds = element.getBoundingClientRect();
return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) =>
element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)),
);
}))).toBe(true);
const heading = await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).boundingBox();
expect(heading).not.toBeNull();
if (width >= 1024) {
expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left);
expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1);
expect(await moduleCards.evaluateAll(elements => elements.every(element => {
const bounds = element.getBoundingClientRect();
return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) =>
element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)),
);
}))).toBe(true);
} else {
expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements =>
elements.every(element => element.scrollWidth <= element.clientWidth),
)).toBe(true);
await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) });
}
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(375, 800));
await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375);
const compactCards = await readCardMetrics();
expect(Math.max(...compactCards.map(card => card.left)) - Math.min(...compactCards.map(card => card.left))).toBeLessThan(1);
for (const [index, card] of compactCards.entries()) {
if (index > 0) expect(card.top).toBeGreaterThan(compactCards[index - 1]!.top + compactCards[index - 1]!.height);
expect(card.imageWidth).toBeLessThan(card.width / 2);
expect(card.imageHeight).toBeGreaterThan(card.height * 0.75);
expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements =>
elements.every(element => element.scrollWidth <= element.clientWidth),
)).toBe(true);
await page.screenshot({ path: test.info().outputPath('module-entry-375.png') });
await moduleCards.first().hover();
await moduleCards.first().focus();
await expect.poll(async () => await moduleCards.first().evaluate((element) => {
+104 -22
View File
@@ -575,9 +575,13 @@ it('restores the exact previous thread and presents an uncertain operation witho
expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument();
expect(api.resolvePending).not.toHaveBeenCalled();
expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false);
expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ }));
await screen.findByRole('dialog', { name: '待处理记录' });
api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } });
api.recovery.mockResolvedValue({ recent: null, pending: [] });
fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' }));
fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await screen.findByText('发送消息的结果已确认。');
expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false);
});
@@ -605,7 +609,8 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla
input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] });
api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' });
open();
fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' }));
fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ }));
fireEvent.click(await screen.findByRole('button', { name: '再试一次' }));
await screen.findByText(/这次微信验证还需要验证码/);
expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument();
expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false);
@@ -616,11 +621,12 @@ it('refreshes recovery after a definitive rejection without presenting a confirm
api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] });
api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' }));
open();
fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' }));
fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ }));
fireEvent.click(await screen.findByRole('button', { name: '再试一次' }));
expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用');
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('button', { name: '重试并确认结果' })).not.toBeInTheDocument();
expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument();
expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument();
expect(screen.queryByText('查看结果')).not.toBeInTheDocument();
expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false);
expect(api.create).not.toHaveBeenCalled();
@@ -631,12 +637,13 @@ it('keeps an old recovery record retryable when a parameter response cannot dete
api.recovery.mockResolvedValue({ recent: null, pending: [pending] });
api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' }));
open();
fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' }));
fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ }));
fireEvent.click(await screen.findByRole('button', { name: '再试一次' }));
await screen.findByRole('alert');
expect(screen.getByRole('button', { name: '重试并确认结果' })).toBeEnabled();
expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '再试一次' })).toBeEnabled();
expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument();
expect(api.recovery).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' }));
fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]);
expect(api.create).not.toHaveBeenCalled();
@@ -648,13 +655,84 @@ it('offers only explicit discard for a recovery record whose channel operation w
.mockResolvedValue({ recent: null, pending: [] });
api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', discarded: true, retired: true });
open();
fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ }));
expect(await screen.findByText(/旧版联系人权限操作(已移除)/)).toBeVisible();
expect(screen.queryByRole('button', { name: '重试并确认结果' })).toBeNull();
expect(screen.queryByRole('button', { name: '再试一次' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' }));
await screen.findByText(/旧版联系人权限操作已移除/);
expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false);
});
it('keeps historic creation records out of the new form while showing the current creation error inline', async () => {
api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:old', operation: 'createAgent',
input: { operation_id: 'original-old', name: '之前的失败创建' }, created_at: '2026-10-13T03:00:00Z' }] });
api.create.mockRejectedValue(new Error('本次创建失败,请重新检查'));
open();
expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1');
expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument();
expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '创建智能体', exact: true }));
expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: '创建记录' })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的搭档' } });
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('本次创建失败,请重新检查');
expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument();
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('places the records entry only in the list header and preserves it when returning from configuration or chat', async () => {
publishedAssistant(4);
api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:historic', operation: 'createAgent',
input: { operation_id: 'same-historic', name: '旧记录搭档' }, created_at: '2026-10-13T03:00:00Z' }] });
open();
const trigger = await screen.findByRole('button', { name: /^创建记录/ });
expect(trigger.closest('header')).not.toBeNull();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '调整', exact: true }));
await screen.findByTestId('cloud-agent-editor');
expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument();
expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true }));
expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1');
fireEvent.click(screen.getByRole('button', { name: '和它聊聊', exact: true }));
await screen.findByLabelText('消息');
expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument();
expect(screen.queryByText(/有 1 个操作的结果尚未确认/)).not.toBeInTheDocument();
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('retains an unclosed key through internal configuration navigation and clears it across account changes', async () => {
publishedAssistant(4);
const secret = 'configuration-navigation-key-fixture';
const pending = { id: 'key:historical', operation: 'createKey', input: { application_id: 'fixture-application', operation_id: 'original-key' }, created_at: '2026-10-13T03:00:00Z' };
api.recovery.mockResolvedValue({ recent: null, pending: [pending] });
api.resolvePending.mockImplementation(async () => {
api.recovery.mockResolvedValue({ recent: null, pending: [] });
return { result: { secret } };
});
const view = open();
fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ }));
fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await screen.findByText(new RegExp(secret));
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true }));
await screen.findByTestId('cloud-agent-editor');
expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument();
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true }));
fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ }));
expect(await screen.findByText(new RegExp(secret))).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
auth.user.userId = 'different-records-account';
view.rerender(<RouterProvider key="different-records-account" router={view.router} />);
await screen.findByRole('button', { name: '调整', exact: true });
expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument();
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
});
it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => {
const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' };
api.list.mockResolvedValue({ agents: [], next_cursor: null });
@@ -730,15 +808,18 @@ it('preserves the creation identity for unknown command outcomes even when the e
await screen.findByRole('button', { name: '更换头像' });
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果');
await screen.findByText('有 1 个操作的结果尚未确认');
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
expect(retainedPending).toHaveLength(1);
expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument();
expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument();
expect(screen.getByLabelText('名称')).toBeDisabled();
expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled();
expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试创建' }));
await screen.findByTestId('cloud-agent-editor');
expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]);
await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument());
expect(api.recovery).toHaveBeenCalledTimes(3);
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3));
expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] });
expect(api.uploadAvatar).toHaveBeenCalledTimes(1);
});
@@ -747,11 +828,6 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret
let retainedPending: unknown[] = [];
api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending }));
api.uploadAvatar.mockResolvedValue(avatar);
api.resolvePending.mockImplementation(async (_id, discard) => {
expect(discard).toBe(true);
retainedPending = [];
return { discarded: true };
});
api.create.mockImplementationOnce(async input => {
retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }];
throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' });
@@ -763,12 +839,18 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await screen.findByRole('button', { name: '更换头像' });
fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true }));
await screen.findByText('有 1 个操作的结果尚未确认');
fireEvent.click(screen.getByRole('button', { name: '移除提醒', exact: true }));
await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument());
expect(api.resolvePending).toHaveBeenCalledWith('create:discarded', true);
await screen.findByRole('alert');
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
expect(retainedPending).toHaveLength(1);
// Clearing the journal independently must not change the still-mounted form's uncertainty.
// The records entry is now confined to the list, and explicit discard is tested there.
retainedPending = [];
expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true }));
expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝');
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3));
expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] });
expect(api.resolvePending).not.toHaveBeenCalled();
expect(screen.getByLabelText('名称')).toBeDisabled();
expect(screen.getByLabelText('名称')).toHaveValue('原搭档');
expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled();
+209
View File
@@ -0,0 +1,209 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery';
import { AppError } from '@/lib/error-model';
import type { CloudPendingOperation } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ resolvePending: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' };
const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' };
const key: CloudPendingOperation = { id: 'key:original', operation: 'createKey', input: { application_id: 'application', operation_id: 'original-key', name: '应用密钥' }, created_at: '2026-10-13T04:00:00Z' };
const resolved = vi.fn();
beforeEach(() => { vi.resetAllMocks(); });
afterEach(cleanup);
async function openRecords() {
fireEvent.click(screen.getByRole('button', { name: /^(创建记录|待处理记录)/ }));
return await screen.findByRole('dialog');
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(yes => { resolve = yes; });
return { promise, resolve };
}
it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => {
render(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
expect(screen.getByRole('button', { name: /^创建记录/ })).toHaveTextContent('2');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument();
expect(screen.queryByRole('article', { name: '创建「故事搭档」' })).not.toBeInTheDocument();
expect(screen.queryByText(/有 2 个操作的结果尚未确认/)).not.toBeInTheDocument();
const dialog = await openRecords();
expect(within(dialog).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible();
expect(within(dialog).getByRole('article', { name: '创建「阅读伙伴」' })).toBeVisible();
expect(within(dialog).getAllByRole('button', { name: '再试一次', exact: true })).toHaveLength(2);
expect(within(dialog).getAllByRole('button', { name: '不再提醒', exact: true })).toHaveLength(2);
expect(api.resolvePending).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled();
});
it('uses a general records entry for mixed operations and keeps the original message visible only inside the dialog', async () => {
const message = { ...second, id: 'message:original', operation: 'submit', input: { query: '我的上次问题' } };
render(<CloudRecovery pending={[first, message]} onResolved={resolved} />);
expect(screen.getByRole('button', { name: /^待处理记录/ })).toHaveTextContent('2');
expect(screen.queryByText('我的上次问题')).not.toBeInTheDocument();
const dialog = await openRecords();
expect(within(dialog).getByText('我的上次问题')).toBeVisible();
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('keeps failed records and retries the same record ID after closing and reopening the dialog', async () => {
api.resolvePending.mockRejectedValueOnce(new Error('原操作仍未确认')).mockResolvedValueOnce({ result: { slug: 'original-agent' } });
render(<CloudRecovery pending={[first]} onResolved={resolved} />);
const dialog = await openRecords();
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert')).toHaveTextContent('原操作仍未确认');
expect(resolved).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
const reopened = await openRecords();
expect(within(reopened).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible();
fireEvent.click(within(reopened).getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(resolved).toHaveBeenCalledOnce());
expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('removes only the explicitly chosen local reminder and never implicitly retries it', async () => {
api.resolvePending.mockResolvedValue({ discarded: true });
const view = render(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
const dialog = await openRecords();
fireEvent.click(within(dialog).getAllByRole('button', { name: '不再提醒' })[1]);
await waitFor(() => expect(resolved).toHaveBeenCalledOnce());
expect(api.resolvePending.mock.calls).toEqual([[second.id, true]]);
view.rerender(<CloudRecovery pending={[first]} onResolved={resolved} />);
expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible();
expect(screen.queryByRole('article', { name: '创建「阅读伙伴」' })).not.toBeInTheDocument();
});
it('serializes double clicks and other actions and prevents dismissal until a request finishes', async () => {
const response = deferred<{ result: { slug: string } }>();
api.resolvePending.mockReturnValue(response.promise);
render(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
const dialog = await openRecords();
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
const retry = within(dialog).getAllByRole('button', { name: '再试一次' })[0];
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(api.resolvePending).toHaveBeenCalledTimes(1);
expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled();
for (const discard of within(dialog).getAllByRole('button', { name: '不再提醒' })) expect(discard).toBeDisabled();
fireEvent.keyDown(dialog, { key: 'Escape' });
fireEvent.pointerDown(document.body, { button: 0, pointerType: 'mouse' });
expect(screen.getByRole('dialog')).toBeVisible();
expect(resolved).not.toHaveBeenCalled();
await act(async () => response.resolve({ result: { slug: 'original-agent' } }));
expect(resolved).toHaveBeenCalledOnce();
expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeEnabled();
});
it('keeps a returned key available after modal close and reopen until the result is explicitly closed', async () => {
const secret = 'fixture-key-for-unit-only';
api.resolvePending.mockResolvedValue({ result: { secret, id: 'created-key' } });
const view = render(<CloudRecovery pending={[key, first]} onResolved={resolved} />);
const dialog = await openRecords();
fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]);
await screen.findByText(new RegExp(secret));
view.rerender(<CloudRecovery pending={[first]} onResolved={resolved} />);
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
await openRecords();
expect(screen.getByText(new RegExp(secret))).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '关闭结果' }));
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
await openRecords();
expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument();
});
it('keeps a result entry available when the final pending record was acknowledged', async () => {
api.resolvePending.mockResolvedValue({ result: { secret: 'last-key-fixture' } });
const view = render(<CloudRecovery pending={[key]} onResolved={resolved} />);
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await screen.findByText(/last-key-fixture/);
view.rerender(<CloudRecovery pending={[]} onResolved={resolved} />);
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
await openRecords(); expect(screen.getByText(/last-key-fixture/)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '关闭结果' }));
expect(screen.queryByText(/last-key-fixture/)).not.toBeInTheDocument();
});
it('guides verification requiring fresh input without claiming that verification completed', async () => {
const verification = { ...first, id: 'wechat:verification', operation: 'wechatBindVerification', input: { session_key: 'fixture-session' } };
api.resolvePending.mockResolvedValue({ requires_input: true, operation: verification.operation });
render(<CloudRecovery pending={[verification]} onResolved={resolved} />);
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
expect(await screen.findByText(/还需要验证码/)).toBeVisible();
expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument();
expect(api.resolvePending).toHaveBeenCalledWith(verification.id, false);
});
it('offers only explicit discard for retired operations and explains the retired result without auto actions', async () => {
const retired = { ...first, id: 'retired:policy', operation: 'channelPolicy', input: {} };
api.resolvePending.mockResolvedValue({ retired: true, discarded: true, operation: retired.operation });
render(<CloudRecovery pending={[retired]} onResolved={resolved} />);
expect(api.resolvePending).not.toHaveBeenCalled();
await openRecords();
expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' }));
expect(await screen.findByText(/旧版联系人权限操作已移除/)).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false);
});
it('renders no entry or dialog when there are no pending records or retained results', () => {
render(<CloudRecovery pending={[]} onResolved={resolved} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(api.resolvePending).not.toHaveBeenCalled();
});
it('does not let another action replace an unclosed key result, and unlocks actions after explicit result closure', async () => {
const verification = { ...second, operation: 'wechatBindVerification' };
api.resolvePending.mockImplementation(async id => id === key.id
? { result: { secret: 'protected-key-fixture' } } : { requires_input: true, operation: verification.operation });
const view = render(<CloudRecovery pending={[key, verification]} onResolved={resolved} />);
await openRecords();
fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' }));
await screen.findByText(/protected-key-fixture/);
view.rerender(<CloudRecovery pending={[verification]} onResolved={resolved} />);
const row = screen.getByRole('article', { name: '微信连接验证' });
const retry = within(row).getByRole('button', { name: '再试一次' });
const discard = within(row).getByRole('button', { name: '不再提醒' });
expect(retry).toBeDisabled(); expect(discard).toBeDisabled();
fireEvent.click(retry); fireEvent.click(discard);
expect(api.resolvePending).toHaveBeenCalledTimes(1);
expect(screen.getByText(/protected-key-fixture/)).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '关闭结果' }));
expect(retry).toBeEnabled();
fireEvent.click(retry);
await screen.findByText(/还需要验证码/);
expect(api.resolvePending.mock.calls).toEqual([[key.id, false], [verification.id, false]]);
});
it('refreshes pending records after a definite rejection while keeping the error inside the explicit dialog', async () => {
api.resolvePending.mockRejectedValue(new AppError('CONFIG', '这条创建已被拒绝', undefined, { commandOutcome: 'definitive_failure' }));
const view = render(<CloudRecovery pending={[first]} onResolved={resolved} />);
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
expect(await screen.findByRole('alert')).toHaveTextContent('这条创建已被拒绝');
expect(resolved).toHaveBeenCalledOnce();
expect(api.resolvePending).toHaveBeenCalledWith(first.id, false);
view.rerender(<CloudRecovery pending={[]} onResolved={resolved} />);
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
await openRecords();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('这条创建已被拒绝');
});
it('keeps an explicitly unknown command outcome recoverable without refreshing it as a definite failure', async () => {
api.resolvePending.mockRejectedValue(new AppError('NETWORK', '请求结果仍未知', undefined, { commandOutcome: 'unknown' }));
render(<CloudRecovery pending={[first]} onResolved={resolved} />);
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
expect(await screen.findByRole('alert')).toHaveTextContent('请求结果仍未知');
expect(resolved).not.toHaveBeenCalled();
expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]);
});