ghost-bridge 1.0.2 → 1.2.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.
@@ -1,4 +1,4 @@
1
- importScripts('bg-network.js', 'bg-dom.js')
1
+ importScripts('bg-network.js', 'bg-dom.js', 'bg-control.js', 'bg-runtime.js')
2
2
 
3
3
  const DEFAULT_TOKEN = 'ghost-bridge-local'
4
4
 
@@ -66,6 +66,7 @@ function createSession(tabId) {
66
66
  lastErrorLocation: null,
67
67
  requestMap: new Map(),
68
68
  networkRequests: [],
69
+ lastNetworkActivityAt: Date.now(),
69
70
  }
70
71
  }
71
72
 
@@ -87,6 +88,7 @@ function resetDebuggerState(session) {
87
88
  session.scriptSourceCache = new Map()
88
89
  session.networkRequests = []
89
90
  session.requestMap = new Map()
91
+ session.lastNetworkActivityAt = Date.now()
90
92
  }
91
93
 
92
94
  function setBadgeState(status) {
@@ -277,6 +279,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
277
279
 
278
280
  // 网络事件处理
279
281
  if (method === "Network.requestWillBeSent") {
282
+ session.lastNetworkActivityAt = Date.now()
280
283
  const req = params.request || {}
281
284
  const entry = {
282
285
  tabId: source.tabId,
@@ -326,6 +329,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
326
329
  }
327
330
 
328
331
  if (method === "Network.loadingFinished") {
332
+ session.lastNetworkActivityAt = Date.now()
329
333
  const entry = session.requestMap.get(params.requestId)
330
334
  if (entry) {
331
335
  entry.endTime = params.timestamp
@@ -340,6 +344,7 @@ chrome.debugger.onEvent.addListener((source, method, params) => {
340
344
  }
341
345
 
342
346
  if (method === "Network.loadingFailed") {
347
+ session.lastNetworkActivityAt = Date.now()
343
348
  const entry = session.requestMap.get(params.requestId)
344
349
  if (entry) {
345
350
  entry.status = "failed"
@@ -989,10 +994,88 @@ async function handleSymbolicHints(params = {}) {
989
994
 
990
995
  async function handleEval(params = {}) {
991
996
  const target = await ensureAttached(params)
992
- const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
993
- expression: params.code,
994
- returnByValue: true,
997
+ const timeoutMs = Math.min(30000, Math.max(100, Number(params.timeoutMs) || 10000))
998
+ // Runtime.evaluate.timeout is enforced inside V8. The outer transport timeout is only
999
+ // a safety margin for an unresponsive tab and no longer leaves normal timed-out code running.
1000
+ return GhostBridgeRuntime.evaluateScript({
1001
+ sendCommand: chrome.debugger.sendCommand.bind(chrome.debugger),
1002
+ target,
1003
+ code: params.code,
1004
+ awaitPromise: params.awaitPromise !== false,
1005
+ timeoutMs,
1006
+ withTimeout,
995
1007
  })
1008
+ }
1009
+
1010
+ async function handlePageRequest(params = {}) {
1011
+ const target = await ensureAttached(params)
1012
+ if (!params.url || typeof params.url !== 'string') throw new Error("page_request 需要提供 url")
1013
+ const timeoutMs = Math.min(30000, Math.max(100, Number(params.timeoutMs) || 10000))
1014
+ const maxOutputLength = Math.min(50000, Math.max(200, Number(params.maxOutputLength) || 8000))
1015
+ const method = String(params.method || 'GET').toUpperCase()
1016
+ const responseType = ['auto', 'json', 'text'].includes(params.responseType) ? params.responseType : 'auto'
1017
+ const headers = params.headers && typeof params.headers === 'object' ? { ...params.headers } : {}
1018
+ let body = params.body
1019
+ if (body !== undefined && body !== null && typeof body !== 'string') {
1020
+ body = JSON.stringify(body)
1021
+ if (!Object.keys(headers).some((name) => name.toLowerCase() === 'content-type')) {
1022
+ headers['Content-Type'] = 'application/json'
1023
+ }
1024
+ }
1025
+
1026
+ const expression = `(async function() {
1027
+ const controller = new AbortController();
1028
+ const timer = setTimeout(() => controller.abort(), ${timeoutMs});
1029
+ try {
1030
+ const url = new URL(${JSON.stringify(params.url)}, window.location.href);
1031
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
1032
+ throw new Error('仅支持 HTTP(S) URL');
1033
+ }
1034
+ const response = await fetch(url.href, {
1035
+ method: ${JSON.stringify(method)},
1036
+ headers: ${JSON.stringify(headers)},
1037
+ body: ${body === undefined || body === null ? 'undefined' : JSON.stringify(String(body))},
1038
+ credentials: 'include',
1039
+ signal: controller.signal,
1040
+ });
1041
+ const text = await response.text();
1042
+ const contentType = response.headers.get('content-type') || '';
1043
+ const truncated = text.length > ${maxOutputLength};
1044
+ const content = truncated ? text.slice(0, ${maxOutputLength}) : text;
1045
+ let data = content;
1046
+ if (!truncated && (${JSON.stringify(responseType)} === 'json' || (${JSON.stringify(responseType)} === 'auto' && contentType.includes('json')))) {
1047
+ try { data = JSON.parse(content); } catch (e) {
1048
+ if (${JSON.stringify(responseType)} === 'json') throw new Error('响应不是有效 JSON: ' + e.message);
1049
+ }
1050
+ }
1051
+ return {
1052
+ ok: response.ok,
1053
+ status: response.status,
1054
+ statusText: response.statusText,
1055
+ url: response.url,
1056
+ contentType,
1057
+ originalLength: text.length,
1058
+ truncated,
1059
+ data,
1060
+ };
1061
+ } finally {
1062
+ clearTimeout(timer);
1063
+ }
1064
+ })()`
1065
+
1066
+ const { result, exceptionDetails } = await withTimeout(
1067
+ chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1068
+ expression,
1069
+ returnByValue: true,
1070
+ awaitPromise: true,
1071
+ timeout: timeoutMs + 250,
1072
+ }),
1073
+ timeoutMs + 500,
1074
+ "page_request"
1075
+ )
1076
+ if (exceptionDetails) {
1077
+ throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || "页面请求失败")
1078
+ }
996
1079
  return result?.value
997
1080
  }
998
1081
 
@@ -1334,8 +1417,16 @@ async function handleInspectPageSnapshot(params = {}) {
1334
1417
 
1335
1418
  async function handleGetPageContent(params = {}) {
1336
1419
  const target = await ensureAttached(params)
1337
- const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
1338
- const expression = GhostBridgeDom.buildPageContentExpression({ mode, selector, maxLength, includeMetadata })
1420
+ const { mode = "text", selector, maxLength = 50000, offset = 0, includeMetadata = true } = params
1421
+ const safeMaxLength = Math.min(50000, Math.max(1, Number(maxLength) || 8000))
1422
+ const safeOffset = Math.min(100000000, Math.max(0, Math.floor(Number(offset) || 0)))
1423
+ const expression = GhostBridgeDom.buildPageContentExpression({
1424
+ mode,
1425
+ selector,
1426
+ maxLength: safeMaxLength,
1427
+ offset: safeOffset,
1428
+ includeMetadata,
1429
+ })
1339
1430
 
1340
1431
  const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1341
1432
  expression,
@@ -1351,6 +1442,15 @@ async function handleGetPageContent(params = {}) {
1351
1442
  async function handleGetInteractiveSnapshot(params = {}) {
1352
1443
  const { target, session } = await ensureAttachedSession(params)
1353
1444
  const { selector, includeText = true, maxElements = 100 } = params
1445
+ const value = await evaluateInteractiveSnapshot(target, { selector, includeText, maxElements })
1446
+ if (value) {
1447
+ value.target = describeCommandTarget(params, session)
1448
+ value.tabId = session.tabId
1449
+ }
1450
+ return value
1451
+ }
1452
+
1453
+ async function evaluateInteractiveSnapshot(target, { selector, includeText = true, maxElements = 100 } = {}) {
1354
1454
  const expression = GhostBridgeDom.buildInteractiveSnapshotExpression({ selector, includeText, maxElements })
1355
1455
 
1356
1456
  const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
@@ -1359,12 +1459,7 @@ async function handleGetInteractiveSnapshot(params = {}) {
1359
1459
  })
1360
1460
 
1361
1461
  if (result?.value?.error) throw new Error(result.value.error)
1362
- const value = result?.value
1363
- if (value) {
1364
- value.target = describeCommandTarget(params, session)
1365
- value.tabId = session.tabId
1366
- }
1367
- return value
1462
+ return result?.value
1368
1463
  }
1369
1464
 
1370
1465
  // ========== DOM 交互:动作分发器 ==========
@@ -1374,155 +1469,351 @@ async function handleDispatchAction(params = {}) {
1374
1469
  if (anyNamedTargets && !params.target && params.tabId === undefined) {
1375
1470
  throw new Error("已绑定命名 target 时,dispatch_action 必须提供 target,避免跨页面误用 ref")
1376
1471
  }
1377
- const target = await ensureAttached(params)
1378
- const { ref, action, value, key, deltaX, deltaY, waitMs = 500 } = params
1472
+ const { target, session } = await ensureAttachedSession(params)
1473
+ const isBatch = Array.isArray(params.actions)
1474
+ const actions = isBatch ? params.actions : [params]
1475
+ if (!actions.length || actions.length > 20) throw new Error("actions 数量必须在 1-20 之间")
1476
+ actions.forEach(validateDispatchStep)
1477
+ const timeoutMs = Math.min(60000, Math.max(1000, Number(params.timeoutMs) || 30000))
1478
+ const deadline = Date.now() + timeoutMs
1479
+
1480
+ const batch = await GhostBridgeControl.runActionBatch(
1481
+ actions,
1482
+ async (step, index) => {
1483
+ ensureBeforeDeadline(deadline)
1484
+ return executeDispatchAction(target, session, step, index, deadline)
1485
+ },
1486
+ {
1487
+ stopOnError: params.stopOnError !== false,
1488
+ mapError: (error, index) => error.actionResult || { index, success: false, error: error.message },
1489
+ }
1490
+ )
1491
+ const results = batch.results
1379
1492
 
1380
- if (!ref) throw new Error("需要提供 ref(元素标识,如 'e1')")
1381
- if (!action) throw new Error("需要提供 action(动作类型:click/fill/press/scroll/select/hover/focus)")
1493
+ let pageAfter
1494
+ try {
1495
+ pageAfter = await readPageState(target)
1496
+ } catch (error) {
1497
+ pageAfter = { error: error.message }
1498
+ }
1499
+ const response = isBatch
1500
+ ? {
1501
+ success: results.length === actions.length && results.every((item) => item.success),
1502
+ completed: results.filter((item) => item.success).length,
1503
+ total: actions.length,
1504
+ stopped: batch.stopped,
1505
+ timeoutMs,
1506
+ results,
1507
+ pageAfter,
1508
+ }
1509
+ : { ...results[0], pageAfter }
1382
1510
 
1383
- // Step 1: 实时获取目标元素的最新坐标和状态
1384
- const locateExpression = `(function() {
1385
- try {
1386
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1387
- if (!el) return { error: '元素未找到,ref 可能已失效,请重新获取快照' };
1388
- // 关键修复:确保元素在视口内,否则超出屏幕的坐标无法被 CDP 模拟点击
1389
- el.scrollIntoView({ block: 'center', inline: 'center' });
1390
- const rect = el.getBoundingClientRect();
1391
- if (rect.width === 0 && rect.height === 0) return { error: '元素不可见(宽高为 0)' };
1392
- return {
1393
- found: true,
1394
- tag: el.tagName.toLowerCase(),
1395
- type: el.type || '',
1396
- cx: Math.round(rect.left + rect.width / 2),
1397
- cy: Math.round(rect.top + rect.height / 2),
1398
- disabled: el.disabled || false,
1399
- value: (el.value || '').slice(0, 100),
1400
- };
1401
- } catch (e) { return { error: e.message }; }
1402
- })()`
1511
+ if (params.snapshotAfter) {
1512
+ if (Date.now() < deadline) {
1513
+ try {
1514
+ response.snapshotAfter = await evaluateInteractiveSnapshot(target, {
1515
+ selector: params.snapshotSelector,
1516
+ includeText: true,
1517
+ maxElements: Math.min(100, Math.max(1, Number(params.snapshotMaxElements) || 20)),
1518
+ })
1519
+ } catch (error) {
1520
+ response.snapshotError = error.message
1521
+ }
1522
+ } else {
1523
+ response.snapshotSkipped = "批处理已到整体截止时间"
1524
+ }
1525
+ }
1403
1526
 
1404
- const { result: locResult } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1405
- expression: locateExpression,
1406
- returnByValue: true,
1407
- })
1527
+ return response
1528
+ }
1408
1529
 
1409
- const loc = locResult?.value
1410
- if (!loc || loc.error) throw new Error(loc?.error || "无法定位元素")
1411
- if (loc.disabled) throw new Error(`元素 ${ref} 已被禁用 (disabled)`)
1530
+ function batchTimeoutError(message = "批处理已到整体截止时间") {
1531
+ const error = new Error(message)
1532
+ error.batchTimeout = true
1533
+ return error
1534
+ }
1412
1535
 
1413
- const cx = loc.cx
1414
- const cy = loc.cy
1536
+ function ensureBeforeDeadline(deadline) {
1537
+ if (Date.now() >= deadline) throw batchTimeoutError()
1538
+ }
1415
1539
 
1416
- let actionResult = { ref, action, success: true }
1540
+ async function sleepBeforeDeadline(ms, deadline) {
1541
+ if (ms <= 0) return
1542
+ const remaining = deadline - Date.now()
1543
+ if (remaining <= 0) throw batchTimeoutError()
1544
+ await sleep(Math.min(ms, remaining))
1545
+ if (ms >= remaining || Date.now() >= deadline) throw batchTimeoutError()
1546
+ }
1417
1547
 
1418
- // Step 2: 根据动作类型执行 CDP 命令
1419
- if (action === "click") {
1420
- // 物理级 CDP 鼠标点击
1421
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1422
- type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1423
- })
1424
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1425
- type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1426
- })
1427
- actionResult.detail = `已点击 ${ref} (${loc.tag}) 坐标 (${cx}, ${cy})`
1548
+ async function evaluateDomValue(target, expression, options = {}) {
1549
+ const { result, exceptionDetails } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1550
+ expression,
1551
+ returnByValue: true,
1552
+ ...options,
1553
+ })
1554
+ if (exceptionDetails) {
1555
+ throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || "页面脚本执行失败")
1556
+ }
1557
+ if (result?.value?.error) {
1558
+ const error = new Error(result.value.error)
1559
+ error.diagnostics = result.value
1560
+ throw error
1561
+ }
1562
+ return result?.value
1563
+ }
1428
1564
 
1429
- } else if (action === "fill") {
1430
- if (value === undefined || value === null) throw new Error("fill 动作需要提供 value 参数")
1431
- // 先点击聚焦
1432
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1433
- type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1434
- })
1435
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1436
- type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1437
- })
1438
- // 全选并清空已有内容
1439
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1440
- expression: `(function() {
1441
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1442
- if (el) { el.focus(); el.select && el.select(); }
1443
- })()`,
1444
- })
1445
- // 用 CDP 模拟键盘输入
1446
- await chrome.debugger.sendCommand(target, "Input.insertText", {
1447
- text: String(value),
1448
- })
1449
- // 强制触发 input/change 事件(兼容 React/Vue)
1450
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1451
- expression: `(function() {
1452
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1453
- if (el) {
1454
- el.dispatchEvent(new Event('input', { bubbles: true }));
1455
- el.dispatchEvent(new Event('change', { bubbles: true }));
1456
- }
1457
- })()`,
1458
- })
1459
- actionResult.detail = `已在 ${ref} (${loc.tag}) 中填入 "${String(value).slice(0, 50)}"`
1565
+ async function ensureLocatorRuntime(target) {
1566
+ let lastError
1567
+ for (let attempt = 0; attempt < 2; attempt++) {
1568
+ try {
1569
+ const installed = await evaluateDomValue(target, "window.__ghostLocatorRuntime?.version === 1")
1570
+ if (!installed) {
1571
+ await evaluateDomValue(target, GhostBridgeDom.buildInstallLocatorRuntimeExpression())
1572
+ }
1573
+ return
1574
+ } catch (error) {
1575
+ lastError = error
1576
+ if (!isTransientPageError(error) || attempt === 1) throw error
1577
+ await sleep(50)
1578
+ }
1579
+ }
1580
+ throw lastError
1581
+ }
1460
1582
 
1461
- } else if (action === "press") {
1462
- // 模拟键盘按键
1463
- const keyName = key || value || "Enter"
1464
- // 先确保元素聚焦
1465
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1466
- expression: `(function() {
1467
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1468
- if (el) el.focus();
1469
- })()`,
1470
- })
1471
- await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1472
- type: "keyDown", key: keyName,
1473
- })
1474
- await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1475
- type: "keyUp", key: keyName,
1476
- })
1477
- actionResult.detail = `已在 ${ref} 上按下 ${keyName}`
1583
+ function isTransientPageError(error) {
1584
+ return /context|navigat|frame|target closed|cannot find/i.test(String(error?.message || error))
1585
+ }
1478
1586
 
1479
- } else if (action === "scroll") {
1480
- const dx = deltaX || 0
1481
- const dy = deltaY || 300
1482
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1483
- type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
1484
- })
1485
- actionResult.detail = `已在 ${ref} 位置滚动 (${dx}, ${dy})`
1587
+ function validateLocator(locator) {
1588
+ if (!locator || typeof locator !== 'object' || Array.isArray(locator)) throw new Error("locator 必须是对象")
1589
+ const fields = ['css', 'testId', 'role', 'name', 'label', 'placeholder', 'text']
1590
+ if (!fields.some((field) => locator[field] !== undefined && locator[field] !== '')) {
1591
+ throw new Error("locator 至少需要 css/testId/role/name/label/placeholder/text 之一")
1592
+ }
1593
+ if (locator.match && !['exact', 'contains'].includes(locator.match)) {
1594
+ throw new Error("locator.match 仅支持 exact 或 contains")
1595
+ }
1596
+ if (locator.nth !== undefined && (!Number.isInteger(locator.nth) || locator.nth < 0)) {
1597
+ throw new Error("locator.nth 必须是从 0 开始的整数")
1598
+ }
1599
+ }
1486
1600
 
1487
- } else if (action === "select") {
1488
- // 下拉框选择
1489
- if (value === undefined) throw new Error("select 动作需要提供 value 参数")
1490
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1491
- expression: `(function() {
1492
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1493
- if (el && el.tagName === 'SELECT') {
1494
- el.value = ${JSON.stringify(String(value))};
1495
- el.dispatchEvent(new Event('change', { bubbles: true }));
1496
- }
1497
- })()`,
1498
- })
1499
- actionResult.detail = `已在 ${ref} 选择值 "${value}"`
1601
+ function validateWaitFor(waitFor, fallbackLocator) {
1602
+ if (!waitFor || typeof waitFor !== 'object') throw new Error("waitFor 必须是对象")
1603
+ const type = waitFor.type
1604
+ if (!['element', 'url', 'networkIdle', 'expression'].includes(type)) {
1605
+ throw new Error("waitFor.type 仅支持 element/url/networkIdle/expression")
1606
+ }
1607
+ if (type === 'element') {
1608
+ const state = waitFor.state || 'visible'
1609
+ if (!['visible', 'hidden', 'attached', 'detached', 'enabled'].includes(state)) {
1610
+ throw new Error("element waitFor.state 仅支持 visible/hidden/attached/detached/enabled")
1611
+ }
1612
+ if (!waitFor.locator && !fallbackLocator) {
1613
+ throw new Error("element waitFor 需要 locator,或复用当前动作的 locator")
1614
+ }
1615
+ validateLocator(waitFor.locator || fallbackLocator)
1616
+ } else if (type === 'url' && waitFor.equals === undefined && waitFor.contains === undefined) {
1617
+ throw new Error("url waitFor 需要 equals 或 contains")
1618
+ } else if (type === 'expression' && (!waitFor.expression || typeof waitFor.expression !== 'string')) {
1619
+ throw new Error("expression waitFor 需要 expression 字符串")
1620
+ }
1621
+ }
1500
1622
 
1501
- } else if (action === "hover") {
1502
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1503
- type: "mouseMoved", x: cx, y: cy,
1504
- })
1505
- actionResult.detail = `已将鼠标悬停到 ${ref} (${cx}, ${cy})`
1623
+ async function waitForCondition(target, session, waitFor, fallbackLocator, deadline) {
1624
+ validateWaitFor(waitFor, fallbackLocator)
1625
+ const type = waitFor.type
1506
1626
 
1507
- } else if (action === "focus") {
1508
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1509
- expression: `(function() {
1510
- const el = document.querySelector('[data-ghost-ref="${ref}"]');
1511
- if (el) el.focus();
1512
- })()`,
1513
- })
1514
- actionResult.detail = `已聚焦到 ${ref}`
1627
+ const timeoutMs = Math.min(30000, Math.max(100, Number(waitFor.timeoutMs) || 10000))
1628
+ const probe = async () => {
1629
+ try {
1630
+ if (type === 'element') {
1631
+ const state = waitFor.state || 'visible'
1632
+ const locator = waitFor.locator || fallbackLocator
1633
+ await ensureLocatorRuntime(target)
1634
+ const expression = GhostBridgeDom.buildLocatorProbeExpression({ locator, state })
1635
+ return evaluateDomValue(target, expression)
1636
+ } else if (type === 'url') {
1637
+ const page = await readPageState(target)
1638
+ const expected = waitFor.equals ?? waitFor.contains
1639
+ const satisfied = waitFor.equals !== undefined
1640
+ ? page?.url === String(expected)
1641
+ : String(page?.url || '').includes(String(expected))
1642
+ return { satisfied, url: page?.url, match: waitFor.equals !== undefined ? 'equals' : 'contains' }
1643
+ } else if (type === 'networkIdle') {
1644
+ const idleMs = Math.min(10000, Math.max(100, Number(waitFor.idleMs) || 500))
1645
+ const pendingRequests = session.requestMap.size
1646
+ const idleForMs = Date.now() - session.lastNetworkActivityAt
1647
+ return { satisfied: pendingRequests === 0 && idleForMs >= idleMs, pendingRequests, idleForMs, idleMs }
1648
+ } else {
1649
+ const probeTimeout = Math.max(50, Math.min(1000, deadline - Date.now()))
1650
+ const expression = `(async function(){return Boolean(await (${waitFor.expression}));})()`
1651
+ const value = await evaluateDomValue(target, expression, { awaitPromise: true, timeout: probeTimeout })
1652
+ return { satisfied: Boolean(value) }
1653
+ }
1654
+ } catch (error) {
1655
+ if (isTransientPageError(error)) return { satisfied: false, transientError: error.message }
1656
+ throw error
1657
+ }
1658
+ }
1515
1659
 
1516
- } else {
1660
+ const status = await GhostBridgeControl.pollUntil({
1661
+ probe,
1662
+ timeoutMs,
1663
+ overallDeadline: deadline,
1664
+ intervalMs: 200,
1665
+ sleep,
1666
+ })
1667
+ if (status.satisfied) {
1668
+ return {
1669
+ ...status,
1670
+ type,
1671
+ ...(type === 'element' ? { state: waitFor.state || 'visible' } : {}),
1672
+ }
1673
+ }
1674
+
1675
+ const error = status.reason === 'batchTimeout'
1676
+ ? batchTimeoutError(`整体批处理在等待 ${type} 时超过截止时间`)
1677
+ : new Error(`等待条件 ${type} 超时(${timeoutMs}ms)`)
1678
+ error.waitStatus = { ...status, type }
1679
+ throw error
1680
+ }
1681
+
1682
+ function validateDispatchStep(step = {}) {
1683
+ const { ref, selector, locator, action, value, key, deltaX, deltaY, waitMs = 0, waitFor } = step
1684
+ if (!ref && !selector && !locator) throw new Error("需要提供 ref、selector 或 locator")
1685
+ if (ref && !/^e\d+$/.test(String(ref))) throw new Error(`无效的 ref: ${ref}`)
1686
+ if (!action) throw new Error("需要提供 action(动作类型:click/fill/press/scroll/select/hover/focus)")
1687
+ if (!["click", "fill", "press", "scroll", "select", "hover", "focus"].includes(action)) {
1517
1688
  throw new Error(`不支持的动作类型: ${action},可选: click/fill/press/scroll/select/hover/focus`)
1518
1689
  }
1690
+ if (action === "fill" && (value === undefined || value === null)) throw new Error("fill 动作需要提供 value 参数")
1691
+ if (action === "select" && value === undefined) throw new Error("select 动作需要提供 value 参数")
1692
+
1693
+ const semanticLocator = locator || { css: ref ? `[data-ghost-ref="${ref}"]` : String(selector) }
1694
+ validateLocator(semanticLocator)
1695
+ if (waitFor) validateWaitFor(waitFor, semanticLocator)
1696
+ return semanticLocator
1697
+ }
1698
+
1699
+ async function executeDispatchAction(target, session, step = {}, index, deadline) {
1700
+ const { ref, selector, locator, action, value, key, deltaX, deltaY, waitMs = 0, waitFor } = step
1701
+ const semanticLocator = validateDispatchStep(step)
1702
+ const locatorLabel = ref || selector || JSON.stringify(locator)
1703
+ const actionId = `a${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`
1704
+ let actionResult = { index, ...(ref ? { ref } : selector ? { selector } : { locator }), action, success: false }
1705
+ let actionCompleted = false
1706
+
1707
+ try {
1708
+ ensureBeforeDeadline(deadline)
1709
+ await ensureLocatorRuntime(target)
1710
+ const locateExpression = GhostBridgeDom.buildLocateElementExpression({ locator, ref, selector, actionId })
1711
+ const loc = await evaluateDomValue(target, locateExpression)
1712
+ if (!loc?.found) throw new Error("无法定位元素")
1713
+ if (loc.disabled) throw new Error(`元素 ${locatorLabel} 已被禁用 (disabled)`)
1714
+
1715
+ const cx = loc.cx
1716
+ const cy = loc.cy
1717
+ actionResult.matched = {
1718
+ tag: loc.tag,
1719
+ role: loc.role,
1720
+ name: loc.name,
1721
+ text: loc.text,
1722
+ matchCount: loc.matchCount,
1723
+ }
1724
+
1725
+ // Step 2: 根据动作类型执行 CDP 命令
1726
+ if (action === "click") {
1727
+ // 物理级 CDP 鼠标点击
1728
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1729
+ type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1730
+ })
1731
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1732
+ type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1733
+ })
1734
+ actionResult.detail = `已点击 ${locatorLabel} (${loc.tag}) 坐标 (${cx}, ${cy})`
1735
+
1736
+ } else if (action === "fill") {
1737
+ // 先点击聚焦
1738
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1739
+ type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1740
+ })
1741
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1742
+ type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1743
+ })
1744
+ // 全选并清空已有内容
1745
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'prepareFill' }))
1746
+ // 用 CDP 模拟键盘输入
1747
+ await chrome.debugger.sendCommand(target, "Input.insertText", {
1748
+ text: String(value),
1749
+ })
1750
+ // 强制触发 input/change 事件(兼容 React/Vue)
1751
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'dispatchInput' }))
1752
+ actionResult.detail = `已在 ${locatorLabel} (${loc.tag}) 中填入 "${String(value).slice(0, 50)}"`
1753
+
1754
+ } else if (action === "press") {
1755
+ // 模拟键盘按键
1756
+ const keyName = key || value || "Enter"
1757
+ // 先确保元素聚焦
1758
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'focus' }))
1759
+ await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1760
+ type: "keyDown", key: keyName,
1761
+ })
1762
+ await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1763
+ type: "keyUp", key: keyName,
1764
+ })
1765
+ actionResult.detail = `已在 ${locatorLabel} 上按下 ${keyName}`
1766
+
1767
+ } else if (action === "scroll") {
1768
+ const dx = deltaX ?? 0
1769
+ const dy = deltaY ?? 300
1770
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1771
+ type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
1772
+ })
1773
+ actionResult.detail = `已在 ${locatorLabel} 位置滚动 (${dx}, ${dy})`
1519
1774
 
1520
- // Step 3: 等待页面响应
1521
- if (waitMs > 0) {
1522
- await sleep(Math.min(waitMs, 3000))
1775
+ } else if (action === "select") {
1776
+ // 下拉框选择
1777
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'select', payload: { value: String(value) } }))
1778
+ actionResult.detail = `已在 ${locatorLabel} 选择值 "${value}"`
1779
+
1780
+ } else if (action === "hover") {
1781
+ await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1782
+ type: "mouseMoved", x: cx, y: cy,
1783
+ })
1784
+ actionResult.detail = `已将鼠标悬停到 ${locatorLabel} (${cx}, ${cy})`
1785
+
1786
+ } else if (action === "focus") {
1787
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'focus' }))
1788
+ actionResult.detail = `已聚焦到 ${locatorLabel}`
1789
+
1790
+ }
1791
+
1792
+ actionCompleted = true
1793
+ actionResult.success = true
1794
+
1795
+ // Legacy fixed delay remains available, but defaults to zero. A state-based waitFor
1796
+ // is faster when the page responds quickly and safer when it responds slowly.
1797
+ if (waitMs > 0) await sleepBeforeDeadline(Math.min(Number(waitMs) || 0, 3000), deadline)
1798
+ if (waitFor) actionResult.waitFor = await waitForCondition(target, session, waitFor, semanticLocator, deadline)
1799
+
1800
+ return actionResult
1801
+ } catch (error) {
1802
+ actionResult.success = false
1803
+ actionResult.actionCompleted = actionCompleted || undefined
1804
+ actionResult.error = error.message
1805
+ actionResult.diagnostics = error.diagnostics
1806
+ actionResult.waitFor = error.waitStatus
1807
+ error.actionResult = actionResult
1808
+ throw error
1809
+ } finally {
1810
+ try {
1811
+ await evaluateDomValue(target, GhostBridgeDom.buildCleanupElementExpression(actionId))
1812
+ } catch (_) {}
1523
1813
  }
1814
+ }
1524
1815
 
1525
- // Step 4: 获取操作后状态摘要
1816
+ async function readPageState(target) {
1526
1817
  const { result: afterResult } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1527
1818
  expression: `(function() {
1528
1819
  return {
@@ -1533,11 +1824,7 @@ async function handleDispatchAction(params = {}) {
1533
1824
  })()`,
1534
1825
  returnByValue: true,
1535
1826
  })
1536
- if (afterResult?.value) {
1537
- actionResult.pageAfter = afterResult.value
1538
- }
1539
-
1540
- return actionResult
1827
+ return afterResult?.value
1541
1828
  }
1542
1829
 
1543
1830
  // 处理来自服务器的命令
@@ -1578,6 +1865,7 @@ async function handleCommand(message) {
1578
1865
  else if (command === "findByString") result = await handleFindByString(params)
1579
1866
  else if (command === "symbolicHints") result = await handleSymbolicHints(params)
1580
1867
  else if (command === "eval") result = await handleEval(params)
1868
+ else if (command === "pageRequest") result = await handlePageRequest(params)
1581
1869
  else if (command === "listNetworkRequests") result = await handleListNetworkRequests(params)
1582
1870
  else if (command === "getNetworkDetail") result = await handleGetNetworkDetail(params)
1583
1871
  else if (command === "clearNetworkRequests") result = await handleClearNetworkRequests(params)
@@ -1825,6 +2113,24 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
1825
2113
  return false
1826
2114
  })
1827
2115
 
2116
+ // ========== 定时兜底重连 ==========
2117
+ // offscreen 的重连循环在 daemon 重启/长时间找不到服务后偶发停摆(现象:手动点 Connect 才恢复)。
2118
+ // 每分钟检查一次连接状态,未连接则重新触发完整连接流程,保证无人值守时也能自动恢复
2119
+ chrome.alarms.create('ghost-bridge-keepalive', { delayInMinutes: 1, periodInMinutes: 1 })
2120
+ chrome.alarms.onAlarm.addListener(async (alarm) => {
2121
+ if (alarm.name !== 'ghost-bridge-keepalive') return
2122
+ if (!state.enabled) return
2123
+ try {
2124
+ const status = await chrome.runtime.sendMessage({ type: 'getOffscreenStatus' }).catch(() => null)
2125
+ if (!status || !status.connected) {
2126
+ log('定时兜底:连接未建立,重新触发连接流程')
2127
+ await startBridgeConnection()
2128
+ }
2129
+ } catch (e) {
2130
+ log(`定时兜底重连失败:${e.message}`)
2131
+ }
2132
+ })
2133
+
1828
2134
  // ========== 唤醒探活钩子 ==========
1829
2135
  // 系统锁屏/睡眠唤醒后,WebSocket 可能处于半开状态(onclose 不触发、徽章仍显示已连接),
1830
2136
  // 通知 offscreen 立即发一次心跳:无响应则关闭死链并马上重连,不等 15 秒周期心跳超时