Integrate delivered Code navigation and Yuxi channel recovery into Canvas trial source
This commit is contained in:
1 parent
2ca6e3199d
commit
3fe3fdb1c0
38 files changed
+1602
-198
No files matched your search
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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' } } }));
|
||||
|
||||
@@ -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'] }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user