3131 let startupTiming = null ;
3232 let failUpdate = false ;
3333 let checkFailure = null ;
34+ let checkUpToDate = false ;
3435 let statusFailure = false ;
3536 let environmentTelemetry = null ;
3637 await page . exposeFunction ( "nativeInvoke" , async ( command , args ) => {
4041 }
4142 calls . push ( { command, args } ) ;
4243 if ( checkFailure && args . action === "check" ) return { phase : "error" , details : { code : checkFailure } } ;
44+ if ( checkUpToDate && args . action === "check" ) {
45+ nativeState = { phase : "up_to_date" , details : { channel : args . channel } } ;
46+ return nativeState ;
47+ }
4348 if ( failUpdate ) throw new Error ( "private diagnostic must not be displayed" ) ;
4449 await new Promise ( ( done ) => setTimeout ( done , 150 ) ) ;
45- nativeState = { phase : args . action === "check" ? "available" : "restart_required" , details : { version : "0.5.5" , channel : args . channel } } ;
50+ nativeState = {
51+ phase : args . action === "check" ? "available" : args . action === "align_runtime" ? "connecting" : "restart_required" ,
52+ details : { version : "0.5.5" , channel : args . channel } ,
53+ } ;
4654 return nativeState ;
4755 } ) ;
4856 await page . addInitScript ( ( ) => {
@@ -232,6 +240,81 @@ try {
232240 nativeState = { phase : "runtime_required" , details : { code : "runtime_setup_required" } } ;
233241 await page . waitForFunction ( ( ) => document . querySelector ( "main" ) . dataset . state === "error" && document . querySelector ( "#status" ) . innerText . includes ( "请修复当前版本,成功后重启" ) ) ;
234242 environmentTelemetry = null ;
243+ // A different installed CLI runtime is the operator's decision, shown on the
244+ // first screen with both choices: update the App and runtime together, or
245+ // replace the CLI runtime with this App's bundled snapshot. Nothing is
246+ // installed until one of them is chosen.
247+ const pairingState = { phase : "runtime_pairing_required" , details : {
248+ code : "runtime_pairing_required" , app_version : "1.0.5" ,
249+ installed_revision : "a" . repeat ( 40 ) , bundled_revision : "b" . repeat ( 40 ) ,
250+ installed_identity_available : true , revision_matches : false ,
251+ } } ;
252+ nativeState = pairingState ;
253+ await page . reload ( ) ;
254+ const pairingPanel = page . locator ( "#pairing" ) ;
255+ await pairingPanel . waitFor ( { state : "visible" } ) ;
256+ assert . equal ( await startupPanel . getAttribute ( "data-state" ) , "decision" ) ;
257+ assert . equal ( await startupPanel . getAttribute ( "aria-busy" ) , "false" ) ;
258+ assert . equal ( await startupDots . isVisible ( ) , false ) ;
259+ assert . equal ( await page . locator ( "#status" ) . innerText ( ) , "需要你选择 App 与 CLI 运行时的对齐方式" ) ;
260+ assert . equal ( await page . locator ( "#pairing-installed" ) . innerText ( ) , "aaaaaaaaaaaa" ) ;
261+ assert . equal ( await page . locator ( "#pairing-bundled" ) . innerText ( ) , "bbbbbbbbbbbb" ) ;
262+ assert . ok ( ( await page . locator ( "#pairing-status" ) . innerText ( ) ) . includes ( "本地服务需要两者一致" ) ) ;
263+ assert . equal ( await page . getByRole ( "button" , { name : / 升 级 : 更 新 A p p 与 运 行 时 / } ) . isEnabled ( ) , true ) ;
264+ assert . equal ( await page . getByRole ( "button" , { name : / 回 退 C L I : 改 用 本 A p p 自 带 运 行 时 / } ) . isEnabled ( ) , true ) ;
265+ // A decision never escalates into the error projection, and the native boot
266+ // failure callback cannot overwrite it.
267+ await page . evaluate ( ( ) => window . loopxBootFailed ( "启动失败,请重试。" ) ) ;
268+ await page . waitForTimeout ( 2600 ) ;
269+ assert . equal ( await startupPanel . getAttribute ( "data-state" ) , "decision" ) ;
270+ assert . equal ( await pairingPanel . isVisible ( ) , true ) ;
271+ assert . equal ( await page . locator ( "#status" ) . innerText ( ) , "需要你选择 App 与 CLI 运行时的对齐方式" ) ;
272+ await page . screenshot ( { path : resolve ( output , "startup-pairing-decision.png" ) } ) ;
273+ await page . emulateMedia ( { colorScheme : "dark" } ) ;
274+ await page . screenshot ( { path : resolve ( output , "startup-pairing-decision-dark.png" ) } ) ;
275+ await page . emulateMedia ( { colorScheme : "light" } ) ;
276+ await page . setViewportSize ( { width : 390 , height : 844 } ) ;
277+ assert . ok ( await page . evaluate ( ( ) => document . documentElement . scrollWidth <= innerWidth ) , "the decision must fit a phone-width window" ) ;
278+ const upgradeBox = await page . getByRole ( "button" , { name : / 升 级 : 更 新 A p p 与 运 行 时 / } ) . boundingBox ( ) ;
279+ const alignBox = await page . getByRole ( "button" , { name : / 回 退 C L I : 改 用 本 A p p 自 带 运 行 时 / } ) . boundingBox ( ) ;
280+ assert . ok ( upgradeBox && alignBox && upgradeBox . y + upgradeBox . height <= alignBox . y + 1 , "both choices stay visible and ordered" ) ;
281+ await page . screenshot ( { path : resolve ( output , "startup-pairing-decision-mobile.png" ) } ) ;
282+ await page . setViewportSize ( { width : 1280 , height : 900 } ) ;
283+
284+ // "Upgrade" is one intent: check this App's channel, then continue into the
285+ // verified install instead of asking for a second click.
286+ calls . length = 0 ;
287+ await page . getByRole ( "button" , { name : / 升 级 : 更 新 A p p 与 运 行 时 / } ) . click ( ) ;
288+ await page . waitForFunction ( ( ) => document . querySelector ( "#pairing-status" ) . textContent . includes ( "重启" ) ) ;
289+ assert . deepEqual ( calls . map ( ( call ) => call . args ?. action ) , [ "check" , "apply" ] , "upgrade must check then install" ) ;
290+ assert . deepEqual ( calls . at ( - 1 ) . args . channel , "stable" ) ;
291+ assert . equal ( await pairingPanel . isVisible ( ) , true , "the install outcome stays on the decision surface" ) ;
292+
293+ // A channel with nothing newer says so and leaves the CLI choice standing.
294+ nativeState = pairingState ;
295+ await page . reload ( ) ;
296+ await pairingPanel . waitFor ( { state : "visible" } ) ;
297+ calls . length = 0 ;
298+ checkUpToDate = true ;
299+ await page . getByRole ( "button" , { name : / 升 级 : 更 新 A p p 与 运 行 时 / } ) . click ( ) ;
300+ await page . waitForFunction ( ( ) => document . querySelector ( "#pairing-status" ) . textContent . includes ( "没有更新的 App 构建" ) ) ;
301+ assert . deepEqual ( calls . map ( ( call ) => call . args ?. action ) , [ "check" ] , "a channel without a newer build must not install anything" ) ;
302+ assert . equal ( await pairingPanel . isVisible ( ) , true ) ;
303+ checkUpToDate = false ;
304+
305+ // "Roll back the CLI" installs this App's snapshot and reconnects the same
306+ // window; the chooser retires as soon as services connect.
307+ nativeState = pairingState ;
308+ await page . reload ( ) ;
309+ await pairingPanel . waitFor ( { state : "visible" } ) ;
310+ calls . length = 0 ;
311+ await page . getByRole ( "button" , { name : / 回 退 C L I : 改 用 本 A p p 自 带 运 行 时 / } ) . click ( ) ;
312+ await page . getByText ( "正在连接本地服务" , { exact : true } ) . waitFor ( ) ;
313+ assert . deepEqual ( calls . map ( ( call ) => call . args ?. action ) , [ "align_runtime" ] ) ;
314+ assert . equal ( await pairingPanel . isVisible ( ) , false ) ;
315+ nativeState = { phase : "connecting" , details : { service : "chat" } } ;
316+ await page . getByText ( "正在连接管家对话服务" , { exact : true } ) . waitFor ( ) ;
317+
235318 // Production boot surface must expose native installation and service
236319 // stages even while recovery is collapsed; reload keeps native elapsed time.
237320 nativeState = { phase : "installing_runtime" , details : { } } ;
@@ -249,7 +332,7 @@ try {
249332 await page . getByText ( / 启 动 用 时 较 长 / ) . waitFor ( ) ;
250333 nativeState = { phase : "service_error" , details : { code : "service_start_failed" } } ;
251334 await page . getByText ( "本地服务连接失败,正在等待重试" , { exact : true } ) . waitFor ( ) ;
252- console . log ( "desktop-update-browser-smoke: passed (confirmation, failure redaction, mobile, missing assets + reload, startup motion states, startup recovery, startup error escalation)" ) ;
335+ console . log ( "desktop-update-browser-smoke: passed (confirmation, failure redaction, mobile, missing assets + reload, startup motion states, startup recovery, startup error escalation, runtime pairing decision )" ) ;
253336} finally {
254337 await browser . close ( ) ;
255338 await new Promise ( ( done ) => server . close ( done ) ) ;
0 commit comments