2026-08-26 06:41:07 +08:00
import { createRequire } from "node:module" ;
import { randomUUID } from "node:crypto" ;
import { execFileSync } from "node:child_process" ;
import { mkdir } from "node:fs/promises" ;
import { fileURLToPath } from "node:url" ;
const require = createRequire ( process . env . PLAYWRIGHT_REQUIRE_FROM ?? import . meta . url );
const { chromium } = require ( "playwright" );
const outputDirectory = new URL ( "./screenshots/" , import . meta . url );
const webURL = process . env . VISUAL_WEB_URL ?? "http://localhost:3100" ;
const webHostPattern = new RegExp ( new URL ( webURL ). host . replace ( /[.*+?^${}()|[\]\\]/g , "\\$&" ));
const oidcURLPattern = new RegExp ( process . env . VISUAL_OIDC_URL_PATTERN ?? "localhost:58180" );
const username = process . env . VISUAL_USERNAME ?? "owner" ;
const password = process . env . VISUAL_PASSWORD ?? "LocalOnly123!" ;
const contextID = randomUUID ();
const contextPublicID = randomUUID ();
const conversationID = randomUUID ();
const conversationPublicID = randomUUID ();
const turnID = randomUUID ();
const turnPublicID = randomUUID ();
2026-08-29 21:56:51 +08:00
const summaryID = randomUUID ();
const topicID = randomUUID ();
2026-08-26 06:41:07 +08:00
await mkdir ( outputDirectory , { recursive : true });
function psql ( statement ) {
return execFileSync ( "docker" , [ "compose" , "exec" , "-T" , "postgres" , "psql" , "-v" , "ON_ERROR_STOP=1" , "-U" , "voice" , "-d" , "voice_platform" , "-Atc" , statement ], {
cwd : process . cwd (), encoding : "utf8" , stdio : [ "ignore" , "pipe" , "pipe" ]
}). trim ();
}
function cleanup () {
psql ( `BEGIN;
2026-08-29 21:56:51 +08:00
DELETE FROM conversation_summary WHERE id IN (' ${ summaryID } '::uuid,' ${ topicID } '::uuid);
2026-08-26 06:41:07 +08:00
DELETE FROM conversation_turn WHERE id=' ${ turnID } '::uuid;
DELETE FROM conversation_state_history WHERE conversation_id=' ${ conversationID } '::uuid;
DELETE FROM conversation WHERE id=' ${ conversationID } '::uuid;
DELETE FROM context_snapshot WHERE id=' ${ contextID } '::uuid;
COMMIT;` );
}
psql ( `BEGIN;
INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary,preparation_status)
VALUES(' ${ contextID } '::uuid,' ${ contextPublicID } '::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'visual-conversation-v1','s2s_hybrid','0000000000000000000000000000000000000000000000000000000000000000','浏览器通话详情恢复夹具','READY');
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES(' ${ conversationID } '::uuid,' ${ conversationPublicID } '::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid,'10000000-0000-0000-0000-000000000701'::uuid,' ${ contextID } '::uuid,'visual-conversation- ${ conversationPublicID } ','s2s_hybrid','transcript_only','COMPLETED',now()+interval '5 minutes',now()-interval '2 minutes',now()-interval '1 minute','user_hangup');
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at)
VALUES(' ${ conversationID } '::uuid,NULL,'ROOM_ISSUED','visual fixture','platform',now()-interval '2 minutes'),(' ${ conversationID } '::uuid,'ROOM_ISSUED','ACTIVE','visual fixture','provider',now()-interval '110 seconds'),(' ${ conversationID } '::uuid,'ACTIVE','COMPLETED','visual fixture','device',now()-interval '1 minute');
INSERT INTO conversation_turn(id,public_id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type)
VALUES(' ${ turnID } '::uuid,' ${ turnPublicID } '::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,' ${ conversationID } '::uuid,'visual-turn- ${ turnPublicID } ',1,'subject',now()-interval '100 seconds',now()-interval '98 seconds','今天想聊聊以前的工作。',false,'device');
2026-08-29 21:56:51 +08:00
INSERT INTO conversation_summary(id,tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
VALUES
(' ${ summaryID } '::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,' ${ conversationID } '::uuid,'conversation_summary','长者回顾了以前的工作经历。\n关键词:以前的工作、职业经历','[" ${ turnPublicID } "]'::jsonb,'ark:visual-summary-model','story-ark-json-v1',1,0.91,'READY'),
(' ${ topicID } '::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,' ${ conversationID } '::uuid,'open_topic','下次可以继续聊聊工作中印象深刻的人。','[" ${ turnPublicID } "]'::jsonb,'ark:visual-summary-model','story-ark-json-v1',1,0.91,'READY');
2026-08-26 06:41:07 +08:00
COMMIT;` );
const browser = await chromium . launch ({ headless : true , executablePath : process . env . CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser . newPage ({ viewport : { width : 1440 , height : 1100 }, deviceScaleFactor : 1 });
const consoleErrors = [];
const failedRequests = [];
const expectedDetailFailures = [];
const expectedFailureURLs = new Set ();
page . on ( "console" , ( message ) => { if ( message . type () === "error" ) consoleErrors . push ( message . text ()); });
page . on ( "requestfailed" , ( request ) => {
const failure = request . failure () ? . errorText ?? "failed" ;
const requestURL = new URL ( request . url ());
if ( request . method () === "GET" && requestURL . pathname === "/api/v1/events" && failure . includes ( "ERR_ABORTED" )) return ;
if ( expectedFailureURLs . has ( request . url ())) {
expectedDetailFailures . push ( ` ${ request . method () } ${ request . url () } ${ failure } ` );
return ;
}
failedRequests . push ( ` ${ request . method () } ${ request . url () } ${ failure } ` );
});
try {
await page . goto ( webURL , { waitUntil : "networkidle" });
2026-08-29 21:56:51 +08:00
const workspace = page . getByRole ( "region" , { name : "家庭业务工作台" });
const loginButton = page . getByRole ( "button" , { name : /登录并进入家庭|登录研发环境/ });
await Promise . race ([
workspace . waitFor (),
loginButton . waitFor (),
]);
if ( await loginButton . isVisible (). catch (() => false )) {
await loginButton . click ();
await page . waitForURL ( oidcURLPattern );
await page . locator ( "#username" ). fill ( username );
await page . locator ( "#password" ). fill ( password );
await page . locator ( "#kc-login" ). click ();
await page . waitForURL ( webHostPattern , { timeout : 15000 });
}
await workspace . waitFor ();
await page . getByText ( "通话记录" , { exact : true }). first (). click ();
2026-08-26 06:41:07 +08:00
const firstRow = page . locator ( ".conversation-row" ). first ();
await firstRow . waitFor ();
const rowText = await firstRow . innerText ();
const rowUsesBusinessLabels = /端到端语音|端到端混合|识别·理解·合成级联|背景资料准备失败/ . test ( rowText )
&& ! /s2s_pure|s2s_hybrid|cascade|transcript_only|transcript_and_audio/ . test ( rowText );
let failNextDetail = true ;
await page . route ( "**/api/v1/conversations/**" , async ( route ) => {
const request = route . request ();
const requestURL = new URL ( request . url ());
if ( failNextDetail && request . method () === "GET" && /^\/api\/v1\/conversations\/[^/]+$/ . test ( requestURL . pathname )) {
failNextDetail = false ;
expectedFailureURLs . add ( request . url ());
await route . abort ( "failed" );
return ;
}
await route . continue ();
});
await firstRow . click ();
const detailFailure = page . getByText ( "通话详情读取失败" , { exact : true });
await detailFailure . waitFor ();
const retryVisible = await page . getByRole ( "button" , { name : "重新加载" }). isVisible ();
const staleDetailHidden = await page . locator ( ".conversation-detail .resource-detail__head" ). count () === 0 ;
await page . getByRole ( "button" , { name : "重新加载" }). click ();
await page . locator ( ".conversation-detail .resource-detail__head" ). waitFor ();
const detailText = await page . locator ( ".conversation-detail" ). innerText ();
const detailUsesBusinessLabels = /端到端语音|端到端混合|识别·理解·合成级联/ . test ( detailText )
&& /不保存字幕和录音|仅保存字幕|保存字幕和双轨录音/ . test ( detailText )
&& ! /s2s_pure|s2s_hybrid|cascade|transcript_only|transcript_and_audio/ . test ( detailText );
const selectedRowVisible = await firstRow . evaluate (( element ) => element . classList . contains ( "active" ));
2026-08-29 21:56:51 +08:00
const summaryVisible = detailText . includes ( "通话摘要" )
&& detailText . includes ( "长者回顾了以前的工作经历。" )
&& detailText . includes ( "关键词:以前的工作、职业经历" )
&& detailText . includes ( "开放话题" )
&& detailText . includes ( "下次可以继续聊聊工作中印象深刻的人。" );
2026-08-26 06:41:07 +08:00
await page . screenshot ({ path : fileURLToPath ( new URL ( "conversation-detail-recovery.png" , outputDirectory )), fullPage : true });
const overflow = await page . evaluate (() => [... document . querySelectorAll ( "body *" )]
2026-08-29 21:56:51 +08:00
. filter (( element ) => element instanceof HTMLElement
&& ! element . classList . contains ( "ant-badge" )
&& element . scrollWidth > element . clientWidth + 2
&& getComputedStyle ( element ). overflowX === "visible" )
2026-08-26 06:41:07 +08:00
. slice ( 0 , 20 )
. map (( element ) => ` ${ element . tagName } . ${ element . className } : ${ element . clientWidth } / ${ element . scrollWidth } ` ));
const unexpectedConsoleErrors = consoleErrors . filter (( message ) => ! message . includes ( "net::ERR_FAILED" ));
2026-08-29 21:56:51 +08:00
const result = { rowUsesBusinessLabels , retryVisible , staleDetailHidden , detailUsesBusinessLabels , selectedRowVisible , summaryVisible , expectedDetailFailures , overflow , consoleErrors , unexpectedConsoleErrors , failedRequests };
2026-08-26 06:41:07 +08:00
console . log ( JSON . stringify ( result , null , 2 ));
2026-08-29 21:56:51 +08:00
if ( ! rowUsesBusinessLabels || ! retryVisible || ! staleDetailHidden || ! detailUsesBusinessLabels || ! selectedRowVisible || ! summaryVisible || expectedDetailFailures . length !== 1 || overflow . length || unexpectedConsoleErrors . length || failedRequests . length ) {
2026-08-26 06:41:07 +08:00
throw new Error ( "Conversation detail visual smoke failed" );
}
} finally {
await browser . close ();
cleanup ();
}