Compare commits

...
Author SHA1 Message Date
drakezhang 641d934337 perf(vom): optimize rendering algorithms 2026-09-01 14:55:36 +08:00
Zhang GH f06a514bd2 Merge pull request #149 from Tencent/fix/protocol-upgrade-reminder
fix:将协议版本差异改为升级提醒
2026-08-31 12:25:29 +08:00
Zhang GH 1e53ee63ae Merge pull request #155 from Tencent/ci/edge-addons-publishing
ci(extension): publish updates to Microsoft Edge Add-ons
2026-08-31 11:24:22 +08:00
Zhang GH 120dc441bb Merge pull request #158 from Tencent/codex/fix-dsh-screenshot-test-name
test(dsh-plugin): use renamed screenshot tool
2026-08-29 16:08:01 +08:00
drakezhang e9f0584af3 ci(extension): publish updates to Microsoft Edge Add-ons 2026-08-28 21:39:53 +08:00
“haonan”andCursor c48a53269e style: rustfmt doctor protocol-version test asserts
cargo fmt --check failed on two long assert lines in PR CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:50:01 +08:00
“haonan”andCursor 3bbd6ab19f fix: treat protocol version differences as upgrade reminders
Agents were stopping on popup and status copy that looked like a hard incompatibility. Keep the connection usable, reframe exit 5 as an unknown method, and ask users to upgrade instead of aborting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:22:30 +08:00
11 changed files with 487 additions and 94 deletions
+177
View File
@@ -325,3 +325,180 @@ jobs:
echo "Chrome Web Store submission created successfully:"
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(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", () => {
+4 -3
View File
@@ -79,6 +79,7 @@ export function App() {
}, [copiedTick]);
const isSkewed = statusState === "version_skew";
const connectionLive = statusState === "connected" || isSkewed;
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—";
@@ -90,7 +91,7 @@ export function App() {
setCopiedInstanceId(true);
};
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
const recordReady = connectionLive && Boolean(snapshot.instanceId);
const recordPurpose = purposeDraft.trim();
const recordStartUrl = startUrlDraft.trim();
const recordCommand = snapshot.instanceId
@@ -206,12 +207,12 @@ export function App() {
</div>
{isSkewed && (
<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"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
cliProtocol: daemonProtocol,
})}
</p>
)}
+2 -2
View File
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
## Prerequisites
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
## 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 |
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
| `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`.
+18 -11
View File
@@ -351,10 +351,10 @@ fn check_version_compatible(status: Option<&StatusResult>) -> CheckResult {
}
}
/// `bsk doctor` check: every connected browser should use the same
/// protocol version as the daemon. Minor protocol drift is accepted by
/// the daemon but flagged here so the user can see who needs updating
/// (M10.4).
/// `bsk doctor` check: connected browsers should speak a protocol the
/// daemon accepts. A different protocol string is still a live
/// connection — report Ok so agents keep working. The detail is an
/// upgrade reminder, not a blocker.
///
/// Review M2 (round-1 minor): when no browsers are connected, the
/// 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<_>>()
.join(", ");
CheckResult::fail(
CheckResult::ok(
name,
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()
),
"upgrade the browser-skill extension or bsk CLI so both sides use the same protocol version",
)
}
@@ -581,7 +580,7 @@ mod m2_tests {
}
#[test]
fn browsers_check_reports_fail_when_skew_present() {
fn browsers_check_reports_ok_when_skew_present() {
let status = fake_status(
vec![BrowserStatusEntry {
instance_id: "alpha".into(),
@@ -605,9 +604,17 @@ mod m2_tests {
}],
);
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.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]
@@ -635,7 +642,7 @@ mod m2_tests {
}],
);
let check = check_browsers_protocol_compatible(Some(&status));
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.status, CheckStatus::Ok);
assert!(
check
.detail
+2 -2
View File
@@ -65,7 +65,7 @@ fn render_human(s: &StatusResult) {
let bold = "\x1b[1m";
let reset = "\x1b[0m";
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()
);
for skew in &s.version_skew_browsers {
@@ -75,7 +75,7 @@ fn render_human(s: &StatusResult) {
skew.label.clone()
};
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,
label,
display_protocol(&skew.client_protocol_version),
@@ -6,27 +6,27 @@
"disconnected": "Disconnected",
"connecting": "Connecting…",
"connected": "Connected",
"version_skew": "Protocol mismatch",
"version_skew": "Connected",
"disabled": "Connection off"
},
"stateDetail": {
"disconnected": "Open BrowserSkill to connect.",
"connecting": "Connecting to BrowserSkill…",
"connected": "Ready for automation tasks.",
"version_skew": "Some features may be unavailable."
"version_skew": "Protocol versions differ. Please upgrade."
},
"stateBadge": {
"disconnected": "Standby",
"connecting": "Igniting",
"connected": "Ready",
"version_skew": "Action needed",
"version_skew": "Upgradable",
"disabled": "Off"
},
"connectionToggleTitle": "BrowserSkill connection",
"controlHintsToggleTitle": "Control hints",
"controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.",
"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",
"launcher": {
"title": "Quick actions"
@@ -6,27 +6,27 @@
"disconnected": "未连接",
"connecting": "连接中…",
"connected": "已连接",
"version_skew": "协议不一致",
"version_skew": "已连接",
"disabled": "连接已关闭"
},
"stateDetail": {
"disconnected": "请先打开 BrowserSkill。",
"connecting": "正在连接 BrowserSkill…",
"connected": "可接收自动化任务。",
"version_skew": "部分能力可能不可用。"
"version_skew": "协议版本不同,请及时升级。"
},
"stateBadge": {
"disconnected": "Standby",
"connecting": "Igniting",
"connected": "Ready",
"version_skew": "Action needed",
"version_skew": "可升级",
"disabled": "Off"
},
"connectionToggleTitle": "BrowserSkill 连接",
"controlHintsToggleTitle": "控制提示",
"controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。",
"controlHintsInfoLabel": "控制提示说明",
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。",
"upgradeAvailable": "可升级",
"launcher": {
"title": "快捷功能"
+105
View File
@@ -298,6 +298,34 @@ describe("renderVom single-layer page", () => {
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", () => {
const out = renderVom(
scene([
@@ -785,6 +813,55 @@ describe("renderVom single-layer page", () => {
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", () => {
const out = renderVom(
scene([
@@ -833,6 +910,34 @@ describe("renderVom single-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", () => {
const out = renderVom(
scene([
+134 -66
View File
@@ -421,6 +421,41 @@ function buildChildren(nodes: VomNode[]): Map<number | null, VomNode[]> {
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> {
const counts = new Map<string, number>();
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 {
if (
!["heading", "article", "section", "group", "region", "row", "form"].includes(
normalizedRole(node),
)
) {
return undefined;
}
if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node))) return undefined;
return weakContextText(
cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText),
targetName,
@@ -607,19 +636,45 @@ function collectDomContext(
): ContextCandidate[] {
const ancestorIds = node.domAncestorIds ?? [];
if (ancestorIds.length === 0) return [];
const ancestors = new Set(ancestorIds);
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 (!sharesContextScope(node, candidate)) continue;
if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue;
const parentId = candidate.domParentId;
if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue;
if (parentId === undefined || parentId === null) continue;
const label = contextLabel(candidate, nodeName);
if (!label) continue;
const rank = ancestorIds.indexOf(parentId);
contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank });
contexts.push({ text: label, source: "dom", order: ancestorRank.get(parentId) ?? 0 });
}
return contexts;
@@ -734,6 +789,7 @@ interface RenderState {
children: Map<number | null, VomNode[]>;
parentMap: Map<number, number | null>;
nodesById: Map<number, VomNode>;
domContextIndex: DomContextIndex;
duplicateRefNames: Set<string>;
surfaceMap: Map<number, CondSurface>;
scopeMap: Map<number, ActiveScopeBlock>;
@@ -768,20 +824,25 @@ function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: Ren
}
}
function renderTree(
children: Map<number | null, VomNode[]>,
parentId: number | null,
function pushRenderChildren(
stack: Array<{ node: VomNode; depth: number }>,
children: readonly VomNode[],
depth: number,
state: RenderState,
): 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) ?? [];
for (const node of nodes) {
if (state.stopped) return;
function renderTree(children: Map<number | null, VomNode[]>, state: RenderState): void {
const stack: Array<{ node: VomNode; depth: number }> = [];
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)) {
renderTree(children, node.id, depth, state);
pushRenderChildren(stack, children.get(node.id) ?? [], depth);
continue;
}
@@ -798,7 +859,7 @@ function renderTree(
if (nextTokens > state.maxTokens) {
state.truncated = true;
state.stopped = true;
return;
break;
}
state.lines.push(line);
@@ -823,7 +884,7 @@ function renderTree(
if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) {
continue;
}
renderTree(children, node.id, depth + 1, state);
pushRenderChildren(stack, children.get(node.id) ?? [], depth + 1);
}
}
@@ -848,12 +909,13 @@ function renderNodes(
children,
parentMap: buildParentMap(nodes),
nodesById: new Map(nodes.map((node) => [node.id, node])),
domContextIndex: buildDomContextIndex(nodes),
duplicateRefNames: duplicateReferenceNames(nodes),
surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])),
scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])),
};
renderTree(children, null, 1, state);
renderTree(children, state);
return state;
}
@@ -922,23 +984,33 @@ function paintLineageByFrame(
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. */
function comparablePaintNodes(
target: VomNode,
blocker: VomNode,
parentMap: Map<number, number | null>,
nodesById: Map<number, VomNode>,
lineageCache: Map<number, Map<string | undefined, VomNode>>,
): { target: VomNode; blocker: VomNode } | null {
if (target.frameId === blocker.frameId) return { target, blocker };
const targetLineage = paintLineageByFrame(target, parentMap, nodesById);
let blockerInFrame: VomNode | undefined = blocker;
let guard = 0;
while (blockerInFrame && guard <= parentMap.size) {
const targetInFrame = targetLineage.get(blockerInFrame.frameId);
const targetLineage = cachedPaintLineageByFrame(target, parentMap, nodesById, lineageCache);
const blockerLineage = cachedPaintLineageByFrame(blocker, parentMap, nodesById, lineageCache);
for (const [frameId, blockerInFrame] of blockerLineage) {
const targetInFrame = targetLineage.get(frameId);
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;
}
@@ -949,12 +1021,13 @@ function isBlockedByRegion(
parentMap: Map<number, number | null>,
nodesById: Map<number, VomNode>,
viewportCoverage: number,
lineageCache: Map<number, Map<string | undefined, VomNode>>,
): boolean {
if (target.id === blocker.id) return false;
if (isAncestorOf(parentMap, blocker.id, target.id)) return false;
if (isAncestorOf(parentMap, target.id, blocker.id)) 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;
const points = interactionPoints(target.rect);
@@ -979,17 +1052,22 @@ function isBlockedByRegion(
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);
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;
}
const queue = [...roots];
for (let index = 0; index < queue.length; index += 1) {
for (const childId of children.get(queue[index]) ?? []) {
if (included.has(childId)) continue;
included.add(childId);
queue.push(childId);
}
}
return included;
@@ -998,6 +1076,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<numb
function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
const parentMap = buildParentMap(nodes);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const lineageCache = new Map<number, Map<string | undefined, VomNode>>();
const candidates = nodes
.filter(isPositionedRegionCandidate)
.map((node) => {
@@ -1014,40 +1093,29 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
for (const target of nodes) {
if (!isVomReferenceNode(target)) continue;
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 (blockedRoots.size === 0) return nodes;
const blocked = collectDescendantsOfIds(nodes, blockedRoots);
const blocked = collectDescendants(nodes, blockedRoots);
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 {
return nodes.filter(shouldRender).length;
}
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 hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id)));
const header = [
+2 -2
View File
@@ -29,7 +29,7 @@ Drive the user's **real Chromium browser** (with their logins and cookies) throu
## Prerequisites
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
## 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 |
| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open |
| `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`.