@@ -0,0 +1,108 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { WebSocketServer } = require("ws");
|
||||
const { websocketSignature } = require("../electron/hardware-agent-client.cjs");
|
||||
|
||||
const port = Number(process.env.MOCK_HARDWARE_GATEWAY_PORT || 19090);
|
||||
const expectedSecret = process.env.MOCK_VOLC_DEVICE_SECRET || "mock-volc-device-secret-at-least-32-characters";
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port, maxPayload: 1024 * 1024 });
|
||||
|
||||
function send(socket, value) { socket.send(JSON.stringify(value)); }
|
||||
async function requestPlatformStop(conversationID) {
|
||||
const baseURL = String(process.env.MOCK_PLATFORM_API_URL || "").replace(/\/$/, "");
|
||||
const deviceID = String(process.env.MOCK_PLATFORM_DEVICE_ID || "");
|
||||
const secret = String(process.env.MOCK_PLATFORM_DEVICE_SECRET || "");
|
||||
if (!baseURL || !deviceID || !secret || !conversationID) return;
|
||||
const requestPath = `/device/v2/conversations/${encodeURIComponent(conversationID)}/stop`;
|
||||
const body = JSON.stringify({ reason: "windows_e2e_platform_stop", device_time: new Date().toISOString(), last_heartbeat_sequence: 1, rtc_left: true, recording: { near_segments: 0, far_segments: 0 } });
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = crypto.randomBytes(16).toString("hex");
|
||||
const digest = crypto.createHash("sha256").update(body).digest("hex");
|
||||
const canonical = ["POST", requestPath, timestamp, nonce, digest].join("\n");
|
||||
const signature = crypto.createHmac("sha256", secret).update(canonical).digest("base64url");
|
||||
const response = await fetch(`${baseURL}${requestPath}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json", "Content-Type": "application/json", "Idempotency-Key": `windows-platform-stop-${crypto.randomUUID()}`,
|
||||
"X-Device-Id": deviceID, "X-Device-Timestamp": timestamp, "X-Device-Nonce": nonce,
|
||||
"X-Device-Content-SHA256": digest, "X-Device-Signature": signature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!response.ok) throw new Error(`platform stop HTTP ${response.status}: ${await response.text()}`);
|
||||
process.stdout.write(`${JSON.stringify({ type: "platform_stop_requested", conversation_id: conversationID })}\n`);
|
||||
}
|
||||
function tonePCMBase64() {
|
||||
const samples = 2400;
|
||||
const bytes = Buffer.alloc(samples * 2);
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
const sample = Math.round(Math.sin((index / 16000) * Math.PI * 2 * 440) * 1800);
|
||||
bytes.writeInt16LE(sample, index * 2);
|
||||
}
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
server.on("listening", () => process.stdout.write(`${JSON.stringify({ type: "listening", port })}\n`));
|
||||
server.on("connection", (socket, request) => {
|
||||
const header = (name) => String(request.headers[name.toLowerCase()] || "");
|
||||
const expectedSignature = websocketSignature({
|
||||
authType: 1,
|
||||
deviceName: header("x-device-name"),
|
||||
randomNumber: Number(header("x-random-num")),
|
||||
productKey: header("x-product-key"),
|
||||
timestamp: Number(header("x-timestamp")),
|
||||
instanceID: header("x-instance-id"),
|
||||
}, expectedSecret);
|
||||
if (!crypto.timingSafeEqual(Buffer.from(header("x-signature")), Buffer.from(expectedSignature))) {
|
||||
socket.close(1008, "invalid signature");
|
||||
return;
|
||||
}
|
||||
const target = new URL(request.url, `ws://127.0.0.1:${port}`);
|
||||
if (!target.searchParams.get("bot") || target.searchParams.get("wait_for_session_update") !== "true") {
|
||||
socket.close(1008, "invalid query");
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ type: "authenticated", device: header("x-device-name"), bot: target.searchParams.get("bot") })}\n`);
|
||||
send(socket, { type: "session.created", event_id: "mock-created", session: { id: "mock-provider-session-windows", model: "doubao", input_audio_format: "pcm16", output_audio_format: "pcm16", turn_detection: { type: "server_vad" } } });
|
||||
let replied = false;
|
||||
let dialogID = "";
|
||||
socket.on("message", (raw) => {
|
||||
const event = JSON.parse(raw.toString("utf8"));
|
||||
if (event.type === "session.update") {
|
||||
const llmPrompt = event.session?.config?.LLMConfig?.SystemMessages?.[0];
|
||||
const s2sDialog = event.session?.config?.S2SConfig?.ProviderParams?.dialog;
|
||||
const sessionDialogID = String(s2sDialog?.dialog_id || (llmPrompt ? llmPrompt.match(/本次对话标识:([^\n]+)/)?.[1] : "") || "").trim();
|
||||
const frozenPrompt = String(llmPrompt || s2sDialog?.system_role || "").trim();
|
||||
if (event.session?.object !== "realtime.session" || event.session?.config?.ASRConfig?.TurnDetectionMode !== 0 || !sessionDialogID || !frozenPrompt) {
|
||||
send(socket, { type: "error", event_id: "mock-error", error: { code: "INVALID_SESSION_CONFIG", message: "frozen context missing" } });
|
||||
return;
|
||||
}
|
||||
dialogID = sessionDialogID;
|
||||
send(socket, { type: "session.updated", event_id: "mock-updated", session: { ...event.session, id: "mock-provider-session-windows" } });
|
||||
process.stdout.write(`${JSON.stringify({ type: "dynamic_config_applied", dialog_id: dialogID })}\n`);
|
||||
return;
|
||||
}
|
||||
if (event.type === "input_audio_buffer.append" && !replied) {
|
||||
replied = true;
|
||||
send(socket, { type: "input_audio_buffer.speech_started", event_id: "mock-speech-start", audio_start_ms: 0 });
|
||||
send(socket, { type: "input_audio_buffer.speech_stopped", event_id: "mock-speech-stop", audio_end_ms: 800 });
|
||||
send(socket, { type: "conversation.item.input_audio_transcription.completed", event_id: "mock-user-final", item_id: "mock-user-item", transcript: "你好,请介绍一下今天的安排。" });
|
||||
send(socket, { type: "response.created", event_id: "mock-response-created", response: { id: "mock-response-1", status: "in_progress" } });
|
||||
send(socket, { type: "response.audio_transcript.delta", event_id: "mock-agent-delta", response_id: "mock-response-1", delta: "您好,今天可以先聊聊您最想做的事情。" });
|
||||
send(socket, { type: "response.audio.delta", event_id: "mock-audio-delta", response_id: "mock-response-1", delta: tonePCMBase64() });
|
||||
send(socket, { type: "response.audio_transcript.done", event_id: "mock-agent-final", response_id: "mock-response-1", transcript: "您好,今天可以先聊聊您最想做的事情。" });
|
||||
send(socket, { type: "response.audio.done", event_id: "mock-audio-done", response_id: "mock-response-1" });
|
||||
send(socket, { type: "response.done", event_id: "mock-response-done", response: { id: "mock-response-1", status: "completed", output: [] } });
|
||||
process.stdout.write(`${JSON.stringify({ type: "round_completed" })}\n`);
|
||||
if (process.env.MOCK_REQUEST_PLATFORM_STOP === "1") {
|
||||
setTimeout(() => void requestPlatformStop(dialogID).catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
}), 250);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("close", (code) => process.stdout.write(`${JSON.stringify({ type: "closed", code })}\n`));
|
||||
});
|
||||
|
||||
function shutdown() { server.close(() => process.exit(0)); }
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -0,0 +1,120 @@
|
||||
param(
|
||||
[ValidateSet('llm', 's2s')]
|
||||
[string]$ModelMode = 'llm',
|
||||
[string]$ExecutablePath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$simulatorRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
|
||||
$projectRoot = (Resolve-Path -LiteralPath (Join-Path $simulatorRoot '..')).Path
|
||||
$devicePublicID = '10000000-0000-0000-0000-000000000511'
|
||||
$platformSecret = 'local-simulated-device-secret-32-bytes-minimum'
|
||||
$volcSecret = 'mock-volc-device-secret-at-least-32-characters'
|
||||
$mockPort = 19090
|
||||
$runID = [Guid]::NewGuid().ToString('N')
|
||||
$resultPath = Join-Path $env:TEMP "hardware-agent-e2e-$runID.json"
|
||||
$mockOutput = Join-Path $env:TEMP "hardware-agent-gateway-$runID.log"
|
||||
$mockError = Join-Path $env:TEMP "hardware-agent-gateway-$runID.err.log"
|
||||
$mockProcess = $null
|
||||
$conversationID = $null
|
||||
$deviceInternalID = $null
|
||||
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
try {
|
||||
$ready = Invoke-WebRequest -UseBasicParsing -Uri 'http://localhost:58080/health/ready' -TimeoutSec 10
|
||||
if ($ready.StatusCode -ne 200) { throw 'Local API is not ready' }
|
||||
$deviceInternalID = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT id FROM device WHERE public_id='$devicePublicID'::uuid" | Out-String).Trim()
|
||||
if ($deviceInternalID -notmatch '^[0-9a-fA-F-]{36}$') { throw 'Local simulator device is missing' }
|
||||
$bindingCount = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -c "SELECT count(*) FROM hardware_agent_device_binding WHERE device_id='$deviceInternalID'::uuid" | Out-String).Trim()
|
||||
if ($bindingCount -ne '0') { throw 'Simulator device already has a hardware binding; refusing to overwrite it' }
|
||||
"UPDATE device_twin SET desired_config=desired_config||'{`"voice_runtime`":`"volc_hardware_agent`",`"hardware_agent_transport`":`"low_load`",`"minimum_sdk_version`":`"1.0.0`"}'::jsonb WHERE device_id='$deviceInternalID'::uuid; INSERT INTO hardware_agent_device_binding(tenant_id,device_id,bot_id,transport,minimum_sdk_version,status) SELECT tenant_id,id,'mock-windows-hardware-bot','low_load','1.0.0','ACTIVE' FROM device WHERE id='$deviceInternalID'::uuid;" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
|
||||
|
||||
$mockScript = Join-Path $simulatorRoot 'test\mock-hardware-gateway.cjs'
|
||||
$env:MOCK_HARDWARE_GATEWAY_PORT = [string]$mockPort
|
||||
$env:MOCK_VOLC_DEVICE_SECRET = $volcSecret
|
||||
$env:MOCK_REQUEST_PLATFORM_STOP = '1'
|
||||
$env:MOCK_PLATFORM_API_URL = 'http://localhost:58080'
|
||||
$env:MOCK_PLATFORM_DEVICE_ID = $devicePublicID
|
||||
$env:MOCK_PLATFORM_DEVICE_SECRET = $platformSecret
|
||||
$mockProcess = Start-Process -FilePath 'node' -ArgumentList @($mockScript) -PassThru -WindowStyle Hidden -RedirectStandardOutput $mockOutput -RedirectStandardError $mockError
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 150
|
||||
$listening = Test-NetConnection 127.0.0.1 -Port $mockPort -InformationLevel Quiet -WarningAction SilentlyContinue
|
||||
if ($mockProcess.HasExited) { throw "Mock gateway exited: $(Get-Content -LiteralPath $mockError -Raw -ErrorAction SilentlyContinue)" }
|
||||
} until ($listening -or [DateTime]::UtcNow -gt $deadline)
|
||||
if (-not $listening) { throw 'Mock hardware gateway did not start' }
|
||||
|
||||
$env:SIMULATOR_AUTO_TEST = '1'
|
||||
$env:SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP = '1'
|
||||
$env:SIMULATOR_AUTO_RESULT_PATH = $resultPath
|
||||
$env:SIMULATOR_DEFAULT_API_URL = 'http://localhost:58080'
|
||||
$env:SIMULATOR_DEFAULT_DEVICE_ID = $devicePublicID
|
||||
$env:SIMULATOR_DEFAULT_DEVICE_SECRET = $platformSecret
|
||||
$env:SIMULATOR_VOLC_INSTANCE_ID = 'mock-instance'
|
||||
$env:SIMULATOR_VOLC_PRODUCT_KEY = 'mock-product'
|
||||
$env:SIMULATOR_VOLC_DEVICE_NAME = 'windows-e2e-device'
|
||||
$env:SIMULATOR_VOLC_HARDWARE_ID = 'windows-e2e-hardware'
|
||||
$env:SIMULATOR_VOLC_DEVICE_SECRET = $volcSecret
|
||||
$env:SIMULATOR_VOLC_MODEL_MODE = $ModelMode
|
||||
$env:SIMULATOR_HARDWARE_GATEWAY_URL = "ws://127.0.0.1:$mockPort/v1/realtime"
|
||||
Set-Location -LiteralPath $simulatorRoot
|
||||
if ($ExecutablePath) {
|
||||
$resolvedExecutable = (Resolve-Path -LiteralPath $ExecutablePath).Path
|
||||
$electron = Start-Process -FilePath $resolvedExecutable -WorkingDirectory (Split-Path -Parent $resolvedExecutable) -PassThru -Wait
|
||||
} else {
|
||||
$electron = Start-Process -FilePath (Join-Path $simulatorRoot 'node_modules\electron\dist\electron.exe') -ArgumentList @('.') -WorkingDirectory $simulatorRoot -PassThru -Wait
|
||||
}
|
||||
if ($electron.ExitCode -ne 0) { throw "Electron E2E process failed with exit code $($electron.ExitCode)" }
|
||||
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { throw 'Electron E2E result file is missing' }
|
||||
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
|
||||
if ($result.result -ne 'PASS' -or $result.phase -ne '已完成') { throw "Electron E2E failed: $($result | ConvertTo-Json -Depth 8 -Compress)" }
|
||||
if ($result.model_mode -ne $ModelMode) { throw "Electron E2E used model mode '$($result.model_mode)' instead of '$ModelMode'" }
|
||||
$conversationID = [string]$result.conversation_id
|
||||
if ($conversationID -notmatch '^[0-9a-fA-F-]{36}$') { throw 'Electron E2E did not return a conversation ID' }
|
||||
$databaseState = (docker compose exec -T postgres psql -U voice -d voice_platform -t -A -F '|' -c "SELECT c.runtime_type||'|'||c.status||'|'||hs.status||'|'||hs.sdk_version||'|'||(hs.dynamic_config_applied_at IS NOT NULL)::text||'|'||count(DISTINCT ct.id)||'|'||(SELECT count(*) FROM web_event we WHERE we.resource_public_id=c.public_id AND we.event_type IN('conversation.started','conversation.completed')) FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id LEFT JOIN conversation_turn ct ON ct.conversation_id=c.id WHERE c.public_id='$conversationID'::uuid GROUP BY c.runtime_type,c.status,hs.status,hs.sdk_version,hs.dynamic_config_applied_at,c.public_id" | Out-String).Trim()
|
||||
if ($databaseState -ne 'volc_hardware_agent|COMPLETED|COMPLETED|1.1.0-windows-standard-protocol|true|2|2') { throw "Unexpected platform state: $databaseState" }
|
||||
$gatewayEvents = Get-Content -LiteralPath $mockOutput -Raw
|
||||
if ($gatewayEvents -notmatch 'dynamic_config_applied' -or $gatewayEvents -notmatch 'round_completed' -or $gatewayEvents -notmatch 'platform_stop_requested') { throw "Mock gateway did not observe config, media, and platform-stop flow: $gatewayEvents" }
|
||||
[ordered]@{
|
||||
result = 'PASS'
|
||||
conversation_id = $conversationID
|
||||
platform_state = $databaseState
|
||||
transcript_turns = $result.turns.Count
|
||||
checks_passed = @($result.checks | Where-Object status -eq 'passed').Count
|
||||
model_mode = $result.model_mode
|
||||
mock_gateway_log = $mockOutput
|
||||
} | ConvertTo-Json -Compress
|
||||
}
|
||||
finally {
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
if ($mockProcess -and -not $mockProcess.HasExited) { Stop-Process -Id $mockProcess.Id -Force -ErrorAction SilentlyContinue }
|
||||
if ($conversationID -match '^[0-9a-fA-F-]{36}$') {
|
||||
@"
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE hc AS SELECT id,context_snapshot_id,consent_snapshot_id FROM conversation WHERE public_id='$conversationID'::uuid;
|
||||
DELETE FROM audit_event WHERE resource_id IN(SELECT id FROM hc);
|
||||
DELETE FROM web_event WHERE resource_public_id='$conversationID'::uuid;
|
||||
DELETE FROM idempotency_record WHERE resource_id IN(SELECT id FROM hc);
|
||||
DELETE FROM conversation_turn WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM conversation_memory_use WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM memory_tool_call WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM hardware_agent_event WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM hardware_agent_session WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM conversation_usage_budget WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM hc);
|
||||
DELETE FROM conversation_state_history WHERE conversation_id IN(SELECT id FROM hc);
|
||||
DELETE FROM conversation WHERE id IN(SELECT id FROM hc);
|
||||
DELETE FROM context_snapshot WHERE id IN(SELECT context_snapshot_id FROM hc);
|
||||
DELETE FROM consent_snapshot WHERE id IN(SELECT consent_snapshot_id FROM hc);
|
||||
COMMIT;
|
||||
"@ | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
|
||||
}
|
||||
if ($deviceInternalID -match '^[0-9a-fA-F-]{36}$') {
|
||||
"DELETE FROM hardware_agent_device_binding WHERE device_id='$deviceInternalID'::uuid; UPDATE device_twin SET desired_config=desired_config-'voice_runtime'-'hardware_agent_transport'-'minimum_sdk_version' WHERE device_id='$deviceInternalID'::uuid;" | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q | Out-Null
|
||||
}
|
||||
@('MOCK_HARDWARE_GATEWAY_PORT','MOCK_VOLC_DEVICE_SECRET','MOCK_REQUEST_PLATFORM_STOP','MOCK_PLATFORM_API_URL','MOCK_PLATFORM_DEVICE_ID','MOCK_PLATFORM_DEVICE_SECRET','SIMULATOR_AUTO_TEST','SIMULATOR_AUTO_WAIT_FOR_PLATFORM_STOP','SIMULATOR_AUTO_RESULT_PATH','SIMULATOR_DEFAULT_API_URL','SIMULATOR_DEFAULT_DEVICE_ID','SIMULATOR_DEFAULT_DEVICE_SECRET','SIMULATOR_VOLC_INSTANCE_ID','SIMULATOR_VOLC_PRODUCT_KEY','SIMULATOR_VOLC_DEVICE_NAME','SIMULATOR_VOLC_HARDWARE_ID','SIMULATOR_VOLC_DEVICE_SECRET','SIMULATOR_VOLC_MODEL_MODE','SIMULATOR_HARDWARE_GATEWAY_URL') | ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
|
||||
Remove-Item -LiteralPath $resultPath,$mockOutput,$mockError -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Reference in New Issue
Block a user