0270cd601e
- analytics/run.rs:删 Request::DirectCandidate / Request::Settle 与 settle();direct_finished 改收放行代次,终态再读一次 identity_generation,不同则整条不记,相同则直接落 Request::Terminal - analytics/store.rs:删 pending_runs / pending_run_bytes 与两条候选分支(连带 16 条上限),run_request 不再需要字节预算参数 - analytics/gui.rs + main.rs:删 settle_direct_run_analytics 命令与注册 - 放行侧:kick_queue_dispatch 在读 claim 之后读一次平台会话代次并随整轮传下去;user_input 命令不再收 analyticsAttemptId,队列条目不再持有埋点句柄 - 渲染层:删 beginDirectRunAnalytics、句柄表与结算 effect、settle 调用与命令参数;删除 tests/directRunAnalytics.test.ts,appSurface 两处断言同步 - 判据口径随放行搬家:从“发送时与终态同代”改成“放行时与终态同代”,入队后放行前的账号切换不再丢弃成绩 - 验证:cargo test -- analytics::(45 passed,含新增的直写与代际变化两条)、cargo test -- agent::(949 passed)、analytics_real_file_write 用例、npx vitest run tests/appSurface.test.ts(194 passed)、相关 chat/direct 单测 139 passed、typecheck 通过
1103 lines
42 KiB
TypeScript
1103 lines
42 KiB
TypeScript
import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences';
|
||
import * as platformSession from '../../src/services/platformSession';
|
||
import {
|
||
appendDictationText,
|
||
resolveSpeechRecognitionCtor,
|
||
speechRecognitionErrorMessage,
|
||
type SpeechRecognitionEventLike,
|
||
type SpeechRecognitionLike,
|
||
VOICE_INPUT_UNSUPPORTED_MESSAGE,
|
||
} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts';
|
||
import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx';
|
||
import { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts';
|
||
import {
|
||
type DirectPendingTurn,
|
||
enqueuePendingTurn,
|
||
removePendingTurn,
|
||
} from '../../src/view/project-development/chat/conversation/directPendingTurns.ts';
|
||
import {
|
||
act,
|
||
createGameCreationAppManifest,
|
||
createProjectChatRuntimeHarness,
|
||
expect,
|
||
fireEvent,
|
||
it,
|
||
pickProjectFromLauncher,
|
||
React,
|
||
render,
|
||
renderLauncherProjectsAt,
|
||
screen,
|
||
setComposerText,
|
||
vi,
|
||
waitFor,
|
||
within,
|
||
} from './harness';
|
||
|
||
const DYNAMIC_GAME_PROJECT_PATH = '/tmp/chat-composer-controls-game';
|
||
|
||
type InvokeOverrides = Record<
|
||
string,
|
||
(args: Record<string, unknown> | undefined) => unknown
|
||
>;
|
||
|
||
function gameCreatorConfigView(reasoningEffort: string) {
|
||
return {
|
||
path: '/tmp/chat-composer-config.json',
|
||
config: {
|
||
schemaVersion: 'game-creator-config.v2',
|
||
agentMode: 'codex_app_server',
|
||
selectedModelId: 'quality',
|
||
llm: {
|
||
apiKey: '',
|
||
baseUrl: '',
|
||
model: 'quality',
|
||
apiKind: 'openai_responses',
|
||
reasoningEffort,
|
||
stream: true,
|
||
webSearchEnabled: true,
|
||
contextWindowTokens: 128000,
|
||
autoCompactTokenLimit: 64000,
|
||
toolOutputTokenLimit: 12000,
|
||
requestTimeoutMs: 180000,
|
||
maxRetries: 2,
|
||
retryBackoffMs: 500,
|
||
},
|
||
agentLlm: {},
|
||
editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' },
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */
|
||
async function openDirectCodexSurface(
|
||
overrides: InvokeOverrides = {},
|
||
beforeOpen?: (
|
||
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
|
||
) => void,
|
||
) {
|
||
const chatHarness = createProjectChatRuntimeHarness({
|
||
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
||
initialSessionExists: false,
|
||
});
|
||
beforeOpen?.(chatHarness);
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'输入盒控件项目',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
const override = overrides[command];
|
||
if (override) {
|
||
return override(args);
|
||
}
|
||
if (command === 'get_design_agent_runtime_mode') return null;
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'chat-composer-controls',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') return manifest;
|
||
if (command === 'get_local_game_preview_status') {
|
||
return { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
return chatHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: chatHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH);
|
||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||
return {
|
||
invoke,
|
||
path: DYNAMIC_GAME_PROJECT_PATH,
|
||
surface,
|
||
harness: chatHarness,
|
||
};
|
||
}
|
||
|
||
async function submitDirectTurn(
|
||
surface: HTMLElement,
|
||
composer: HTMLElement,
|
||
text: string,
|
||
) {
|
||
await setComposerText(composer, text);
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
}
|
||
|
||
/**
|
||
* 回合运行中发送钮位置是终止钮,Enter 仍然提交表单(`requestSubmit`),
|
||
* 因此"运行中再次发送"走的是表单提交而不是那颗按钮。
|
||
*/
|
||
function submitComposerForm(composer: HTMLElement) {
|
||
fireEvent.submit(composer.closest('form') as HTMLFormElement);
|
||
}
|
||
|
||
/**
|
||
* 一轮 Direct 回合发送的 canonical 文本:只从 `userItem.content` 读。
|
||
* 客户端投影文本(`prompt`)已从 IPC 契约删除,断言不得再依赖它。
|
||
*/
|
||
function directTurnInputText(args?: Record<string, unknown>) {
|
||
const content = (
|
||
args?.userItem as
|
||
| { content?: Array<{ type: string; text?: string }> }
|
||
| undefined
|
||
)?.content;
|
||
return (content ?? [])
|
||
.map((part) => (part.type === 'input_text' ? (part.text ?? '') : ''))
|
||
.join('');
|
||
}
|
||
|
||
/** 断言这一轮发出的 canonical 文本。 */
|
||
function expectDirectTurnWithText(text: string) {
|
||
return expect.objectContaining({
|
||
userItem: expect.objectContaining({
|
||
content: expect.arrayContaining([{ type: 'input_text', text }]),
|
||
}),
|
||
});
|
||
}
|
||
|
||
type FakeSpeechRecognition = SpeechRecognitionLike & {
|
||
emitTranscript: (transcript: string) => void;
|
||
};
|
||
|
||
function installFakeSpeechRecognition(): {
|
||
instances: FakeSpeechRecognition[];
|
||
restore: () => void;
|
||
} {
|
||
const instances: FakeSpeechRecognition[] = [];
|
||
function FakeSpeechRecognitionCtor(this: FakeSpeechRecognition) {
|
||
const instance = {
|
||
lang: '',
|
||
continuous: false,
|
||
interimResults: false,
|
||
maxAlternatives: 1,
|
||
onresult: null,
|
||
onerror: null,
|
||
onend: null,
|
||
start: vi.fn(),
|
||
stop: vi.fn(() => instance.onend?.()),
|
||
abort: vi.fn(),
|
||
emitTranscript: (transcript: string) => {
|
||
const event: SpeechRecognitionEventLike = {
|
||
resultIndex: 0,
|
||
results: {
|
||
length: 1,
|
||
0: { isFinal: true, length: 1, 0: { transcript } },
|
||
},
|
||
};
|
||
instance.onresult?.(event);
|
||
},
|
||
} as unknown as FakeSpeechRecognition;
|
||
instances.push(instance);
|
||
return instance;
|
||
}
|
||
const scope = window as unknown as Record<string, unknown>;
|
||
scope.webkitSpeechRecognition = FakeSpeechRecognitionCtor;
|
||
return {
|
||
instances,
|
||
restore: () => {
|
||
delete scope.webkitSpeechRecognition;
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 一条待发消息的投影形状:事件带 canonical user item,chip 文案由它派生。
|
||
*
|
||
* 身份换算与生产同源(`direct-codex:{clientTurnId}:{role}`),不另造一套。
|
||
*/
|
||
function pendingTurn(
|
||
clientTurnId: string,
|
||
text: string,
|
||
at = 1,
|
||
): DirectPendingTurn {
|
||
return {
|
||
clientTurnId,
|
||
userItem: directCodexUserItemFromContent(
|
||
[{ type: 'input_text', text }],
|
||
`direct-codex:${clientTurnId}:user`,
|
||
),
|
||
at,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 宿主的队列动作在测试里的替身:入队、取消、回合收尾后放行队首。
|
||
*
|
||
* 队列归宿主之后界面看到的每一步都来自事件——入队发 `queue.enqueued`,线程空闲时在同一次认领里
|
||
* 追加 `queue.removed{dispatched}` 与 `turn.started`,取消发 `queue.removed{cancelled}`。
|
||
* 这里只做 Thread Manager 会做的那几件事,顺序就是入队顺序(认领永远认队首)。
|
||
*/
|
||
function hostQueue(
|
||
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
|
||
/** 打开面板时线程上是否已经有一条没收口的回合(恢复出来的那一轮)。 */
|
||
{ running: alreadyRunning = false }: { running?: boolean } = {},
|
||
) {
|
||
const queued: Array<{ clientTurnId: string; userItem: unknown }> = [];
|
||
let running = alreadyRunning;
|
||
/** 正在跑那一轮的回合身份:与宿主一致,`alreadyDispatched` 只认它(不是"随便哪一轮在跑")。 */
|
||
let dispatchedClientTurnId: string | null = null;
|
||
const dispatchHeadIfIdle = () => {
|
||
if (running) return;
|
||
const head = queued.shift();
|
||
if (!head) return;
|
||
running = true;
|
||
dispatchedClientTurnId = head.clientTurnId;
|
||
harness.emitDirectThreadEvents(
|
||
{
|
||
type: 'queue.removed',
|
||
clientTurnId: head.clientTurnId,
|
||
reason: 'dispatched',
|
||
at: 2,
|
||
},
|
||
{
|
||
type: 'turn.started',
|
||
at: 2,
|
||
userItemId: `direct-codex:${head.clientTurnId}:user`,
|
||
},
|
||
);
|
||
};
|
||
return {
|
||
/** 入队命令的宿主实现:入队 → 线程空闲就顺手放行。 */
|
||
enqueue(args?: Record<string, unknown>) {
|
||
const clientTurnId = String(args?.clientTurnId ?? '');
|
||
queued.push({ clientTurnId, userItem: args?.userItem });
|
||
harness.emitDirectThreadEvents({
|
||
type: 'queue.enqueued',
|
||
clientTurnId,
|
||
userItem: args?.userItem,
|
||
at: 1,
|
||
});
|
||
dispatchHeadIfIdle();
|
||
return null;
|
||
},
|
||
/** 取消命令的宿主实现:真的在队里才移除,并广播原因。 */
|
||
cancel(args?: Record<string, unknown>) {
|
||
const clientTurnId = String(args?.clientTurnId ?? '');
|
||
const index = queued.findIndex(
|
||
(item) => item.clientTurnId === clientTurnId,
|
||
);
|
||
if (index < 0) {
|
||
return dispatchedClientTurnId === clientTurnId
|
||
? 'alreadyDispatched'
|
||
: 'notFound';
|
||
}
|
||
queued.splice(index, 1);
|
||
harness.emitDirectThreadEvents({
|
||
type: 'queue.removed',
|
||
clientTurnId,
|
||
reason: 'cancelled',
|
||
at: 3,
|
||
});
|
||
return 'removed';
|
||
},
|
||
/** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */
|
||
complete(status = 'completed') {
|
||
running = false;
|
||
dispatchedClientTurnId = null;
|
||
harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 });
|
||
dispatchHeadIfIdle();
|
||
},
|
||
};
|
||
}
|
||
|
||
export function registerChatComposerControlTests() {
|
||
it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => {
|
||
const turn: DirectPendingTurn = {
|
||
clientTurnId: 'client-ref',
|
||
userItem: directCodexUserItemFromContent(
|
||
[
|
||
{ type: 'input_text', text: '用这张图改一下' },
|
||
{ type: 'agc_resource_reference', resourceId: 'asset:hero' },
|
||
],
|
||
'direct-codex:client-ref:user',
|
||
),
|
||
at: 1,
|
||
};
|
||
const manifestAssets = [
|
||
{
|
||
id: 'asset:hero',
|
||
kind: 'character',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/hero.png',
|
||
source: { kind: 'uploaded' as const },
|
||
},
|
||
];
|
||
|
||
// chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`;
|
||
// 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。
|
||
expect(pendingTurnChipLabel(turn, manifestAssets)).toBe(
|
||
'用这张图改一下 @hero',
|
||
);
|
||
// 空内容也给一行可显示的字,不让 chip 变成一条空白带。
|
||
expect(pendingTurnChipLabel(pendingTurn('client-empty', ''), [])).toBe(
|
||
'未命名消息',
|
||
);
|
||
});
|
||
|
||
it('folds the pending queue from events: FIFO append, identity removal, idempotent enqueue', () => {
|
||
let pending: readonly DirectPendingTurn[] = [];
|
||
pending = enqueuePendingTurn(pending, pendingTurn('client-1', '第一条'));
|
||
pending = enqueuePendingTurn(pending, pendingTurn('client-2', '第二条'));
|
||
pending = enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'));
|
||
|
||
// 顺序就是事件顺序(宿主认领队首,所以界面不许自己排)。
|
||
expect(pending.map((turn) => pendingTurnChipLabel(turn, []))).toEqual([
|
||
'第一条',
|
||
'第二条',
|
||
'第三条',
|
||
]);
|
||
|
||
// 同一条消息重复入队不产生第二个 chip:宿主的入队是幂等的,投影这一层也按身份折回原状态
|
||
// (引用不变,调用方能直接跳过重渲染)。
|
||
expect(enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'))).toBe(
|
||
pending,
|
||
);
|
||
|
||
// 取消与放行共用同一条移除:按身份删,其余顺序不变。
|
||
expect(
|
||
removePendingTurn(pending, 'client-2').map((turn) =>
|
||
pendingTurnChipLabel(turn, []),
|
||
),
|
||
).toEqual(['第一条', '第三条']);
|
||
// 身份不在队里时原样返回(放行事件与 `turn.started` 同批到达、或事件被重放)。
|
||
expect(removePendingTurn(pending, 'client-missing')).toBe(pending);
|
||
});
|
||
|
||
it('keeps the draft and explains why when the host rejects the enqueue as full', async () => {
|
||
// 队列上限只在宿主一处(`MAX_PENDING_DIRECT_TURNS`):界面不再自己数一份、不再自己造一句
|
||
// 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。
|
||
const { invoke, surface } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: () => {
|
||
throw {
|
||
error: { type: 'queueFull', limit: 5 },
|
||
message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送',
|
||
};
|
||
},
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '第六条消息');
|
||
|
||
const conversation = await within(surface).findByLabelText('陶泥儿消息');
|
||
await waitFor(() => {
|
||
expect(
|
||
within(conversation).getByText(
|
||
'待发消息已达上限(最多 5 条),请等前面几条发完再发送',
|
||
),
|
||
).not.toBeNull();
|
||
});
|
||
// 草稿没丢:这条消息还在输入盒里,用户改一改或等一会儿就能重发。
|
||
expect(composer.textContent ?? '').toContain('第六条消息');
|
||
expect(
|
||
within(surface).getByRole('button', { name: '发送' }),
|
||
).not.toBeNull();
|
||
// 入队没成立就是没成立:只发了一次命令,也不会有回合事件来结算埋点。
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('degrades the voice input button with a readable hint when speech recognition is missing', async () => {
|
||
// jsdom 不提供 SpeechRecognition / webkitSpeechRecognition:必须禁用并说明原因。
|
||
expect(
|
||
resolveSpeechRecognitionCtor(window as unknown as object),
|
||
).toBeNull();
|
||
expect(
|
||
resolveSpeechRecognitionCtor({ SpeechRecognition: undefined }),
|
||
).toBeNull();
|
||
|
||
const onTranscript = vi.fn();
|
||
const onNotice = vi.fn();
|
||
render(
|
||
React.createElement(ComposerVoiceButton, {
|
||
disabled: false,
|
||
onTranscript,
|
||
onNotice,
|
||
}),
|
||
);
|
||
|
||
const button = screen.getByRole('button', {
|
||
name: VOICE_INPUT_UNSUPPORTED_MESSAGE,
|
||
});
|
||
expect(button).toHaveProperty('disabled', true);
|
||
expect(button.getAttribute('title')).toBe(VOICE_INPUT_UNSUPPORTED_MESSAGE);
|
||
fireEvent.click(button);
|
||
expect(onTranscript).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('marks the recording state and only appends dictated text', async () => {
|
||
const fake = installFakeSpeechRecognition();
|
||
try {
|
||
const onTranscript = vi.fn();
|
||
const onNotice = vi.fn();
|
||
render(
|
||
React.createElement(ComposerVoiceButton, {
|
||
disabled: false,
|
||
onTranscript,
|
||
onNotice,
|
||
}),
|
||
);
|
||
|
||
const button = screen.getByRole('button', { name: '语音输入' });
|
||
expect(button).toHaveProperty('disabled', false);
|
||
fireEvent.click(button);
|
||
|
||
// 录音态有明确视觉/可访问性反馈。
|
||
const recording = screen.getByRole('button', { name: '停止语音输入' });
|
||
expect(recording.getAttribute('aria-pressed')).toBe('true');
|
||
expect(recording.className).toContain('is-recording');
|
||
expect(fake.instances).toHaveLength(1);
|
||
|
||
act(() => {
|
||
fake.instances[0]?.emitTranscript('帮我做一个跳跃动作');
|
||
});
|
||
expect(onTranscript).toHaveBeenCalledWith('帮我做一个跳跃动作');
|
||
|
||
fireEvent.click(recording);
|
||
expect(
|
||
screen
|
||
.getByRole('button', { name: '语音输入' })
|
||
.getAttribute('aria-pressed'),
|
||
).toBe('false');
|
||
} finally {
|
||
fake.restore();
|
||
}
|
||
});
|
||
|
||
it('appends dictated text after the existing draft without overwriting it', () => {
|
||
expect(appendDictationText('', '帮我做一个跳跃动作')).toBe(
|
||
'帮我做一个跳跃动作',
|
||
);
|
||
// 中文直接拼接,用户已输入的内容原样保留在识别结果之前。
|
||
expect(appendDictationText('先做一个主菜单', '再补一个商店')).toBe(
|
||
'先做一个主菜单再补一个商店',
|
||
);
|
||
// 英文识别结果补一个空格,避免两个单词粘在一起。
|
||
expect(appendDictationText('add jump', 'dash')).toBe('add jump dash');
|
||
expect(appendDictationText('先做一个主菜单', ' ')).toBe('先做一个主菜单');
|
||
expect(speechRecognitionErrorMessage('not-allowed')).toContain(
|
||
'麦克风权限',
|
||
);
|
||
expect(speechRecognitionErrorMessage('network')).toContain('语音识别服务');
|
||
expect(speechRecognitionErrorMessage('no-speech')).toContain('重试');
|
||
});
|
||
|
||
it('does not re-run the whole DirectProject turn when authentication fails', async () => {
|
||
// 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现,
|
||
// 整轮只 invoke 一次。
|
||
const { invoke, surface } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: () => {
|
||
throw new Error('authentication-required');
|
||
},
|
||
});
|
||
// 桩掉实现:真回归(回合期间误调保活刷新)时以 mock 结果干净失败,不在测试里发起真实刷新。
|
||
const refresh = vi
|
||
.spyOn(platformSession, 'requestPlatformSessionRefresh')
|
||
.mockResolvedValue({ status: 'stale' });
|
||
try {
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '继续制作');
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
const attempts = invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
);
|
||
expect(attempts).toHaveLength(1);
|
||
expect(refresh).not.toHaveBeenCalled();
|
||
// 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'),
|
||
).not.toBeNull();
|
||
});
|
||
} finally {
|
||
refresh.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => {
|
||
// 宿主 / 环境事实的入队失败没有放行、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在
|
||
// (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。
|
||
const { surface } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: () => {
|
||
throw {
|
||
error: {
|
||
type: 'environmentNotReady',
|
||
detail: 'Codex app-server 启动失败:找不到可执行文件',
|
||
},
|
||
message:
|
||
'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断',
|
||
};
|
||
},
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '起不来也要说一声');
|
||
const conversation = await within(surface).findByLabelText('陶泥儿消息');
|
||
await waitFor(() => {
|
||
expect(
|
||
within(conversation).getByText(
|
||
'陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断',
|
||
),
|
||
).not.toBeNull();
|
||
});
|
||
// 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。
|
||
expect(conversation.textContent ?? '').not.toContain(
|
||
'direct-codex-failure',
|
||
);
|
||
expect(conversation.textContent ?? '').not.toContain('stage=');
|
||
// 入队失败没有成立:忙碌态要放掉,用户能直接重发。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getByRole('button', { name: '发送' }),
|
||
).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
it('enqueues messages sent while a turn runs, cancels one chip, and never sends the same message twice', async () => {
|
||
let host: ReturnType<typeof hostQueue> | null = null;
|
||
const { invoke, surface, harness } = await openDirectCodexSurface({
|
||
// 宿主替身:入队就发 `queue.enqueued`,线程空闲时同一次认领里放行队首。
|
||
enqueue_direct_codex_turn: (args) => host?.enqueue(args),
|
||
remove_direct_project_pending_turn: (args) => host?.cancel(args),
|
||
});
|
||
host = hostQueue(harness);
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '第一条消息');
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'enqueue_direct_codex_turn',
|
||
expectDirectTurnWithText('第一条消息'),
|
||
);
|
||
});
|
||
// 第一条被立刻放行:回合运行中,发送钮位置变成终止钮,队列里没有它(它不是待发消息)。
|
||
expect(
|
||
await within(surface).findByRole('button', { name: '终止' }),
|
||
).not.toBeNull();
|
||
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
|
||
|
||
await setComposerText(composer, '第二条消息');
|
||
submitComposerForm(composer);
|
||
await setComposerText(composer, '第三条消息');
|
||
submitComposerForm(composer);
|
||
|
||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||
await waitFor(() => {
|
||
expect(within(queue).getAllByRole('listitem')).toHaveLength(2);
|
||
});
|
||
expect(
|
||
within(queue)
|
||
.getAllByRole('listitem')
|
||
.map((item) => item.textContent),
|
||
).toEqual([
|
||
expect.stringContaining('第二条消息'),
|
||
expect.stringContaining('第三条消息'),
|
||
]);
|
||
|
||
// 单条取消:命令交给宿主判,界面按 `queue.removed{cancelled}` 撤掉那一条,顺序不变。
|
||
fireEvent.click(
|
||
within(queue).getByRole('button', { name: '取消待发消息 第二条消息' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'remove_direct_project_pending_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(queue).getAllByRole('listitem')).toHaveLength(1);
|
||
});
|
||
expect(queue.textContent ?? '').toContain('第三条消息');
|
||
|
||
// 回合收尾 → 宿主放行剩下的那条:chip 消失与它自己的回合开始在同一批事件里。
|
||
act(() => {
|
||
host?.complete();
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
|
||
});
|
||
|
||
// 关键不变式:排队的那条**没有**再走一次发送命令,入队就是一次性动作。
|
||
expect(
|
||
invoke.mock.calls
|
||
.filter(([command]) => command === 'enqueue_direct_codex_turn')
|
||
.map(([, args]) => directTurnInputText(args)),
|
||
).toEqual(['第一条消息', '第二条消息', '第三条消息']);
|
||
});
|
||
|
||
it('tells the user when the chip they tried to cancel has already been dispatched', async () => {
|
||
// 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。
|
||
const { invoke, surface, harness } = await openDirectCodexSurface({
|
||
// 入队成立但并不放行(线程上已经有别的回合在跑):chip 留着,取消才有东西可取消。
|
||
enqueue_direct_codex_turn: (args) => {
|
||
harness.emitDirectThreadEvents({
|
||
type: 'queue.enqueued',
|
||
clientTurnId: String(args?.clientTurnId ?? ''),
|
||
userItem: args?.userItem,
|
||
at: 1,
|
||
});
|
||
return null;
|
||
},
|
||
remove_direct_project_pending_turn: () => 'alreadyDispatched',
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '还没跑的这条');
|
||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||
expect(within(queue).getByText('还没跑的这条')).not.toBeNull();
|
||
|
||
fireEvent.click(
|
||
within(queue).getByRole('button', { name: '取消待发消息 还没跑的这条' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getByText(
|
||
'这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」',
|
||
),
|
||
).not.toBeNull();
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'remove_direct_project_pending_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
it('keeps the accept window silent in the chat and busy in the composer', async () => {
|
||
const pending: Array<{ resolve: (value: string) => void }> = [];
|
||
let userItemId = '';
|
||
const { invoke, surface, harness } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: (args) => {
|
||
userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
|
||
return new Promise<string>((resolve) => {
|
||
pending.push({ resolve });
|
||
});
|
||
},
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '窗口期的消息');
|
||
|
||
// 写权限门是异步的,先等命令真的发出去,否则下面的窗口期断言会在 invoke 还没发生时就
|
||
// 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'enqueue_direct_codex_turn',
|
||
expectDirectTurnWithText('窗口期的消息'),
|
||
);
|
||
});
|
||
|
||
// 本地不再造乐观气泡:宿主既没回 turn.started、也没下发用户条目,聊天区里就没有这一轮,
|
||
// 也就不会有"已结束"的终态文案;窗口期的反馈只有 composer 的忙态与这张过程卡。
|
||
const conversation = within(surface).getByLabelText('陶泥儿消息');
|
||
expect(within(conversation).queryByText('窗口期的消息')).toBeNull();
|
||
expect(within(conversation).queryByText(/本轮结束于/)).toBeNull();
|
||
expect(within(conversation).queryByTestId('turn-usage')).toBeNull();
|
||
// 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前
|
||
// 那段界面完全不说"正在处理"。但起点还得等宿主给(`turn.started.at`),所以这一刻
|
||
// 卡片只报"正在处理"、不读秒。
|
||
expect(
|
||
within(surface).getAllByText('陶泥儿正在处理').length,
|
||
).toBeGreaterThan(0);
|
||
expect(within(surface).queryByText(/^已耗时 /u)).toBeNull();
|
||
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
||
expect(
|
||
within(surface).getByRole('button', { name: '终止' }),
|
||
).not.toBeNull();
|
||
|
||
// 宿主认领这一轮:开始事件 + 开口用户条目下发。用户那条消息这时才第一次出现在聊天区,
|
||
// 卡片也开始按宿主的起点读秒。
|
||
const startedAt = Date.now() - 1_500;
|
||
act(() => {
|
||
harness.emitDirectThreadEvents(
|
||
{ type: 'turn.started', at: startedAt, userItemId },
|
||
{
|
||
type: 'item.completed',
|
||
at: startedAt,
|
||
item: {
|
||
itemType: 'message',
|
||
itemId: userItemId,
|
||
role: 'user',
|
||
text: '窗口期的消息',
|
||
at: startedAt,
|
||
},
|
||
},
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(conversation).getByText('窗口期的消息')).not.toBeNull();
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull();
|
||
});
|
||
|
||
await act(async () => {
|
||
pending[0]?.resolve('回复');
|
||
});
|
||
});
|
||
|
||
it('closes the turn from the host failure payload instead of leaving it running', async () => {
|
||
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
|
||
null;
|
||
const { surface } = await openDirectCodexSurface(
|
||
{
|
||
enqueue_direct_codex_turn: (
|
||
args: Record<string, unknown> | undefined,
|
||
) => {
|
||
// 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来,
|
||
// 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。
|
||
const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
|
||
harness?.emitDirectThreadEvents(
|
||
{ type: 'turn.started', at: 5_000, userItemId },
|
||
{
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: {
|
||
kind: 'request-rejected',
|
||
message: 'codex-app-server-error:context-window-exceeded',
|
||
},
|
||
at: 6_000,
|
||
userItemId,
|
||
},
|
||
);
|
||
throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败');
|
||
},
|
||
},
|
||
(directHarness) => {
|
||
harness = directHarness;
|
||
},
|
||
);
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '超限的那条');
|
||
|
||
// 聊天里的失败说明来自事件载荷(经同一份可见文案映射),不是命令返回的错误文本。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getAllByText(
|
||
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
|
||
).length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
// 终态已经到了:卡片和输入区都不能再声称"还在处理"。
|
||
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
|
||
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
|
||
expect(
|
||
within(surface).getByRole('button', { name: '发送' }),
|
||
).not.toBeNull();
|
||
// 命令返回那条通道只负责横幅:它不写第二条聊天文案。真失败时 `runTurn` 会先把原始错误
|
||
// 映射成通用可见文案再走横幅(横幅在头部的状态行,不在会话列表里),所以这里查映射后的
|
||
// 文案有没有出现在会话列表里,而不是那条永远不会被渲染的原始错误文本。
|
||
const conversation = within(surface).getByLabelText('陶泥儿消息');
|
||
expect(
|
||
within(conversation).queryAllByText(
|
||
'陶泥儿智能创作 执行失败,请稍后重试',
|
||
),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
it('keeps the composer moving when the host fails the turn right after accepting it', async () => {
|
||
// 落盘失败发生在**放行之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个
|
||
// 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。
|
||
const seen: string[] = [];
|
||
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
|
||
null;
|
||
const { surface } = await openDirectCodexSurface(
|
||
{
|
||
enqueue_direct_codex_turn: (
|
||
args: Record<string, unknown> | undefined,
|
||
) => {
|
||
const text = directTurnInputText(args);
|
||
seen.push(text);
|
||
if (text === '落盘失败的那条') {
|
||
const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
|
||
harness?.emitDirectThreadEvents(
|
||
{ type: 'turn.started', at: 5_000, userItemId },
|
||
{
|
||
type: 'turn.completed',
|
||
status: 'failed',
|
||
failure: {
|
||
kind: 'environment-not-ready',
|
||
message: '写入本项目对话历史失败:项目对话历史追加写失败',
|
||
},
|
||
at: 5_100,
|
||
userItemId,
|
||
},
|
||
);
|
||
}
|
||
return Promise.resolve(null);
|
||
},
|
||
},
|
||
(directHarness) => {
|
||
harness = directHarness;
|
||
},
|
||
);
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '落盘失败的那条');
|
||
const conversation = await within(surface).findByLabelText('陶泥儿消息');
|
||
// 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(conversation).getAllByText(
|
||
'陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
// 忙态已放掉、也没卡成忙碌:下一条能直接发出去。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(conversation).queryAllByText('陶泥儿正在处理'),
|
||
).toHaveLength(0);
|
||
});
|
||
await submitDirectTurn(surface, composer, '后面这条');
|
||
await waitFor(() => {
|
||
expect(seen).toEqual(['落盘失败的那条', '后面这条']);
|
||
});
|
||
});
|
||
|
||
it('does not fire a second enqueue while the first one is still in flight', async () => {
|
||
// 草稿要等命令的入队结果才知道能不能清,所以这一段提交是"在飞"的。这期间第二次提交必须被挡住:
|
||
// 同一份草稿并发出两条就是两条消息(宿主按 `clientTurnId` 幂等也救不了——第二次是新身份)。
|
||
const pending: Array<{ resolve: (value: null) => void }> = [];
|
||
const { invoke, surface } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: () =>
|
||
new Promise<null>((resolve) => {
|
||
pending.push({ resolve });
|
||
}),
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '在飞的那条');
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
submitComposerForm(composer);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
|
||
// 命令落地(入队成立):草稿清掉、IPC 在飞这一段结束,用户能接着发下一条。
|
||
await act(async () => {
|
||
pending[0]?.resolve(null);
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getByRole('button', { name: '发送' }),
|
||
).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
it('只有待发消息、没有回合在跑时不显示终止钮', async () => {
|
||
// 队列非空但既没有在飞的命令、也没有在跑的回合(比如宿主还没认领队首):这时"终止"没有作用
|
||
// 对象,界面不该给一个点了只会报「当前没有正在运行的回合」的按钮。
|
||
const { surface, harness } = await openDirectCodexSurface({
|
||
enqueue_direct_codex_turn: (args) => {
|
||
harness.emitDirectThreadEvents({
|
||
type: 'queue.enqueued',
|
||
clientTurnId: String(args?.clientTurnId ?? ''),
|
||
userItem: args?.userItem,
|
||
at: 1,
|
||
});
|
||
return null;
|
||
},
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '排着的那条');
|
||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||
await waitFor(() => {
|
||
expect(within(queue).getByText('排着的那条')).not.toBeNull();
|
||
});
|
||
|
||
// 队列非空仍然是"忙"(卡片照旧说正在处理),但发送入口是发送钮:用户可以继续排。
|
||
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
|
||
expect(
|
||
await within(surface).findByRole('button', { name: '发送' }),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('queues the next message on the host while a turn is already running', async () => {
|
||
// 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。
|
||
let host: ReturnType<typeof hostQueue> | null = null;
|
||
const { invoke, surface, harness } = await openDirectCodexSurface(
|
||
{ enqueue_direct_codex_turn: (args) => host?.enqueue(args) },
|
||
(directHarness) => {
|
||
directHarness.emitDirectThreadEvents({ type: 'turn.started' });
|
||
},
|
||
);
|
||
// 线程上已经有回合在跑:入队成立但不放行,chip 就留着。
|
||
host = hostQueue(harness, { running: true });
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
|
||
expect(
|
||
await within(surface).findByRole('button', { name: '终止' }),
|
||
).not.toBeNull();
|
||
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
||
|
||
// 回合运行中发送钮位置是终止钮,所以这里走表单提交(Enter 也是这条路)。
|
||
await setComposerText(composer, '排在后面的消息');
|
||
submitComposerForm(composer);
|
||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||
await waitFor(() => {
|
||
expect(within(queue).getByText('排在后面的消息')).not.toBeNull();
|
||
});
|
||
// 入队就是一次性动作:这条消息只有这一次命令,没有"先存本地、之后由这个窗口再发一次"。
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
|
||
// 宿主在回合收尾后自己放行队首:chip 消失,那一轮的回合开始事件同批到达。
|
||
act(() => {
|
||
host?.complete();
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
await within(surface).findByRole('button', { name: '终止' }),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('terminates the running turn and returns the composer to the idle state', async () => {
|
||
const { invoke, path, surface, harness } = await openDirectCodexSurface({
|
||
// 命令只入队:入队成立(放行与开始事件都由 Thread Manager 自己完成)后它立刻返回,"这一轮还在跑"
|
||
// 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。
|
||
enqueue_direct_codex_turn: () => {
|
||
harness.emitDirectThreadEvents({ type: 'turn.started' });
|
||
return Promise.resolve(null);
|
||
},
|
||
cancel_direct_codex_turn: async () => ({
|
||
outcome: 'interrupted',
|
||
message: '已向正在运行的回合发出终止',
|
||
clientTurnId: 'direct-turn-cancel',
|
||
}),
|
||
});
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await submitDirectTurn(surface, composer, '做一个小游戏');
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'enqueue_direct_codex_turn',
|
||
expectDirectTurnWithText('做一个小游戏'),
|
||
);
|
||
});
|
||
|
||
const stopButton = await within(surface).findByRole('button', {
|
||
name: '终止',
|
||
});
|
||
fireEvent.click(stopButton);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', {
|
||
projectPath: path,
|
||
});
|
||
});
|
||
|
||
// 用户点「终止」的可读反馈走 composer 提示;这一轮怎么收场只由终态事件回答。
|
||
act(() => {
|
||
harness.emitDirectThreadEvents({
|
||
type: 'turn.completed',
|
||
status: 'interrupted',
|
||
});
|
||
});
|
||
await waitFor(() => {
|
||
const send = within(surface).getByRole('button', { name: '发送' });
|
||
expect(send).toHaveProperty('disabled', false);
|
||
});
|
||
expect(
|
||
within(surface).getByText('已向正在运行的回合发出终止'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => {
|
||
let stored = 'high';
|
||
const { invoke, surface } = await openDirectCodexSurface({
|
||
select_game_creator_reasoning_effort: (args) => {
|
||
stored = String(args?.effort ?? '');
|
||
return gameCreatorConfigView(stored);
|
||
},
|
||
});
|
||
|
||
const select = within(surface).getByRole('button', { name: '推理档' });
|
||
await waitFor(() => {
|
||
expect(select.textContent).toContain('高');
|
||
});
|
||
// 档位就在模型选择器这一排(同一控制排容器里)。
|
||
expect(
|
||
within(surface).getByRole('button', { name: '对话模型' }),
|
||
).not.toBeNull();
|
||
expect(select.closest('.project-chat-composer-controls')).not.toBeNull();
|
||
|
||
fireEvent.click(select);
|
||
fireEvent.click(within(surface).getByRole('option', { name: '低' }));
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'select_game_creator_reasoning_effort',
|
||
{ effort: 'low' },
|
||
);
|
||
});
|
||
// 以落盘后的回读值为准。
|
||
await waitFor(() => {
|
||
expect(
|
||
within(surface).getByRole('button', { name: '推理档' }).textContent,
|
||
).toContain('低');
|
||
});
|
||
expect(stored).toBe('low');
|
||
// 只影响后续回合:当前没有发出任何新一轮直接对话。
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'enqueue_direct_codex_turn',
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('appends the dictated transcript after the draft typed into the composer', async () => {
|
||
const fake = installFakeSpeechRecognition();
|
||
try {
|
||
const { surface } = await openDirectCodexSurface();
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await setComposerText(composer, '已经写好的需求');
|
||
|
||
fireEvent.click(
|
||
within(surface).getByRole('button', { name: '语音输入' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(fake.instances).toHaveLength(1);
|
||
});
|
||
act(() => {
|
||
fake.instances[0]?.emitTranscript('再补一个跳跃动作');
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(composer.textContent).toContain('已经写好的需求');
|
||
expect(composer.textContent).toContain('再补一个跳跃动作');
|
||
});
|
||
} finally {
|
||
fake.restore();
|
||
}
|
||
});
|
||
}
|