From 4b80d880b5f9d946cc4d3a56a65eb0ecfc86705f Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:21:37 +0800 Subject: [PATCH] perf(workbench): load route views on first visit The eight rail pages were statically imported into the entry chunk, so every boot downloaded agents/settings/projects/contacts/tasks/docs even when the user only read chat. - wrap each route view in React.lazy with a shared Suspense boundary; hooks stay mounted in WorkbenchRoutes so drafts and caches survive navigation - replace the 40-line rationale block with a two-line invariant - drop leftover blank lines in the web i18n bootstrap - tests navigate through a new openRailPage helper that flushes the lazy chunk inside act(), so assertions stay synchronous Web build (raw / gzip): entry 1401 KB / 412 KB -> 1117 KB / 347 KB routes now load on demand: agents 191 KB, contacts 28 KB, settings 25 KB, projects 25 KB, tasks 22 KB, docs 11 KB, devices 6 KB, usage 5 KB Co-authored-by: Codex --- app/web/src/i18n/index.ts | 12 --- app/workbench/src/WorkbenchRoutes.tsx | 82 ++++++++++--------- app/workbench/src/__tests__/composer.test.tsx | 4 +- app/workbench/src/__tests__/helpers.tsx | 24 +++++- app/workbench/src/__tests__/hubPages.test.tsx | 50 +++++------ app/workbench/src/__tests__/pages.test.tsx | 16 ++-- 6 files changed, 102 insertions(+), 86 deletions(-) diff --git a/app/web/src/i18n/index.ts b/app/web/src/i18n/index.ts index 0397d461c..d315f201c 100644 --- a/app/web/src/i18n/index.ts +++ b/app/web/src/i18n/index.ts @@ -43,24 +43,12 @@ i18n.use(initReactI18next).init({ resources: { zh: { common: zhCommon, - - [SHARED_WORKBENCH_I18N_NAMESPACE]: sharedWorkbenchResources.zh, - - - - [CHATVIEW_I18N_NAMESPACE]: chatviewResources.zh, }, en: { common: enCommon, - - [SHARED_WORKBENCH_I18N_NAMESPACE]: sharedWorkbenchResources.en, - - - - [CHATVIEW_I18N_NAMESPACE]: chatviewResources.en, }, }, diff --git a/app/workbench/src/WorkbenchRoutes.tsx b/app/workbench/src/WorkbenchRoutes.tsx index e9f45ed96..f1344b338 100644 --- a/app/workbench/src/WorkbenchRoutes.tsx +++ b/app/workbench/src/WorkbenchRoutes.tsx @@ -1,14 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { CHATVIEW_I18N_NAMESPACE } from '@shared/chatview/i18n/resources'; -import { WorkbenchAgentsRouteView } from './routes/WorkbenchAgentsRouteView'; -import { WorkbenchContactsRouteView } from './routes/WorkbenchContactsRouteView'; -import { WorkbenchDevicesRouteView } from './routes/WorkbenchDevicesRouteView'; -import { WorkbenchDocsRouteView } from './routes/WorkbenchDocsRouteView'; -import { WorkbenchProjectsRouteView } from './routes/WorkbenchProjectsRouteView'; -import { WorkbenchSettingsRouteView } from './routes/WorkbenchSettingsRouteView'; -import { WorkbenchTasksRouteView } from './routes/WorkbenchTasksRouteView'; -import { WorkbenchUsageRouteView } from './routes/WorkbenchUsageRouteView'; import { PageErrorBoundary } from './PageErrorBoundary'; import { useWorkbenchAgentsRoute } from './routes/useWorkbenchAgentsRoute'; import { useWorkbenchContactsRoute } from './routes/useWorkbenchContactsRoute'; @@ -23,7 +15,46 @@ import type { WorkbenchRoutesProps } from './routes/workbenchRoutesTypes'; import type { WorkbenchProfileSource } from './profileRegistry'; import styles from './AgentHubWorkbench.module.css'; -// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ── +const WorkbenchAgentsRouteView = React.lazy(() => + import('./routes/WorkbenchAgentsRouteView').then((mod) => ({ + default: mod.WorkbenchAgentsRouteView, + })), +); +const WorkbenchContactsRouteView = React.lazy(() => + import('./routes/WorkbenchContactsRouteView').then((mod) => ({ + default: mod.WorkbenchContactsRouteView, + })), +); +const WorkbenchDevicesRouteView = React.lazy(() => + import('./routes/WorkbenchDevicesRouteView').then((mod) => ({ + default: mod.WorkbenchDevicesRouteView, + })), +); +const WorkbenchDocsRouteView = React.lazy(() => + import('./routes/WorkbenchDocsRouteView').then((mod) => ({ + default: mod.WorkbenchDocsRouteView, + })), +); +const WorkbenchProjectsRouteView = React.lazy(() => + import('./routes/WorkbenchProjectsRouteView').then((mod) => ({ + default: mod.WorkbenchProjectsRouteView, + })), +); +const WorkbenchSettingsRouteView = React.lazy(() => + import('./routes/WorkbenchSettingsRouteView').then((mod) => ({ + default: mod.WorkbenchSettingsRouteView, + })), +); +const WorkbenchTasksRouteView = React.lazy(() => + import('./routes/WorkbenchTasksRouteView').then((mod) => ({ + default: mod.WorkbenchTasksRouteView, + })), +); +const WorkbenchUsageRouteView = React.lazy(() => + import('./routes/WorkbenchUsageRouteView').then((mod) => ({ + default: mod.WorkbenchUsageRouteView, + })), +); export type { WorkbenchAgentProfilesStatus, @@ -34,33 +65,8 @@ export type { WorkbenchContactsActions, WorkbenchContactsData } from './routes/u export type { WorkbenchDocumentsActions } from './routes/useWorkbenchDocsRoute'; export type { WorkbenchProjectsStatus } from './routes/useWorkbenchProjectsRoute'; -/* ═══════════════════════════════════════════════════════════════════════ - #21 性能:route hook 条件化(保留状态的最小方案) - - 问题:所有 6 个 route hook 曾在 WorkbenchRoutes 中无条件执行 —— 不管 - active page 是哪一页,agents/projects/contacts/docs/tasks/settings 的 - hook 每次渲染都跑。 - - 约束排查: - - hooks 规则禁条件调用;route hook 内 "inactive 提前 return" 会让同一实例 - 的 hook 数量在页面切换时变化(0 ↔ N),违反规则。 - - lazy mount(activePage === 'x' && )会改变 unmount 行为: - useWorkbenchAgentsRoute 持有未保存的 agent 草稿(agentDrafts)、 - useWorkbenchTasksRoute 持有任务编辑草稿、useWorkbenchProjectsRoute 持有 - hub 项目缓存与分页游标 —— 切页即丢,属回归,不可用。 - - 方案:把 runs/projects/docs 三个 route 的 hook 移入独立、常驻挂载的 - gate 组件(React.memo + active 标志): - - hook 调用无条件(组件挂载期 hook 数量恒定),状态跨页面切换保留; - - inactive 时只渲染 null(视图挂载语义与原来 switch 一致); - - WorkbenchRoutes 因无关原因重渲染时,gate props(数据引用 + 原始值 + - useMemo 的 profiles)稳定 → memo 跳过 → hook 不再空转; - - 各 route 内部状态变化也只触发自身 gate 重渲染,不再带动其余 hook。 - agents/contacts/settings 保留在 WorkbenchRoutes:agents+contacts 输出是 - profileSources 的输入(agentsRoute.agentConfigs / contactsData.members), - settings 输出 realDataMode 供 3 个 hook 复用;三者热路径均已 useMemo, - 未 memo 化的残余仅为闭包工厂分配(µs 级)。 - ═══════════════════════════════════════════════════════════════════════ */ +// Route hooks stay mounted across page switches so in-progress drafts and cached +// data survive navigation; the gates only skip rendering when inactive. interface WorkbenchTasksRouteGateProps { /** 当前是否为该 route 的 active page(rail 上为 'runs')。 */ @@ -280,7 +286,7 @@ export function WorkbenchRoutes({ activePage === 'settings'; return ( - <> + {activePage === 'contacts' && ( )} @@ -369,6 +375,6 @@ export function WorkbenchRoutes({ {t('routes.unconfigured', { page: activePage })} )} - + ); } diff --git a/app/workbench/src/__tests__/composer.test.tsx b/app/workbench/src/__tests__/composer.test.tsx index 6286c7b03..a84d1e5ae 100644 --- a/app/workbench/src/__tests__/composer.test.tsx +++ b/app/workbench/src/__tests__/composer.test.tsx @@ -4,7 +4,7 @@ // Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench // test shards. Must stay the first import so mock factories register before // the component tree (and its virtua/@lobehub/icons deps) is evaluated. -import { installWorkbenchTestHooks } from './helpers'; +import { installWorkbenchTestHooks, openRailPage } from './helpers'; import { fireEvent, @@ -378,7 +378,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '设置' })); + await openRailPage('设置'); fireEvent.click(screen.getByRole('button', { name: '本地开发' })); fireEvent.click(screen.getByRole('button', { name: 'Ctrl+Enter 发送' })); expect(window.localStorage.getItem('agenthub.workbench.composerSubmitBehavior')).toBe('ctrl-enter-send'); diff --git a/app/workbench/src/__tests__/helpers.tsx b/app/workbench/src/__tests__/helpers.tsx index df545a0d6..732a31466 100644 --- a/app/workbench/src/__tests__/helpers.tsx +++ b/app/workbench/src/__tests__/helpers.tsx @@ -7,7 +7,7 @@ * @lobehub/icons) are evaluated. */ import React from 'react'; -import { cleanup, fireEvent, screen, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, screen, within } from '@testing-library/react'; import { afterEach, beforeAll, vi } from 'vitest'; // Workbench chrome + transcript copy resolve through the shared test @@ -16,6 +16,18 @@ import { useTestI18nLanguage } from '@shared/testing/i18n'; beforeAll(async () => { await useTestI18nLanguage('zh'); + // Route views are code-split; warm the module cache so the lazy chunk + // resolves inside the act() flush of openRailPage instead of a real I/O tick. + await Promise.all([ + import('../routes/WorkbenchAgentsRouteView'), + import('../routes/WorkbenchContactsRouteView'), + import('../routes/WorkbenchDevicesRouteView'), + import('../routes/WorkbenchDocsRouteView'), + import('../routes/WorkbenchProjectsRouteView'), + import('../routes/WorkbenchSettingsRouteView'), + import('../routes/WorkbenchTasksRouteView'), + import('../routes/WorkbenchUsageRouteView'), + ]); }); // jsdom has no layout engine, so virtua cannot measure the viewport/rows and @@ -106,6 +118,16 @@ export function installWorkbenchTestHooks(): void { }); } +/** + * Navigates the rail to a page view. Route views are lazy chunks, so the click + * is flushed through act() and assertions can stay synchronous afterwards. + */ +export async function openRailPage(name: string): Promise { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })); + }); +} + /** inspector tabs beyond overview open on demand via + menu. */ export function restoreInspectorTab(mode: 'files' | 'browser'): void { fireEvent.click(screen.getByRole('button', { name: '新建右侧窗口' })); diff --git a/app/workbench/src/__tests__/hubPages.test.tsx b/app/workbench/src/__tests__/hubPages.test.tsx index a20263074..36122d3e6 100644 --- a/app/workbench/src/__tests__/hubPages.test.tsx +++ b/app/workbench/src/__tests__/hubPages.test.tsx @@ -4,7 +4,7 @@ // Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench // test shards. Must stay the first import so mock factories register before // the component tree (and its virtua/@lobehub/icons deps) is evaluated. -import { installWorkbenchTestHooks } from './helpers'; +import { installWorkbenchTestHooks, openRailPage } from './helpers'; import { fireEvent, @@ -55,7 +55,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main'); expect(projectMain).not.toBeNull(); const projectScope = within(projectMain as HTMLElement); @@ -105,7 +105,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectMain = screen.getByRole('heading', { name: '暂无项目' }).closest('main'); expect(projectMain).not.toBeNull(); const projectScope = within(projectMain as HTMLElement); @@ -123,7 +123,7 @@ describe('AgentHubWorkbench', () => { }); }); - it('hides Hub Projects create affordances when project creation is unavailable', () => { + it('hides Hub Projects create affordances when project creation is unavailable', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -140,7 +140,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); expect(screen.getByRole('heading', { name: '暂无项目' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '新建项目' })).not.toBeInTheDocument(); @@ -148,7 +148,7 @@ describe('AgentHubWorkbench', () => { expect(screen.queryByRole('button', { name: '创建项目' })).not.toBeInTheDocument(); }); - it('hides Hub Projects update affordances when project updates are unavailable', () => { + it('hides Hub Projects update affordances when project updates are unavailable', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -176,7 +176,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main'); expect(projectMain).not.toBeNull(); @@ -186,7 +186,7 @@ describe('AgentHubWorkbench', () => { expect(projectScope.queryByRole('button', { name: '保存项目' })).not.toBeInTheDocument(); }); - it('reports selected Hub project ids to the Web adapter', () => { + it('reports selected Hub project ids to the Web adapter', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -231,7 +231,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); fireEvent.click(screen.getByText('Hub 项目二')); expect(handleActiveProjectChange).toHaveBeenCalledWith('hub-project-2'); @@ -278,7 +278,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main'); expect(projectMain).not.toBeNull(); const projectScope = within(projectMain as HTMLElement); @@ -327,7 +327,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectMain = screen.getByRole('heading', { name: 'Hub 项目' }).closest('main'); expect(projectMain).not.toBeNull(); const projectScope = within(projectMain as HTMLElement); @@ -347,7 +347,7 @@ describe('AgentHubWorkbench', () => { expect(projectScope.getByLabelText('项目名称')).toHaveValue('失败项目'); }); - it('renders supplied Hub AgentProfiles on the Agents rail page instead of mock agents', () => { + it('renders supplied Hub AgentProfiles on the Agents rail page instead of mock agents', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -376,7 +376,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!; expect(within(page).getAllByText('Hub Architect').length).toBeGreaterThan(0); @@ -407,7 +407,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!; const emptyState = within(page).getByRole('region', { name: '暂无已安装 Agent' }); @@ -427,7 +427,7 @@ describe('AgentHubWorkbench', () => { }); }); - it('installs a marketplace fixture into the runnable Agents page', () => { + it('installs a marketplace fixture into the runnable Agents page', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -442,7 +442,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); fireEvent.click(screen.getByRole('button', { name: 'Agent 市场' })); fireEvent.click(screen.getAllByRole('button', { name: '安装' })[0]!); @@ -454,7 +454,7 @@ describe('AgentHubWorkbench', () => { expect(within(page).getByText('记忆未启用')).toBeInTheDocument(); }); - it('does not render mock Agents, Projects, or Tasks when approved-real data is missing', () => { + it('does not render mock Agents, Projects, or Tasks when approved-real data is missing', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -472,7 +472,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); const agentsPage = screen.getByRole('region', { name: '工作台页面' }); const agentsAlert = within(agentsPage).getByRole('alert', { name: 'Agent 加载失败' }); expect(agentsAlert).toHaveTextContent('Hub AgentProfiles unavailable'); @@ -480,13 +480,13 @@ describe('AgentHubWorkbench', () => { expect(within(agentsPage).queryByText('Browser QA')).not.toBeInTheDocument(); expect(within(agentsPage).queryByText('DeepSeek-V4-Pro')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); const projectsPage = screen.getByRole('region', { name: '工作台页面' }); expect(within(projectsPage).getByRole('alert')).toHaveTextContent('Hub Projects unavailable'); expect(within(projectsPage).getByRole('heading', { name: '暂无项目' })).toBeInTheDocument(); expect(within(projectsPage).queryByRole('heading', { name: 'AI 游戏项目' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '任务' })); + await openRailPage('任务'); const tasksPage = screen.getByRole('region', { name: '工作台页面' }); // Real mode without a task backend shows the honest coming-soon empty // state instead of mock task rows (#1818). @@ -526,7 +526,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); const page = screen.getByRole('heading', { name: 'Agent 管理' }).closest('main')!; fireEvent.change(within(page).getByLabelText('名称'), { @@ -543,7 +543,7 @@ describe('AgentHubWorkbench', () => { await waitFor(() => expect(onAgentDelete).toHaveBeenCalledWith('hub-agent-architect')); }); - it('renders supplied Hub contacts on the Contacts rail page', () => { + it('renders supplied Hub contacts on the Contacts rail page', async () => { const platform = createMockPlatform({ surface: 'web', capabilities: { browserPreview: true }, @@ -572,7 +572,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '联系人' })); + await openRailPage('联系人'); const contactsPage = screen.getByRole('heading', { name: '组织内联系人' }).closest('main') || document.body; expect(within(contactsPage).getByText('Hub 联系人')).toBeInTheDocument(); @@ -589,7 +589,7 @@ describe('AgentHubWorkbench', () => { expect(within(servicePage).queryByText('账号与权限')).not.toBeInTheDocument(); }); - it('keeps the Tasks rail page interactive without leaving the v4 table shell', () => { + it('keeps the Tasks rail page interactive without leaving the v4 table shell', async () => { const platform = createMockPlatform({ surface: 'desktop', capabilities: { browserPreview: true }, @@ -606,7 +606,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '任务' })); + await openRailPage('任务'); const page = screen.getByRole('region', { name: '工作台页面' }); expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'runs'); diff --git a/app/workbench/src/__tests__/pages.test.tsx b/app/workbench/src/__tests__/pages.test.tsx index 236991de4..390d77b40 100644 --- a/app/workbench/src/__tests__/pages.test.tsx +++ b/app/workbench/src/__tests__/pages.test.tsx @@ -3,7 +3,7 @@ // Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench // test shards. Must stay the first import so mock factories register before // the component tree (and its virtua/@lobehub/icons deps) is evaluated. -import { installWorkbenchTestHooks } from './helpers'; +import { installWorkbenchTestHooks, openRailPage } from './helpers'; import { fireEvent, @@ -25,7 +25,7 @@ installWorkbenchTestHooks(); describe('AgentHubWorkbench', () => { - it('routes global rail pages into the design workbench mode', () => { + it('routes global rail pages into the design workbench mode', async () => { const platform = createMockPlatform({ surface: 'desktop', capabilities: { browserPreview: true }, @@ -41,7 +41,7 @@ describe('AgentHubWorkbench', () => { />, ); - fireEvent.click(screen.getByRole('button', { name: '联系人' })); + await openRailPage('联系人'); expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'contacts'); expect(screen.getByRole('main', { name: '工作区' })).toHaveAttribute('data-mode', 'workbench'); @@ -51,14 +51,14 @@ describe('AgentHubWorkbench', () => { expect(screen.queryByPlaceholderText('发消息给 Builder')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '组织内联系人' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Agent' })); + await openRailPage('Agent'); expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'agents'); expect(screen.getAllByText('Builder').length).toBeGreaterThan(0); expect(screen.getAllByText('glm-5.1').length).toBeGreaterThan(0); expect(screen.queryByRole('button', { name: '@Agent' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '项目' })); + await openRailPage('项目'); expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-page', 'projects'); expect(screen.getByRole('heading', { name: 'AI 游戏项目' })).toBeInTheDocument(); @@ -69,7 +69,7 @@ describe('AgentHubWorkbench', () => { // Verify the project page renders correctly with its heading. }); - it('opens the account profile popover from the global rail', () => { + it('opens the account profile popover from the global rail', async () => { const platform = createMockPlatform({ surface: 'desktop', capabilities: { browserPreview: true }, @@ -102,7 +102,7 @@ describe('AgentHubWorkbench', () => { expect(screen.queryByRole('dialog', { name: '用户 账号菜单' })).not.toBeInTheDocument(); }); - it('opens a human contact profile instead of an Agent config error for friend avatars', () => { + it('opens a human contact profile instead of an Agent config error for friend avatars', async () => { const platform = createMockPlatform({ surface: 'desktop', capabilities: { browserPreview: true }, @@ -181,7 +181,7 @@ describe('AgentHubWorkbench', () => { }); }); - it('roves the global rail page buttons with arrow keys (single tab stop)', () => { + it('roves the global rail page buttons with arrow keys (single tab stop)', async () => { const platform = createMockPlatform({ surface: 'desktop', capabilities: { browserPreview: false },