Integrate delivered Code navigation and Yuxi channel recovery into Canvas trial source

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 00:10:15 +08:00
1 parent 2ca6e3199d
commit 3fe3fdb1c0
38 files changed
+1602 -198

No files matched your search

+252 -1
View File
@@ -103,7 +103,9 @@ async function more(name = legacy.display_name) {
return current;
}
async function adding() {
fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true }));
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
await act(async () =>
@@ -797,3 +799,252 @@ it('keeps an uncertain operation locked when its recovery read is also unavailab
expect(within(await card()).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(calls('routeChannelAccount')).toHaveLength(1);
});
it('blocks every new owner management write when server logout is pending without a local journal', async () => {
accounts = [
{ ...legacy, health: 'connected', sync_state: 'ready', blockers: ['wechat_disconnect_pending'] },
{
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
health: 'login_required',
blockers: [],
},
];
renderPage();
const first = await card();
const second = await card(spare.display_name);
expect(within(first).getByText('正在确认')).toBeVisible();
expect(within(first).queryByText('使用中')).not.toBeInTheDocument();
expect(
screen.getByText('请回到发起操作的设备确认原来的操作,再刷新这里;无需再次扫码或解除绑定。')
).toBeVisible();
expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更换智能体' })).toBeDisabled();
expect(within(first).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '重新扫码' })).toBeDisabled();
expect(within(second).getByRole('button', { name: '更多操作' })).toBeDisabled();
expect(writes()).toEqual([]);
accounts[0].blockers = [];
fireEvent.click(screen.getByRole('button', { name: '刷新确认', exact: true }));
await waitFor(() => expect(within(first).getByRole('button', { name: '更换智能体' })).toBeEnabled());
expect(writes()).toEqual([]);
});
it.each(['switch', 'unbind'] as const)(
'blocks a %s dialog opened before another account logout becomes pending',
async (mode) => {
renderPage();
let dialog: HTMLElement;
if (mode === 'switch') {
dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
} else {
fireEvent.click(within(await more()).getByRole('button', { name: '解除微信绑定' }));
dialog = screen.getByRole('dialog', { name: '解除微信绑定' });
}
accounts[1].blockers = ['wechat_disconnect_pending'];
fireEvent.click(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
);
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(
within(dialog).getByRole('button', { name: mode === 'switch' ? '确认更换' : '确认解除微信绑定' })
).toBeDisabled();
expect(within(dialog).getByText(/请回到发起操作的设备确认原来的操作/)).toBeVisible();
expect(writes()).toEqual([]);
}
);
it('does not create an account when an already-open add dialog first reads server pending', async () => {
renderPage();
const addButton = await screen.findByRole('button', { name: '添加微信', exact: true });
await waitFor(() => expect(addButton).toBeEnabled());
fireEvent.click(addButton);
const dialog = screen.getByRole('dialog', { name: '添加微信' });
fireEvent.click(within(dialog).getByRole('radio', { name: agentA.name }));
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(dialog).getByRole('button', { name: '下一步:扫码连接' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('createChannelAccount')).toHaveLength(0);
expect(writes()).toEqual([]);
});
it('does not start a QR on another account after the preflight sees owner logout pending', async () => {
accounts[1] = {
...spare,
target_agent_slug: agentB.slug,
target_agent_name: agentB.name,
blockers: [],
};
renderPage();
const second = await card(spare.display_name);
accounts[0].blockers = ['wechat_disconnect_pending'];
fireEvent.click(within(second).getByRole('button', { name: '重新扫码' }));
const dialog = screen.getByRole('dialog', { name: '重新连接微信' });
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('channelAccountWechatBindStart')).toHaveLength(0);
});
it('does not auto-enable after QR confirmation if another account logout becomes pending', async () => {
const base = api.call.getMockImplementation()!;
qrStatus = 'confirmed';
api.call.mockImplementation(async (op, input) => {
const result = await base(op, input);
if (op === 'channelAccountWechatBindStart') accounts[0].blockers = ['wechat_disconnect_pending'];
return result;
});
renderPage();
const dialog = await adding();
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(within(dialog).queryByRole('heading', { name: '微信绑定成功' })).not.toBeInTheDocument();
});
it('keeps original-operation recovery available while server logout is pending', async () => {
accounts[0].blockers = ['wechat_disconnect_pending'];
pending = [
{
id: 'original-logout',
operation: 'unbindChannelAccount',
input: { channel_account_id: legacy.id, operation_id: 'original-id', expected_revision: 7 },
created_at: '2026-10-13T00:00:00Z',
},
];
api.resolvePending.mockImplementation(async (id) => {
expect(id).toBe('original-logout');
pending = [];
Object.assign(accounts[0], {
enabled: false,
target_agent_slug: null,
target_agent_name: null,
health: 'login_required',
blockers: ['target_required'],
});
return { operation: 'unbindChannelAccount', result: { unbound: true, disconnected: true } };
});
renderPage();
await card();
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const dialog = screen.getByRole('dialog', { name: '待处理记录' });
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
await within(dialog).findByText('解除微信绑定的结果已确认。');
fireEvent.click(within(dialog).getByRole('button', { name: '返回列表' }));
expect(within(await card()).getByText('未绑定')).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledWith('original-logout', false);
expect(writes()).toEqual([]);
});
it.each(['worker_offline', 'channel_sync_degraded'])(
'preserves safe stop and unbind for ordinary %s',
async (blocker) => {
accounts[0].blockers = [blocker];
if (blocker === 'channel_sync_degraded') accounts[0].sync_state = 'degraded';
renderPage();
const current = await more();
expect(within(current).getByRole('button', { name: '暂停自动回复' })).toBeEnabled();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
}
);
it('uses known disabled-target metadata without blocking replacement or safe stopping', async () => {
agents[0] = { ...agentA, enabled: false };
renderPage();
const current = await card();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).queryByText('使用中')).not.toBeInTheDocument();
expect(within(current).getByText('当前智能体已停用,可以更换智能体。')).toBeVisible();
const menu = await more();
expect(within(menu).getByRole('button', { name: '暂停自动回复' })).toBeEnabled();
expect(within(menu).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
const dialog = await chooseExisting();
expect(within(dialog).queryByRole('radio', { name: agentA.name })).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(within(await card()).getByText('使用中')).toBeVisible();
});
it('does not resume a paused binding with a known disabled target', async () => {
agents[0] = { ...agentA, enabled: false };
accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
renderPage();
const current = await more();
expect(within(current).getByText('智能体已停用')).toBeVisible();
expect(within(current).getByRole('button', { name: '恢复自动回复' })).toBeDisabled();
expect(within(current).getByRole('button', { name: '解除微信绑定' })).toBeEnabled();
expect(calls('enableChannelAccount')).toHaveLength(0);
});
it('sends no mutation when the management preflight cannot read authoritative accounts', async () => {
const base = api.call.getMockImplementation()!;
renderPage();
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
api.call.mockImplementation(async (op, input) => {
if (op === 'channelAccounts') throw new Error('微信状态暂时无法读取');
return base(op, input);
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态暂时无法读取');
expect(writes()).toEqual([]);
});
it.each(['enable', 'pause'] as const)(
'blocks a new %s when a different account begins server logout before preflight',
async (action) => {
if (action === 'enable') accounts[0] = { ...legacy, enabled: false, blockers: ['binding_paused'] };
renderPage();
const current = await more();
accounts[1].blockers = ['wechat_disconnect_pending'];
fireEvent.click(
within(current).getByRole('button', {
name: action === 'enable' ? '恢复自动回复' : '暂停自动回复',
})
);
expect(await screen.findByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(calls('enableChannelAccount')).toHaveLength(0);
expect(calls('pauseChannelAccount')).toHaveLength(0);
}
);
it('still sends a safe pause while an ordinary Worker-offline blocker is present', async () => {
accounts[0].blockers = ['worker_offline'];
renderPage();
fireEvent.click(within(await more()).getByRole('button', { name: '暂停自动回复' }));
expect(await screen.findByText('已暂停')).toBeVisible();
expect(calls('pauseChannelAccount')).toHaveLength(1);
});
it('sends no write when a superseded preflight observes pending after the newer account read fails', async () => {
renderPage();
await card();
const refreshButton = screen.getByRole('button', { name: '刷新微信列表' });
const dialog = await chooseExisting();
fireEvent.click(within(dialog).getByRole('radio', { name: agentB.name }));
const base = api.call.getMockImplementation()!;
let accountReads = 0;
let finishPreflight: ((value: { items: CloudUserChannelAccount[] }) => void) | undefined;
api.call.mockImplementation(async (op, input) => {
if (op !== 'channelAccounts') return base(op, input);
accountReads += 1;
if (accountReads === 1)
return new Promise<{ items: CloudUserChannelAccount[] }>((resolve) => {
finishPreflight = resolve;
});
throw new Error('较新的微信状态读取失败');
});
act(() => {
fireEvent.click(within(dialog).getByRole('button', { name: '确认更换' }));
fireEvent.click(refreshButton);
});
await waitFor(() => expect(accountReads).toBe(2));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('较新的微信状态读取失败');
await act(async () =>
finishPreflight!({
items: [{ ...legacy }, { ...spare, blockers: ['wechat_disconnect_pending'] }],
})
);
expect(within(dialog).getByRole('alert')).toHaveTextContent('微信退出还在确认中');
expect(writes()).toEqual([]);
});
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { ModuleSelection } from '@/pages/ModuleSelection';
@@ -55,9 +55,16 @@ it('opens the card list from the home Agents choice without changing the remembe
expect(await screen.findByRole('article', { name: draft.name })).toBeVisible();
const records = await screen.findByRole('button', { name: '待处理记录(1)' });
expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument();
expect(screen.queryByText('上次的问题')).not.toBeInTheDocument();
fireEvent.click(records);
expect(await screen.findByRole('dialog', { name: '待处理记录' })).toHaveTextContent('上次的问题');
fireEvent.click(screen.getByRole('button', { name: '关闭记录' }));
const dialog = await screen.findByRole('dialog', { name: '待处理记录' });
expect(within(dialog).getByRole('article', { name: '发送消息' })).toHaveTextContent('上次的问题');
expect(within(dialog).getByRole('button', { name: '再试一次' })).toBeEnabled();
fireEvent.click(within(dialog).getByRole('button', { name: '关闭记录' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: '待处理记录(1)' }));
const reopened = await screen.findByRole('dialog', { name: '待处理记录' });
expect(within(reopened).getByRole('article', { name: '发送消息' })).toHaveTextContent('上次的问题');
expect(router.state.location.pathname + router.state.location.search).toBe('/cloud-agents?view=mine');
expect(screen.queryByTestId('entry-editor')).not.toBeInTheDocument();
expect(api.get).not.toHaveBeenCalled();
@@ -39,7 +39,7 @@ it('removes subagent selection while other capability edits preserve historical
expect(screen.queryByText(/missing-child/)).not.toBeInTheDocument();
expect(changed).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole('combobox', { name: '模型' }), { target: { value: 'new-model' } });
for (const [button, checkbox] of [['选择工具箱', '查询工具'], ['选择做事手册', '研究手册'], ['选择MCP 服务', '资料连接']]) {
for (const [button, checkbox] of [['选择TOOL', '查询工具'], ['选择SKILL', '研究手册'], ['选择MCP 服务', '资料连接']]) {
fireEvent.click(screen.getByRole('button', { name: button, exact: true }));
fireEvent.click(screen.getByRole('checkbox', { name: checkbox, exact: true }));
}
+66
View File
@@ -596,10 +596,15 @@ describe('Main cloud Agents boundary', () => {
const plan = operationPlan({ operation: 'unbindChannelAccount', input: {
channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2,
target_agent_slug: draft.slug, access_token: 'private',
expected_provider_generation: 'forged-generation', expected_binding_id: 'forged-binding',
expected_route_revision: 999, source_route_operation_id: 'forged-route',
provider_generation: 'forged-generation', provider_unbind: { status: 'completed', token: 'private' },
cookie: 'private',
} });
expect(plan.path).toBe('/api/makelore/channel-accounts/wechat-1/unbind');
expect(plan.method).toBe('POST');
expect(plan.body).toEqual({ operation_id: input.operation_id, expected_revision: 2 });
expect(plan.input).toEqual({ channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 });
expect(plan.project({ channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready', access_token: 'private' },
unbound: true, disconnected: true, wechat: { status: 'empty', token: 'private' } })).toEqual({
channel: { id: 'wechat-1', desired_state: 'paused', sync_state: 'ready' },
@@ -654,6 +659,67 @@ describe('Main cloud Agents boundary', () => {
]);
});
it.each([
['channel_operation_legacy', '这次旧微信操作无法安全恢复,请联系管理员核对后再操作'],
['identity_conflict', '当前账号关系不一致,请刷新状态并核对账号后再操作'],
['target_agent_unavailable', '当前智能体暂不可用,请刷新状态并重新选择智能体'],
])('keeps the original uncertain channel request after the safe %s recovery error', async (code, message) => {
const channel = { id: 'wechat-1', enabled: true, target_agent_slug: draft.slug, revision: 3, health: 'connected' };
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json({ detail: { code, message: 'private Core owner and provider credentials' } }, 409))
.mockResolvedValueOnce(json({ channel }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'enableChannelAccount', input: original })).rejects.toMatchObject({
commandOutcome: 'unknown',
});
const pending = (await module.recovery()).pending[0];
expect(pending).toMatchObject({ operation: 'enableChannelAccount', input: original });
await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({
status: 409, code, message, commandOutcome: 'unknown',
});
expect((await module.recovery()).pending).toEqual([pending]);
expect(await module.resolvePending({ id: pending.id })).toMatchObject({
operation: 'enableChannelAccount', input: original, result: { channel },
});
expect((await module.recovery()).pending).toEqual([]);
expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual(
Array.from({ length: 3 }, () => ['https://agents.example.test/api/makelore/channel-accounts/wechat-1/enable', {
operation_id: input.operation_id, expected_revision: 2,
}]),
);
});
it.each([
{ blockers: ['wechat_disconnect_pending'] },
{ sync_state: 'degraded' },
{ sync_state: 'pending' },
{ sync_state: 'unknown' },
{ desired_state: 'enabled' },
])('recovers the same unbind request after a flagged success still has unfinished logout state %o', async unfinished => {
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required',
desired_state: 'paused', sync_state: 'ready', blockers: [] };
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
.mockResolvedValueOnce(json({ channel: { ...channel, ...unfinished }, unbound: true, disconnected: true }))
.mockResolvedValueOnce(json({ channel, unbound: true, disconnected: true }));
const module = moduleFor(fetchImpl);
const original = { channel_account_id: 'wechat-1', operation_id: input.operation_id, expected_revision: 2 };
await expect(module.execute({ operation: 'unbindChannelAccount', input: original })).rejects.toMatchObject({
status: 503, code: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
});
const pending = (await module.recovery()).pending[0];
expect(pending).toMatchObject({ operation: 'unbindChannelAccount', input: original });
expect(await module.resolvePending({ id: pending.id })).toMatchObject({
operation: 'unbindChannelAccount', input: original, result: { channel, unbound: true, disconnected: true },
});
expect((await module.recovery()).pending).toEqual([]);
expect(fetchImpl.mock.calls.slice(1).map(call => [call[0], JSON.parse(call[1].body)])).toEqual(
Array.from({ length: 2 }, () => ['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
operation_id: input.operation_id, expected_revision: 2,
}]),
);
});
it('recovers a completed WeChat verification from its receipt without replaying the code', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response'))
.mockResolvedValueOnce(json({ operation_id: input.operation_id, status: 'completed', result: { channel: { id: 'channel-1' } } }));
+13 -13
View File
@@ -486,8 +486,8 @@ it('shows only model and capability settings together and preserves unfinished s
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
const model = screen.getByLabelText('模型');
const toolbox = screen.getByRole('group', { name: /^工具箱(Tool)/ });
const handbook = screen.getByRole('group', { name: /^做事手册(Skill)/ });
const toolbox = screen.getByRole('group', { name: 'TOOL', exact: true });
const handbook = screen.getByRole('group', { name: 'SKILL', exact: true });
expect(model).toBeVisible();
expect(model).toBeRequired();
expect(toolbox).toBeVisible();
@@ -504,10 +504,10 @@ it('shows only model and capability settings together and preserves unfinished s
expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['介绍与提示词', '高级设置']);
fireEvent.change(model, { target: { value: 'model-a' } });
fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '选择SKILL', exact: true }));
fireEvent.click(screen.getByRole('checkbox', { name: '写作方法', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '选择工具箱', exact: true }));
const search = screen.getByLabelText('搜索工具箱');
fireEvent.click(screen.getByRole('button', { name: '选择TOOL', exact: true }));
const search = screen.getByLabelText('搜索TOOL');
fireEvent.change(search, { target: { value: '文章' } });
fireEvent.click(screen.getByRole('checkbox', { name: '文章整理工具', exact: true }));
@@ -520,7 +520,7 @@ it('shows only model and capability settings together and preserves unfinished s
expect(screen.getByLabelText('模型')).toBe(model);
expect(model).toHaveValue('model-a');
expect(screen.getByLabelText('搜索工具箱')).toBe(search);
expect(screen.getByLabelText('搜索TOOL')).toBe(search);
expect(search).toHaveValue('文章');
expect(screen.getByRole('checkbox', { name: '文章整理工具', exact: true })).toBeChecked();
expect(screen.getByRole('checkbox', { name: '写作方法', exact: true })).toBeChecked();
@@ -1003,7 +1003,7 @@ it('preserves hidden runtime policies while saving model, prompt and capability
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
await waitFor(() => expect(screen.getByLabelText('模型')).toBeEnabled());
fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'another-model' } });
fireEvent.click(screen.getByRole('button', { name: '选择工具箱', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '选择TOOL', exact: true }));
fireEvent.click(screen.getByRole('checkbox', { name: '文章整理工具', exact: true }));
expect(screen.queryByLabelText('最多执行步数')).not.toBeInTheDocument();
expect(screen.queryByLabelText('每日上限(词元点数)')).not.toBeInTheDocument();
@@ -1054,19 +1054,19 @@ it('searches capabilities while retaining existing selections outside the result
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
expect(await screen.findByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择工具箱', exact: true }));
fireEvent.change(screen.getByLabelText('搜索工具箱'), { target: { value: '资料' } });
fireEvent.click(screen.getByRole('button', { name: '选择TOOL', exact: true }));
fireEvent.change(screen.getByLabelText('搜索TOOL'), { target: { value: '资料' } });
fireEvent.mouseDown(screen.getByRole('tab', { name: '介绍与提示词' }), { button: 0, ctrlKey: false });
fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false });
expect(screen.getByLabelText('搜索工具箱')).toHaveValue('资料');
expect(screen.getByLabelText('搜索TOOL')).toHaveValue('资料');
expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /上网查资料/ }));
fireEvent.click(screen.getByRole('button', { name: '完成选择' }));
expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked();
fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true }));
fireEvent.change(screen.getByLabelText('搜索做事手册'), { target: { value: '出处' } });
fireEvent.click(screen.getByRole('button', { name: '选择SKILL', exact: true }));
fireEvent.change(screen.getByLabelText('搜索SKILL'), { target: { value: '出处' } });
fireEvent.click(screen.getByRole('checkbox', { name: /查资料,做研究/ }));
fireEvent.click(screen.getByRole('checkbox', { name: '开始对话前,先读好已选做事手册的使用方法' }));
fireEvent.click(screen.getByRole('checkbox', { name: '开始对话前,先读好已选SKILL的使用方法' }));
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }));
await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({
configuration: expect.objectContaining({ tools: ['missing-tool', 'web_search'], skills: ['deep-research'], preload_skills: ['deep-research'] }),
+13 -6
View File
@@ -50,9 +50,9 @@ it('joins readable IDs to the owned-only catalog and selects multiple libraries
const selection = screen.getByRole('region', { name: '选择知识库', exact: true });
expect(within(creation).getByRole('button', { name: '新建知识库', exact: true })).toBeVisible();
expect(within(creation).queryByRole('combobox', { name: '管理的知识库' })).not.toBeInTheDocument();
expect(within(creation).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(within(creation).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(within(management).getByRole('combobox', { name: '管理的知识库' })).toBeVisible();
expect(within(management).getByRole('button', { name: '从电脑上传', exact: true })).toBeDisabled();
expect(within(management).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(within(management).queryByRole('button', { name: '新建知识库', exact: true })).not.toBeInTheDocument();
expect(within(selection).getByRole('combobox', { name: '选择知识库', exact: true })).toBeVisible();
expect(creation.compareDocumentPosition(management) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -150,10 +150,15 @@ it('limits bindings to 100 libraries without preventing browsing or creation', a
it('browses an owned library without binding it and only explicit selection changes the draft', async () => {
render(<Harness initial={['shared']} />);
await openOwn();
const creation = screen.getByRole('region', { name: '上传自己的文件', exact: true });
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
const upload = within(creation).getByRole('button', { name: '从电脑上传', exact: true });
expect(upload).toBeEnabled();
expect(within(management).queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument();
expect(changes).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '移除知识库 ' + own.name })).not.toBeInTheDocument();
api.uploadKnowledge.mockResolvedValue(null);
fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true }));
fireEvent.click(upload);
await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled());
expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true);
@@ -163,7 +168,7 @@ it('browses an owned library without binding it and only explicit selection chan
expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] }));
});
it('keeps an upload failure in the management column while the creation form is open', async () => {
it('keeps document errors in management when the left-card upload is used beside an open creation form', async () => {
api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认'));
render(<Harness initial={['shared']} />);
await openOwn();
@@ -171,8 +176,10 @@ it('keeps an upload failure in the management column while the creation form is
const management = screen.getByRole('region', { name: '查看我的知识库', exact: true });
fireEvent.click(within(creation).getByRole('button', { name: '新建知识库', exact: true }));
fireEvent.change(within(creation).getByRole('textbox', { name: '知识库名称' }), { target: { value: '还没创建的资料' } });
fireEvent.click(within(management).getByRole('button', { name: '从电脑上传', exact: true }));
fireEvent.click(within(creation).getByRole('button', { name: '从电脑上传', exact: true }));
expect(await within(management).findByRole('alert')).toHaveTextContent('上传结果未确认');
expect(within(creation).getByRole('button', { name: '重新选择同一文件并重试', exact: true })).toBeEnabled();
expect(within(management).queryByRole('button', { name: '重新选择同一文件并重试', exact: true })).not.toBeInTheDocument();
expect(within(creation).queryByRole('alert')).not.toBeInTheDocument();
expect(within(creation).getByRole('textbox', { name: '知识库名称' })).toHaveValue('还没创建的资料');
expect(changes).not.toHaveBeenCalled();
@@ -277,7 +284,7 @@ it('preserves uncertain processing while refreshing and releases it when the ser
files = [{ ...uploaded, processing_task: { task_id: 'observed-new-task', status: 'pending', error: null } }];
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
await screen.findByText('等待处理');
await waitFor(() => expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled());
expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeEnabled();
});
it('retains unconfirmed operations in a disabled manager if its owned library disappears and requires explicit discard', async () => {
+156 -1
View File
@@ -4,7 +4,7 @@ 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() }));
const api = vi.hoisted(() => ({ resolvePending: vi.fn(), call: vi.fn(), list: 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' };
@@ -255,3 +255,158 @@ it('keeps an explicitly unknown command outcome recoverable without refreshing i
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]);
});
const wechatOperation: CloudPendingOperation = {
id: 'unbindChannelAccount::original-wechat-op',
operation: 'unbindChannelAccount',
input: { channel_account_id: 'wechat-account', operation_id: 'original-wechat-op', expected_revision: 7 },
created_at: '2026-10-13T04:00:00Z',
};
it.each([
['channel_operation_legacy', '无法安全恢复'],
])('keeps an original WeChat record requiring attention after %s without blind replay', async (backendCode, text) => {
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '这次微信操作需要核对', undefined, {
status: 409, backendCode, commandOutcome: 'unknown',
}));
render(<CloudRecovery pending={[wechatOperation]} onResolved={resolved} />);
const dialog = await openRecords();
const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
expect(await within(row).findByRole('status')).toHaveTextContent(text);
expect(within(row).getByRole('button', { name: '再试一次' })).toBeDisabled();
expect(within(dialog).getByRole('alert')).toHaveTextContent('需要核对');
expect(resolved).not.toHaveBeenCalled();
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
expect(api.resolvePending).toHaveBeenCalledOnce();
expect(within(dialog).getByText(/不会取消微信服务端仍在处理的操作/)).toBeVisible();
api.resolvePending.mockResolvedValueOnce({ discarded: true });
fireEvent.click(within(row).getByRole('button', { name: '不再提醒' }));
await waitFor(() => expect(resolved).toHaveBeenCalledOnce());
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, true]]);
});
it('does not claim success or replay a pending receipt that says an old WeChat intent cannot recover', async () => {
const verification: CloudPendingOperation = {
...wechatOperation, operation: 'channelAccountWechatBindVerification', id: 'verification:original',
};
api.resolvePending.mockResolvedValue({
operation: verification.operation, pending: true,
result: { status: 'unknown', error_code: 'channel_operation_legacy' },
});
render(<CloudRecovery pending={[verification]} onResolved={resolved} />);
const dialog = await openRecords();
const row = within(dialog).getByRole('article', { name: '微信连接验证' });
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
expect(await within(row).findByRole('status')).toHaveTextContent('无法安全恢复');
expect(within(row).getByRole('button', { name: '再试一次' })).toBeDisabled();
expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
expect(api.resolvePending.mock.calls).toEqual([[verification.id, false]]);
});
it('keeps an unknown WeChat network result retryable using only the original record', async () => {
api.resolvePending.mockRejectedValue(new AppError('NETWORK', '微信操作结果暂时未知', undefined, {
status: 503, backendCode: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
}));
render(<CloudRecovery pending={[wechatOperation]} onResolved={resolved} />);
const dialog = await openRecords();
const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
await within(dialog).findByRole('alert');
expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2));
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, false]]);
expect(resolved).not.toHaveBeenCalled();
});
it('requires attention only for the rejected WeChat record while preserving another creation recovery', async () => {
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '旧微信请求无法恢复', undefined, {
status: 409, backendCode: 'channel_operation_legacy', commandOutcome: 'unknown',
})).mockResolvedValueOnce({ result: { slug: 'created-agent' } });
render(<CloudRecovery pending={[wechatOperation, first]} onResolved={resolved} />);
const dialog = await openRecords();
const wechat = within(dialog).getByRole('article', { name: '解除微信绑定' });
fireEvent.click(within(wechat).getByRole('button', { name: '再试一次' }));
await within(wechat).findByRole('status');
const creation = within(dialog).getByRole('article', { name: '创建「故事搭档」' });
expect(within(creation).getByRole('button', { name: '再试一次' })).toBeEnabled();
fireEvent.click(within(creation).getByRole('button', { name: '再试一次' }));
await within(dialog).findByText(/创建成功/);
expect(within(wechat).getByRole('button', { name: '再试一次' })).toBeDisabled();
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [first.id, false]]);
});
it.each(['identity_conflict', 'target_agent_unavailable'])('lets a corrected %s explicitly refresh before confirming the same original WeChat record', async backendCode => {
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '这次微信操作需要核对', undefined, {
status: 409, backendCode, commandOutcome: 'unknown',
})).mockResolvedValueOnce({ result: { status: 'unbound' } });
const accounts = deferred<unknown>();
api.call.mockReturnValue(accounts.promise);
api.list.mockResolvedValue({ items: [], next_cursor: null });
render(<CloudRecovery pending={[wechatOperation, first]} onResolved={resolved} />);
const dialog = await openRecords();
const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
await within(row).findByRole('status');
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
expect(api.call).not.toHaveBeenCalled();
fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
expect(api.call.mock.calls).toEqual([['channelAccounts', {}]]);
expect(api.list).toHaveBeenCalledWith();
expect(within(row).getByRole('button', { name: '正在刷新…' })).toBeDisabled();
expect(within(row).getByRole('button', { name: '不再提醒' })).toBeDisabled();
expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled();
expect(within(screen.getByRole('article', { name: '创建「故事搭档」' })).getByRole('button', { name: '再试一次' })).toBeDisabled();
await act(async () => accounts.resolve({ items: [], next_cursor: null }));
expect(await within(dialog).findByText(/微信状态已刷新/)).toBeVisible();
expect(api.resolvePending).toHaveBeenCalledOnce();
expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
expect(await within(dialog).findByText('解除微信绑定的结果已确认。')).toBeVisible();
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false], [wechatOperation.id, false]]);
});
it('keeps the original record requiring attention when its explicit state refresh fails', async () => {
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '目标暂不可用', undefined, {
status: 409, backendCode: 'target_agent_unavailable', commandOutcome: 'unknown',
}));
api.call.mockRejectedValueOnce(new Error('微信状态读取失败')).mockResolvedValueOnce({ items: [] });
api.list.mockResolvedValue({ items: [], next_cursor: null });
render(<CloudRecovery pending={[wechatOperation]} onResolved={resolved} />);
const dialog = await openRecords();
const row = within(dialog).getByRole('article', { name: '解除微信绑定' });
fireEvent.click(within(row).getByRole('button', { name: '再试一次' }));
await within(row).findByRole('status');
fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('微信状态读取失败');
expect(within(row).queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument();
expect(within(row).getByRole('button', { name: '刷新状态' })).toBeEnabled();
expect(resolved).not.toHaveBeenCalled();
expect(api.resolvePending).toHaveBeenCalledOnce();
fireEvent.click(within(row).getByRole('button', { name: '刷新状态' }));
await within(dialog).findByText(/微信状态已刷新/);
expect(api.resolvePending).toHaveBeenCalledOnce();
expect(within(row).getByRole('button', { name: '再试一次' })).toBeEnabled();
});
it('does not announce or replay a late state refresh after the recovery component unmounts', async () => {
api.resolvePending.mockRejectedValueOnce(new AppError('UNKNOWN', '账号关系需核对', undefined, {
status: 409, backendCode: 'identity_conflict', commandOutcome: 'unknown',
}));
const accounts = deferred<unknown>();
api.call.mockReturnValue(accounts.promise);
api.list.mockResolvedValue({ items: [] });
const view = render(<CloudRecovery pending={[wechatOperation]} onResolved={resolved} />);
await openRecords();
fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
await screen.findByRole('button', { name: '刷新状态' });
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
view.unmount();
await act(async () => accounts.resolve({ items: [] }));
expect(resolved).not.toHaveBeenCalled();
expect(api.resolvePending.mock.calls).toEqual([[wechatOperation.id, false]]);
});
+105 -8
View File
@@ -523,7 +523,15 @@ describe('CodingChatPanel first Conversation', () => {
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(false);
return <>
<button onClick={() => setArchived(true)}>打开归档列表</button>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
@@ -533,7 +541,8 @@ describe('CodingChatPanel first Conversation', () => {
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
fireEvent.click(screen.getByRole('button', { name: '打开归档列表' }));
expect(screen.getByText('已归档对话')).toBeVisible();
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
@@ -541,6 +550,92 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
expect(screen.queryByText('已归档对话')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })).toHaveAttribute('aria-current', 'page');
expect(conversationApi.abort).not.toHaveBeenCalled();
});
it('returns to recent conversations when restoring an unselected archived conversation from its menu', async () => {
const archivedConversation = {
...conversation,
id: 'archived-conversation',
title: '归档中的对话',
archivedAt: '2026-09-20T00:00:00Z',
};
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation, archivedConversation]);
projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null });
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByText('已归档对话')).toBeVisible();
expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument();
fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${archivedConversation.title}` }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '恢复到最近对话' }));
await waitFor(() => expect(list.queryByText('已归档对话')).not.toBeInTheDocument());
expect(projectApi.patch).toHaveBeenCalledWith(archivedConversation.id, { archived: false });
expect(list.getByRole('button', { name: archivedConversation.title, exact: true })).toBeVisible();
expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id);
expect(projectApi.create).not.toHaveBeenCalled();
});
it('keeps the new-conversation label visible in compact archive view and returns to recent conversations after creation', async () => {
const archivedConversation = { ...conversation, archivedAt: '2026-09-20T00:00:00Z' };
const createdConversation = { ...conversation, id: 'created-conversation', title: '新建的对话' };
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([archivedConversation]);
projectApi.create.mockResolvedValue(createdConversation);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, createdConversation));
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { useState } = await import('react');
const CodingChatPanel = () => {
const [archived, setArchived] = useState(true);
return <>
<CodingProjectConversations projectId={project.id} compact archived={archived} onArchivedChange={setArchived} />
<CoreChatPanel />
</>;
};
render(<CodingChatPanel />);
const list = within(await screen.findByTestId('project-conversations'));
const create = list.getByRole('button', { name: '新建对话', exact: true });
expect(create).toHaveTextContent('新对话');
expect(list.getByText('已归档对话')).toBeVisible();
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(create);
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(createdConversation.id));
expect(projectApi.create).toHaveBeenCalledOnce();
expect(list.queryByText('已归档对话')).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
expect(list.queryByRole('button', { name: archivedConversation.title, exact: true })).not.toBeInTheDocument();
expect(list.getByRole('button', { name: createdConversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
expect(conversationApi.submit).not.toHaveBeenCalled();
});
it('renames an unselected conversation from its menu without preparing that conversation', async () => {
@@ -640,7 +735,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.snapshot).not.toHaveBeenCalled();
fireEvent.change(textbox, {target:{value:'First prompt'}});
fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' }));
fireEvent.click(screen.getByRole('button', { name: '作品广场' }));
await waitFor(() => expect(external).toHaveBeenCalledWith('shell:openExternal', 'https://square.nianxx.cn/#explore'));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBe(textbox);
@@ -959,7 +1054,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
});
it('keeps project settings only in the Conversation list and removes legacy Header actions', async () => {
it('removes project toolbar actions and legacy Header actions while keeping conversation creation', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
@@ -986,10 +1081,12 @@ describe('CodingChatPanel first Conversation', () => {
expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument();
expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument();
expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
expect(navigate).toHaveBeenCalledWith('/project-config');
expect(screen.queryByRole('button', { name: '项目设置' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '查看归档对话' })).not.toBeInTheDocument();
const list = within(screen.getByTestId('project-conversations'));
expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话');
expect(list.getByRole('button', { name: `对话操作:${conversation.title}` })).toBeInTheDocument();
expect(onOpenProjectSettings).not.toHaveBeenCalled();
});
it('remounts pending interaction state when switching Conversations', async () => {
+29 -4
View File
@@ -1,21 +1,29 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { FormEvent } from 'react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { invokeIpc } from '@/lib/api-client';
import { COMMUNITY_URL } from '@/lib/community';
import { CommunityLink } from '@/components/community/CommunityLink';
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
import { CodingStartHeading } from '@/pages/Chat/CodingStartHeading';
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
beforeEach(() => { vi.mocked(invokeIpc).mockReset().mockResolvedValue(undefined); });
afterEach(cleanup);
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it('only opens the public explore URL after a click, without fetching or embedding works', async () => {
const fetch = vi.spyOn(globalThis, 'fetch');
render(<CommunityInvitation />);
render(<CodingStartHeading />);
expect(screen.getByRole('heading', { name: '今天,你想创造什么?' })).toBeInTheDocument();
expect(screen.getByText('一个游戏、一段故事,或是一个天马行空的想法。')).toBeInTheDocument();
expect(screen.queryByTestId('community-invitation')).not.toBeInTheDocument();
expect(invokeIpc).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(document.querySelector('iframe')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' }));
const button = screen.getByRole('button', { name: '作品广场' });
expect(button).toHaveAttribute('type', 'button');
expect(button.querySelector('.coding-start-mark img')).not.toBeNull();
expect(button.querySelectorAll('.coding-start-mark > svg')).toHaveLength(2);
fireEvent.click(button);
await waitFor(() => expect(invokeIpc).toHaveBeenCalledExactlyOnceWith('shell:openExternal', COMMUNITY_URL));
fetch.mockRestore();
});
@@ -24,6 +32,7 @@ it('provides an accessible collapsed sidebar entry and a retry after opening fai
vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable'));
render(<CommunityLink variant="sidebar" collapsed />);
const button = screen.getByRole('button', { name: '作品广场' });
expect(button.querySelector('.community-link-icon svg')).not.toBeNull();
fireEvent.click(button);
await screen.findByRole('alert');
await waitFor(() => expect(button).toBeEnabled());
@@ -31,3 +40,19 @@ it('provides an accessible collapsed sidebar entry and a retry after opening fai
await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('keeps the welcome entry available for retry without submitting its surrounding form', async () => {
const submit = vi.fn((event: FormEvent) => event.preventDefault());
vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable'));
render(<form onSubmit={submit}><CodingStartHeading /></form>);
const button = screen.getByRole('button', { name: '作品广场' });
fireEvent.click(button);
await screen.findByRole('alert');
await waitFor(() => expect(button).toBeEnabled());
expect(submit).not.toHaveBeenCalled();
fireEvent.click(button);
await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2));
expect(invokeIpc).toHaveBeenLastCalledWith('shell:openExternal', COMMUNITY_URL);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled();
});
+9 -5
View File
@@ -46,7 +46,7 @@ beforeEach(() => {
});
afterEach(cleanup);
it('shows the authenticated account balance and controls without a recharge entry', async () => {
it('shows the authenticated account and opens the real personal wallet with read requests only', async () => {
renderHome();
const trigger = await screen.findByTestId('home-account-menu-trigger');
await waitFor(() => expect(trigger).toHaveTextContent('小麦'));
@@ -56,10 +56,14 @@ it('shows the authenticated account balance and controls without a recharge entr
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点');
expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument();
expect(within(menu).queryByRole('menuitem', { name: '词元点数与充值', exact: true })).not.toBeInTheDocument();
expect(screen.queryByTestId('point-wallet-dialog')).not.toBeInTheDocument();
for (const name of ['个人资料', '设置', '退出登录']) expect(within(menu).getByRole('menuitem', { name, exact: true })).toBeVisible();
expect(api.fetch.mock.calls.filter(([path]) => path.includes('/billing/recharge/') || path.includes('/billing/points/transactions'))).toEqual([]);
fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true }));
const dialog = await screen.findByTestId('point-wallet-dialog');
await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled());
expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)');
expect(api.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders?offset=0', undefined);
fireEvent.mouseDown(within(dialog).getByText('我的可用点数', { exact: true }));
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(dialog).toBeVisible();
expect(writes()).toEqual([]);
});
+19 -2
View File
@@ -7,7 +7,7 @@ import { useSettingsStore } from '@/stores/settings';
type PeekProps = {
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar' | 'menu') => void;
forceExpanded?: boolean;
};
@@ -42,7 +42,10 @@ vi.mock('@/components/layout/Sidebar', () => ({
data-force-expanded={forceExpanded ? 'true' : 'false'}
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
/>
>
<button onClick={() => onSidebarPeekChange?.(true, 'menu')}>打开项目菜单</button>
<button onClick={() => onSidebarPeekChange?.(false, 'menu')}>关闭项目菜单</button>
</aside>
),
}));
@@ -124,6 +127,20 @@ describe('MainLayout sidebar hover preview', () => {
expect(screen.getByTestId('peek-trigger')).toHaveAttribute('data-peek-open', 'false');
});
it('keeps the preview open for a project menu after the pointer leaves the sidebar', () => {
renderLayout();
const sidebar = screen.getByTestId('peek-sidebar');
fireEvent.pointerEnter(sidebar);
fireEvent.click(screen.getByRole('button', { name: '打开项目菜单' }));
fireEvent.pointerLeave(sidebar);
act(() => vi.advanceTimersByTime(300));
expect(sidebar).toHaveAttribute('data-peek-open', 'true');
fireEvent.click(screen.getByRole('button', { name: '关闭项目菜单' }));
act(() => vi.advanceTimersByTime(180));
expect(sidebar).toHaveAttribute('data-peek-open', 'false');
});
it('closes once after leaving the only hover zone', () => {
renderLayout();
+144
View File
@@ -0,0 +1,144 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '@/components/layout/Sidebar';
const projects = vi.hoisted(() => ['A', 'B'].map((name) => ({
id: `project-${name.toLowerCase()}`,
name,
createdAt: '2026-10-13T00:00:00.000Z',
updatedAt: '2026-10-13T00:00:00.000Z',
lastOpenedAt: '2026-10-13T00:00:00.000Z',
})));
const codingState = vi.hoisted(() => ({
projects,
activeProjectId: projects[0].id,
activeProject: projects[0],
load: vi.fn().mockResolvedValue(undefined),
setActiveProject: vi.fn(),
removeProject: vi.fn(),
createProject: vi.fn(),
}));
const projectConfigState = vi.hoisted(() => ({
load: vi.fn<(projectId: string) => Promise<unknown>>(),
remove: vi.fn(),
}));
const providerState = vi.hoisted(() => ({
refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
}));
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
vi.mock('@/hooks/use-current-user-profile', () => ({
useCurrentUserProfile: () => ({
userProfile: { displayName: '测试用户', avatarUrl: null },
profileRequired: false,
profileSyncError: null,
syncProfileNow: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('@/stores/auth', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: null, logout: vi.fn() }),
}));
vi.mock('@/stores/settings', () => ({
useSettingsStore: (selector: (state: unknown) => unknown) => selector({ sidebarCollapsed: false }),
}));
vi.mock('@/stores/coding-workspace', () => ({
codingWorkspaceStore: { getState: () => codingState },
useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState),
}));
vi.mock('@/stores/project-config', () => ({
useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => selector(projectConfigState),
}));
vi.mock('@/stores/providers', () => ({
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
}));
vi.mock('@/pages/Chat/CodingProjectConversations', () => ({
CodingProjectConversations: ({ archived }: { archived: boolean }) => (
<div data-testid="conversation-list-mode">{archived ? 'archived' : 'recent'}</div>
),
}));
vi.mock('@/components/account/PointWallet', () => ({ PointWallet: () => null }));
vi.mock('@/components/community/CommunityLink', () => ({ CommunityLink: () => null }));
vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null }));
vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null }));
vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null }));
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
function LocationProbe() {
return <div data-testid="location">{useLocation().pathname}</div>;
}
async function renderSidebar() {
render(<MemoryRouter initialEntries={['/chat']}><Sidebar /><LocationProbe /></MemoryRouter>);
await act(async () => undefined);
projectConfigState.load.mockClear();
}
async function selectProjectAction(projectName: string, action: string) {
fireEvent.keyDown(screen.getByRole('button', { name: `项目操作:${projectName}` }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: action }));
}
describe('Sidebar project actions', () => {
beforeEach(() => {
codingState.activeProjectId = projects[0].id;
codingState.activeProject = projects[0];
codingState.setActiveProject.mockReset();
codingState.setActiveProject.mockImplementation(async (projectId: string) => {
const project = projects.find((item) => item.id === projectId)!;
codingState.activeProjectId = projectId;
codingState.activeProject = project;
return project;
});
projectConfigState.load.mockReset();
projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} });
});
it.each(['项目设置', '已归档对话'])('%s for the current project supersedes an earlier pending project open', async (action) => {
await renderSidebar();
let resolveProjectB!: (value: unknown) => void;
const pendingProjectB = new Promise((resolve) => { resolveProjectB = resolve; });
projectConfigState.load.mockImplementation((projectId) => projectId === projects[1].id
? pendingProjectB
: Promise.resolve({ status: 'valid', config: {} }));
fireEvent.click(screen.getByRole('button', { name: '进入项目 B' }));
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
expect(codingState.setActiveProject).not.toHaveBeenCalled();
await selectProjectAction('A', action);
await waitFor(() => {
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
else expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
});
await act(async () => {
resolveProjectB({ status: 'valid', config: {} });
await pendingProjectB;
});
expect(codingState.activeProjectId).toBe(projects[0].id);
expect(codingState.setActiveProject).not.toHaveBeenCalledWith(projects[1].id);
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
else {
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
}
});
it.each(['项目设置', '已归档对话'])('rejects %s for an inactive project with invalid configuration', async (action) => {
await renderSidebar();
projectConfigState.load.mockResolvedValue({ status: 'invalid', config: null, error: '配置文件无效' });
await selectProjectAction('B', action);
expect(await screen.findByRole('alertdialog')).toHaveTextContent('配置文件无效');
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
expect(codingState.setActiveProject).not.toHaveBeenCalled();
expect(codingState.activeProjectId).toBe(projects[0].id);
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('recent');
});
});