mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641d934337 | ||
|
|
f06a514bd2 | ||
|
|
1e53ee63ae | ||
|
|
e9f0584af3 | ||
|
|
c48a53269e | ||
|
|
3bbd6ab19f |
@@ -325,3 +325,180 @@ jobs:
|
|||||||
|
|
||||||
echo "Chrome Web Store submission created successfully:"
|
echo "Chrome Web Store submission created successfully:"
|
||||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||||
|
|
||||||
|
publish-edge-addons:
|
||||||
|
needs: [resolve, build-extension]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: edge-addons
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: extension-zip
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Validate Microsoft Edge Add-ons configuration
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||||
|
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||||
|
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$CLIENT_ID" ]; then
|
||||||
|
echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$PRODUCT_ID" ]; then
|
||||||
|
echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||||
|
echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||||
|
if [ -z "$ARCHIVE" ]; then
|
||||||
|
echo "::error::No extension zip found in the downloaded artifact"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Upload and publish Microsoft Edge Add-ons extension
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||||
|
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||||
|
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||||
|
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1"
|
||||||
|
UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt"
|
||||||
|
UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json"
|
||||||
|
STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json"
|
||||||
|
PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt"
|
||||||
|
PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json"
|
||||||
|
|
||||||
|
operation_id_from_headers() {
|
||||||
|
local headers="$1"
|
||||||
|
local location
|
||||||
|
location="$(awk '
|
||||||
|
tolower($1) == "location:" {
|
||||||
|
sub(/\r$/, "")
|
||||||
|
sub(/^[^:]*:[[:space:]]*/, "")
|
||||||
|
print
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$headers")"
|
||||||
|
location="${location%%\?*}"
|
||||||
|
printf '%s' "${location##*/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
poll_operation() {
|
||||||
|
local operation_name="$1"
|
||||||
|
local status_url="$2"
|
||||||
|
local status="InProgress"
|
||||||
|
local http_status
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
sleep 5
|
||||||
|
http_status="$(curl --silent --show-error \
|
||||||
|
--output "$STATUS_RESPONSE" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
"$status_url")"
|
||||||
|
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status"
|
||||||
|
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status="$(jq -r '.status // empty' "$STATUS_RESPONSE")"
|
||||||
|
echo "$operation_name status check $attempt/30: $status"
|
||||||
|
case "$status" in
|
||||||
|
Succeeded)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
InProgress)
|
||||||
|
;;
|
||||||
|
Failed)
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name failed"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks"
|
||||||
|
jq . "$STATUS_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP_STATUS="$(curl --silent --show-error \
|
||||||
|
--output "$UPLOAD_RESPONSE" \
|
||||||
|
--dump-header "$UPLOAD_HEADERS" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
--header "Content-Type: application/zip" \
|
||||||
|
--data-binary "@$ARCHIVE" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")"
|
||||||
|
if [ "$HTTP_STATUS" != "202" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS"
|
||||||
|
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")"
|
||||||
|
if [ -z "$UPLOAD_OPERATION_ID" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID"
|
||||||
|
cat "$UPLOAD_HEADERS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
poll_operation \
|
||||||
|
"package upload" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID"
|
||||||
|
|
||||||
|
HTTP_STATUS="$(curl --silent --show-error \
|
||||||
|
--output "$PUBLISH_RESPONSE" \
|
||||||
|
--dump-header "$PUBLISH_HEADERS" \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--request POST \
|
||||||
|
--header "Authorization: ApiKey $API_KEY" \
|
||||||
|
--header "X-ClientID: $CLIENT_ID" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions")"
|
||||||
|
if [ "$HTTP_STATUS" != "202" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS"
|
||||||
|
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")"
|
||||||
|
if [ -z "$PUBLISH_OPERATION_ID" ]; then
|
||||||
|
echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID"
|
||||||
|
cat "$PUBLISH_HEADERS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
poll_operation \
|
||||||
|
"submission" \
|
||||||
|
"$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID"
|
||||||
|
|
||||||
|
echo "Microsoft Edge Add-ons submission created successfully:"
|
||||||
|
jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"
|
||||||
|
|||||||
@@ -247,6 +247,41 @@ describe("App", () => {
|
|||||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats protocol drift as still connected and prompts an upgrade", async () => {
|
||||||
|
mockUseConnectionState.mockReturnValue({
|
||||||
|
snapshot: {
|
||||||
|
...baseSnapshot,
|
||||||
|
state: "version_skew",
|
||||||
|
instanceId: "03c3e47f",
|
||||||
|
handshake: {
|
||||||
|
server: "bh",
|
||||||
|
version: mockDaemonVersion,
|
||||||
|
protocol_version: "1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusState: "version_skew",
|
||||||
|
setLabel,
|
||||||
|
setConnectionEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByText("已连接")).toBeTruthy();
|
||||||
|
expect(screen.getByText("可升级")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("协议不一致")).toBeNull();
|
||||||
|
expect(screen.queryByText("Action needed")).toBeNull();
|
||||||
|
expect(screen.queryByText("兼容")).toBeNull();
|
||||||
|
const warning = screen.getByText(/协议版本不同,请及时升级/);
|
||||||
|
expect(warning.textContent).toContain("CLI 协议");
|
||||||
|
expect(warning.textContent).toContain("扩展协议");
|
||||||
|
|
||||||
|
openRecordView();
|
||||||
|
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||||
|
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||||
|
fireEvent.click(copyButton);
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("control hints toggle", () => {
|
describe("control hints toggle", () => {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function App() {
|
|||||||
}, [copiedTick]);
|
}, [copiedTick]);
|
||||||
|
|
||||||
const isSkewed = statusState === "version_skew";
|
const isSkewed = statusState === "version_skew";
|
||||||
|
const connectionLive = statusState === "connected" || isSkewed;
|
||||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||||
const extensionVersion = snapshot.extensionVersion || "—";
|
const extensionVersion = snapshot.extensionVersion || "—";
|
||||||
@@ -90,7 +91,7 @@ export function App() {
|
|||||||
setCopiedInstanceId(true);
|
setCopiedInstanceId(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||||
const recordPurpose = purposeDraft.trim();
|
const recordPurpose = purposeDraft.trim();
|
||||||
const recordStartUrl = startUrlDraft.trim();
|
const recordStartUrl = startUrlDraft.trim();
|
||||||
const recordCommand = snapshot.instanceId
|
const recordCommand = snapshot.instanceId
|
||||||
@@ -206,12 +207,12 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
{isSkewed && (
|
{isSkewed && (
|
||||||
<p
|
<p
|
||||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||||
data-slot="popup-version-skew-warning"
|
data-slot="popup-version-skew-warning"
|
||||||
>
|
>
|
||||||
{t("popup.versionSkewWarning", {
|
{t("popup.versionSkewWarning", {
|
||||||
extensionProtocol: PROTOCOL_VERSION,
|
extensionProtocol: PROTOCOL_VERSION,
|
||||||
daemonProtocol,
|
cliProtocol: daemonProtocol,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||||
|
|
||||||
## Mandatory workflow
|
## Mandatory workflow
|
||||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
|||||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||||
|
|
||||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||||
|
|
||||||
|
|||||||
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `bsk doctor` check: every connected browser should use the same
|
/// `bsk doctor` check: connected browsers should speak a protocol the
|
||||||
/// protocol version as the daemon. Minor protocol drift is accepted by
|
/// daemon accepts. A different protocol string is still a live
|
||||||
/// the daemon but flagged here so the user can see who needs updating
|
/// connection — report Ok so agents keep working. The detail is an
|
||||||
/// (M10.4).
|
/// upgrade reminder, not a blocker.
|
||||||
///
|
///
|
||||||
/// Review M2 (round-1 minor): when no browsers are connected, the
|
/// Review M2 (round-1 minor): when no browsers are connected, the
|
||||||
/// check has nothing to compare against, so it now reports
|
/// check has nothing to compare against, so it now reports
|
||||||
@@ -396,13 +396,12 @@ fn check_browsers_protocol_compatible(status: Option<&StatusResult>) -> CheckRes
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
CheckResult::fail(
|
CheckResult::ok(
|
||||||
name,
|
name,
|
||||||
format!(
|
format!(
|
||||||
"{} browser(s) have protocol minor drift from the daemon: {stale}",
|
"{} browser(s) report a different protocol version (still usable — continue, and upgrade soon): {stale}",
|
||||||
status.version_skew_browsers.len()
|
status.version_skew_browsers.len()
|
||||||
),
|
),
|
||||||
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +580,7 @@ mod m2_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browsers_check_reports_fail_when_skew_present() {
|
fn browsers_check_reports_ok_when_skew_present() {
|
||||||
let status = fake_status(
|
let status = fake_status(
|
||||||
vec![BrowserStatusEntry {
|
vec![BrowserStatusEntry {
|
||||||
instance_id: "alpha".into(),
|
instance_id: "alpha".into(),
|
||||||
@@ -605,9 +604,17 @@ mod m2_tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let check = check_browsers_protocol_compatible(Some(&status));
|
let check = check_browsers_protocol_compatible(Some(&status));
|
||||||
assert_eq!(check.status, CheckStatus::Fail);
|
assert_eq!(check.status, CheckStatus::Ok);
|
||||||
assert!(check.detail.contains("alpha"));
|
assert!(check.detail.contains("alpha"));
|
||||||
assert!(check.hint.is_some());
|
assert!(
|
||||||
|
check
|
||||||
|
.detail
|
||||||
|
.contains("still usable — continue, and upgrade soon")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
check.hint.is_none(),
|
||||||
|
"a protocol-version note must not hint to stop"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -635,7 +642,7 @@ mod m2_tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
let check = check_browsers_protocol_compatible(Some(&status));
|
let check = check_browsers_protocol_compatible(Some(&status));
|
||||||
assert_eq!(check.status, CheckStatus::Fail);
|
assert_eq!(check.status, CheckStatus::Ok);
|
||||||
assert!(
|
assert!(
|
||||||
check
|
check
|
||||||
.detail
|
.detail
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
|
|||||||
let bold = "\x1b[1m";
|
let bold = "\x1b[1m";
|
||||||
let reset = "\x1b[0m";
|
let reset = "\x1b[0m";
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{bold}{yellow}warning:{reset} {} browser(s) have protocol version drift from the daemon{reset}",
|
"{bold}{yellow}note:{reset} {} browser(s) report a different protocol version; still usable — continue, and run `bsk update` soon{reset}",
|
||||||
s.version_skew_browsers.len()
|
s.version_skew_browsers.len()
|
||||||
);
|
);
|
||||||
for skew in &s.version_skew_browsers {
|
for skew in &s.version_skew_browsers {
|
||||||
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
|
|||||||
skew.label.clone()
|
skew.label.clone()
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {} ({}) — protocol ext {} vs daemon {} (app ext v{}, daemon v{}) — please align protocol versions",
|
" {} ({}) — protocol ext {} vs CLI {} (app ext v{}, CLI v{}) — still usable; please upgrade",
|
||||||
skew.instance_id,
|
skew.instance_id,
|
||||||
label,
|
label,
|
||||||
display_protocol(&skew.client_protocol_version),
|
display_protocol(&skew.client_protocol_version),
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"version_skew": "Protocol mismatch",
|
"version_skew": "Connected",
|
||||||
"disabled": "Connection off"
|
"disabled": "Connection off"
|
||||||
},
|
},
|
||||||
"stateDetail": {
|
"stateDetail": {
|
||||||
"disconnected": "Open BrowserSkill to connect.",
|
"disconnected": "Open BrowserSkill to connect.",
|
||||||
"connecting": "Connecting to BrowserSkill…",
|
"connecting": "Connecting to BrowserSkill…",
|
||||||
"connected": "Ready for automation tasks.",
|
"connected": "Ready for automation tasks.",
|
||||||
"version_skew": "Some features may be unavailable."
|
"version_skew": "Protocol versions differ. Please upgrade."
|
||||||
},
|
},
|
||||||
"stateBadge": {
|
"stateBadge": {
|
||||||
"disconnected": "Standby",
|
"disconnected": "Standby",
|
||||||
"connecting": "Igniting",
|
"connecting": "Igniting",
|
||||||
"connected": "Ready",
|
"connected": "Ready",
|
||||||
"version_skew": "Action needed",
|
"version_skew": "Upgradable",
|
||||||
"disabled": "Off"
|
"disabled": "Off"
|
||||||
},
|
},
|
||||||
"connectionToggleTitle": "BrowserSkill connection",
|
"connectionToggleTitle": "BrowserSkill connection",
|
||||||
"controlHintsToggleTitle": "Control hints",
|
"controlHintsToggleTitle": "Control hints",
|
||||||
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
|
||||||
"controlHintsInfoLabel": "About control hints",
|
"controlHintsInfoLabel": "About control hints",
|
||||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.",
|
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, CLI protocol v{{cliProtocol}}. Protocol versions differ — please upgrade.",
|
||||||
"upgradeAvailable": "Upgradable",
|
"upgradeAvailable": "Upgradable",
|
||||||
"launcher": {
|
"launcher": {
|
||||||
"title": "Quick actions"
|
"title": "Quick actions"
|
||||||
|
|||||||
@@ -6,27 +6,27 @@
|
|||||||
"disconnected": "未连接",
|
"disconnected": "未连接",
|
||||||
"connecting": "连接中…",
|
"connecting": "连接中…",
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"version_skew": "协议不一致",
|
"version_skew": "已连接",
|
||||||
"disabled": "连接已关闭"
|
"disabled": "连接已关闭"
|
||||||
},
|
},
|
||||||
"stateDetail": {
|
"stateDetail": {
|
||||||
"disconnected": "请先打开 BrowserSkill。",
|
"disconnected": "请先打开 BrowserSkill。",
|
||||||
"connecting": "正在连接 BrowserSkill…",
|
"connecting": "正在连接 BrowserSkill…",
|
||||||
"connected": "可接收自动化任务。",
|
"connected": "可接收自动化任务。",
|
||||||
"version_skew": "部分能力可能不可用。"
|
"version_skew": "协议版本不同,请及时升级。"
|
||||||
},
|
},
|
||||||
"stateBadge": {
|
"stateBadge": {
|
||||||
"disconnected": "Standby",
|
"disconnected": "Standby",
|
||||||
"connecting": "Igniting",
|
"connecting": "Igniting",
|
||||||
"connected": "Ready",
|
"connected": "Ready",
|
||||||
"version_skew": "Action needed",
|
"version_skew": "可升级",
|
||||||
"disabled": "Off"
|
"disabled": "Off"
|
||||||
},
|
},
|
||||||
"connectionToggleTitle": "BrowserSkill 连接",
|
"connectionToggleTitle": "BrowserSkill 连接",
|
||||||
"controlHintsToggleTitle": "控制提示",
|
"controlHintsToggleTitle": "控制提示",
|
||||||
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
|
||||||
"controlHintsInfoLabel": "控制提示说明",
|
"controlHintsInfoLabel": "控制提示说明",
|
||||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
|
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。",
|
||||||
"upgradeAvailable": "可升级",
|
"upgradeAvailable": "可升级",
|
||||||
"launcher": {
|
"launcher": {
|
||||||
"title": "快捷功能"
|
"title": "快捷功能"
|
||||||
|
|||||||
@@ -298,6 +298,34 @@ describe("renderVom single-layer page", () => {
|
|||||||
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders through deeply nested transparent wrappers without exhausting the call stack", () => {
|
||||||
|
const wrapperCount = 10_000;
|
||||||
|
const nodes = [node({ id: 1, role: "RootWebArea", name: "Doc" })];
|
||||||
|
for (let index = 0; index < wrapperCount; index += 1) {
|
||||||
|
nodes.push(
|
||||||
|
node({
|
||||||
|
id: index + 2,
|
||||||
|
parentId: index + 1,
|
||||||
|
role: "generic",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodes.push(
|
||||||
|
node({
|
||||||
|
id: wrapperCount + 2,
|
||||||
|
parentId: wrapperCount + 1,
|
||||||
|
role: "button",
|
||||||
|
name: "Deep action",
|
||||||
|
tag: "button",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = renderVom(scene(nodes));
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "Deep action"');
|
||||||
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: wrapperCount + 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => {
|
it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => {
|
||||||
const out = renderVom(
|
const out = renderVom(
|
||||||
scene([
|
scene([
|
||||||
@@ -785,6 +813,55 @@ describe("renderVom single-layer page", () => {
|
|||||||
expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]");
|
expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives duplicate-label context from indexed DOM ancestry without semantic proximity", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc", contextScopeId: "root" }),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 1,
|
||||||
|
role: "button",
|
||||||
|
name: "View",
|
||||||
|
tag: "button",
|
||||||
|
domParentId: 2,
|
||||||
|
domAncestorIds: [2, 1],
|
||||||
|
contextScopeId: "root",
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 5,
|
||||||
|
parentId: 1,
|
||||||
|
role: "button",
|
||||||
|
name: "View",
|
||||||
|
tag: "button",
|
||||||
|
domParentId: 4,
|
||||||
|
domAncestorIds: [4, 1],
|
||||||
|
contextScopeId: "root",
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "group",
|
||||||
|
name: "Project Alpha",
|
||||||
|
domParentId: 1,
|
||||||
|
domAncestorIds: [1],
|
||||||
|
contextScopeId: "root",
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 4,
|
||||||
|
parentId: 1,
|
||||||
|
role: "group",
|
||||||
|
name: "Project Beta",
|
||||||
|
domParentId: 1,
|
||||||
|
domAncestorIds: [1],
|
||||||
|
contextScopeId: "root",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "View" [ctx: Project Alpha]');
|
||||||
|
expect(out.text).toContain('@e2 button "View" [ctx: Project Beta]');
|
||||||
|
});
|
||||||
|
|
||||||
it("does not add low-value root context to duplicated brand links", () => {
|
it("does not add low-value root context to duplicated brand links", () => {
|
||||||
const out = renderVom(
|
const out = renderVom(
|
||||||
scene([
|
scene([
|
||||||
@@ -833,6 +910,34 @@ describe("renderVom single-layer page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("renderVom double-layer page", () => {
|
describe("renderVom double-layer page", () => {
|
||||||
|
it("collects blocking-layer descendants even when children precede parents in source order", () => {
|
||||||
|
const out = renderVom(
|
||||||
|
scene([
|
||||||
|
node({ id: 1, role: "RootWebArea", name: "Doc" }),
|
||||||
|
node({
|
||||||
|
id: 3,
|
||||||
|
parentId: 2,
|
||||||
|
role: "button",
|
||||||
|
name: "Close",
|
||||||
|
tag: "button",
|
||||||
|
paintOrder: 0,
|
||||||
|
}),
|
||||||
|
node({
|
||||||
|
id: 2,
|
||||||
|
parentId: 1,
|
||||||
|
role: "dialog",
|
||||||
|
name: "Modal",
|
||||||
|
rect: { x: 0, y: 0, w: 1280, h: 720 },
|
||||||
|
paintOrder: 10,
|
||||||
|
position: "fixed",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(out.text).toContain('@e1 button "Close"');
|
||||||
|
expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a blocking modal first and folds the base page into an occluded summary", () => {
|
it("renders a blocking modal first and folds the base page into an occluded summary", () => {
|
||||||
const out = renderVom(
|
const out = renderVom(
|
||||||
scene([
|
scene([
|
||||||
|
|||||||
+134
-66
@@ -421,6 +421,41 @@ function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONTEXT_LABEL_ROLES = new Set([
|
||||||
|
"heading",
|
||||||
|
"article",
|
||||||
|
"section",
|
||||||
|
"group",
|
||||||
|
"region",
|
||||||
|
"row",
|
||||||
|
"form",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface DomContextIndex {
|
||||||
|
nonBoundaryByScopeParent: Map<string | undefined, Map<number, VomNode[]>>;
|
||||||
|
sourceOrderById: Map<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDomContextIndex(nodes: VomNode[]): DomContextIndex {
|
||||||
|
const nonBoundaryByScopeParent = new Map<string | undefined, Map<number, VomNode[]>>();
|
||||||
|
const sourceOrderById = new Map<number, number>();
|
||||||
|
for (const [sourceOrder, node] of nodes.entries()) {
|
||||||
|
sourceOrderById.set(node.id, sourceOrder);
|
||||||
|
if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node)) || isContextBoundary(node)) continue;
|
||||||
|
const parentId = node.domParentId;
|
||||||
|
if (parentId === undefined || parentId === null) continue;
|
||||||
|
let byParent = nonBoundaryByScopeParent.get(node.contextScopeId);
|
||||||
|
if (!byParent) {
|
||||||
|
byParent = new Map();
|
||||||
|
nonBoundaryByScopeParent.set(node.contextScopeId, byParent);
|
||||||
|
}
|
||||||
|
const siblings = byParent.get(parentId) ?? [];
|
||||||
|
siblings.push(node);
|
||||||
|
byParent.set(parentId, siblings);
|
||||||
|
}
|
||||||
|
return { nonBoundaryByScopeParent, sourceOrderById };
|
||||||
|
}
|
||||||
|
|
||||||
function duplicateReferenceNames(nodes: VomNode[]): Set<string> {
|
function duplicateReferenceNames(nodes: VomNode[]): Set<string> {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -440,13 +475,7 @@ function needsHandleContext(nodeName: string, duplicateRefNames: Set<string>): b
|
|||||||
}
|
}
|
||||||
|
|
||||||
function contextLabel(node: VomNode, targetName: string): string | undefined {
|
function contextLabel(node: VomNode, targetName: string): string | undefined {
|
||||||
if (
|
if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node))) return undefined;
|
||||||
!["heading", "article", "section", "group", "region", "row", "form"].includes(
|
|
||||||
normalizedRole(node),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return weakContextText(
|
return weakContextText(
|
||||||
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
|
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
|
||||||
targetName,
|
targetName,
|
||||||
@@ -607,19 +636,45 @@ function collectDomContext(
|
|||||||
): ContextCandidate[] {
|
): ContextCandidate[] {
|
||||||
const ancestorIds = node.domAncestorIds ?? [];
|
const ancestorIds = node.domAncestorIds ?? [];
|
||||||
if (ancestorIds.length === 0) return [];
|
if (ancestorIds.length === 0) return [];
|
||||||
const ancestors = new Set(ancestorIds);
|
|
||||||
const contexts: ContextCandidate[] = [];
|
const contexts: ContextCandidate[] = [];
|
||||||
|
const ancestorRank = new Map<number, number>();
|
||||||
|
for (const [rank, ancestorId] of ancestorIds.entries()) {
|
||||||
|
if (!ancestorRank.has(ancestorId)) ancestorRank.set(ancestorId, rank);
|
||||||
|
}
|
||||||
|
|
||||||
for (const candidate of state.nodesById.values()) {
|
const candidates = new Map<number, VomNode>();
|
||||||
|
const nonBoundaryByParent = state.domContextIndex.nonBoundaryByScopeParent.get(
|
||||||
|
node.contextScopeId,
|
||||||
|
);
|
||||||
|
for (const ancestorId of ancestorRank.keys()) {
|
||||||
|
const boundary = state.nodesById.get(ancestorId);
|
||||||
|
const boundaryParentId = boundary?.domParentId;
|
||||||
|
if (
|
||||||
|
boundary &&
|
||||||
|
sharesContextScope(node, boundary) &&
|
||||||
|
isContextBoundary(boundary) &&
|
||||||
|
boundaryParentId !== undefined &&
|
||||||
|
boundaryParentId !== null &&
|
||||||
|
ancestorRank.has(boundaryParentId)
|
||||||
|
) {
|
||||||
|
candidates.set(boundary.id, boundary);
|
||||||
|
}
|
||||||
|
for (const candidate of nonBoundaryByParent?.get(ancestorId) ?? []) {
|
||||||
|
candidates.set(candidate.id, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOrderById = state.domContextIndex.sourceOrderById;
|
||||||
|
const orderedCandidates = [...candidates.values()].sort(
|
||||||
|
(a, b) => (sourceOrderById.get(a.id) ?? 0) - (sourceOrderById.get(b.id) ?? 0),
|
||||||
|
);
|
||||||
|
for (const candidate of orderedCandidates) {
|
||||||
if (candidate.id === node.id) continue;
|
if (candidate.id === node.id) continue;
|
||||||
if (!sharesContextScope(node, candidate)) continue;
|
|
||||||
if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue;
|
|
||||||
const parentId = candidate.domParentId;
|
const parentId = candidate.domParentId;
|
||||||
if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue;
|
if (parentId === undefined || parentId === null) continue;
|
||||||
const label = contextLabel(candidate, nodeName);
|
const label = contextLabel(candidate, nodeName);
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
const rank = ancestorIds.indexOf(parentId);
|
contexts.push({ text: label, source: "dom", order: ancestorRank.get(parentId) ?? 0 });
|
||||||
contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return contexts;
|
return contexts;
|
||||||
@@ -734,6 +789,7 @@ interface RenderState {
|
|||||||
children: Map<number | null, VomNode[]>;
|
children: Map<number | null, VomNode[]>;
|
||||||
parentMap: Map<number, number | null>;
|
parentMap: Map<number, number | null>;
|
||||||
nodesById: Map<number, VomNode>;
|
nodesById: Map<number, VomNode>;
|
||||||
|
domContextIndex: DomContextIndex;
|
||||||
duplicateRefNames: Set<string>;
|
duplicateRefNames: Set<string>;
|
||||||
surfaceMap: Map<number, CondSurface>;
|
surfaceMap: Map<number, CondSurface>;
|
||||||
scopeMap: Map<number, ActiveScopeBlock>;
|
scopeMap: Map<number, ActiveScopeBlock>;
|
||||||
@@ -768,20 +824,25 @@ function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: Ren
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTree(
|
function pushRenderChildren(
|
||||||
children: Map<number | null, VomNode[]>,
|
stack: Array<{ node: VomNode; depth: number }>,
|
||||||
parentId: number | null,
|
children: readonly VomNode[],
|
||||||
depth: number,
|
depth: number,
|
||||||
state: RenderState,
|
|
||||||
): void {
|
): void {
|
||||||
if (state.stopped) return;
|
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||||
|
stack.push({ node: children[index], depth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = children.get(parentId) ?? [];
|
function renderTree(children: Map<number | null, VomNode[]>, state: RenderState): void {
|
||||||
for (const node of nodes) {
|
const stack: Array<{ node: VomNode; depth: number }> = [];
|
||||||
if (state.stopped) return;
|
pushRenderChildren(stack, children.get(null) ?? [], 1);
|
||||||
|
|
||||||
|
while (stack.length > 0 && !state.stopped) {
|
||||||
|
const { node, depth } = stack.pop() as { node: VomNode; depth: number };
|
||||||
|
|
||||||
if (!shouldRender(node)) {
|
if (!shouldRender(node)) {
|
||||||
renderTree(children, node.id, depth, state);
|
pushRenderChildren(stack, children.get(node.id) ?? [], depth);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +859,7 @@ function renderTree(
|
|||||||
if (nextTokens > state.maxTokens) {
|
if (nextTokens > state.maxTokens) {
|
||||||
state.truncated = true;
|
state.truncated = true;
|
||||||
state.stopped = true;
|
state.stopped = true;
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.lines.push(line);
|
state.lines.push(line);
|
||||||
@@ -823,7 +884,7 @@ function renderTree(
|
|||||||
if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) {
|
if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
renderTree(children, node.id, depth + 1, state);
|
pushRenderChildren(stack, children.get(node.id) ?? [], depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,12 +909,13 @@ function renderNodes(
|
|||||||
children,
|
children,
|
||||||
parentMap: buildParentMap(nodes),
|
parentMap: buildParentMap(nodes),
|
||||||
nodesById: new Map(nodes.map((node) => [node.id, node])),
|
nodesById: new Map(nodes.map((node) => [node.id, node])),
|
||||||
|
domContextIndex: buildDomContextIndex(nodes),
|
||||||
duplicateRefNames: duplicateReferenceNames(nodes),
|
duplicateRefNames: duplicateReferenceNames(nodes),
|
||||||
surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])),
|
surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])),
|
||||||
scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])),
|
scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])),
|
||||||
};
|
};
|
||||||
|
|
||||||
renderTree(children, null, 1, state);
|
renderTree(children, state);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -922,23 +984,33 @@ function paintLineageByFrame(
|
|||||||
return lineage;
|
return lineage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedPaintLineageByFrame(
|
||||||
|
node: VomNode,
|
||||||
|
parentMap: Map<number, number | null>,
|
||||||
|
nodesById: Map<number, VomNode>,
|
||||||
|
cache: Map<number, Map<string | undefined, VomNode>>,
|
||||||
|
): Map<string | undefined, VomNode> {
|
||||||
|
const cached = cache.get(node.id);
|
||||||
|
if (cached) return cached;
|
||||||
|
const lineage = paintLineageByFrame(node, parentMap, nodesById);
|
||||||
|
cache.set(node.id, lineage);
|
||||||
|
return lineage;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve both nodes to the nearest document where their paint order is comparable. */
|
/** Resolve both nodes to the nearest document where their paint order is comparable. */
|
||||||
function comparablePaintNodes(
|
function comparablePaintNodes(
|
||||||
target: VomNode,
|
target: VomNode,
|
||||||
blocker: VomNode,
|
blocker: VomNode,
|
||||||
parentMap: Map<number, number | null>,
|
parentMap: Map<number, number | null>,
|
||||||
nodesById: Map<number, VomNode>,
|
nodesById: Map<number, VomNode>,
|
||||||
|
lineageCache: Map<number, Map<string | undefined, VomNode>>,
|
||||||
): { target: VomNode; blocker: VomNode } | null {
|
): { target: VomNode; blocker: VomNode } | null {
|
||||||
if (target.frameId === blocker.frameId) return { target, blocker };
|
if (target.frameId === blocker.frameId) return { target, blocker };
|
||||||
const targetLineage = paintLineageByFrame(target, parentMap, nodesById);
|
const targetLineage = cachedPaintLineageByFrame(target, parentMap, nodesById, lineageCache);
|
||||||
let blockerInFrame: VomNode | undefined = blocker;
|
const blockerLineage = cachedPaintLineageByFrame(blocker, parentMap, nodesById, lineageCache);
|
||||||
let guard = 0;
|
for (const [frameId, blockerInFrame] of blockerLineage) {
|
||||||
while (blockerInFrame && guard <= parentMap.size) {
|
const targetInFrame = targetLineage.get(frameId);
|
||||||
const targetInFrame = targetLineage.get(blockerInFrame.frameId);
|
|
||||||
if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame };
|
if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame };
|
||||||
const parentId: number | null = parentMap.get(blockerInFrame.id) ?? null;
|
|
||||||
blockerInFrame = parentId === null ? undefined : nodesById.get(parentId);
|
|
||||||
guard += 1;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -949,12 +1021,13 @@ function isBlockedByRegion(
|
|||||||
parentMap: Map<number, number | null>,
|
parentMap: Map<number, number | null>,
|
||||||
nodesById: Map<number, VomNode>,
|
nodesById: Map<number, VomNode>,
|
||||||
viewportCoverage: number,
|
viewportCoverage: number,
|
||||||
|
lineageCache: Map<number, Map<string | undefined, VomNode>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (target.id === blocker.id) return false;
|
if (target.id === blocker.id) return false;
|
||||||
if (isAncestorOf(parentMap, blocker.id, target.id)) return false;
|
if (isAncestorOf(parentMap, blocker.id, target.id)) return false;
|
||||||
if (isAncestorOf(parentMap, target.id, blocker.id)) return false;
|
if (isAncestorOf(parentMap, target.id, blocker.id)) return false;
|
||||||
if (!target.rect || !blocker.rect) return false;
|
if (!target.rect || !blocker.rect) return false;
|
||||||
const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById);
|
const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById, lineageCache);
|
||||||
if (!paintNodes) return false;
|
if (!paintNodes) return false;
|
||||||
|
|
||||||
const points = interactionPoints(target.rect);
|
const points = interactionPoints(target.rect);
|
||||||
@@ -979,17 +1052,22 @@ function isBlockedByRegion(
|
|||||||
return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y));
|
return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<number> {
|
function collectDescendants(nodes: VomNode[], roots: Set<number>): Set<number> {
|
||||||
|
const children = new Map<number, number[]>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.parentId === null) continue;
|
||||||
|
const siblings = children.get(node.parentId) ?? [];
|
||||||
|
siblings.push(node.id);
|
||||||
|
children.set(node.parentId, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
const included = new Set(roots);
|
const included = new Set(roots);
|
||||||
let changed = true;
|
const queue = [...roots];
|
||||||
while (changed) {
|
for (let index = 0; index < queue.length; index += 1) {
|
||||||
changed = false;
|
for (const childId of children.get(queue[index]) ?? []) {
|
||||||
for (const node of nodes) {
|
if (included.has(childId)) continue;
|
||||||
if (included.has(node.id)) continue;
|
included.add(childId);
|
||||||
if (node.parentId !== null && included.has(node.parentId)) {
|
queue.push(childId);
|
||||||
included.add(node.id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return included;
|
return included;
|
||||||
@@ -998,6 +1076,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<numb
|
|||||||
function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
|
function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
|
||||||
const parentMap = buildParentMap(nodes);
|
const parentMap = buildParentMap(nodes);
|
||||||
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
const lineageCache = new Map<number, Map<string | undefined, VomNode>>();
|
||||||
const candidates = nodes
|
const candidates = nodes
|
||||||
.filter(isPositionedRegionCandidate)
|
.filter(isPositionedRegionCandidate)
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
@@ -1014,40 +1093,29 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
|
|||||||
for (const target of nodes) {
|
for (const target of nodes) {
|
||||||
if (!isVomReferenceNode(target)) continue;
|
if (!isVomReferenceNode(target)) continue;
|
||||||
const blocked = candidates.some((candidate) =>
|
const blocked = candidates.some((candidate) =>
|
||||||
isBlockedByRegion(target, candidate.node, parentMap, nodesById, candidate.viewportCoverage),
|
isBlockedByRegion(
|
||||||
|
target,
|
||||||
|
candidate.node,
|
||||||
|
parentMap,
|
||||||
|
nodesById,
|
||||||
|
candidate.viewportCoverage,
|
||||||
|
lineageCache,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (blocked) blockedRoots.add(target.id);
|
if (blocked) blockedRoots.add(target.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockedRoots.size === 0) return nodes;
|
if (blockedRoots.size === 0) return nodes;
|
||||||
const blocked = collectDescendantsOfIds(nodes, blockedRoots);
|
const blocked = collectDescendants(nodes, blockedRoots);
|
||||||
return nodes.filter((node) => !blocked.has(node.id));
|
return nodes.filter((node) => !blocked.has(node.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDescendantsOfMembers(nodes: VomNode[], members: Set<number>): Set<number> {
|
|
||||||
const included = new Set(members);
|
|
||||||
let changed = true;
|
|
||||||
|
|
||||||
while (changed) {
|
|
||||||
changed = false;
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (included.has(node.id)) continue;
|
|
||||||
if (node.parentId !== null && included.has(node.parentId)) {
|
|
||||||
included.add(node.id);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return included;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countRenderable(nodes: VomNode[]): number {
|
function countRenderable(nodes: VomNode[]): number {
|
||||||
return nodes.filter(shouldRender).length;
|
return nodes.filter(shouldRender).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult {
|
function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult {
|
||||||
const included = collectDescendantsOfMembers(scene.nodes, layer.members);
|
const included = collectDescendants(scene.nodes, layer.members);
|
||||||
const visibleNodes = scene.nodes.filter((node) => included.has(node.id));
|
const visibleNodes = scene.nodes.filter((node) => included.has(node.id));
|
||||||
const hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id)));
|
const hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id)));
|
||||||
const header = [
|
const header = [
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
1. `bsk` on `PATH` (Rust CLI from browser-skill)
|
||||||
2. browser-skill **extension** loaded in Chromium and connected (popup shows green)
|
2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue)
|
||||||
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails
|
||||||
|
|
||||||
## Mandatory workflow
|
## Mandatory workflow
|
||||||
@@ -299,7 +299,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
|
|||||||
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command |
|
||||||
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
|
||||||
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` |
|
||||||
| `5` | Version skew (CLI vs extension) | Upgrade/reinstall matching versions |
|
| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. |
|
||||||
|
|
||||||
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user