Files
prop-ai-hr/mobile-uni/tests/realtime-practice.test.mjs
adminandClaude Opus 5 5cf7ce5847 feat(practice): run realtime practice on App-Plus through renderjs
App-Plus has no WebRTC or getUserMedia in the logic layer, so realtime
practice was H5-only and the native entry had to advertise a record-then-
transcribe downgrade.

Move the media/WebRTC engine into `realtime-browser-engine.js` and drive it
from `RealtimePracticeAppBridge.vue`, whose renderjs script runs inside the
system WebView where those APIs do exist. The logic layer keeps the
authenticated calls: SDP exchange and `search_knowledge` tool invocation stay
server-proxied, so the access token is never handed to the view layer. Both
sides talk over the renderjs bridge only in session payloads the server
already assembled.

Verified against a real App build (`app-renderjs.js` holds RTCPeerConnection
and getUserMedia; `app-service.js` holds the SDP endpoint, renderjs side has
zero hits). Microphone permission, live transcription, remote playback,
foreground/background switching and disconnect fallback still require Android
and iOS device verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:58:10 +08:00

92 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('实时陪练通过受认证代理接入,前端不携带供应商密钥、不自组会话配置', async () => {
const [service, engine, appBridge, page, backendService, backendSessionService, backendPersonaRegistry] = await Promise.all([
source('../src/services/realtime-practice.ts'),
source('../src/services/realtime-browser-engine.js'),
source('../src/components/practice/RealtimePracticeAppBridge.vue'),
source('../src/pages/user/practice/index.vue'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java')
]);
const transport = `${service}\n${engine}`;
// 传输与事件协议
assert.match(service, /\/api\/train\/practice\/realtime\/sdp/);
assert.match(transport, /conversation\.item\.input_audio_transcription\.completed/);
assert.match(transport, /response\.audio_transcript\.done/);
assert.match(transport, /session\.created/);
assert.match(transport, /session\.updated/);
assert.match(transport, /activeAttempt/);
assert.match(transport, /resumeAudio/);
// 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端
assert.match(service, /\/api\/train\/practice\/realtime\/session/);
assert.match(transport, /sendSessionUpdate/);
assert.doesNotMatch(transport, /instructions/);
assert.doesNotMatch(transport, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/);
assert.doesNotMatch(transport, /semantic_vad/);
assert.doesNotMatch(transport, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
// function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议
assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/);
assert.match(transport, /response\.function_call_arguments\.done/);
assert.match(transport, /function_call_output/);
assert.match(transport, /response\.create/);
assert.match(transport, /toolCallChain/);
assert.match(transport, /工具暂时不可用,请直接回应员工/);
assert.match(transport, /calling-tool/);
// App-Plus:WebRTC 在 renderjs/WebView 执行,鉴权 SDP 与工具调用回到逻辑层。
assert.match(appBridge, /lang="renderjs"/);
assert.match(appBridge, /createRealtimeBrowserEngine/);
assert.match(appBridge, /exchangeRealtimeSdp/);
assert.match(appBridge, /invokeRealtimeTool/);
assert.match(appBridge, /owner.*callMethod\('onBridgeEvent'/);
assert.match(page, /RealtimePracticeAppBridge/);
assert.match(page, /isNativeAppRuntime\(\) \? realtimeAppBridge\.value : realtimePractice/);
assert.doesNotMatch(transport, /!isNativeAppRuntime\(\)/);
// 服务端是会话配置的唯一组装点
assert.match(backendSessionService, /session\.update/);
assert.match(backendSessionService, /semantic_vad/);
assert.match(backendSessionService, /TOOL_SEARCH_KNOWLEDGE/);
assert.match(backendPersonaRegistry, /search_knowledge/);
assert.match(backendPersonaRegistry, /owner-calm/);
assert.match(backendPersonaRegistry, /digital-mentor/);
assert.match(backendService, /实时陪练限流暂时不可用/);
// 页面:人设卡片选择 + 本地持久化;音色 picker 已删除
assert.match(page, /实时陪练 Beta/);
assert.match(page, /选择陪练角色/);
assert.match(page, /aihr_realtime_practice_persona/);
assert.match(page, /selectRealtimePersona/);
assert.match(page, /fetchRealtimePersonas/);
assert.match(page, /createRealtimeSession/);
assert.match(page, /正在查知识库/);
assert.match(page, /activeScenarioName\.value = session\.personaName \|\| '实时陪练'/);
assert.match(page, /realtimeActive && !turns\.length/);
assert.match(page, /class="panel scenario-card realtime-entry-card employee-card"/);
assert.match(page, /const canStartRealtime = computed/);
assert.match(page, /if \(!canStartRealtime\.value \|\| !requireLogin\(\)\) return;/);
assert.doesNotMatch(page, /realtimeVoice|selectRealtimeVoice|aihr_realtime_practice_voice/);
});
test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => {
const service = await source('../src/services/realtime-browser-engine.js');
// 串行链必须基于上一次调用收尾,且带末端 catch 防毒化
assert.match(service, /toolCallChain = toolCallChain\.then/);
assert.match(service, /\.catch\(\(\) => \{/);
// stop() 递增 attempt 使迟到的工具回调失效,并重置链
assert.match(service, /activeAttempt \+= 1/);
assert.match(service, /toolCallChain = Promise\.resolve\(\)/);
// 回传前双检:attempt 仍当前 + 数据通道仍打开
assert.match(service, /!isCurrent\(attempt\) \|\| channel\.readyState !== 'open'/);
});