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.
- package/README.md +41 -9
- package/dist/cli.js +0 -0
- package/dist/server.js +191 -144
- package/extension/background.js +328 -167
- package/extension/bg-control.js +52 -0
- package/extension/bg-dom.js +411 -44
- package/extension/bg-runtime.js +17 -0
- package/extension/manifest.json +2 -2
- package/package.json +2 -1
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1500
|
-
const
|
|
1530
|
+
function batchTimeoutError(message = "批处理已到整体截止时间") {
|
|
1531
|
+
const error = new Error(message)
|
|
1532
|
+
error.batchTimeout = true
|
|
1533
|
+
return error
|
|
1534
|
+
}
|
|
1501
1535
|
|
|
1502
|
-
|
|
1503
|
-
if (
|
|
1504
|
-
|
|
1536
|
+
function ensureBeforeDeadline(deadline) {
|
|
1537
|
+
if (Date.now() >= deadline) throw batchTimeoutError()
|
|
1538
|
+
}
|
|
1505
1539
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
const
|
|
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
|
-
|
|
1511
|
-
|
|
1565
|
+
async function ensureLocatorRuntime(target) {
|
|
1566
|
+
let lastError
|
|
1567
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1512
1568
|
try {
|
|
1513
|
-
const
|
|
1514
|
-
if (!
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
1541
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1786
|
+
} else if (action === "focus") {
|
|
1787
|
+
await evaluateDomValue(target, GhostBridgeDom.buildElementCommandExpression({ actionId, command: 'focus' }))
|
|
1788
|
+
actionResult.detail = `已聚焦到 ${locatorLabel}`
|
|
1646
1789
|
|
|
1647
|
-
|
|
1648
|
-
if (waitMs > 0) {
|
|
1649
|
-
await sleep(Math.min(waitMs, 3000))
|
|
1650
|
-
}
|
|
1790
|
+
}
|
|
1651
1791
|
|
|
1652
|
-
|
|
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)
|