/** @vitest-environment jsdom */ /** * 运行入口「预览已经在跑就切视图」的回归用例。 * * 背景:工作台壳过去用 `/preview open` 聊天命令切到已经在跑的客户端运行视图,那条命令链 * 随 Project Supervisor 前端链路整体退役;按 ADR(`docs/adr/【ADR】DirectProject独立聊天 * 容器与工作台钱包布局-2026-09-18.md`),这个动作由 DirectProject 的「运行」入口承接, * 结果经聊天自己的 `announce` 交给聊天的本地消息流。 * * 因此运行入口进入客户端运行视图前先问 Rust 要活体预览:`activate_local_game_preview` * 只核对内存 registry 并回传 loopback 地址,活着就复用(不重启预览服务),没活着 * (stopped / 不属于这个项目 / 权限位要求确认)才回落到 `start_local_game_preview`。 * * 成功反馈走工作台壳的 toast(`onRunNotice`),**不再**写进对话区:那条「已载入客户端 * 运行视图:URL」以前常驻对话底部挡住运行画面。用例因此直接断言这条通道,并反过来钉住 * 对话容器里没有这类提示。 * * 这条行为过去只剩 `scripts/check-native-shells.mjs` 的源码守卫与 Rust 实现,前端没有 * 用例;一旦有人再把它当死链路删掉,守卫会先报「missing await invoke」而不知道现场。 */ import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { App, createGameCreationAppManifest, createProjectChatRuntimeHarness, expect, render, screen, waitFor, } from './appSurface/harness'; const PROJECT_PATH = '/tmp/preview-activation-project'; const PROJECT_ID = 'preview-activation-project'; const PREVIEW_URL = 'http://127.0.0.1:43210/game/index.html'; function createFixtureManifest(): GameCreationAppManifest { return createGameCreationAppManifest(PROJECT_ID, '预览切换项目'); } /** * `activate_local_game_preview` 的替身由用例给定:它决定「这条预览还活着吗」, * 其余命令沿用聊天 harness,运行入口之外的链路保持真实形状。 */ function installTauri( activateLocalGamePreview: () => unknown, options: { startFails?: string } = {}, ) { const manifest = createFixtureManifest(); const chatHarness = createProjectChatRuntimeHarness({ projectPath: PROJECT_PATH, }); const invokeCounts = new Map(); const invoke = vi.fn( async (command: string, args?: Record) => { invokeCounts.set(command, (invokeCounts.get(command) ?? 0) + 1); if (command === 'get_local_game_manifest') { return manifest; } if (command === 'get_local_game_project_revision') { return { revision: 3 }; } if (command === 'activate_local_game_preview') { return activateLocalGamePreview(); } if (command === 'start_local_game_preview') { if (options.startFails) { throw new Error(options.startFails); } return { url: PREVIEW_URL, port: 43210, root: PROJECT_PATH }; } return chatHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke: invoke as never }, event: { listen: chatHarness.listen as never }, }; return { invoke, invokeCounts }; } /** 「运行」入口由工作台壳的播放请求驱动,这里直接用同一形状的请求触发。 */ function renderRunningProjectChat() { const onRunNotice = vi.fn(); render( , ); return { onRunNotice }; } /** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */ async function expectRunNoticeWithoutChatTip( onRunNotice: ReturnType, message: string, ) { await waitFor(() => expect(onRunNotice).toHaveBeenCalledWith({ message })); const surface = await screen.findByLabelText('陶泥儿项目对话'); expect(surface.textContent ?? '').not.toContain('运行通过'); expect(surface.textContent ?? '').not.toContain('已切换到客户端运行视图'); } beforeEach(() => { window.history.pushState({}, '', '/'); }); describe('运行入口切到已经在跑的客户端预览', () => { it('预览还活着时只切视图,不重启预览服务', async () => { const { invoke, invokeCounts } = installTauri(() => ({ status: 'running', url: PREVIEW_URL, port: 43210, root: PROJECT_PATH, })); const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { projectPath: PROJECT_PATH, }), ); await expectRunNoticeWithoutChatTip(onRunNotice, '已载入客户端运行视图'); expect(invokeCounts.get('start_local_game_preview') ?? 0).toBe(0); }); it('预览不在跑时回落到重新启动预览', async () => { const { invokeCounts } = installTauri(() => ({ status: 'stopped', url: null, port: null, root: null, })); const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); await expectRunNoticeWithoutChatTip( onRunNotice, '运行通过,已载入客户端运行视图', ); }); it('权限位要求确认时不把拒绝当错误,直接回落到重新启动预览', async () => { const { invokeCounts } = installTauri(() => { throw new Error('需要先确认预览权限'); }); const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); await expectRunNoticeWithoutChatTip( onRunNotice, '运行通过,已载入客户端运行视图', ); }); it('启动预览失败时走失败色提示,不再写进对话区', async () => { installTauri( () => ({ status: 'stopped', url: null, port: null, root: null }), { startFails: '预览端口被占用' }, ); const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(onRunNotice).toHaveBeenCalledWith({ tone: 'error', message: '运行游戏失败:预览端口被占用', }), ); const surface = await screen.findByLabelText('陶泥儿项目对话'); expect(surface.textContent ?? '').not.toContain('预览端口被占用'); }); });