6dde321bd1
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m13s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m55s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m21s
Project CI / Native shell tests (pull_request) Successful in 8m53s
- executeRunLocal 的三条失败路径(缺 Tauri / 缺项目 / 启动预览报错)也改走 onRunNotice 失败色提示,对话区不再出现过程行;随之删除已无调用方的 announceProjectChatMessage 与不再有意义的 announceToChat 参数 - RunNoticeToast 的失败提示延长到 6 秒(成功仍 2.6 秒),避免错误一闪而过 - 版本入口在 UI 编辑器壳里不渲染;版本名改由一层 span 承载省略号,长版本名不再被硬裁 - 视图拿不到 onNotice 时把「在浏览器打开失败」写进 console,不再静默吞掉 - check-native-shells 增加「运行页视图里 openUrl( 只有一个调用点」的判据,堵住自动开浏览器的口子 - 新增外壳级接线用例 runNoticeShellWiring;previewActivation 补「启动失败走失败色提示且不写对话区」;runNoticeToast 补失败时长;样式守卫补省略号 - decision-log 收敛到最终口径(成功与失败都出对话区、失败留 6 秒、编辑器不挂版本入口)
192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
/** @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<string, number>();
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
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(
|
||
<App
|
||
initialProjectPath={PROJECT_PATH}
|
||
initialProjectManifest={createFixtureManifest()}
|
||
onPlayRequestHandled={vi.fn()}
|
||
onRunNotice={onRunNotice}
|
||
playRequest={{ projectPath: PROJECT_PATH, requestId: 1 }}
|
||
/>,
|
||
);
|
||
return { onRunNotice };
|
||
}
|
||
|
||
/** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */
|
||
async function expectRunNoticeWithoutChatTip(
|
||
onRunNotice: ReturnType<typeof vi.fn>,
|
||
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('预览端口被占用');
|
||
});
|
||
});
|