ghost-bridge 1.1.0 → 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"
@@ -990,19 +995,16 @@ async function handleSymbolicHints(params = {}) {
990
995
  async function handleEval(params = {}) {
991
996
  const target = await ensureAttached(params)
992
997
  const timeoutMs = Math.min(30000, Math.max(100, Number(params.timeoutMs) || 10000))
993
- const { result, exceptionDetails } = await withTimeout(
994
- chrome.debugger.sendCommand(target, "Runtime.evaluate", {
995
- expression: params.code,
996
- returnByValue: true,
997
- awaitPromise: params.awaitPromise !== false,
998
- }),
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,
999
1005
  timeoutMs,
1000
- "eval_script"
1001
- )
1002
- if (exceptionDetails) {
1003
- throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || "脚本执行失败")
1004
- }
1005
- return result?.value
1006
+ withTimeout,
1007
+ })
1006
1008
  }
1007
1009
 
1008
1010
  async function handlePageRequest(params = {}) {
@@ -1066,6 +1068,7 @@ async function handlePageRequest(params = {}) {
1066
1068
  expression,
1067
1069
  returnByValue: true,
1068
1070
  awaitPromise: true,
1071
+ timeout: timeoutMs + 250,
1069
1072
  }),
1070
1073
  timeoutMs + 500,
1071
1074
  "page_request"
@@ -1414,8 +1417,16 @@ async function handleInspectPageSnapshot(params = {}) {
1414
1417
 
1415
1418
  async function handleGetPageContent(params = {}) {
1416
1419
  const target = await ensureAttached(params)
1417
- const { mode = "text", selector, maxLength = 50000, includeMetadata = true } = params
1418
- 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
+ })
1419
1430
 
1420
1431
  const { result } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1421
1432
  expression,
@@ -1458,198 +1469,348 @@ async function handleDispatchAction(params = {}) {
1458
1469
  if (anyNamedTargets && !params.target && params.tabId === undefined) {
1459
1470
  throw new Error("已绑定命名 target 时,dispatch_action 必须提供 target,避免跨页面误用 ref")
1460
1471
  }
1461
- const target = await ensureAttached(params)
1472
+ const { target, session } = await ensureAttachedSession(params)
1462
1473
  const isBatch = Array.isArray(params.actions)
1463
1474
  const actions = isBatch ? params.actions : [params]
1464
1475
  if (!actions.length || actions.length > 20) throw new Error("actions 数量必须在 1-20 之间")
1465
-
1466
- const results = []
1467
- for (let index = 0; index < actions.length; index++) {
1468
- try {
1469
- results.push(await executeDispatchAction(target, actions[index]))
1470
- } catch (e) {
1471
- if (!isBatch) throw e
1472
- results.push({ index, success: false, error: e.message })
1473
- if (params.stopOnError !== false) break
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 },
1474
1489
  }
1475
- }
1490
+ )
1491
+ const results = batch.results
1476
1492
 
1477
- const pageAfter = await readPageState(target)
1493
+ let pageAfter
1494
+ try {
1495
+ pageAfter = await readPageState(target)
1496
+ } catch (error) {
1497
+ pageAfter = { error: error.message }
1498
+ }
1478
1499
  const response = isBatch
1479
1500
  ? {
1480
1501
  success: results.length === actions.length && results.every((item) => item.success),
1481
1502
  completed: results.filter((item) => item.success).length,
1482
1503
  total: actions.length,
1504
+ stopped: batch.stopped,
1505
+ timeoutMs,
1483
1506
  results,
1484
1507
  pageAfter,
1485
1508
  }
1486
1509
  : { ...results[0], pageAfter }
1487
1510
 
1488
1511
  if (params.snapshotAfter) {
1489
- response.snapshotAfter = await evaluateInteractiveSnapshot(target, {
1490
- selector: params.snapshotSelector,
1491
- includeText: true,
1492
- maxElements: Math.min(100, Math.max(1, Number(params.snapshotMaxElements) || 20)),
1493
- })
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
+ }
1494
1525
  }
1495
1526
 
1496
1527
  return response
1497
1528
  }
1498
1529
 
1499
- async function executeDispatchAction(target, step = {}) {
1500
- const { ref, selector, action, value, key, deltaX, deltaY, waitMs = 500 } = step
1530
+ function batchTimeoutError(message = "批处理已到整体截止时间") {
1531
+ const error = new Error(message)
1532
+ error.batchTimeout = true
1533
+ return error
1534
+ }
1501
1535
 
1502
- if (!ref && !selector) throw new Error("需要提供 ref 或 selector")
1503
- if (ref && !/^e\d+$/.test(String(ref))) throw new Error(`无效的 ref: ${ref}`)
1504
- if (!action) throw new Error("需要提供 action(动作类型:click/fill/press/scroll/select/hover/focus)")
1536
+ function ensureBeforeDeadline(deadline) {
1537
+ if (Date.now() >= deadline) throw batchTimeoutError()
1538
+ }
1505
1539
 
1506
- const locator = ref ? `[data-ghost-ref="${ref}"]` : String(selector)
1507
- const locatorExpression = JSON.stringify(locator)
1508
- const locatorLabel = ref || selector
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
+ }
1547
+
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
+ }
1509
1564
 
1510
- // Step 1: 实时获取目标元素的最新坐标和状态
1511
- const locateExpression = `(function() {
1565
+ async function ensureLocatorRuntime(target) {
1566
+ let lastError
1567
+ for (let attempt = 0; attempt < 2; attempt++) {
1512
1568
  try {
1513
- const el = document.querySelector(${locatorExpression});
1514
- if (!el) return { error: '元素未找到:' + ${JSON.stringify(locatorLabel)} };
1515
- // 关键修复:确保元素在视口内,否则超出屏幕的坐标无法被 CDP 模拟点击
1516
- el.scrollIntoView({ block: 'center', inline: 'center' });
1517
- const rect = el.getBoundingClientRect();
1518
- if (rect.width === 0 && rect.height === 0) return { error: '元素不可见(宽高为 0)' };
1519
- return {
1520
- found: true,
1521
- tag: el.tagName.toLowerCase(),
1522
- type: el.type || '',
1523
- cx: Math.round(rect.left + rect.width / 2),
1524
- cy: Math.round(rect.top + rect.height / 2),
1525
- disabled: el.disabled || false,
1526
- value: (el.value || '').slice(0, 100),
1527
- };
1528
- } catch (e) { return { error: e.message }; }
1529
- })()`
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
+ }
1530
1582
 
1531
- const { result: locResult } = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1532
- expression: locateExpression,
1533
- returnByValue: true,
1583
+ function isTransientPageError(error) {
1584
+ return /context|navigat|frame|target closed|cannot find/i.test(String(error?.message || error))
1585
+ }
1586
+
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
+ }
1600
+
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
+ }
1622
+
1623
+ async function waitForCondition(target, session, waitFor, fallbackLocator, deadline) {
1624
+ validateWaitFor(waitFor, fallbackLocator)
1625
+ const type = waitFor.type
1626
+
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
+ }
1659
+
1660
+ const status = await GhostBridgeControl.pollUntil({
1661
+ probe,
1662
+ timeoutMs,
1663
+ overallDeadline: deadline,
1664
+ intervalMs: 200,
1665
+ sleep,
1534
1666
  })
1667
+ if (status.satisfied) {
1668
+ return {
1669
+ ...status,
1670
+ type,
1671
+ ...(type === 'element' ? { state: waitFor.state || 'visible' } : {}),
1672
+ }
1673
+ }
1535
1674
 
1536
- const loc = locResult?.value
1537
- if (!loc || loc.error) throw new Error(loc?.error || "无法定位元素")
1538
- if (loc.disabled) throw new Error(`元素 ${locatorLabel} 已被禁用 (disabled)`)
1675
+ const error = status.reason === 'batchTimeout'
1676
+ ? batchTimeoutError(`整体批处理在等待 ${type} 时超过截止时间`)
1677
+ : new Error(`等待条件 ${type} 超时(${timeoutMs}ms)`)
1678
+ error.waitStatus = { ...status, type }
1679
+ throw error
1680
+ }
1539
1681
 
1540
- const cx = loc.cx
1541
- const cy = loc.cy
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)) {
1688
+ throw new Error(`不支持的动作类型: ${action},可选: click/fill/press/scroll/select/hover/focus`)
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 参数")
1542
1692
 
1543
- let actionResult = { ...(ref ? { ref } : { selector }), action, success: true }
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
+ }
1544
1698
 
1545
- // Step 2: 根据动作类型执行 CDP 命令
1546
- if (action === "click") {
1547
- // 物理级 CDP 鼠标点击
1548
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1549
- type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1550
- })
1551
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1552
- type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1553
- })
1554
- actionResult.detail = `已点击 ${locatorLabel} (${loc.tag}) 坐标 (${cx}, ${cy})`
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
1555
1706
 
1556
- } else if (action === "fill") {
1557
- if (value === undefined || value === null) throw new Error("fill 动作需要提供 value 参数")
1558
- // 先点击聚焦
1559
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1560
- type: "mousePressed", x: cx, y: cy, button: "left", clickCount: 1,
1561
- })
1562
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1563
- type: "mouseReleased", x: cx, y: cy, button: "left", clickCount: 1,
1564
- })
1565
- // 全选并清空已有内容
1566
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1567
- expression: `(function() {
1568
- const el = document.querySelector(${locatorExpression});
1569
- if (el) { el.focus(); el.select && el.select(); }
1570
- })()`,
1571
- })
1572
- // 用 CDP 模拟键盘输入
1573
- await chrome.debugger.sendCommand(target, "Input.insertText", {
1574
- text: String(value),
1575
- })
1576
- // 强制触发 input/change 事件(兼容 React/Vue)
1577
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1578
- expression: `(function() {
1579
- const el = document.querySelector(${locatorExpression});
1580
- if (el) {
1581
- el.dispatchEvent(new Event('input', { bubbles: true }));
1582
- el.dispatchEvent(new Event('change', { bubbles: true }));
1583
- }
1584
- })()`,
1585
- })
1586
- actionResult.detail = `已在 ${locatorLabel} (${loc.tag}) 中填入 "${String(value).slice(0, 50)}"`
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
+ }
1587
1724
 
1588
- } else if (action === "press") {
1589
- // 模拟键盘按键
1590
- const keyName = key || value || "Enter"
1591
- // 先确保元素聚焦
1592
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1593
- expression: `(function() {
1594
- const el = document.querySelector(${locatorExpression});
1595
- if (el) el.focus();
1596
- })()`,
1597
- })
1598
- await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1599
- type: "keyDown", key: keyName,
1600
- })
1601
- await chrome.debugger.sendCommand(target, "Input.dispatchKeyEvent", {
1602
- type: "keyUp", key: keyName,
1603
- })
1604
- actionResult.detail = `已在 ${locatorLabel} 上按下 ${keyName}`
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})`
1605
1735
 
1606
- } else if (action === "scroll") {
1607
- const dx = deltaX || 0
1608
- const dy = deltaY || 300
1609
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1610
- type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
1611
- })
1612
- actionResult.detail = `已在 ${locatorLabel} 位置滚动 (${dx}, ${dy})`
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}`
1613
1766
 
1614
- } else if (action === "select") {
1615
- // 下拉框选择
1616
- if (value === undefined) throw new Error("select 动作需要提供 value 参数")
1617
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1618
- expression: `(function() {
1619
- const el = document.querySelector(${locatorExpression});
1620
- if (el && el.tagName === 'SELECT') {
1621
- el.value = ${JSON.stringify(String(value))};
1622
- el.dispatchEvent(new Event('change', { bubbles: true }));
1623
- }
1624
- })()`,
1625
- })
1626
- actionResult.detail = `已在 ${locatorLabel} 选择值 "${value}"`
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})`
1627
1774
 
1628
- } else if (action === "hover") {
1629
- await chrome.debugger.sendCommand(target, "Input.dispatchMouseEvent", {
1630
- type: "mouseMoved", x: cx, y: cy,
1631
- })
1632
- actionResult.detail = `已将鼠标悬停到 ${locatorLabel} (${cx}, ${cy})`
1775
+ } else if (action === "select") {
1776
+ // 下拉框选择
1777
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'select', payload: { value: String(value) } }))
1778
+ actionResult.detail = `已在 ${locatorLabel} 选择值 "${value}"`
1633
1779
 
1634
- } else if (action === "focus") {
1635
- await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
1636
- expression: `(function() {
1637
- const el = document.querySelector(${locatorExpression});
1638
- if (el) el.focus();
1639
- })()`,
1640
- })
1641
- actionResult.detail = `已聚焦到 ${locatorLabel}`
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})`
1642
1785
 
1643
- } else {
1644
- throw new Error(`不支持的动作类型: ${action},可选: click/fill/press/scroll/select/hover/focus`)
1645
- }
1786
+ } else if (action === "focus") {
1787
+ await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'focus' }))
1788
+ actionResult.detail = `已聚焦到 ${locatorLabel}`
1646
1789
 
1647
- // Step 3: 等待页面响应
1648
- if (waitMs > 0) {
1649
- await sleep(Math.min(waitMs, 3000))
1650
- }
1790
+ }
1651
1791
 
1652
- return actionResult
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 (_) {}
1813
+ }
1653
1814
  }
1654
1815
 
1655
1816
  async function readPageState(target) {
@@ -0,0 +1,52 @@
1
+ (function initGhostBridgeControl(global) {
2
+ async function pollUntil({
3
+ probe,
4
+ timeoutMs,
5
+ overallDeadline = Infinity,
6
+ intervalMs = 200,
7
+ now = () => Date.now(),
8
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
9
+ }) {
10
+ const startedAt = now()
11
+ const conditionDeadline = startedAt + timeoutMs
12
+ let attempts = 0
13
+ let detail
14
+
15
+ while (now() < conditionDeadline && now() < overallDeadline) {
16
+ attempts++
17
+ detail = await probe()
18
+ if (detail?.satisfied) {
19
+ return { satisfied: true, elapsedMs: now() - startedAt, attempts, detail }
20
+ }
21
+ const remaining = Math.min(conditionDeadline, overallDeadline) - now()
22
+ if (remaining > 0) await sleep(Math.min(intervalMs, remaining))
23
+ }
24
+
25
+ return {
26
+ satisfied: false,
27
+ reason: now() >= overallDeadline ? 'batchTimeout' : 'conditionTimeout',
28
+ elapsedMs: now() - startedAt,
29
+ attempts,
30
+ detail,
31
+ }
32
+ }
33
+
34
+ async function runActionBatch(actions, execute, {
35
+ stopOnError = true,
36
+ isTerminalError = (error) => Boolean(error?.batchTimeout),
37
+ mapError = (error, index) => ({ index, success: false, error: error.message }),
38
+ } = {}) {
39
+ const results = []
40
+ for (let index = 0; index < actions.length; index++) {
41
+ try {
42
+ results.push(await execute(actions[index], index))
43
+ } catch (error) {
44
+ results.push(mapError(error, index))
45
+ if (stopOnError || isTerminalError(error)) break
46
+ }
47
+ }
48
+ return { results, stopped: results.length < actions.length }
49
+ }
50
+
51
+ global.GhostBridgeControl = { pollUntil, runActionBatch }
52
+ })(self)