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.
- package/README.md +64 -5
- package/dist/cli.js +0 -0
- package/dist/server.js +260 -89
- package/extension/background.js +453 -147
- package/extension/bg-control.js +52 -0
- package/extension/bg-dom.js +422 -38
- package/extension/bg-runtime.js +17 -0
- package/extension/manifest.json +3 -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"
|
|
@@ -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
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1378
|
-
const
|
|
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
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
returnByValue: true,
|
|
1407
|
-
})
|
|
1527
|
+
return response
|
|
1528
|
+
}
|
|
1408
1529
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1530
|
+
function batchTimeoutError(message = "批处理已到整体截止时间") {
|
|
1531
|
+
const error = new Error(message)
|
|
1532
|
+
error.batchTimeout = true
|
|
1533
|
+
return error
|
|
1534
|
+
}
|
|
1412
1535
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1536
|
+
function ensureBeforeDeadline(deadline) {
|
|
1537
|
+
if (Date.now() >= deadline) throw batchTimeoutError()
|
|
1538
|
+
}
|
|
1415
1539
|
|
|
1416
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 秒周期心跳超时
|