@hyzyn/dsh-docker 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1589,6 +1589,70 @@ await test('活动条:时间格式化(本地时区)与防抖(连续事
1589
1589
  assert.equal(runs, 1, 'cancel 后不应再触发(切 target / 关面板的清理路径)')
1590
1590
  })
1591
1591
 
1592
+ await test('事件流断开:状态判定只认 closed,unsupported 不给重连', () => {
1593
+ const api = eventsApi()
1594
+ assert.ok(api.canReconnect !== undefined, '缺少 __events.canReconnect 测试缝')
1595
+ assert.equal(api.canReconnect('closed'), true, 'closed 是终止态,必须能重连')
1596
+ // 其余都不是「需要人工重连」:open 正常、connecting / reconnecting 在自愈路上
1597
+ assert.equal(api.canReconnect('open'), false)
1598
+ assert.equal(api.canReconnect('connecting'), false)
1599
+ assert.equal(api.canReconnect('reconnecting'), false)
1600
+ // 环境没有 EventSource:重连必然再失败,给按钮等于骗人
1601
+ assert.equal(api.canReconnect('unsupported'), false)
1602
+ assert.equal(api.canReconnect(''), false)
1603
+ assert.equal(api.canReconnect(undefined), false)
1604
+ })
1605
+
1606
+ await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按钮)与 ⟳ 接手动刷新', () => {
1607
+ const decoded = decodeBundle(code)
1608
+ assert.ok(decoded.includes('重连'), '缺少重连按钮文案')
1609
+ assert.ok(decoded.includes('不会自己回来'), '缺少「不会自愈」的说明')
1610
+ assert.ok(code.includes('dk_activityHeadRow'), '缺少头行容器(兄弟布局的前提)')
1611
+ assert.ok(code.includes('dk_activityRetry'), '缺少重连按钮样式钩子')
1612
+
1613
+ /*
1614
+ * 接线判据走**源码级**(局部标识符会被压缩改名,产物里只剩属性名 / 字符串):
1615
+ * 与上面那条「哨兵被拦在换目标逻辑之前」同一套路。
1616
+ */
1617
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
1618
+
1619
+ // 按钮不能嵌套:折叠头本身是 <button>,重连必须是它的兄弟
1620
+ const headRow = source.indexOf('dk_activityHeadRow')
1621
+ const retry = source.indexOf('dk_activityRetry')
1622
+ assert.ok(headRow !== -1, 'ActivityBar 应有头行容器')
1623
+ assert.ok(retry !== -1, 'ActivityBar 应有重连按钮')
1624
+ assert.ok(headRow < retry, '重连按钮应挂在头行容器(head 的兄弟)里')
1625
+ const headOpen = source.indexOf("className: 'dk_activityHead'")
1626
+ assert.ok(headOpen !== -1)
1627
+ assert.ok(retry > headOpen, '重连是要排在折叠头之后的兄弟')
1628
+ // 折叠头的 children 里不许出现重连按钮(那就是嵌套按钮)
1629
+ const headClose = source.indexOf("}, 'head')", headOpen)
1630
+ assert.ok(headClose !== -1, '折叠头应有结束标记')
1631
+ assert.ok(!source.slice(headOpen, headClose).includes('dk_activityRetry'), '重连按钮不能嵌在折叠按钮里(非法 HTML + 读屏会听成一个动作)')
1632
+
1633
+ // 事件流 effect 的依赖里必须有重连令牌,否则点了不重开
1634
+ assert.ok(/\}, \[active, view, target, eventsReconnect\]\)/.test(source), '事件流 effect 的依赖数组里必须含 eventsReconnect(否则点了不重开)')
1635
+ // 令牌必须自增:布尔翻回 false 时 effect 看不出「又要求了一次」
1636
+ assert.ok(/setEventsReconnect\(\(value\) => value \+ 1\)/.test(source), '重连令牌必须自增')
1637
+
1638
+ /*
1639
+ * ⟳ 走 refreshManually(既刷列表也重连);AUTO REFRESH 轮询必须仍只调 refresh——
1640
+ * 塞进轮询会让一条已终止的流被不断重开,既掩盖「它断了」,又白建 SSE。
1641
+ */
1642
+ assert.ok(source.includes('refreshManually'), '缺少 ⟳ 的手动刷新包装')
1643
+ assert.ok(/setInterval\(refresh,/.test(source), 'AUTO REFRESH 轮询不该被改成重连(否则把终止态掩盖成自愈)')
1644
+ assert.ok(/onClick: refreshManually/.test(source), '⟳ 按钮应接手动刷新')
1645
+
1646
+ /*
1647
+ * 重连成功后要补一次全量列表刷新:daemon 全程没起时流**从未 open 过**,只按
1648
+ * 「opened」判会把补偿一起省掉——活动条绿了但列表还停在「这个目标的数据没读到」,
1649
+ * 看着像好了其实没好。所以判据里必须把「这次是用户点的重连」也算进去。
1650
+ */
1651
+ assert.ok(/manualReconnect/.test(source), '缺少「这次是手动重连」的判定')
1652
+ assert.ok(/eventsOpenedRef\.current\.opened \|\| manualReconnect/.test(source),
1653
+ '重连补偿的判据必须含 manualReconnect(否则从未 open 过的流重连后列表不刷新)')
1654
+ })
1655
+
1592
1656
  await test('变更操作执行中态:按钮转圈 + 卡片锁组 + 确认框不关', () => {
1593
1657
  // 三处过渡态钩子:按钮 data-busy(图标换转圈)、卡片 data-pending(整组变更按钮压暗锁住)、
1594
1658
  // 确认框 k_confirmBusy(保持打开 + 「执行中…」)。真实点击路径需要浏览器,这里锁契约。
@@ -2214,6 +2278,336 @@ await test('日志缓冲:快照切分不把结尾换行算成一行(D136 ——
2214
2278
  assert.deepEqual(splitLines(''), [])
2215
2279
  })
2216
2280
 
2281
+ /* ------------------------------------------------------------------ *
2282
+ * 一键连接本机:按钮出现的位置与 HTTP 接线
2283
+ * ------------------------------------------------------------------ */
2284
+
2285
+ /**
2286
+ * 往模块级 config 缓存里塞一份 config(按钮的三态判定读的就是它,而它平时只由
2287
+ * `/config` 响应推进去)。走 bundle 自己导出的 publishConfig,与生产路径同一份语义。
2288
+ */
2289
+ function publishConfigForTest(config) {
2290
+ registration.factory((spec) => SEED[spec]).__config.publishConfig(config)
2291
+ }
2292
+
2293
+ /*
2294
+ * 「复用已有本机目标,不看名字」这条判定的**归属**是宿主:它是写路径的一部分,由
2295
+ * /connect-local 的 connectLocal() / findLocalTargetName 决定,用例在 connect-local.test.ts。
2296
+ * 客户端曾经也有一份 localTargetName(按钮三态用),按钮搬进空态后那份就没了——这里不再重复钉。
2297
+ */
2298
+ await test('连接本机:客户端不再自己判「复用还是新建」(那是宿主的写路径)', () => {
2299
+ const exports_ = registration.factory((spec) => SEED[spec])
2300
+ assert.equal(exports_.__panel.localTargetName, undefined, '客户端不该保留写路径的判定副本')
2301
+ assert.equal(typeof exports_.__api.connectLocal, 'function', '真正该有的客户端能力是这条 HTTP 接线')
2302
+ })
2303
+
2304
+ await test('连接本机:api.connectLocal 走 POST /api/dsh-docker/connect-local(不带参数)', async () => {
2305
+ const exports_ = registration.factory((spec) => SEED[spec])
2306
+ const api = exports_.__api
2307
+ assert.ok(api !== undefined && typeof api.connectLocal === 'function', '缺少 __api.connectLocal 测试缝')
2308
+ /*
2309
+ * bundle 闭包里的 `fetch` 是 factory 注入的那个参数(见冒烟脚本开头的 fetchStub),
2310
+ * 不是 globalThis.fetch —— 所以「真的打到了哪个 URL」要看桩的兜底错误:
2311
+ * 兜底分支把路径原样写进 error,命中它就等于证明这次调用打的就是这个路径。
2312
+ * (成功路径的响应体由 fetchStub 决定,这里只钉接线,不重复测宿主的行为。)
2313
+ */
2314
+ await assert.rejects(() => api.connectLocal(), /unexpected \/api\/dsh-docker\/connect-local/)
2315
+ })
2316
+
2317
+ await test('连接本机:已配目标时**不得**出现在工具条(用户报的「多余」就是这条)', () => {
2318
+ const exports_ = registration.factory((spec) => SEED[spec])
2319
+ const Panel = exports_.__render.ContainerPanel
2320
+ assert.equal(typeof Panel, 'function')
2321
+
2322
+ /*
2323
+ * 这条用例是**反向**钉住一个设计决定,所以断言方向很重要:第一版把按钮挂在工具条上
2324
+ * (目标选择器右边)常驻,已有目标时它是纯噪音——用户当场指着截图说「有点多余」。
2325
+ * 所以判据不是「按钮在不在」,而是「有目标时它**不在**」。
2326
+ * 只看 dk_pillLocal 这个类:空态那个按钮用的是 dk_btn dk_btnPrimary,不会误命中。
2327
+ */
2328
+ const toolbarButton = (tree) => treeFind(tree, (el) => {
2329
+ const cls = String(el.props?.className ?? '')
2330
+ return cls.includes('dk_pillLocal')
2331
+ })
2332
+
2333
+ // ① 全是 SSH 目标(选择器里有可用目标):工具条不该有这个按钮
2334
+ publishConfigForTest({ ...FAKE_CONFIG, targets: [{ name: 'prod', kind: 'ssh', book: 'prod-a' }] })
2335
+ const sshOnly = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: 'prod' })
2336
+ assert.equal(toolbarButton(sshOnly).length, 0, '有可用目标时工具条里不该有「连接本机」')
2337
+
2338
+ // ② 已经有本机目标、且当前看的就是它:同样不该出现
2339
+ publishConfigForTest({
2340
+ ...FAKE_CONFIG,
2341
+ targets: [{ name: 'prod', kind: 'ssh', book: 'prod-a' }, { name: 'local', kind: 'local' }],
2342
+ })
2343
+ const withLocal = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: 'local' })
2344
+ assert.equal(toolbarButton(withLocal).length, 0, '当前目标就是本机时更不该有它')
2345
+
2346
+ publishConfigForTest(FAKE_CONFIG)
2347
+ })
2348
+
2349
+ await test('连接本机:只在「一个目标都没有」的空态里出现,且是可点的主按钮', () => {
2350
+ const exports_ = registration.factory((spec) => SEED[spec])
2351
+ const Panel = exports_.__render.ContainerPanel
2352
+
2353
+ /*
2354
+ * 这正是它该在的唯一场景:面板全空、原先只让你去设置卡片手配。
2355
+ * 空态里 `target === ''` ⇒ 不可能已有本机目标可复用 ⇒ 这一下必然是「新建并选中」,
2356
+ * 所以这里只断言「有一个可点的主按钮,文案是『连接本机』」——没有三态。
2357
+ */
2358
+ publishConfigForTest({ ...FAKE_CONFIG, targets: [] })
2359
+ const empty = Panel({ carrier: 'modal', onClose: () => {}, initialTarget: '' })
2360
+ const buttons = treeFind(empty, (el) => el.props?.className === 'dk_btn dk_btnPrimary')
2361
+ assert.equal(buttons.length, 1, '无目标空态里应恰有一个一键入口')
2362
+ assert.equal(buttons[0].props.type, 'button')
2363
+ assert.equal(buttons[0].props.disabled, false)
2364
+ assert.equal(typeof buttons[0].props.onClick, 'function')
2365
+ assert.ok(treeText(empty).includes('还没有配置 Docker 目标'), '空态文案应保留(一键入口是它的补充,不是替代)')
2366
+ assert.ok(treeText(empty).includes('连接本机'))
2367
+ // 恢复模块级 configCache,避免影响后续用例
2368
+ publishConfigForTest(FAKE_CONFIG)
2369
+ })
2370
+
2371
+ await test('连接本机:目标下拉里的「+ 连接本机」判定(有 SSH、无本机时才列)', () => {
2372
+ const exports_ = registration.factory((spec) => SEED[spec])
2373
+ const panel = exports_.__panel
2374
+ const SENTINEL = panel.CONNECT_LOCAL_OPTION
2375
+ const offer = panel.shouldOfferConnectLocal
2376
+ assert.equal(typeof SENTINEL, 'string', '哨兵值应作为测试缝暴露')
2377
+ assert.equal(typeof offer, 'function', '判定应作为纯函数暴露')
2378
+
2379
+ /*
2380
+ * 这条对应的正是用户报的场景:手上有几台 SSH、想加本机。第一版把按钮从工具条撤掉后,
2381
+ * 这条路只剩「开设置卡片」,用户直接问「我怎么看到本机的容器」。入口要放在**用户找机器
2382
+ * 的地方**(目标下拉末尾),而不是常驻工具条。
2383
+ *
2384
+ * 为什么驱动纯函数而不是遍历渲染树:离线冒烟的 React 桩把 useState 冻在初值上
2385
+ * (组件里 `targets` 初值是 []),树里永远走不到那一项——所以判定抽成了纯函数。
2386
+ */
2387
+ assert.equal(offer([{ name: 'prod', kind: 'ssh' }]), true,
2388
+ '有目标但没有本机目标时,应列出「+ 连接本机」')
2389
+ assert.equal(offer([
2390
+ { name: 'prod', kind: 'ssh' },
2391
+ { name: '本机-自定义', kind: 'local' },
2392
+ ]), false, '已经有本机目标时不该再列(选择器里已经有它了)')
2393
+ assert.equal(offer([]), false,
2394
+ '一个目标都没有时入口在正文空态里,下拉里不该重复列一项')
2395
+
2396
+ /*
2397
+ * 哨兵值必须撞不上真实目标名:目标名是用户自由填的(sanitizeTargets 只限非空与长度)。
2398
+ * 若用普通字符串(如 `local`),用户恰好有同名目标时点「连接本机」就会切到那台机器。
2399
+ * NUL 开头的值在输入框里打不出来,所以它不可能与任何真实目标重名。
2400
+ */
2401
+ assert.equal(SENTINEL.charCodeAt(0), 0, '哨兵应以 NUL 开头(输入框里打不出来)')
2402
+ assert.ok(SENTINEL.length > 1, '哨兵不能就是那个 NUL 本身')
2403
+ })
2404
+
2405
+ await test('连接本机:下拉项接的是 connectLocal(哨兵被拦在换目标逻辑之前)', () => {
2406
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
2407
+ /*
2408
+ * 接线判据(源码级):哨兵一旦漏进 setTarget 就会把当前目标换成一个不存在的名字,
2409
+ * 面板随即报「未知目标」。所以 `if (... === CONNECT_LOCAL_OPTION)` 分支必须在
2410
+ * `setTarget(event.target.value)` **之前**,且分支里调的是 connectLocal。
2411
+ */
2412
+ const guard = source.indexOf('if (event.target.value === CONNECT_LOCAL_OPTION)')
2413
+ const setTarget = source.indexOf('setTarget(event.target.value)')
2414
+ assert.ok(guard !== -1, 'onChange 里应有哨兵分支')
2415
+ assert.ok(setTarget !== -1, 'onChange 里应有 setTarget')
2416
+ assert.ok(guard < setTarget, '哨兵分支必须排在 setTarget 之前(否则会切到不存在的目标)')
2417
+ const branch = source.slice(guard, setTarget)
2418
+ assert.ok(/connectLocal\(\)/.test(branch), '哨兵分支里应调用 connectLocal()')
2419
+ assert.ok(/return/.test(branch), '哨兵分支必须 return,不能继续走换目标')
2420
+ })
2421
+
2422
+ /* ------------------------------------------------------------------ *
2423
+ * 就地提权:多能力 pending → 一条命令解锁全部(一次粘贴)
2424
+ * ------------------------------------------------------------------ */
2425
+
2426
+ /**
2427
+ * 为什么这一组用例必须存在:改前 `elevation` 是**单槽** state(`setElevation({capability})`),
2428
+ * 面板开着时再点另一个开关会把前一个能力的面板**替换**掉、它的命令从界面消失(宿主侧 challenge
2429
+ * 仍在 pending)。2026-09-26 的设置卡片截图实测:两次授权时刻差 14 秒——用户必须在宿主终端
2430
+ * **粘贴两次**。逐能力记录 + 合并复制修掉的就是这件事:两个开关各点一次,界面上同时有两块面板
2431
+ * 与一个「复制全部」,终端**粘贴一次**两行。
2432
+ *
2433
+ * 断言打在**渲染树**与**按钮交下去的字节**上(不只是纯函数):纯函数对而接线错(按钮接错回调、
2434
+ * 只复制了第一条、顺序反了)在真机上才暴露,正是离线冒烟要拦的那一类。
2435
+ */
2436
+ function elevationApi() {
2437
+ const exports_ = registration.factory((spec) => SEED[spec])
2438
+ assert.ok(exports_.__elevation !== undefined, '缺少 __elevation 测试缝')
2439
+ return exports_.__elevation
2440
+ }
2441
+
2442
+ /** 面板组的渲染:回调用桩记录,配置值默认都关着(与「开关还没打开」的真实初态一致)。 */
2443
+ function renderElevationPanels(records, overrides = {}) {
2444
+ const api = elevationApi()
2445
+ const copied = []
2446
+ const closed = []
2447
+ const regenerated = []
2448
+ const tree = api.view(records, {
2449
+ now: overrides.now ?? 1_700_000_000_000,
2450
+ copiedKey: overrides.copiedKey ?? '',
2451
+ configured: overrides.configured ?? (() => false),
2452
+ onCopy: (capability, command) => copied.push({ capability, command }),
2453
+ onCopyAll: (text) => copied.push({ capability: api.COPY_ALL_KEY, command: text }),
2454
+ onRegenerate: (capability) => regenerated.push(capability),
2455
+ onClose: (capability) => closed.push(capability),
2456
+ onDisableFirst: () => {},
2457
+ })
2458
+ return { api, tree, copied, closed, regenerated }
2459
+ }
2460
+
2461
+ /** 合并按钮:从渲染树里找(而不是读入参),保证「界面上真的有」这件事被测到。 */
2462
+ function mergeButtonOf(tree) {
2463
+ return treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copyAll')[0]
2464
+ }
2465
+
2466
+ await test('就地提权:两个 pending → 合并按钮复制的内容 = 两条命令按发起顺序的换行连接(逐字节)', () => {
2467
+ const api = elevationApi()
2468
+ const expiresAt = 1_700_000_000_000 + 300_000
2469
+ // 发起顺序:先点 allowMutations,再点 allowExec(数组顺序就是点击顺序)
2470
+ const records = [
2471
+ { capability: 'allowMutations', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/aaa'", expiresAt },
2472
+ { capability: 'allowExec', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/bbb'", expiresAt },
2473
+ ]
2474
+ const { tree, copied } = renderElevationPanels(records)
2475
+ const button = mergeButtonOf(tree)
2476
+ assert.ok(button !== undefined, '两个 pending 时必须出「复制全部」按钮')
2477
+
2478
+ /*
2479
+ * 驱动按钮的 onClick(真跑一次「点了会怎样」):断言的是**交给复制动作的那个字符串**
2480
+ * (桩里没有 navigator,剪贴板本身是浏览器的事)——内容错(少一条 / 顺序反 / 用 && 连接)
2481
+ * 才是本用例要拦的。
2482
+ */
2483
+ button.props.onClick()
2484
+ assert.equal(copied.length, 1, '点一次合并按钮应恰好触发一次复制')
2485
+ const text = copied[0].command
2486
+ const expected = records[0].command + '\n' + records[1].command
2487
+ assert.equal(text, expected, '合并内容必须逐字节等于两条命令的换行连接')
2488
+ assert.equal(
2489
+ Buffer.from(text, 'utf8').toString('base64'),
2490
+ Buffer.from(expected, 'utf8').toString('base64'),
2491
+ '按字节比对(换行符形态也是契约)',
2492
+ )
2493
+ assert.equal(text.split('\n').length, 2, '两条命令 = 两行')
2494
+ assert.ok(!text.includes('&&'), '不得用 && 连接(PS 5.1 不认)')
2495
+ assert.ok(!text.includes('; '), '不得用 ; 连接(cmd 不认)')
2496
+ assert.ok(text.indexOf(records[0].command) < text.indexOf(records[1].command), '顺序必须是发起顺序(先点的在前)')
2497
+ assert.equal(copied[0].capability, api.COPY_ALL_KEY, '合并复制的反馈键必须与单条按钮隔离')
2498
+
2499
+ // 每块面板自己的单条复制按钮仍在(用户仍可只复制一条),且只复制自己那条
2500
+ const singleButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy')
2501
+ assert.equal(singleButtons.length, 2, '两块面板各自保留单条复制按钮')
2502
+ singleButtons[0].props.onClick()
2503
+ assert.equal(copied[1].command, records[0].command, '单条复制只复制自己那条命令')
2504
+ assert.equal(copied[1].capability, 'allowMutations', '单条复制的反馈键是能力名(与合并按钮互不串)')
2505
+ })
2506
+
2507
+ await test('就地提权:单个 pending → 无合并按钮(那条命令就在自己的面板里)', () => {
2508
+ const expiresAt = 1_700_000_000_000 + 300_000
2509
+ const single = [{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt }]
2510
+ const { tree, api } = renderElevationPanels(single)
2511
+ assert.equal(mergeButtonOf(tree), undefined, '只点过一个开关时不该出现「复制全部(1 条)」')
2512
+ // 反面判据:纯判定同样为假(防止某天有人把「≥2」放宽成「≥1」而用例只看渲染树)
2513
+ assert.equal(api.shouldOfferMerge(single, 1_700_000_000_000), false)
2514
+ // 界面结构不变形:一块面板 + 一个单条复制按钮
2515
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1)
2516
+ assert.equal(treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy').length, 1)
2517
+ })
2518
+
2519
+ await test('就地提权:一个 granted 后 → 合并按钮只剩剩下那一条(组里还有别的能力没解锁)', () => {
2520
+ const api = elevationApi()
2521
+ const expiresAt = 1_700_000_000_000 + 300_000
2522
+ const two = [
2523
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2524
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2525
+ ]
2526
+ // allowMutations 先解锁:记录留痕(面板消失),命令清掉
2527
+ const afterGrant = api.markGranted(two, 'allowMutations')
2528
+ assert.equal(api.shouldOfferMerge(afterGrant, 1_700_000_000_000), true, '还有一条没解锁时按钮要留着')
2529
+ assert.deepEqual(api.merge(afterGrant, 1_700_000_000_000), ["touch '/tmp/b'"], '只剩仍 pending 的那一条')
2530
+ assert.equal(api.text(afterGrant, 1_700_000_000_000), "touch '/tmp/b'", '合并内容退化成那一行(没有多余换行)')
2531
+
2532
+ const { tree, copied } = renderElevationPanels(afterGrant)
2533
+ const button = mergeButtonOf(tree)
2534
+ assert.ok(button !== undefined, '一个 granted 后合并按钮仍在')
2535
+ button.props.onClick()
2536
+ assert.equal(copied[0].command, "touch '/tmp/b'", '按钮此刻只复制仍 pending 的那条')
2537
+ // 已授权的那块面板消失,仍 pending 的那块还在
2538
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1, '已授权的面板必须消失')
2539
+
2540
+ // 两条都解锁 → 组里没有可复制命令:留痕被收掉,按钮自然消失
2541
+ const allGranted = api.markGranted(afterGrant, 'allowExec')
2542
+ assert.deepEqual(allGranted, [], '全部解锁后整组清空(含留痕)')
2543
+ assert.equal(
2544
+ api.view(allGranted, { copiedKey: '', configured: () => false, onCopy() {}, onCopyAll() {}, onRegenerate() {}, onClose() {}, onDisableFirst() {} }),
2545
+ null,
2546
+ '全部结束后面板组不渲染',
2547
+ )
2548
+ })
2549
+
2550
+ await test('就地提权:过期的那条不进合并(复制过去只会让宿主落一个过期后才出现的文件)', () => {
2551
+ const api = elevationApi()
2552
+ const now = 1_700_000_000_000
2553
+ const records = [
2554
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt: now - 1 },
2555
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt: now + 300_000 },
2556
+ ]
2557
+ assert.deepEqual(api.merge(records, now), ["touch '/tmp/b'"], '过期的从合并里剔掉')
2558
+ const { tree, copied } = renderElevationPanels(records, { now })
2559
+ const button = mergeButtonOf(tree)
2560
+ assert.ok(button !== undefined, '组里仍有两条记录时按钮在(只是内容少了一条)')
2561
+ button.props.onClick()
2562
+ assert.equal(copied[0].command, "touch '/tmp/b'", '合并按钮只复制仍 pending 的那些')
2563
+ // 两条都过期 → 没有可复制的:按钮收起(不再承诺一个不可能发生的解锁)
2564
+ const bothExpired = records.map((record) => ({ ...record, expiresAt: now - 1 }))
2565
+ assert.equal(api.shouldOfferMerge(bothExpired, now), false)
2566
+ })
2567
+
2568
+ await test('就地提权:✕ 只关一个能力的面板,另一个照常解锁(不撤销宿主 challenge)', () => {
2569
+ const api = elevationApi()
2570
+ const expiresAt = 1_700_000_000_000 + 300_000
2571
+ const records = [
2572
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2573
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2574
+ ]
2575
+ const { tree, closed } = renderElevationPanels(records)
2576
+ const closeButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'close')
2577
+ assert.equal(closeButtons.length, 2, '每块面板各有自己的收起按钮')
2578
+ closeButtons[0].props.onClick()
2579
+ assert.deepEqual(closed, ['allowMutations'], '✕ 只收起被点的那一个能力')
2580
+ // 关掉 A 之后 B 照常解锁:记录集里仍留着 B,合并内容只剩 B 那条
2581
+ const afterClose = api.remove(records, 'allowMutations')
2582
+ assert.deepEqual(api.merge(afterClose, 1_700_000_000_000), ["touch '/tmp/b'"])
2583
+ assert.equal(api.shouldOfferMerge(afterClose, 1_700_000_000_000), false, '只剩一个 pending 时合并按钮收起')
2584
+ })
2585
+
2586
+ await test('就地提权:两条命令各自独立——一条出错 / 在途不影响另一条的合并', () => {
2587
+ const api = elevationApi()
2588
+ const expiresAt = 1_700_000_000_000 + 300_000
2589
+ const records = [
2590
+ { capability: 'allowMutations', error: 'HTTP 429' },
2591
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2592
+ ]
2593
+ assert.deepEqual(api.merge(records, 1_700_000_000_000), ["touch '/tmp/b'"], '出错那条没有命令,不进合并')
2594
+ assert.equal(api.shouldOfferMerge(records, 1_700_000_000_000), false, '只有一条可复制时不出按钮')
2595
+ // 在途(begin 还没回)同样不参与合并
2596
+ assert.deepEqual(api.merge([{ capability: 'allowExec' }], 1_700_000_000_000), [])
2597
+ })
2598
+
2599
+ await test('就地提权:i18n 双份齐(新增键 zh/en 都在,且都进了产物)', () => {
2600
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
2601
+ for (const key of ['elev.copyAll', 'elev.copyAllHint', 'elev.copyAllCopied']) {
2602
+ const hits = source.split(`'${key}':`).length - 1
2603
+ assert.equal(hits, 2, `${key} 必须在 zh / en 两份目录里各出现一次(现在是 ${String(hits)} 次)`)
2604
+ }
2605
+ const decoded = decodeBundle(code)
2606
+ assert.ok(decoded.includes('复制全部'), 'bundle 里应有合并按钮的中文兜底文案')
2607
+ assert.ok(code.includes('dk_elevPanels'), 'bundle 里应有面板组的样式钩子')
2608
+ assert.ok(code.includes('dk_elevMerge'), 'bundle 里应有合并复制行的样式钩子')
2609
+ })
2610
+
2217
2611
  /* ------------------------------------------------------------------ *
2218
2612
  * 结果
2219
2613
  * ------------------------------------------------------------------ */
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, copyFileSync, mkdirSync, statSync } from 'nod
18
18
  import { join, extname, resolve, dirname } from 'node:path'
19
19
  import { fileURLToPath } from 'node:url'
20
20
  import { Chrome } from '../../../scripts/chrome-cdp.mjs'
21
+ import { describeChromeSearch, findChrome, playwrightChromeCandidates } from '../../../scripts/chrome-path.mjs'
21
22
 
22
23
  const here = dirname(fileURLToPath(import.meta.url))
23
24
  const pkg = resolve(here, '..')
@@ -62,8 +63,14 @@ function skip(reason) {
62
63
  process.exit(0)
63
64
  }
64
65
 
65
- const chromePath = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
66
- if (!existsSync(chromePath)) skip('没找到 Chrome(可用 CHROME_PATH 指定)')
66
+ /*
67
+ * 浏览器发现走共享模块:此前这里是 `process.env.CHROME_PATH ?? '<写死的 macOS 路径>'`,
68
+ * 与 tty / search / codegraph 那三处同一个毛病(非 macOS 上永远命中不了)。走
69
+ * `findChrome` 后环境变量仍最优先,只是**多了一层「指到不存在的路径时继续找」**
70
+ * (旧写法 `??` 会直接把那个坏路径交给 spawn)。
71
+ */
72
+ const chromePath = findChrome({ extraCandidates: playwrightChromeCandidates })
73
+ if (chromePath === null) skip('没找到 Chrome/Chromium(可用 CHROME_PATH 指定):' + describeChromeSearch())
67
74
  for (const file of ['harness.html', 'mock-host.js', 'harness.js', 'vendor/react.js', 'vendor/react-dom.js']) {
68
75
  if (!existsSync(join(fixtureDir, file))) skip('预览夹具缺 ' + file + '(先跑 node packages/tty/scripts/preview.mjs)')
69
76
  }