Merge paired WeChat binding and compound unbinding workflow
This commit is contained in:
commit
1bf831ef97
10 files changed
+2372
-769
No files matched your search
@@ -22,7 +22,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。
|
||||
Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。
|
||||
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
|
||||
|
||||
@@ -60,6 +60,7 @@ function projectMutation(value: unknown): unknown {
|
||||
...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}),
|
||||
...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}),
|
||||
...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }),
|
||||
...(source.unbound === undefined ? {} : { unbound: source.unbound === true }),
|
||||
};
|
||||
}
|
||||
function projectWechat(value: unknown): unknown {
|
||||
@@ -187,7 +188,7 @@ function projectSchedules(value: unknown): unknown { return { jobs: list(object(
|
||||
function validateChannelInput(operation: string, args: ObjectRecord): void {
|
||||
const channelOperations = new Set([
|
||||
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount',
|
||||
'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus',
|
||||
'disconnectChannelAccount', 'unbindChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus',
|
||||
'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind',
|
||||
'channelAccountOperation',
|
||||
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
|
||||
@@ -211,6 +212,9 @@ function validateChannelInput(operation: string, args: ObjectRecord): void {
|
||||
if (operation === 'routeChannelAccount' && args.target_agent_slug !== null
|
||||
&& (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
|
||||
if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input');
|
||||
if (operation === 'unbindChannelAccount' && (typeof args.channel_account_id !== 'string'
|
||||
|| typeof args.operation_id !== 'string' || !Number.isSafeInteger(args.expected_revision)
|
||||
|| Number(args.expected_revision) < 0)) throw new Error('invalid_input');
|
||||
if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
|
||||
if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
|
||||
if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
|
||||
@@ -294,6 +298,7 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
||||
enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
|
||||
pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
|
||||
disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation },
|
||||
unbindChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/unbind', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected', 'unbound'], project: projectMutation },
|
||||
channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
|
||||
@@ -55,6 +55,7 @@ const MESSAGES: Record<string, string> = {
|
||||
download_failed: '文件保存失败,请重试',
|
||||
channel_not_found: '微信渠道不存在或已解绑',
|
||||
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
|
||||
channel_operation_unconfirmed: '微信绑定的变更尚未确认,请查看待确认操作后重试',
|
||||
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
|
||||
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
|
||||
channel_conversation_not_found: '微信会话不存在或已关闭',
|
||||
@@ -424,9 +425,20 @@ export class CloudAgentsModule {
|
||||
let plan;
|
||||
try { plan = operationPlan(value); }
|
||||
catch { throw new CloudAgentsError(422, 'invalid_input'); }
|
||||
const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
|
||||
const invoke = async () => {
|
||||
const result = plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
|
||||
if (plan.operation === 'unbindChannelAccount') {
|
||||
const value = record(result), channel = record(value.channel);
|
||||
if (value.unbound !== true || value.disconnected !== true || channel.id !== plan.input.channel_account_id
|
||||
|| channel.enabled !== false || channel.target_agent_slug !== null || channel.health !== 'login_required') {
|
||||
// Keep the original operation in recovery until both routing and login are confirmed removed.
|
||||
throw new CloudAgentsError(503, 'channel_operation_unconfirmed');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
|
||||
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount',
|
||||
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount', 'unbindChannelAccount',
|
||||
'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind',
|
||||
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding',
|
||||
'wechatBindStart', 'wechatBindVerification', 'wechatUnbind',
|
||||
|
||||
@@ -222,10 +222,11 @@ export interface CloudUserChannelAccount {
|
||||
target_agent_published_version: number | null;
|
||||
provider_generation?: string;
|
||||
desired_state?: string;
|
||||
sync_state?: string;
|
||||
last_confirmed_at?: string | null;
|
||||
}
|
||||
export interface CloudChannelAccountPage { items: CloudUserChannelAccount[] }
|
||||
export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record<string, unknown>; disconnected?: boolean }
|
||||
export interface CloudChannelAccountMutation { channel: CloudUserChannelAccount; target_agent?: string | null; wechat?: Record<string, unknown>; disconnected?: boolean; unbound?: boolean }
|
||||
export interface CloudChannelOperation {
|
||||
operation_id: string;
|
||||
status: 'pending' | 'completed' | 'failed' | 'unknown' | string;
|
||||
@@ -424,6 +425,7 @@ export interface CloudAgentOperations {
|
||||
enableChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
|
||||
pauseChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
|
||||
disconnectChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
|
||||
unbindChannelAccount: Op<{ channel_account_id: string; operation_id: string; expected_revision: number }, CloudChannelAccountMutation>;
|
||||
channelAccountWechatBindStart: Op<{ channel_account_id: string; operation_id: string; force: boolean }, CloudWechatBindState>;
|
||||
channelAccountWechatBindStatus: Op<{ channel_account_id: string; session_key: string }, CloudWechatBindState>;
|
||||
channelAccountWechatBindVerification: Op<{ channel_account_id: string; operation_id: string; session_key: string; verify_code: string }, CloudWechatBindState>;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -17,6 +17,7 @@ const labels: Record<string, string> = {
|
||||
createChannelAccount: '添加微信账号', routeChannelAccount: '切换目标智能体', enableChannelAccount: '启用渠道',
|
||||
pauseChannelAccount: '暂停渠道', disconnectChannelAccount: '断开微信账号', channelAccountWechatBindStart: '微信扫码连接',
|
||||
channelAccountWechatUnbind: '解除微信登录',
|
||||
unbindChannelAccount: '解除微信绑定',
|
||||
};
|
||||
const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']);
|
||||
|
||||
@@ -45,6 +46,9 @@ export function CloudRecovery({ pending, onResolved, triggerContainer }: { pendi
|
||||
if (response.requires_input) {
|
||||
setResult(null);
|
||||
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
|
||||
} else if (response.pending) {
|
||||
setResult(null);
|
||||
setNotice('这次操作还在确认中,请稍后再查看。');
|
||||
} else if (response.retired) {
|
||||
setResult(null);
|
||||
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
|
||||
|
||||
test('guides an empty channel workspace from the sidebar back to My Agents', async ({ launchElectronApp }, testInfo) => {
|
||||
test('guides an empty channel workspace from the sidebar back to My Agents', async ({
|
||||
launchElectronApp,
|
||||
}, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
@@ -13,19 +15,61 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy
|
||||
Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls });
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
|
||||
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
|
||||
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } });
|
||||
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
|
||||
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
||||
calls.push({ method: request.method ?? 'GET', path, operation: body.operation });
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null });
|
||||
if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] });
|
||||
throw new Error('Unexpected empty channel fixture request ' + path);
|
||||
});
|
||||
ipcMain.handle(
|
||||
'hostapi:fetch',
|
||||
async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync')
|
||||
return result({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'ui-fixture',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: false,
|
||||
},
|
||||
});
|
||||
if (path === '/api/auth/me')
|
||||
return result({
|
||||
success: true,
|
||||
user: {
|
||||
username: 'creator',
|
||||
userId: 'creator',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
|
||||
});
|
||||
if (path === '/api/works/user/agent-profile')
|
||||
return result({
|
||||
success: true,
|
||||
profile: {
|
||||
display_name: '创作者',
|
||||
age: null,
|
||||
gender: null,
|
||||
avatar_url: null,
|
||||
share_age_with_agents: false,
|
||||
share_gender_with_agents: false,
|
||||
analysis_enabled: true,
|
||||
completed: true,
|
||||
version: 1,
|
||||
updated_at: '2026-10-13T00:00:00Z',
|
||||
},
|
||||
});
|
||||
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
|
||||
const body =
|
||||
typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {});
|
||||
calls.push({ method: request.method ?? 'GET', path, operation: body.operation });
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/agents') || path.endsWith('/bootstrap'))
|
||||
return result({ agents: [], next_cursor: null });
|
||||
if (path.endsWith('/actions') && body.operation === 'channelAccounts')
|
||||
return result({ items: [] });
|
||||
throw new Error('Unexpected empty channel fixture request ' + path);
|
||||
}
|
||||
);
|
||||
});
|
||||
await page.goto(applicationUrl);
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
@@ -34,36 +78,70 @@ test('guides an empty channel workspace from the sidebar back to My Agents', asy
|
||||
await expect(page).toHaveURL(/\/cloud-agents\/channels$/);
|
||||
const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true });
|
||||
await expect(hint).toBeVisible();
|
||||
await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true });
|
||||
|
||||
await page.getByRole('button', { name: '添加微信', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '添加微信', exact: true });
|
||||
await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled();
|
||||
await dialog.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
|
||||
await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); });
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.setMinimumSize(800, 600);
|
||||
window.setSize(980, 680);
|
||||
});
|
||||
await expect(hint).toBeVisible();
|
||||
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth)
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: '去我的智能体', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/);
|
||||
await expect(page.getByTestId('cloud-agents-page')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls);
|
||||
expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']);
|
||||
expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
);
|
||||
const calls = await app.evaluate(
|
||||
() =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[];
|
||||
}
|
||||
).cloudChannelEmptyFixtureCalls
|
||||
);
|
||||
expect(calls.filter((call) => call.operation).map((call) => call.operation)).toEqual([
|
||||
'channelAccounts',
|
||||
]);
|
||||
expect(calls.every((call) => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
|
||||
await app
|
||||
.evaluate(({ BrowserWindow }) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) window.destroy();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => {
|
||||
test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({
|
||||
launchElectronApp,
|
||||
}, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const agentSlug = 'ml-' + 'a'.repeat(32);
|
||||
const secondAgentSlug = 'ml-' + 'b'.repeat(32);
|
||||
@@ -77,69 +155,319 @@ test('WeChat binding cards connect an account and switch its Agent through the c
|
||||
const calls: { operation: string; input: unknown }[] = [];
|
||||
Object.assign(globalThis, { cloudChannelFixtureCalls: calls });
|
||||
let verified = false;
|
||||
let rejectUnbind = true;
|
||||
const slug = 'ml-' + 'a'.repeat(32);
|
||||
const configuration = { model: 'test-model', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'default', max_execution_steps: 40, max_output_tokens: 4096, max_run_seconds: 600 };
|
||||
const draft = { slug, name: '我的创作助手', purpose: '整理选题与制作演示文稿', system_prompt: '使用简洁自然的中文', draft_revision: 2, updated_at: '2026-09-13T00:00:00Z', configuration, published_version: 1, enabled: true, archived: false };
|
||||
const configuration = {
|
||||
model: 'test-model',
|
||||
tools: [],
|
||||
knowledges: [],
|
||||
mcps: [],
|
||||
skills: [],
|
||||
preload_skills: [],
|
||||
subagents: [],
|
||||
tool_approval_mode: 'default',
|
||||
max_execution_steps: 40,
|
||||
max_output_tokens: 4096,
|
||||
max_run_seconds: 600,
|
||||
};
|
||||
const draft = {
|
||||
slug,
|
||||
name: '我的创作助手',
|
||||
purpose: '整理选题与制作演示文稿',
|
||||
system_prompt: '使用简洁自然的中文',
|
||||
draft_revision: 2,
|
||||
updated_at: '2026-09-13T00:00:00Z',
|
||||
configuration,
|
||||
published_version: 1,
|
||||
enabled: true,
|
||||
archived: false,
|
||||
};
|
||||
const secondSlug = 'ml-' + 'b'.repeat(32);
|
||||
const secondDraft = { ...draft, slug: secondSlug, name: '资料整理助手', draft_revision: 1, published_version: 1 };
|
||||
const channel = { id: 'wechat-a', address: 'wechat:personal', display_name: '创作微信', target_agent_address: 'yuxi:makelore:' + slug, route_revision: 3, provider_generation: 'generation-a', binding_id: 'binding-a', enabled: true, status: 'active', worker_online: true, agent_slug: slug, published_version: 1, desired_state: 'enabled', sync_state: 'ready', health: 'connected', last_confirmed_at: '2026-09-13T01:00:00Z', revision: 3, blockers: [], access_mode: 'self_only', policy_revision: 0, target_agent_slug: slug, target_agent_name: draft.name, target_agent_published_version: 1 };
|
||||
const spare = { id: 'wechat-b', address: 'wechat:spare', display_name: '备用微信', target_agent_address: null, route_revision: 1, provider_generation: 'generation-b', binding_id: 'binding-b', enabled: false, status: 'paused', worker_online: true, agent_slug: null, published_version: null, desired_state: 'disabled', sync_state: 'ready', health: 'login_required', last_confirmed_at: null, revision: 1, blockers: ['target_required'], access_mode: 'self_only', policy_revision: 0, target_agent_slug: null, target_agent_name: null, target_agent_published_version: null };
|
||||
const secondDraft = {
|
||||
...draft,
|
||||
slug: secondSlug,
|
||||
name: '资料整理助手',
|
||||
draft_revision: 1,
|
||||
published_version: 1,
|
||||
};
|
||||
const channel = {
|
||||
id: 'wechat-a',
|
||||
address: 'wechat:personal',
|
||||
display_name: '创作微信',
|
||||
target_agent_address: 'yuxi:makelore:' + slug,
|
||||
route_revision: 3,
|
||||
provider_generation: 'generation-a',
|
||||
binding_id: 'binding-a',
|
||||
enabled: true,
|
||||
status: 'active',
|
||||
worker_online: true,
|
||||
agent_slug: slug,
|
||||
published_version: 1,
|
||||
desired_state: 'enabled',
|
||||
sync_state: 'ready',
|
||||
health: 'connected',
|
||||
last_confirmed_at: '2026-09-13T01:00:00Z',
|
||||
revision: 3,
|
||||
blockers: [],
|
||||
access_mode: 'self_only',
|
||||
policy_revision: 0,
|
||||
target_agent_slug: slug,
|
||||
target_agent_name: draft.name,
|
||||
target_agent_published_version: 1,
|
||||
};
|
||||
const spare = {
|
||||
id: 'wechat-b',
|
||||
address: 'wechat:spare',
|
||||
display_name: '备用微信',
|
||||
target_agent_address: null,
|
||||
route_revision: 1,
|
||||
provider_generation: 'generation-b',
|
||||
binding_id: 'binding-b',
|
||||
enabled: false,
|
||||
status: 'paused',
|
||||
worker_online: true,
|
||||
agent_slug: null,
|
||||
published_version: null,
|
||||
desired_state: 'disabled',
|
||||
sync_state: 'ready',
|
||||
health: 'login_required',
|
||||
last_confirmed_at: null,
|
||||
revision: 1,
|
||||
blockers: ['target_required'],
|
||||
access_mode: 'self_only',
|
||||
policy_revision: 0,
|
||||
target_agent_slug: null,
|
||||
target_agent_name: null,
|
||||
target_agent_published_version: null,
|
||||
};
|
||||
let channelAccounts = [channel, spare];
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
|
||||
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
|
||||
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-09-13T00:00:00Z' } });
|
||||
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
|
||||
const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {};
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/recovery/recent')) return result({ saved: true });
|
||||
if (!path.endsWith('/actions')) return result(path.endsWith('/agents') || path.endsWith('/bootstrap') ? { agents: [draft, secondDraft], next_cursor: null } : path.endsWith(secondSlug) ? secondDraft : draft);
|
||||
const { operation, input = {} } = body;
|
||||
calls.push({ operation, input });
|
||||
if (operation === 'threads') return result({ threads: [], next_offset: null });
|
||||
if (operation === 'history') return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null });
|
||||
if (operation === 'attachments' || operation === 'files') return result({ attachments: [], files: [] });
|
||||
if (operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型' }], resources: {}, pricing: null });
|
||||
if (operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: 'niancode://agents/' + slug });
|
||||
if (operation === 'budget') return result({ request_limit_points: '2.50', daily_limit_points: '10.00', daily_committed_points: '0.20' });
|
||||
if (operation === 'costs') return result({ items: [], next_offset: null, summary: { count: 0, settled_points: '0', pending_points: '0' } });
|
||||
if (operation === 'channelAccounts') return result({ items: channelAccounts });
|
||||
if (operation === 'createChannelAccount') {
|
||||
const created = { ...spare, id: 'wechat-created', display_name: input.display_name, binding_id: 'binding-created' };
|
||||
channelAccounts.push(created);
|
||||
return result({ channel: created });
|
||||
ipcMain.handle(
|
||||
'hostapi:fetch',
|
||||
async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync')
|
||||
return result({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'ui-fixture',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: false,
|
||||
},
|
||||
});
|
||||
if (path === '/api/auth/me')
|
||||
return result({
|
||||
success: true,
|
||||
user: {
|
||||
username: 'creator',
|
||||
userId: 'creator',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
|
||||
});
|
||||
if (path === '/api/works/user/agent-profile')
|
||||
return result({
|
||||
success: true,
|
||||
profile: {
|
||||
display_name: '创作者',
|
||||
age: null,
|
||||
gender: null,
|
||||
avatar_url: null,
|
||||
share_age_with_agents: false,
|
||||
share_gender_with_agents: false,
|
||||
analysis_enabled: true,
|
||||
completed: true,
|
||||
version: 1,
|
||||
updated_at: '2026-09-13T00:00:00Z',
|
||||
},
|
||||
});
|
||||
if (!path.startsWith('/api/cloud-agents/')) return result({ success: true });
|
||||
const body =
|
||||
typeof request.body === 'string' ? JSON.parse(request.body) : (request.body ?? {});
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/recovery/recent')) return result({ saved: true });
|
||||
if (!path.endsWith('/actions'))
|
||||
return result(
|
||||
path.endsWith('/agents') || path.endsWith('/bootstrap')
|
||||
? { agents: [draft, secondDraft], next_cursor: null }
|
||||
: path.endsWith(secondSlug)
|
||||
? secondDraft
|
||||
: draft
|
||||
);
|
||||
const { operation, input = {} } = body;
|
||||
calls.push({ operation, input });
|
||||
if (operation === 'threads') return result({ threads: [], next_offset: null });
|
||||
if (operation === 'history')
|
||||
return result({ thread_id: 'desktop-thread', messages: [], run: null, next_offset: null });
|
||||
if (operation === 'attachments' || operation === 'files')
|
||||
return result({ attachments: [], files: [] });
|
||||
if (operation === 'catalog')
|
||||
return result({
|
||||
models: [{ id: 'test-model', name: '创作模型' }],
|
||||
resources: {},
|
||||
pricing: null,
|
||||
});
|
||||
if (operation === 'access')
|
||||
return result({
|
||||
published_version: 1,
|
||||
enabled: true,
|
||||
versions: [],
|
||||
grants: [],
|
||||
applications: [],
|
||||
share_url: 'niancode://agents/' + slug,
|
||||
});
|
||||
if (operation === 'budget')
|
||||
return result({
|
||||
request_limit_points: '2.50',
|
||||
daily_limit_points: '10.00',
|
||||
daily_committed_points: '0.20',
|
||||
});
|
||||
if (operation === 'costs')
|
||||
return result({
|
||||
items: [],
|
||||
next_offset: null,
|
||||
summary: { count: 0, settled_points: '0', pending_points: '0' },
|
||||
});
|
||||
if (operation === 'channelAccounts') return result({ items: channelAccounts });
|
||||
if (operation === 'createChannelAccount') {
|
||||
const created = {
|
||||
...spare,
|
||||
id: 'wechat-created',
|
||||
display_name: input.display_name,
|
||||
binding_id: 'binding-created',
|
||||
};
|
||||
channelAccounts.push(created);
|
||||
return result({ channel: created });
|
||||
}
|
||||
if (operation === 'routeChannelAccount') {
|
||||
const stale = { ...channelAccounts.find((item) => item.id === input.channel_account_id) };
|
||||
const target =
|
||||
input.target_agent_slug === secondSlug
|
||||
? secondDraft
|
||||
: input.target_agent_slug === slug
|
||||
? draft
|
||||
: null;
|
||||
channelAccounts = channelAccounts.map((item) =>
|
||||
item.id !== input.channel_account_id
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
enabled: input.enabled,
|
||||
target_agent_slug: target?.slug ?? null,
|
||||
target_agent_name: target?.name ?? null,
|
||||
target_agent_published_version: target?.published_version ?? null,
|
||||
agent_slug: target?.slug ?? null,
|
||||
published_version: target?.published_version ?? null,
|
||||
target_agent_address: target ? 'yuxi:makelore:' + target.slug : null,
|
||||
blockers: target ? [] : ['target_required'],
|
||||
}
|
||||
);
|
||||
return result({ channel: stale, target_agent: target?.slug ?? null });
|
||||
}
|
||||
if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') {
|
||||
const enabled = operation === 'enableChannelAccount';
|
||||
channelAccounts = channelAccounts.map((item) =>
|
||||
item.id !== input.channel_account_id
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
enabled,
|
||||
desired_state: enabled ? 'enabled' : 'disabled',
|
||||
}
|
||||
);
|
||||
return result({
|
||||
channel: channelAccounts.find((item) => item.id === input.channel_account_id),
|
||||
});
|
||||
}
|
||||
if (operation === 'unbindChannelAccount') {
|
||||
if (rejectUnbind) {
|
||||
rejectUnbind = false;
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 409,
|
||||
ok: false,
|
||||
json: {
|
||||
error: '解除暂时失败,请重试',
|
||||
code: 'channel_operation_conflict',
|
||||
commandOutcome: 'definitive_failure',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
channelAccounts = channelAccounts.map((item) =>
|
||||
item.id === input.channel_account_id
|
||||
? {
|
||||
...item,
|
||||
health: 'login_required',
|
||||
enabled: false,
|
||||
desired_state: 'paused',
|
||||
sync_state: 'ready',
|
||||
target_agent_slug: null,
|
||||
target_agent_name: null,
|
||||
target_agent_address: '',
|
||||
target_agent_published_version: null,
|
||||
agent_slug: null,
|
||||
published_version: null,
|
||||
provider_generation: 'logged-out-generation',
|
||||
revision: item.revision + 1,
|
||||
}
|
||||
: item
|
||||
);
|
||||
return result({
|
||||
channel: channelAccounts.find((item) => item.id === input.channel_account_id),
|
||||
disconnected: true,
|
||||
unbound: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'channelBindings')
|
||||
return result({
|
||||
items: channelAccounts.filter((item) => item.target_agent_slug === input.slug),
|
||||
available_accounts: [],
|
||||
});
|
||||
if (
|
||||
operation === 'channelAccountWechatBindStart' ||
|
||||
operation === 'channelAccountWechatBindStatus'
|
||||
)
|
||||
return result({
|
||||
session_key: 'qr-session',
|
||||
status: verified ? 'confirmed' : 'verification_required',
|
||||
qrcode_url: 'https://login.example.test/confirm?token=fixture',
|
||||
message: '请确认微信验证码',
|
||||
});
|
||||
if (operation === 'channelAccountWechatBindVerification') {
|
||||
verified = true;
|
||||
channelAccounts = channelAccounts.map((item) =>
|
||||
item.id === input.channel_account_id ? { ...item, health: 'connected' } : item
|
||||
);
|
||||
return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' });
|
||||
}
|
||||
if (operation === 'schedules') return result({ jobs: [] });
|
||||
if (operation === 'scheduleContext')
|
||||
return result({
|
||||
version: 1,
|
||||
enabled: true,
|
||||
configuration,
|
||||
result_destination: '任务历史',
|
||||
payer: 'creator',
|
||||
});
|
||||
throw new Error('Unexpected channel UI fixture operation ' + operation);
|
||||
}
|
||||
if (operation === 'routeChannelAccount') {
|
||||
const target = input.target_agent_slug === secondSlug ? secondDraft : input.target_agent_slug === slug ? draft : null;
|
||||
channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled: input.enabled,
|
||||
target_agent_slug: target?.slug ?? null, target_agent_name: target?.name ?? null, target_agent_published_version: target?.published_version ?? null,
|
||||
agent_slug: target?.slug ?? null, published_version: target?.published_version ?? null, target_agent_address: target ? 'yuxi:makelore:' + target.slug : null,
|
||||
blockers: target ? [] : ['target_required'] });
|
||||
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), target_agent: target?.slug ?? null });
|
||||
}
|
||||
if (operation === 'enableChannelAccount' || operation === 'pauseChannelAccount') {
|
||||
const enabled = operation === 'enableChannelAccount';
|
||||
channelAccounts = channelAccounts.map(item => item.id !== input.channel_account_id ? item : { ...item, revision: item.revision + 1, enabled, desired_state: enabled ? 'enabled' : 'disabled' });
|
||||
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id) });
|
||||
}
|
||||
if (operation === 'disconnectChannelAccount') {
|
||||
channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'login_required', enabled: false, revision: item.revision + 1 } : item);
|
||||
return result({ channel: channelAccounts.find(item => item.id === input.channel_account_id), disconnected: true });
|
||||
}
|
||||
if (operation === 'channelBindings') return result({ items: channelAccounts.filter(item => item.target_agent_slug === input.slug), available_accounts: [] });
|
||||
if (operation === 'channelAccountWechatBindStart' || operation === 'channelAccountWechatBindStatus') return result({ session_key: 'qr-session', status: verified ? 'confirmed' : 'verification_required', qrcode_url: 'https://login.example.test/confirm?token=fixture', message: '请确认微信验证码' });
|
||||
if (operation === 'channelAccountWechatBindVerification') { verified = true; channelAccounts = channelAccounts.map(item => item.id === input.channel_account_id ? { ...item, health: 'connected' } : item); return result({ session_key: 'qr-session', status: 'confirmed', message: '连接已确认' }); }
|
||||
if (operation === 'schedules') return result({ jobs: [] });
|
||||
if (operation === 'scheduleContext') return result({ version: 1, enabled: true, configuration, result_destination: '任务历史', payer: 'creator' });
|
||||
throw new Error('Unexpected channel UI fixture operation ' + operation);
|
||||
});
|
||||
);
|
||||
});
|
||||
await page.goto(applicationUrl);
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await page.getByRole('article', { name: '我的创作助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
|
||||
await page
|
||||
.getByRole('article', { name: '我的创作助手', exact: true })
|
||||
.getByRole('button', { name: '和它聊聊', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '我的创作助手', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
@@ -151,7 +479,7 @@ test('WeChat binding cards connect an account and switch its Agent through the c
|
||||
const legacyCard = page.getByRole('article', { name: '创作微信微信', exact: true });
|
||||
const spareCard = page.getByRole('article', { name: '备用微信微信', exact: true });
|
||||
await expect(legacyCard.getByText('我的创作助手', { exact: true })).toBeVisible();
|
||||
await expect(spareCard.getByRole('button', { name: '继续绑定', exact: true })).toBeVisible();
|
||||
await expect(spareCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
@@ -159,59 +487,225 @@ test('WeChat binding cards connect an account and switch its Agent through the c
|
||||
|
||||
await page.getByRole('button', { name: '添加微信', exact: true }).click();
|
||||
const adding = page.getByRole('dialog', { name: '添加微信', exact: true });
|
||||
await adding.getByLabel('选择智能体', { exact: true }).selectOption(agentSlug);
|
||||
await adding.getByLabel('微信备注(可选)', { exact: true }).fill('新创作微信');
|
||||
await adding
|
||||
.getByRole('radiogroup', { name: '选择智能体' })
|
||||
.getByText('我的创作助手', { exact: true })
|
||||
.click();
|
||||
await expect(adding.getByRole('radio', { name: '我的创作助手', exact: true })).toBeChecked();
|
||||
await adding.getByRole('button', { name: '下一步:扫码连接', exact: true }).click();
|
||||
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute('src', /^data:image\//);
|
||||
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls
|
||||
.filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-created' && call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) && call.input.enabled === false))).toBe(true);
|
||||
await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toBeDisabled();
|
||||
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveAttribute(
|
||||
'src',
|
||||
/^data:image\//
|
||||
);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
app.evaluate(() =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
|
||||
}
|
||||
).cloudChannelFixtureCalls
|
||||
.filter((call) => call.operation === 'routeChannelAccount')
|
||||
.some(
|
||||
(call) =>
|
||||
call.input.channel_account_id === 'wechat-created' &&
|
||||
call.input.target_agent_slug === 'ml-' + 'a'.repeat(32) &&
|
||||
call.input.enabled === false
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(adding.getByRole('button', { name: '完成绑定并启用', exact: true })).toHaveCount(0);
|
||||
await adding.getByLabel('微信验证码', { exact: true }).fill('123456');
|
||||
await adding.getByRole('button', { name: '提交验证码', exact: true }).click();
|
||||
await expect(adding.getByRole('img', { name: '个人微信登录二维码', exact: true })).toHaveCount(0);
|
||||
await expect(adding.getByLabel('微信验证码', { exact: true })).toHaveCount(0);
|
||||
await expect(adding.getByText('微信已连接', { exact: true })).toBeVisible();
|
||||
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'enableChannelAccount'))).toBe(false);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-connection-confirmed.png'), fullPage: true });
|
||||
await adding.getByRole('button', { name: '完成绑定并启用', exact: true }).click();
|
||||
await expect(adding.getByRole('heading', { name: '微信绑定成功', exact: true })).toBeVisible();
|
||||
expect(
|
||||
await app.evaluate(
|
||||
() =>
|
||||
(
|
||||
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
|
||||
).cloudChannelFixtureCalls.filter((call) => call.operation === 'enableChannelAccount').length
|
||||
)
|
||||
).toBe(1);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('wechat-automatically-connected.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await expect(adding.getByRole('button', { name: '完成', exact: true })).toBeVisible();
|
||||
await adding.getByRole('button', { name: '完成', exact: true }).click();
|
||||
await expect(page.getByRole('article', { name: '新创作微信微信', exact: true }).getByText('我的创作助手', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('article', { name: '微信 3微信', exact: true })
|
||||
.getByText('我的创作助手', { exact: true })
|
||||
).toBeVisible();
|
||||
|
||||
await legacyCard.getByRole('button', { name: '管理绑定', exact: true }).click();
|
||||
const managing = page.getByRole('dialog', { name: '管理微信绑定', exact: true });
|
||||
const qrStartsBeforeSwitch = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length);
|
||||
await managing.getByLabel('绑定的智能体', { exact: true }).selectOption(secondAgentSlug);
|
||||
await managing.getByRole('button', { name: '保存更换', exact: true }).click();
|
||||
const switching = page.getByRole('dialog', { name: '更换绑定的智能体', exact: true });
|
||||
await switching.getByRole('button', { name: /确认更换/ }).click();
|
||||
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls
|
||||
.filter(call => call.operation === 'routeChannelAccount').some(call => call.input.channel_account_id === 'wechat-a' && call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) && call.input.enabled === true))).toBe(true);
|
||||
await expect(managing.getByText('已更换绑定的智能体,微信连接保留。', { exact: true })).toBeVisible();
|
||||
await expect(managing.getByLabel('绑定的智能体', { exact: true })).toHaveValue(secondAgentSlug);
|
||||
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.filter(call => call.operation === 'channelAccountWechatBindStart').length)).toBe(qrStartsBeforeSwitch);
|
||||
await legacyCard.getByRole('button', { name: '更换智能体', exact: true }).click();
|
||||
const managing = page.getByRole('dialog', { name: '更换智能体', exact: true });
|
||||
const qrStartsBeforeSwitch = await app.evaluate(
|
||||
() =>
|
||||
(
|
||||
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
|
||||
).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart')
|
||||
.length
|
||||
);
|
||||
await managing
|
||||
.getByRole('radiogroup', { name: '选择智能体' })
|
||||
.getByText('资料整理助手', { exact: true })
|
||||
.click();
|
||||
await expect(managing.getByRole('radio', { name: '资料整理助手', exact: true })).toBeChecked();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-agent-choice.png'), fullPage: true });
|
||||
await managing.getByRole('button', { name: '确认更换', exact: true }).click();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
app.evaluate(() =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
|
||||
}
|
||||
).cloudChannelFixtureCalls
|
||||
.filter((call) => call.operation === 'routeChannelAccount')
|
||||
.some(
|
||||
(call) =>
|
||||
call.input.channel_account_id === 'wechat-a' &&
|
||||
call.input.target_agent_slug === 'ml-' + 'b'.repeat(32) &&
|
||||
call.input.enabled === true
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(managing).toHaveCount(0);
|
||||
await expect(page.getByText('已更换智能体,微信连接和历史保留。', { exact: true })).toBeVisible();
|
||||
expect(
|
||||
await app.evaluate(
|
||||
() =>
|
||||
(
|
||||
globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }
|
||||
).cloudChannelFixtureCalls.filter((call) => call.operation === 'channelAccountWechatBindStart')
|
||||
.length
|
||||
)
|
||||
).toBe(qrStartsBeforeSwitch);
|
||||
await expect(page.getByText('活动记录', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '微信对话', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/本人|受邀|邀请|使用范围|使用权限|授权调用者/)).toHaveCount(0);
|
||||
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });
|
||||
await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-manage-binding-compact.png'), fullPage: true });
|
||||
await managing.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await expect(managing).toHaveCount(0);
|
||||
await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible();
|
||||
const finalCalls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[] }).cloudChannelFixtureCalls);
|
||||
expect(finalCalls.some(call => ['channelActivity', 'channelConversations', 'channelConversation', 'channelConversationControl', 'channelFiles', 'downloadChannelArtifact', 'channelPolicy', 'createChannelPairing', 'channelCallers', 'channelSelfCallers', 'revokeChannelCaller', 'submit', 'preview', 'resume'].includes(call.operation))).toBe(false);
|
||||
expect(finalCalls.filter(call => call.operation === 'createChannelAccount')).toHaveLength(1);
|
||||
expect(finalCalls.find(call => call.operation === 'createChannelAccount')?.input).toEqual({ display_name: '新创作微信', operation_id: expect.any(String) });
|
||||
expect(finalCalls.find(call => call.operation === 'channelAccountWechatBindVerification')?.input).toEqual({ channel_account_id: 'wechat-created', session_key: 'qr-session', verify_code: '123456', operation_id: expect.any(String) });
|
||||
expect(finalCalls.find(call => call.operation === 'enableChannelAccount')?.input).toEqual({ channel_account_id: 'wechat-created', expected_revision: 2, operation_id: expect.any(String) });
|
||||
expect(finalCalls.filter(call => call.operation === 'routeChannelAccount').map(call => call.input)).toEqual([
|
||||
{ channel_account_id: 'wechat-created', target_agent_slug: agentSlug, expected_revision: 1, enabled: false, operation_id: expect.any(String) },
|
||||
{ channel_account_id: 'wechat-a', target_agent_slug: secondAgentSlug, expected_revision: 3, enabled: true, operation_id: expect.any(String) },
|
||||
]);
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
win.setMinimumSize(800, 600);
|
||||
win.setSize(980, 680);
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId('main-content').evaluate((element) => element.scrollWidth - element.clientWidth)
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('wechat-binding-cards-compact.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await expect(legacyCard.getByText('资料整理助手', { exact: true })).toBeVisible();
|
||||
const finalCalls = await app.evaluate(
|
||||
() =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
|
||||
}
|
||||
).cloudChannelFixtureCalls
|
||||
);
|
||||
expect(
|
||||
finalCalls.some((call) =>
|
||||
[
|
||||
'channelActivity',
|
||||
'channelConversations',
|
||||
'channelConversation',
|
||||
'channelConversationControl',
|
||||
'channelFiles',
|
||||
'downloadChannelArtifact',
|
||||
'channelPolicy',
|
||||
'createChannelPairing',
|
||||
'channelCallers',
|
||||
'channelSelfCallers',
|
||||
'revokeChannelCaller',
|
||||
'submit',
|
||||
'preview',
|
||||
'resume',
|
||||
].includes(call.operation)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(finalCalls.filter((call) => call.operation === 'createChannelAccount')).toHaveLength(1);
|
||||
expect(finalCalls.find((call) => call.operation === 'createChannelAccount')?.input).toEqual({
|
||||
display_name: '微信 3',
|
||||
operation_id: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
finalCalls.find((call) => call.operation === 'channelAccountWechatBindVerification')?.input
|
||||
).toEqual({
|
||||
channel_account_id: 'wechat-created',
|
||||
session_key: 'qr-session',
|
||||
verify_code: '123456',
|
||||
operation_id: expect.any(String),
|
||||
});
|
||||
expect(finalCalls.find((call) => call.operation === 'enableChannelAccount')?.input).toEqual({
|
||||
channel_account_id: 'wechat-created',
|
||||
expected_revision: 2,
|
||||
operation_id: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
finalCalls.filter((call) => call.operation === 'routeChannelAccount').map((call) => call.input)
|
||||
).toEqual([
|
||||
{
|
||||
channel_account_id: 'wechat-created',
|
||||
target_agent_slug: agentSlug,
|
||||
expected_revision: 1,
|
||||
enabled: false,
|
||||
operation_id: expect.any(String),
|
||||
},
|
||||
{
|
||||
channel_account_id: 'wechat-a',
|
||||
target_agent_slug: secondAgentSlug,
|
||||
expected_revision: 3,
|
||||
enabled: true,
|
||||
operation_id: expect.any(String),
|
||||
},
|
||||
]);
|
||||
await legacyCard.getByRole('button', { name: '更多操作', exact: true }).click();
|
||||
await page.screenshot({ path: testInfo.outputPath('wechat-binding-more-compact.png'), fullPage: true });
|
||||
await expect(legacyCard.getByRole('button', { name: '暂停自动回复', exact: true })).toBeVisible();
|
||||
await legacyCard.getByRole('button', { name: '解除微信绑定', exact: true }).click();
|
||||
const unbinding = page.getByRole('dialog', { name: '解除微信绑定', exact: true });
|
||||
await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click();
|
||||
await expect(unbinding.getByRole('alert')).toHaveText(/解除暂时失败/);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(1);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('wechat-unbind-visible-failure.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await unbinding.getByRole('button', { name: '确认解除微信绑定', exact: true }).click();
|
||||
await expect(unbinding).toHaveCount(0);
|
||||
await expect(legacyCard.getByText('未绑定', { exact: true })).toBeVisible();
|
||||
await expect(legacyCard.getByRole('button', { name: '绑定智能体', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('断开微信连接', { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('wechat-unbound-card-compact.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
const unbindCalls = await app.evaluate(() =>
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
cloudChannelFixtureCalls: { operation: string; input: Record<string, unknown> }[];
|
||||
}
|
||||
).cloudChannelFixtureCalls.filter((call) => call.operation === 'unbindChannelAccount')
|
||||
);
|
||||
expect(unbindCalls).toHaveLength(2);
|
||||
expect(unbindCalls[0].input).toEqual(unbindCalls[1].input);
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
|
||||
await app
|
||||
.evaluate(({ BrowserWindow }) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) window.destroy();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -592,6 +592,68 @@ describe('Main cloud Agents boundary', () => {
|
||||
.toBe('/api/makelore/channel-operations/op-route');
|
||||
});
|
||||
|
||||
it('limits unified WeChat unbinding to its owned account and revision contract', () => {
|
||||
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',
|
||||
} });
|
||||
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.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' },
|
||||
unbound: true, disconnected: true, wechat: { status: 'empty' },
|
||||
});
|
||||
for (const invalid of [
|
||||
{ operation_id: input.operation_id, expected_revision: 2 },
|
||||
{ channel_account_id: 'wechat-1', expected_revision: 2 },
|
||||
{ channel_account_id: 'wechat-1', operation_id: input.operation_id },
|
||||
{ channel_account_id: '../other', operation_id: input.operation_id, expected_revision: 2 },
|
||||
]) expect(() => operationPlan({ operation: 'unbindChannelAccount', input: invalid })).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ health: 'connected' }, { enabled: true }, { target_agent_slug: draft.slug }, { id: 'another-account' },
|
||||
])('keeps unified unbinding pending when a success response contains unfinished account state %o', async unfinished => {
|
||||
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required', ...unfinished };
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
|
||||
.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({
|
||||
code: 'channel_operation_unconfirmed', commandOutcome: 'unknown',
|
||||
});
|
||||
expect((await module.recovery()).pending).toEqual([expect.objectContaining({
|
||||
operation: 'unbindChannelAccount', input: original,
|
||||
})]);
|
||||
});
|
||||
|
||||
it('requires compound completion and retries the same unbind intent after a partial success response', async () => {
|
||||
const channel = { id: 'wechat-1', enabled: false, target_agent_slug: null, health: 'login_required' };
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(session())
|
||||
.mockResolvedValueOnce(json({ channel, 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({
|
||||
code: 'channel_operation_unconfirmed',
|
||||
});
|
||||
const pending = (await module.recovery()).pending[0];
|
||||
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([
|
||||
['https://agents.example.test/api/makelore/channel-accounts/wechat-1/unbind', {
|
||||
operation_id: input.operation_id, expected_revision: 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' } } }));
|
||||
|
||||
@@ -184,6 +184,21 @@ it('offers only explicit discard for retired operations and explains the retired
|
||||
expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false);
|
||||
});
|
||||
|
||||
it('keeps a channel operation visibly pending without claiming a confirmed result', async () => {
|
||||
const operation: CloudPendingOperation = { id: 'unbind:original', operation: 'unbindChannelAccount',
|
||||
input: { channel_account_id: 'wechat-1', operation_id: 'original-unbind', expected_revision: 2 },
|
||||
created_at: '2026-10-13T04:00:00Z' };
|
||||
api.resolvePending.mockResolvedValue({ operation: operation.operation, pending: true, result: { status: 'pending' } });
|
||||
render(<CloudRecovery pending={[operation]} onResolved={resolved} />);
|
||||
const dialog = await openRecords();
|
||||
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' }));
|
||||
expect(await within(dialog).findByRole('status')).toHaveTextContent('这次操作还在确认中');
|
||||
expect(within(dialog).queryByText(/结果已确认/)).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('article', { name: '解除微信绑定' })).toBeVisible();
|
||||
expect(resolved).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders no entry or dialog when there are no pending records or retained results', () => {
|
||||
render(<CloudRecovery pending={[]} onResolved={resolved} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
Reference in new issue
Block a user