752505547d
新增正式测试切片合同、状态机与上一项/播放暂停/下一项控制 新增可信悬停资源解析与资源管理画布定位 扩展 Tauri 版本登记、运行 Bridge 和 Agent 生成合同 补齐 TypeScript、Rust 与 AppSurface 回归测试 同步工作台 PRD、技术方案与共享决策记录 保持 P2 资源替换、参数写入和下一版本能力未接线
238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { GameRuntimeMessage } from '../../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
createGameHostMessage,
|
|
createGameRunSession,
|
|
type GameRunBridgeIdentity,
|
|
GameRunBridgeRuntimeGuard,
|
|
gameRunSessionFromRuntimeMessage,
|
|
validateGameRunBridgeIdentity,
|
|
} from '../src/features/project-workspace/gameRunBridge';
|
|
|
|
const identity: GameRunBridgeIdentity = {
|
|
projectId: 'project-1',
|
|
versionId: 'runnable-r7',
|
|
projectRevision: 7,
|
|
previewId: 'preview-1',
|
|
previewOrigin: 'http://127.0.0.1:4173',
|
|
};
|
|
|
|
function runtimeMessage(
|
|
overrides: Partial<GameRuntimeMessage> = {},
|
|
): GameRuntimeMessage {
|
|
return {
|
|
schemaVersion: 'game-creator-runtime-message.v1',
|
|
protocolVersion: 'game-creator-run-bridge.v1',
|
|
requestId: 'runtime-request-1',
|
|
responseTo: 'host-request-1',
|
|
sessionId: 'session-1',
|
|
previewId: identity.previewId,
|
|
projectId: identity.projectId,
|
|
sequence: 1,
|
|
type: 'runtime.ready',
|
|
versionId: identity.versionId,
|
|
projectRevision: identity.projectRevision,
|
|
payload: { capabilities: ['state', 'slice', 'inspection'] },
|
|
...overrides,
|
|
} as GameRuntimeMessage;
|
|
}
|
|
|
|
describe('P3 Game Run Bridge', () => {
|
|
it('creates a fully bound host request and accepts its exact ready response', () => {
|
|
expect(validateGameRunBridgeIdentity(identity)).toEqual(identity);
|
|
const host = createGameHostMessage({
|
|
identity,
|
|
sessionId: 'session-1',
|
|
requestId: 'host-request-1',
|
|
sequence: 1,
|
|
type: 'host.start',
|
|
});
|
|
expect(host).toMatchObject({
|
|
protocolVersion: 'game-creator-run-bridge.v1',
|
|
projectId: identity.projectId,
|
|
previewId: identity.previewId,
|
|
type: 'host.start',
|
|
});
|
|
|
|
const sliceStart = createGameHostMessage({
|
|
identity,
|
|
sessionId: 'session-1',
|
|
requestId: 'host-request-slice',
|
|
sequence: 2,
|
|
type: 'host.slice.start',
|
|
payload: { sliceId: 'core-gameplay' },
|
|
});
|
|
expect(sliceStart).toMatchObject({
|
|
type: 'host.slice.start',
|
|
payload: { sliceId: 'core-gameplay' },
|
|
});
|
|
|
|
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
|
expect(guard.registerHostRequest(host.requestId, host.type)).toBe(true);
|
|
expect(guard.accept(runtimeMessage())).toEqual(runtimeMessage());
|
|
expect(
|
|
guard.accept(
|
|
runtimeMessage({
|
|
requestId: 'runtime-request-2',
|
|
responseTo: undefined,
|
|
sequence: 2,
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing' },
|
|
}),
|
|
),
|
|
).toMatchObject({ type: 'runtime.state' });
|
|
});
|
|
|
|
it('rejects stale identities, bad ordering and uncorrelated ready messages', () => {
|
|
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
|
guard.registerHostRequest('host-request-1', 'host.start');
|
|
expect(
|
|
guard.accept(runtimeMessage({ responseTo: 'unknown-request' })),
|
|
).toBeNull();
|
|
expect(
|
|
guard.accept(runtimeMessage({ projectId: 'other-project' })),
|
|
).toBeNull();
|
|
expect(guard.accept(runtimeMessage({ sequence: 2 }))).toBeNull();
|
|
expect(guard.accept(runtimeMessage())).not.toBeNull();
|
|
expect(
|
|
guard.accept(
|
|
runtimeMessage({
|
|
requestId: 'runtime-request-2',
|
|
responseTo: undefined,
|
|
sequence: 2,
|
|
previewId: 'old-preview',
|
|
type: 'runtime.state',
|
|
payload: { status: 'paused' },
|
|
}),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('enforces message and payload whitelists without consuming a rejected sequence', () => {
|
|
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
|
guard.registerHostRequest('host-request-1', 'host.start');
|
|
expect(guard.accept(runtimeMessage())).not.toBeNull();
|
|
|
|
const rejectedPayloads = [
|
|
{
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing', summary: '/tmp/secret' },
|
|
},
|
|
{
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing', summary: 'https://untrusted.example' },
|
|
},
|
|
{
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing', summary: '<script>run()</script>' },
|
|
},
|
|
{
|
|
type: 'runtime.inspection',
|
|
payload: {
|
|
inspectionId: 'inspection-1',
|
|
label: '运行画面',
|
|
metrics: { filePath: '/tmp/secret' },
|
|
},
|
|
},
|
|
{
|
|
type: 'runtime.inspection',
|
|
payload: {
|
|
inspectionId: 'inspection-1',
|
|
label: '运行画面',
|
|
metrics: {},
|
|
targetKind: 'resource',
|
|
},
|
|
},
|
|
{
|
|
type: 'runtime.inspection',
|
|
payload: {
|
|
inspectionId: 'inspection-1',
|
|
label: '运行画面',
|
|
metrics: { nested: { command: 'host.stop' } },
|
|
},
|
|
},
|
|
] as const;
|
|
for (const [index, rejected] of rejectedPayloads.entries()) {
|
|
expect(
|
|
guard.accept(
|
|
runtimeMessage({
|
|
requestId: `runtime-request-rejected-${index}`,
|
|
responseTo: undefined,
|
|
sequence: 2,
|
|
...rejected,
|
|
} as Partial<GameRuntimeMessage>),
|
|
),
|
|
).toBeNull();
|
|
}
|
|
expect(
|
|
guard.accept({
|
|
...runtimeMessage({
|
|
requestId: 'runtime-request-extra-field',
|
|
responseTo: undefined,
|
|
sequence: 2,
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing' },
|
|
}),
|
|
command: 'host.stop',
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
guard.accept(
|
|
runtimeMessage({
|
|
requestId: 'runtime-request-2',
|
|
responseTo: undefined,
|
|
sequence: 2,
|
|
type: 'runtime.inspection',
|
|
payload: {
|
|
inspectionId: 'inspection-1',
|
|
label: '运行画面',
|
|
metrics: { fps: 60, visible: true },
|
|
},
|
|
}),
|
|
),
|
|
).toMatchObject({ type: 'runtime.inspection' });
|
|
|
|
const oversized = runtimeMessage({
|
|
requestId: 'runtime-request-3',
|
|
responseTo: undefined,
|
|
sequence: 3,
|
|
type: 'runtime.state',
|
|
payload: { status: 'playing', summary: 'x'.repeat(70 * 1024) },
|
|
});
|
|
expect(guard.accept(oversized)).toBeNull();
|
|
});
|
|
|
|
it('projects only validated runtime states into the ephemeral session', () => {
|
|
const session = createGameRunSession(identity, 'session-1', 100);
|
|
expect(session.status).toBe('starting');
|
|
const ready = runtimeMessage();
|
|
const playing = gameRunSessionFromRuntimeMessage(session, ready, 101);
|
|
expect(playing).toMatchObject({ status: 'playing', updatedAt: 101 });
|
|
const failed = gameRunSessionFromRuntimeMessage(
|
|
playing,
|
|
runtimeMessage({
|
|
type: 'runtime.error',
|
|
payload: { code: 'game-error', message: '运行失败', recoverable: true },
|
|
}),
|
|
102,
|
|
);
|
|
expect(failed).toMatchObject({ status: 'failed', updatedAt: 102 });
|
|
});
|
|
|
|
it('accepts only exact 127.0.0.1 origins with explicit ports', () => {
|
|
expect(
|
|
validateGameRunBridgeIdentity({
|
|
...identity,
|
|
previewOrigin: 'http://localhost:4173',
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
validateGameRunBridgeIdentity({
|
|
...identity,
|
|
previewOrigin: 'http://127.0.0.1:4173/path',
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|