mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
190
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6300eda0aa | ||
|
|
87cdcfe5ca | ||
|
|
21476c3884 | ||
|
|
c2a225377b | ||
|
|
e64f4cac9f | ||
|
|
368507b07d | ||
|
|
b750eed5d4 | ||
|
|
3d159c0254 | ||
|
|
524516cb56 | ||
|
|
f98198949a | ||
|
|
d05279f991 | ||
|
|
2719e76438 | ||
|
|
dd9fc9edba | ||
|
|
005a6fd534 | ||
|
|
20554508db | ||
|
|
8352c3c67a | ||
|
|
4eedd98c1d | ||
|
|
6aa931b60a | ||
|
|
3df2738280 | ||
|
|
f86e506d47 | ||
|
|
45a246c41b | ||
|
|
6b78211e79 | ||
|
|
eebf334b40 | ||
|
|
a284ffc5ae | ||
|
|
e720f1ffe5 | ||
|
|
2b7e10ca2b | ||
|
|
1ef93463dd | ||
|
|
c7252529f4 | ||
|
|
0b65b4b295 | ||
|
|
94ec7e73fc | ||
|
|
42403e55f2 | ||
|
|
22019cdf07 | ||
|
|
8d7c0aa66c | ||
|
|
a9489282fa | ||
|
|
d4cddd6224 | ||
|
|
8e4de6cf4b | ||
|
|
fea66ce404 | ||
|
|
8bae85127a | ||
|
|
edb149611b | ||
|
|
14c5884cbc | ||
|
|
e7aad9bc16 | ||
|
|
c8c5c4a7c4 | ||
|
|
3fb9e9f581 | ||
|
|
9854ee2f45 | ||
|
|
7035e46024 | ||
|
|
51085b960b | ||
|
|
51df868008 | ||
|
|
43ebfab7ba | ||
|
|
ce11325de2 | ||
|
|
4d1dcd122f | ||
|
|
a4e668542d | ||
|
|
52aa356153 | ||
|
|
d1001e14b2 | ||
|
|
eb71ddc1da | ||
|
|
b07b8fec65 | ||
|
|
47070fdc14 | ||
|
|
916229dc62 | ||
|
|
dde707b558 | ||
|
|
e2d07574d7 | ||
|
|
461d204ca3 | ||
|
|
bc5d9610a3 | ||
|
|
2955260d11 | ||
|
|
ce89e95211 | ||
|
|
24faa10659 | ||
|
|
a7bc8bacf6 | ||
|
|
3d050e0f26 | ||
|
|
afdc398730 | ||
|
|
6b96fcca32 | ||
|
|
0771292336 | ||
|
|
54869cabd0 | ||
|
|
2de7c5c6ff | ||
|
|
703fc64399 | ||
|
|
4549ed0892 | ||
|
|
37fbaacc94 | ||
|
|
f81a1e7796 | ||
|
|
78c2719718 | ||
|
|
f443cfae74 | ||
|
|
0ce497f017 | ||
|
|
08212497f1 | ||
|
|
f98c0606a1 | ||
|
|
1371c92721 | ||
|
|
c75f97119d | ||
|
|
ac806ae8f4 | ||
|
|
89200975f3 | ||
|
|
238a98476e | ||
|
|
1da2d54ddc | ||
|
|
6e35e62199 | ||
|
|
610782698b | ||
|
|
9504072914 | ||
|
|
b4450fbb8c | ||
|
|
1268761611 | ||
|
|
37d3e2b22f | ||
|
|
566b6c17fd | ||
|
|
2ccc0aafb9 | ||
|
|
ce8bc6c870 | ||
|
|
eeab97bcdd | ||
|
|
6c610faed9 | ||
|
|
d685c75d25 | ||
|
|
73724617f9 | ||
|
|
4ba6fb45e1 | ||
|
|
93df62a356 | ||
|
|
20002e1ff2 | ||
|
|
674c53339f | ||
|
|
9d4ac90592 | ||
|
|
cde6f22ecf | ||
|
|
d24bb6aa93 | ||
|
|
dbc4845c0d | ||
|
|
d0bee1a09b | ||
|
|
91225e5fdb | ||
|
|
66684c4ce3 | ||
|
|
0f264c5df1 | ||
|
|
49378d70de | ||
|
|
d7f0f1e78c | ||
|
|
71255ae142 | ||
|
|
3f091318d0 | ||
|
|
c6a2b3ec1e | ||
|
|
d67c1a71b0 | ||
|
|
03d2adc035 | ||
|
|
8f5df59b79 | ||
|
|
000fbf5540 | ||
|
|
a52c2d6bf2 | ||
|
|
c002c394e6 | ||
|
|
4fb2e331a6 | ||
|
|
e14ae9cea0 | ||
|
|
f319672721 | ||
|
|
5e0d26ccb2 | ||
|
|
9dbc553e33 | ||
|
|
b657ab574b | ||
|
|
959bfac1ae | ||
|
|
c095128218 | ||
|
|
f73a0e9fb3 | ||
|
|
4e44fb2823 | ||
|
|
c8963e3a68 | ||
|
|
aab3b7a8e5 | ||
|
|
2dad162a87 | ||
|
|
aa63465050 | ||
|
|
063b0bbc91 | ||
|
|
0de67c537a | ||
|
|
ff74db48c8 | ||
|
|
bc8d6fa190 | ||
|
|
19981526c7 | ||
|
|
d1de850b42 | ||
|
|
585308df45 | ||
|
|
29ff239726 | ||
|
|
31d2a1b3dc | ||
|
|
ed92e54dff | ||
|
|
cb11cecdf6 | ||
|
|
ea03f36f3a | ||
|
|
234e9101ed | ||
|
|
5e91178e03 | ||
|
|
ac028a7a18 | ||
|
|
ccf3c32140 | ||
|
|
9799b98937 | ||
|
|
259b4826b7 | ||
|
|
3d6c95c22a | ||
|
|
11ec51dfb8 | ||
|
|
e16b37aaa0 | ||
|
|
d27ef1f5e0 | ||
|
|
e55ce8ad99 | ||
|
|
7bee906ffb | ||
|
|
bf6c384b2e | ||
|
|
ec2cde4467 | ||
|
|
c5e83eac4c | ||
|
|
9f82d89788 | ||
|
|
2964f6219d | ||
|
|
f8907e5f5a | ||
|
|
d422f655d4 | ||
|
|
d4d4c3beeb | ||
|
|
3c7a22e7c7 | ||
|
|
dca2b58cbd | ||
|
|
13279a7f56 | ||
|
|
ed6d14eab3 | ||
|
|
913a3f80a4 | ||
|
|
f60d1e65a7 | ||
|
|
d4a89e89f1 | ||
|
|
3c936982a3 | ||
|
|
37056b3c6e | ||
|
|
fa3133a094 | ||
|
|
d395faa0ab | ||
|
|
a7c48275f9 | ||
|
|
647ae659b7 | ||
|
|
eda8b22c43 | ||
|
|
c987e42da9 | ||
|
|
7f5093d97a | ||
|
|
ce29ddaa53 | ||
|
|
2e7998477d | ||
|
|
c9e00bb641 | ||
|
|
ad2a12e57e | ||
|
|
61096110c0 | ||
|
|
8e8dd3840b |
@@ -0,0 +1,84 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust fmt, clippy, tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, tests, build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare extension types
|
||||
run: pnpm --filter @browser-skill/extension exec wxt prepare
|
||||
|
||||
- name: Run lint checks
|
||||
run: pnpm lint
|
||||
|
||||
- name: Compile extension TypeScript
|
||||
run: pnpm --filter @browser-skill/extension compile
|
||||
|
||||
- name: Run extension tests
|
||||
run: pnpm ext:test
|
||||
|
||||
- name: Build extension
|
||||
run: pnpm ext:build
|
||||
|
||||
node-scripts:
|
||||
name: Node script tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run script tests
|
||||
run: node --test scripts/*.test.mjs
|
||||
@@ -139,6 +139,7 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--server-url "${{ github.server_url }}" \
|
||||
--branch "${{ github.event.repository.default_branch }}" \
|
||||
--dist dist \
|
||||
--out dist/version.json
|
||||
|
||||
- name: Publish GitHub Release
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Release dsh plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- dsh-plugin-v*
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-dsh-plugin-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PACKAGE_NAME: "@wxg-prc-cpg/browser-skill-dsh-plugin"
|
||||
PACKAGE_DIR: packages/dsh-plugin-browserskill
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve plugin version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#dsh-plugin-v}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=dsh-plugin-v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
guard:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Verify tag matches package.json name and version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_NAME="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').name")"
|
||||
PKG_VERSION="$(node -p "require('./${{ env.PACKAGE_DIR }}/package.json').version")"
|
||||
RELEASE_VERSION="${{ needs.resolve.outputs.version }}"
|
||||
if [ "$PKG_NAME" != "$PACKAGE_NAME" ]; then
|
||||
echo "package.json name (${PKG_NAME}) does not match expected ${PACKAGE_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PKG_VERSION" != "$RELEASE_VERSION" ]; then
|
||||
echo "${PACKAGE_DIR}/package.json version (${PKG_VERSION}) does not match release version (${RELEASE_VERSION})"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version guard passed: ${PKG_NAME}@${RELEASE_VERSION}"
|
||||
|
||||
publish:
|
||||
needs: [resolve, guard]
|
||||
runs-on: ubuntu-latest
|
||||
# Secret NPM_TOKEN is stored on the "npm-publish" GitHub Environment.
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
registry-url: https://registry.npmjs.org
|
||||
scope: "@wxg-prc-cpg"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck plugin
|
||||
run: pnpm --filter "${{ env.PACKAGE_NAME }}" typecheck
|
||||
|
||||
- name: Test plugin
|
||||
run: pnpm --filter "${{ env.PACKAGE_NAME }}" test
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ${{ env.PACKAGE_DIR }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||
echo "::error::Environment secret NPM_TOKEN is not configured"
|
||||
exit 1
|
||||
fi
|
||||
pnpm publish --access public --no-git-checks
|
||||
echo "Published ${PACKAGE_NAME}@${{ needs.resolve.outputs.version }}"
|
||||
@@ -126,3 +126,202 @@ jobs:
|
||||
make_latest: false
|
||||
generate_release_notes: true
|
||||
files: dist/*.zip
|
||||
|
||||
publish-cws:
|
||||
needs: [resolve, build-extension]
|
||||
runs-on: ubuntu-latest
|
||||
environment: chrome-web-store
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: extension-zip
|
||||
path: dist
|
||||
|
||||
- name: Validate Chrome Web Store configuration
|
||||
shell: bash
|
||||
env:
|
||||
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
|
||||
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$PUBLISHER_ID" ]; then
|
||||
echo "::error::Repository variable CHROME_WEBSTORE_PUBLISHER_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$EXTENSION_ID" ]; then
|
||||
echo "::error::Repository variable CHROME_WEBSTORE_EXTENSION_ID is not configured"
|
||||
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: Authenticate with service account
|
||||
id: cws-auth
|
||||
env:
|
||||
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'NODE'
|
||||
import { sign } from "node:crypto";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const rawCredentials = process.env.SERVICE_ACCOUNT_JSON;
|
||||
if (!rawCredentials) {
|
||||
throw new Error(
|
||||
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not configured",
|
||||
);
|
||||
}
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(rawCredentials);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Repository secret CHROME_WEBSTORE_SERVICE_ACCOUNT_JSON is not valid JSON",
|
||||
);
|
||||
}
|
||||
if (!credentials.client_email || !credentials.private_key) {
|
||||
throw new Error(
|
||||
"Service account JSON must contain client_email and private_key",
|
||||
);
|
||||
}
|
||||
|
||||
const encode = (value) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
kid: credentials.private_key_id,
|
||||
};
|
||||
const claim = {
|
||||
iss: credentials.client_email,
|
||||
scope: "https://www.googleapis.com/auth/chromewebstore",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + 3600,
|
||||
};
|
||||
const unsignedJwt = `${encode(header)}.${encode(claim)}`;
|
||||
const signature = sign(
|
||||
"RSA-SHA256",
|
||||
Buffer.from(unsignedJwt),
|
||||
credentials.private_key,
|
||||
).toString("base64url");
|
||||
const assertion = `${unsignedJwt}.${signature}`;
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Service account token exchange failed (${response.status}): ${responseText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenResponse = JSON.parse(responseText);
|
||||
if (!tokenResponse.access_token) {
|
||||
throw new Error("Service account token response has no access_token");
|
||||
}
|
||||
|
||||
console.log(`::add-mask::${tokenResponse.access_token}`);
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`access_token=${tokenResponse.access_token}\n`,
|
||||
);
|
||||
NODE
|
||||
|
||||
- name: Upload and publish Chrome Web Store extension
|
||||
shell: bash
|
||||
env:
|
||||
ACCESS_TOKEN: ${{ steps.cws-auth.outputs.access_token }}
|
||||
PUBLISHER_ID: ${{ vars.CHROME_WEBSTORE_PUBLISHER_ID }}
|
||||
EXTENSION_ID: ${{ vars.CHROME_WEBSTORE_EXTENSION_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ITEM_NAME="publishers/${PUBLISHER_ID}/items/${EXTENSION_ID}"
|
||||
API_ROOT="https://chromewebstore.googleapis.com"
|
||||
UPLOAD_RESPONSE="$RUNNER_TEMP/cws-upload-response.json"
|
||||
STATUS_RESPONSE="$RUNNER_TEMP/cws-status-response.json"
|
||||
PUBLISH_RESPONSE="$RUNNER_TEMP/cws-publish-response.json"
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$UPLOAD_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
--header "Content-Type: application/zip" \
|
||||
--data-binary "@$ARCHIVE" \
|
||||
"$API_ROOT/upload/v2/$ITEM_NAME:upload")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store upload failed with HTTP $HTTP_STATUS"
|
||||
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_STATE="$(jq -r '.uploadState // empty' "$UPLOAD_RESPONSE")"
|
||||
echo "Chrome Web Store upload state: $UPLOAD_STATE"
|
||||
|
||||
if [ "$UPLOAD_STATE" = "IN_PROGRESS" ] || [ "$UPLOAD_STATE" = "UPLOAD_IN_PROGRESS" ]; then
|
||||
for attempt in $(seq 1 30); do
|
||||
sleep 5
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$STATUS_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
"$API_ROOT/v2/$ITEM_NAME:fetchStatus")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store status check failed with HTTP $HTTP_STATUS"
|
||||
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_STATE="$(jq -r '.lastAsyncUploadState // empty' "$STATUS_RESPONSE")"
|
||||
echo "Upload status check $attempt/30: $UPLOAD_STATE"
|
||||
if [ "$UPLOAD_STATE" = "SUCCEEDED" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$UPLOAD_STATE" != "IN_PROGRESS" ] && [ "$UPLOAD_STATE" != "UPLOAD_IN_PROGRESS" ]; then
|
||||
echo "::error::Chrome Web Store upload did not succeed: $UPLOAD_STATE"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$UPLOAD_STATE" != "SUCCEEDED" ]; then
|
||||
echo "::error::Chrome Web Store upload did not complete: $UPLOAD_STATE"
|
||||
jq . "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$PUBLISH_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{"publishType":"DEFAULT_PUBLISH"}' \
|
||||
"$API_ROOT/v2/$ITEM_NAME:publish")"
|
||||
if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then
|
||||
echo "::error::Chrome Web Store publish failed with HTTP $HTTP_STATUS"
|
||||
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Chrome Web Store submission created successfully:"
|
||||
jq '{itemId, state, warningInfo}' "$PUBLISH_RESPONSE"
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
**/.output/**
|
||||
**/target/**
|
||||
**/node_modules/**
|
||||
# Generated client CSS (tailwind compile output); built by
|
||||
# packages/dsh-plugin-browserskill/scripts/build-client-css.mjs.
|
||||
**/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css
|
||||
|
||||
+9
-5
@@ -34,13 +34,17 @@ where only `extension connected` fails is expected; go to Step 3.
|
||||
## 3. Open the extension install page
|
||||
|
||||
If `extension connected` is `FAIL` (`0 browsers connected`), the user likely
|
||||
has not installed or enabled the browser extension yet. Open the
|
||||
[Chrome Web Store install page](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
|
||||
in the user's browser, then tell the user:
|
||||
has not installed or enabled the browser extension yet. Open the install page
|
||||
matching the user's browser —
|
||||
[Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
|
||||
for Chrome and other Chromium browsers,
|
||||
[Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg)
|
||||
for Microsoft Edge — then tell the user:
|
||||
|
||||
> Install **BrowserSkill** from the
|
||||
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi),
|
||||
> open the popup, and wait until it turns green. Reply when done.
|
||||
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
|
||||
> (or [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg)
|
||||
> on Edge), open the popup, and wait until it turns green. Reply when done.
|
||||
|
||||
If opening the page fails, give the user the same link. Then run `bsk doctor`
|
||||
once more. All `ok`/`na` → tell the user it's ready.
|
||||
|
||||
Generated
+1220
-10
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.6"
|
||||
version = "0.1.10"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
@@ -31,13 +31,18 @@ console = "0.15"
|
||||
dialoguer = "0.11"
|
||||
dirs = "5"
|
||||
fs2 = "0.4"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
|
||||
flate2 = "1.1.9"
|
||||
tar = "0.4.46"
|
||||
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
||||
sha2 = "0.11.0"
|
||||
tempfile = "3"
|
||||
nix = { version = "0.29", features = ["signal", "process"] }
|
||||
rand = "0.8"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# Workspace crates (version required for `cargo publish` of dependents)
|
||||
bsk-protocol = { version = "0.1.6", path = "crates/bsk-protocol" }
|
||||
bsk-protocol = { version = "0.1.9", path = "crates/bsk-protocol" }
|
||||
|
||||
[profile.release]
|
||||
strip = "symbols"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</p>
|
||||
|
||||
**BrowserSkill** connects Cursor, Claude Code, Codex, OpenClaw, CodeBuddy,
|
||||
WorkBuddy, Pi, Hermes Agent, and other shell-capable AI agents to your already logged-in
|
||||
WorkBuddy, Pi, Hermes Agent, DeepSeek Harness, and other AI agents to your already logged-in
|
||||
browser.
|
||||
|
||||
Need the agent to touch a tab you already have open? It must borrow that tab
|
||||
@@ -67,7 +67,8 @@ Set up browser-skill on this machine by following https://raw.githubusercontent.
|
||||
|
||||
<br>
|
||||
|
||||
Install the CLI, then install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi).
|
||||
Install the CLI, then install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
|
||||
or [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg).
|
||||
|
||||
#### 1. Install the `bsk` CLI
|
||||
|
||||
@@ -91,7 +92,14 @@ bsk --version
|
||||
|
||||
#### 2. Install the browser extension
|
||||
|
||||
Install BrowserSkill from the [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi).
|
||||
Install BrowserSkill from your browser's store:
|
||||
|
||||
| Browser | Store listing |
|
||||
| --- | --- |
|
||||
| Chrome | [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) |
|
||||
| Microsoft Edge | [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) |
|
||||
|
||||
On other Chromium-based browsers, install the Chrome Web Store build.
|
||||
|
||||
#### 3. Install the skill
|
||||
|
||||
@@ -123,7 +131,8 @@ internal variants and install paths.
|
||||
|
||||
Other shell-capable agent harnesses are supported too. Copy
|
||||
[`skill/SKILL.md`](skill/SKILL.md) into your harness's skills directory as
|
||||
`browser-skill/SKILL.md` to install the skill manually.
|
||||
`browser-skill/SKILL.md` to install the skill manually. DeepSeek Harness uses a
|
||||
dedicated plugin instead — see [DeepSeek Harness plugin](#deepseek-harness-plugin).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -133,6 +142,26 @@ Start a new Agent session and write a prompt that needs the browser, for example
|
||||
/browser-skill open example.com and summarize what is on the page.
|
||||
```
|
||||
|
||||
## DeepSeek Harness plugin
|
||||
|
||||
Using [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)?
|
||||
BrowserSkill ships a first-class dsh plugin on npm as
|
||||
[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin).
|
||||
It injects native `browser_*` tools (no shelling out to `bsk`) and a live Web UI
|
||||
overlay of each Agent Window.
|
||||
|
||||
Add it to a dsh profile, then start that profile:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile web
|
||||
```
|
||||
|
||||
The plugin carries its own copy of the skill, so `bsk install-skill` is not needed
|
||||
for dsh — but the `bsk` CLI and the browser extension are still prerequisites. See
|
||||
the [plugin README](packages/dsh-plugin-browserskill/README.md) for the tool list,
|
||||
configuration, and the observation overlay.
|
||||
|
||||
## How It Works
|
||||
|
||||
BrowserSkill is a local bridge between your agent harness and your browser.
|
||||
@@ -166,7 +195,9 @@ flowchart TB
|
||||
|
||||
The agent never talks to the browser directly. It asks the `bsk` CLI to perform a
|
||||
browser task; the local daemon routes that request to the extension; the
|
||||
extension runs it in an Agent Window.
|
||||
extension runs it in an Agent Window. DeepSeek Harness takes the same path
|
||||
through the [plugin](#deepseek-harness-plugin): the agent calls injected
|
||||
`browser_*` tools, and the plugin invokes `bsk` on its behalf.
|
||||
|
||||
## For Developers
|
||||
|
||||
@@ -176,6 +207,7 @@ The repository is a Cargo + pnpm workspace:
|
||||
- `crates/bsk-protocol` — shared wire types and JSON schemas
|
||||
- `apps/extension` — browser extension
|
||||
- `packages/ui` and `packages/i18n` — shared extension UI support
|
||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+32
-7
@@ -12,7 +12,7 @@
|
||||
<a href="README.md">English</a> · 中文
|
||||
</p>
|
||||
|
||||
**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent 等支持 Shell 的 AI Agent 连接到你已登录的浏览器。
|
||||
**BrowserSkill** 把 Cursor、Claude Code、Codex、OpenClaw、CodeBuddy、WorkBuddy、Pi、Hermes Agent、DeepSeek Harness 等 AI Agent 连接到你已登录的浏览器。
|
||||
|
||||
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
|
||||
|
||||
@@ -54,7 +54,8 @@ BrowserSkill 由两个本地运行组件组成:`bsk` CLI/daemon 和浏览器
|
||||
|
||||
<br>
|
||||
|
||||
先安装 CLI,再从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装浏览器扩展。
|
||||
先安装 CLI,再从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
|
||||
或 [Edge 加载项商店](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) 安装浏览器扩展。
|
||||
|
||||
#### 1. 安装 `bsk` CLI
|
||||
|
||||
@@ -64,8 +65,11 @@ BrowserSkill 由两个本地运行组件组成:`bsk` CLI/daemon 和浏览器
|
||||
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
|
||||
```
|
||||
|
||||
**Windows**:从 [最新 CLI release](https://github.com/Tencent/BrowserSkill/releases/latest)
|
||||
下载 `bsk-v<version>-x86_64-pc-windows-msvc.zip`,解压后将 `bsk.exe` 加入 `PATH`。
|
||||
**Windows**(PowerShell,安装到 `~/.local/bin`):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
|
||||
```
|
||||
|
||||
验证二进制:
|
||||
|
||||
@@ -75,7 +79,14 @@ bsk --version
|
||||
|
||||
#### 2. 安装浏览器扩展
|
||||
|
||||
从 [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) 安装 BrowserSkill。
|
||||
在对应浏览器的商店安装 BrowserSkill:
|
||||
|
||||
| 浏览器 | 商店页面 |
|
||||
| --- | --- |
|
||||
| Chrome | [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi) |
|
||||
| Microsoft Edge | [Edge 加载项商店](https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg) |
|
||||
|
||||
其他基于 Chromium 的浏览器,安装 Chrome Web Store 版本即可。
|
||||
|
||||
#### 3. 安装 skill
|
||||
|
||||
@@ -102,7 +113,7 @@ bsk install-skill
|
||||
|
||||
用 <kbd>Space</kbd> 选择需要安装的 Agent harness,然后按 <kbd>Enter</kbd> 安装 skill。运行 `bsk install-skill --list` 可查看 internal 变体及安装路径。
|
||||
|
||||
其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。
|
||||
其他支持 Shell 的 Agent harness 也可使用 BrowserSkill,但需手动将 [`skill/SKILL.md`](skill/SKILL.md) 复制到对应 skills 目录下的 `browser-skill/SKILL.md`。DeepSeek Harness 走独立插件,见 [DeepSeek Harness 插件](#deepseek-harness-插件)。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -112,6 +123,19 @@ bsk install-skill
|
||||
/browser-skill open example.com and summarize what is on the page.
|
||||
```
|
||||
|
||||
## DeepSeek Harness 插件
|
||||
|
||||
在用 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)?BrowserSkill 提供了官方 dsh 插件,已发布到 npm:[`@wxg-prc-cpg/browser-skill-dsh-plugin`](https://www.npmjs.com/package/@wxg-prc-cpg/browser-skill-dsh-plugin)。它会注入原生 `browser_*` 工具(无需再通过 Shell 调用 `bsk`),并在 Web UI 中实时观察每个 Agent Window。
|
||||
|
||||
把它装进某个 dsh profile,然后启动该 profile:
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add @wxg-prc-cpg/browser-skill-dsh-plugin
|
||||
dsh --profile web
|
||||
```
|
||||
|
||||
插件自带 skill,所以在 dsh 下无需执行 `bsk install-skill`;但 `bsk` CLI 和浏览器扩展仍是前置条件。工具清单、配置项与观察浮层见[插件 README](packages/dsh-plugin-browserskill/README.md)。
|
||||
|
||||
## 工作原理
|
||||
|
||||
BrowserSkill 是 Agent 运行时与浏览器之间的本地桥接层。
|
||||
@@ -143,7 +167,7 @@ flowchart TB
|
||||
style UserWindows fill:#f8fafc,stroke:#cbd5e1,color:#334155
|
||||
```
|
||||
|
||||
Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。
|
||||
Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;本地 daemon 把请求路由到扩展;扩展在 Agent Window 中执行。DeepSeek Harness 走同一条链路,只是经由 [插件](#deepseek-harness-插件):Agent 调用注入的 `browser_*` 工具,由插件代为执行 `bsk`。
|
||||
|
||||
## 面向开发者
|
||||
|
||||
@@ -153,6 +177,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
|
||||
- `crates/bsk-protocol` — 共享协议类型与 JSON Schema
|
||||
- `apps/extension` — 浏览器扩展
|
||||
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
|
||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
|
||||
- **`tabs`** — Inspect, create, and close tabs in the Agent Window; query tab metadata.
|
||||
- **`windows`** — Create and manage the dedicated Agent Window that isolates agent activity from the user's normal browsing.
|
||||
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive.
|
||||
- **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted.
|
||||
- **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
|
||||
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`.
|
||||
- **Host permission `<all_urls>`** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.30589 17.9712C6.21652 18.4603 6.63298 18.8722 7.12749 18.7838C7.88158 18.6491 8.86635 18.4075 9.25454 18.0236C9.49544 17.7853 9.61164 17.4432 9.57757 17.0726C9.5435 16.702 9.36195 16.3331 9.07287 16.0472C8.78378 15.7613 8.41084 15.5818 8.03608 15.5481C7.66133 15.5144 7.31545 15.6293 7.07455 15.8676C6.6865 16.2513 6.4422 17.2253 6.30589 17.9712Z" fill="#E94500"/>
|
||||
<path d="M7.77425 8.64281C10.7374 4.12636 15.4876 3.7066 17.9364 4.13499C18.3335 4.20461 18.6474 4.48261 18.7821 4.84105C20.6715 9.87191 18.4167 13.7056 16.5008 15.5334C16.1262 15.8909 15.9395 16.3745 16.0282 16.8215L16.12 17.2834C16.24 17.887 15.871 18.4819 15.2723 18.6496L12.7811 19.3469C12.1057 19.536 11.4124 19.1112 11.285 18.4289L11.036 17.0959C10.9844 16.82 10.8353 16.5712 10.6151 16.3948L8.12777 14.4035C7.90665 14.2266 7.62818 14.1345 7.34359 14.1428L5.72835 14.1897C4.9877 14.2112 4.40832 13.5657 4.51644 12.8401L4.89632 10.2912C4.98194 9.71734 5.47627 9.2895 6.06234 9.28148L6.69124 9.27171C7.11716 9.26594 7.52243 9.02649 7.77425 8.64281ZM14.4959 7.91429C13.8284 7.91436 13.2869 8.45568 13.2869 9.12327C13.2871 9.79078 13.8284 10.3322 14.4959 10.3323C15.1635 10.3323 15.7048 9.79082 15.7049 9.12327C15.7049 8.45564 15.1636 7.91429 14.4959 7.91429Z" fill="#E94500"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@browser-skill/i18n": "workspace:*",
|
||||
"@browser-skill/ui": "workspace:*",
|
||||
"@browser-skill/vom": "workspace:*",
|
||||
"@remixicon/react": "^4.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp";
|
||||
import { type CdpDebuggee, type CdpDebuggerApi, ChromiumCdp } from "../chromium-cdp";
|
||||
|
||||
function fakeChromeEvent<TArgs extends unknown[]>() {
|
||||
const listeners = new Set<(...args: TArgs) => void>();
|
||||
@@ -14,7 +14,7 @@ function fakeChromeEvent<TArgs extends unknown[]>() {
|
||||
}
|
||||
|
||||
function fakeApi() {
|
||||
const onEvent = fakeChromeEvent<[chrome.debugger.Debuggee, string, unknown]>();
|
||||
const onEvent = fakeChromeEvent<[CdpDebuggee, string, unknown]>();
|
||||
const onDetach = fakeChromeEvent<[chrome.debugger.Debuggee, string]>();
|
||||
const api: CdpDebuggerApi = {
|
||||
attach: vi.fn(async () => {}),
|
||||
@@ -29,6 +29,135 @@ function fakeApi() {
|
||||
}
|
||||
|
||||
describe("ChromiumCdp", () => {
|
||||
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (
|
||||
target: chrome.debugger.Debuggee & { sessionId?: string },
|
||||
method: string,
|
||||
params?: { frameId?: string },
|
||||
) => {
|
||||
if (method === "DOM.getFrameOwner") {
|
||||
return { backendNodeId: params?.frameId === "nested" ? 300 : 200 };
|
||||
}
|
||||
if (method !== "Page.getFrameTree") return {};
|
||||
if (target.sessionId === "right-session") {
|
||||
return {
|
||||
frameTree: {
|
||||
frame: { id: "right", url: "https://right.test" },
|
||||
childFrames: [
|
||||
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (target.sessionId === "nested-session") {
|
||||
return { frameTree: { frame: { id: "nested", url: "https://nested.test" } } };
|
||||
}
|
||||
return {
|
||||
frameTree: {
|
||||
frame: { id: "main", url: "https://app.test" },
|
||||
childFrames: [
|
||||
{ frame: { id: "left", parentId: "main", url: "https://left.test" } },
|
||||
{
|
||||
frame: { id: "right", parentId: "main", url: "https://right.test" },
|
||||
childFrames: [
|
||||
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(4);
|
||||
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "right-session" });
|
||||
onEvent.fire({ tabId: 4 } as chrome.debugger.Debuggee, "Target.attachedToTarget", {
|
||||
sessionId: "nested-session",
|
||||
});
|
||||
|
||||
const graph = await cdp.getFrameGraph(4);
|
||||
|
||||
expect(graph.frames).toHaveLength(4);
|
||||
expect(graph.frames.find((frame) => frame.frameId === "left")?.target).toEqual({ tabId: 4 });
|
||||
expect(graph.frames.find((frame) => frame.frameId === "right")?.target.sessionId).toBe(
|
||||
"right-session",
|
||||
);
|
||||
expect(graph.frames.find((frame) => frame.frameId === "nested")?.target.sessionId).toBe(
|
||||
"nested-session",
|
||||
);
|
||||
expect(graph.frames.find((frame) => frame.frameId === "right")?.ownerBackendNodeId).toBe(200);
|
||||
expect(graph.frames.find((frame) => frame.frameId === "nested")?.ownerBackendNodeId).toBe(300);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 4, sessionId: "right-session" },
|
||||
"DOM.getFrameOwner",
|
||||
{ frameId: "nested" },
|
||||
);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 4, sessionId: "nested-session" },
|
||||
"Target.setAutoAttach",
|
||||
expect.objectContaining({ flatten: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for recursively attached iframe targets without a depth limit", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const depth = 6;
|
||||
const scheduled = new Set<string>();
|
||||
const nestedTree = (index: number): Record<string, unknown> => ({
|
||||
frame: {
|
||||
id: index === 0 ? "main" : `frame-${index}`,
|
||||
...(index > 0 ? { parentId: index === 1 ? "main" : `frame-${index - 1}` } : {}),
|
||||
},
|
||||
...(index < depth ? { childFrames: [nestedTree(index + 1)] } : {}),
|
||||
});
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (target: CdpDebuggee, method: string, params?: { frameId?: string }) => {
|
||||
if (method === "Target.setAutoAttach") {
|
||||
const parentIndex = target.sessionId
|
||||
? Number(target.sessionId.replace("session-", ""))
|
||||
: 0;
|
||||
const nextIndex = parentIndex + 1;
|
||||
const key = `${target.sessionId ?? "root"}:${nextIndex}`;
|
||||
if (nextIndex <= depth && !scheduled.has(key)) {
|
||||
scheduled.add(key);
|
||||
setTimeout(() => {
|
||||
onEvent.fire(
|
||||
{ tabId: 4, ...(target.sessionId ? { sessionId: target.sessionId } : {}) },
|
||||
"Target.attachedToTarget",
|
||||
{
|
||||
sessionId: `session-${nextIndex}`,
|
||||
targetInfo: { type: "iframe" },
|
||||
},
|
||||
);
|
||||
}, 5);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
if (method === "Page.getFrameTree") {
|
||||
if (!target.sessionId) return { frameTree: nestedTree(0) };
|
||||
const index = Number(target.sessionId.replace("session-", ""));
|
||||
return { frameTree: nestedTree(index) };
|
||||
}
|
||||
if (method === "DOM.getFrameOwner") {
|
||||
return { backendNodeId: Number(params?.frameId?.replace("frame-", "")) + 100 };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
const graph = await cdp.getFrameGraph(4);
|
||||
|
||||
expect(graph.frames).toHaveLength(depth + 1);
|
||||
for (let index = 1; index <= depth; index += 1) {
|
||||
expect(
|
||||
graph.frames.find((frame) => frame.frameId === `frame-${index}`)?.target.sessionId,
|
||||
).toBe(`session-${index}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces concurrent attach calls for the same tab", async () => {
|
||||
const { api } = fakeApi();
|
||||
let releaseAttach!: () => void;
|
||||
@@ -67,6 +196,30 @@ describe("ChromiumCdp", () => {
|
||||
expect(cdp.isAttached(5)).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back the raw attach when a domain enable fails so the tab is not left stuck", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Page.enable") throw new Error("Page.enable rejected");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
// Raw attach succeeds, but Page.enable fails. Without the rollback
|
||||
// the debugger would stay attached while `attachedTabs` omits the id,
|
||||
// leaving the tab stuck on "Another debugger is already attached" for
|
||||
// every later call until the extension is reloaded.
|
||||
await expect(cdp.ensureAttached(42)).rejects.toThrow(/Page\.enable rejected/);
|
||||
expect(cdp.isAttached(42)).toBe(false);
|
||||
expect(api.detach).toHaveBeenCalledWith({ tabId: 42 });
|
||||
|
||||
// The rollback detached, so a fresh attach can succeed afterwards.
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
||||
await expect(cdp.ensureAttached(42)).resolves.toBeUndefined();
|
||||
expect(cdp.isAttached(42)).toBe(true);
|
||||
});
|
||||
|
||||
it("send() rejects on chrome.runtime.lastError-style failures", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
@@ -119,27 +272,82 @@ describe("ChromiumCdp", () => {
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {});
|
||||
});
|
||||
|
||||
it("enables console capture domains best-effort during attach", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Log.enable") throw new Error("restricted");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await expect(cdp.ensureAttached(12)).resolves.toBeUndefined();
|
||||
expect(cdp.isAttached(12)).toBe(true);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {});
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {});
|
||||
});
|
||||
|
||||
it("retries console capture after a domain enable fails during attach", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Log.enable") throw new Error("restricted");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
// Attach is best-effort: Log.enable fails but attach still succeeds,
|
||||
// leaving the tab unmarked so capture can be retried.
|
||||
await cdp.ensureAttached(12);
|
||||
const afterAttach = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([, method]) => method === "Runtime.enable" || method === "Log.enable",
|
||||
);
|
||||
expect(afterAttach).toHaveLength(2);
|
||||
|
||||
// Before the fix the "attempted" flag was set before success, so this
|
||||
// was a no-op and the tab silently returned no console output forever.
|
||||
// Now it retries both domains.
|
||||
await cdp.ensureConsoleCapture(12);
|
||||
const afterRetry = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([, method]) => method === "Runtime.enable" || method === "Log.enable",
|
||||
);
|
||||
expect(afterRetry).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("retries and surfaces Network.enable failures for explicit capture", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Network.enable") throw new Error("network restricted");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
|
||||
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
|
||||
|
||||
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([, method]) => method === "Network.enable",
|
||||
);
|
||||
expect(enableCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("records javascriptDialogOpening and auto-accepts", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(3);
|
||||
const cursor = cdp.dialogCursor(3);
|
||||
onEvent.fire(
|
||||
{ tabId: 3 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{
|
||||
type: "alert",
|
||||
message: "hello",
|
||||
url: "https://example.com/",
|
||||
hasBrowserHandler: false,
|
||||
},
|
||||
);
|
||||
onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "hello",
|
||||
url: "https://example.com/",
|
||||
hasBrowserHandler: false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(api.sendCommand).toHaveBeenCalledWith(
|
||||
{ tabId: 3 },
|
||||
"Page.handleJavaScriptDialog",
|
||||
{ accept: true },
|
||||
);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", {
|
||||
accept: true,
|
||||
});
|
||||
const dialogs = cdp.dialogsSince(3, cursor);
|
||||
expect(dialogs).toHaveLength(1);
|
||||
expect(dialogs[0]).toMatchObject({
|
||||
@@ -170,11 +378,11 @@ describe("ChromiumCdp", () => {
|
||||
await cdp.ensureAttached(5);
|
||||
const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" });
|
||||
await Promise.resolve();
|
||||
onEvent.fire(
|
||||
{ tabId: 5 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{ type: "alert", message: "blocked", url: "https://example.com/" },
|
||||
);
|
||||
onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "blocked",
|
||||
url: "https://example.com/",
|
||||
});
|
||||
releaseEvaluate();
|
||||
await expect(pending).resolves.toEqual({ result: { value: 2 } });
|
||||
});
|
||||
@@ -183,17 +391,266 @@ describe("ChromiumCdp", () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(11);
|
||||
onEvent.fire(
|
||||
{ tabId: 11 },
|
||||
"Page.javascriptDialogOpening",
|
||||
{ type: "alert", message: "x", url: "https://example.com/" },
|
||||
);
|
||||
onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "x",
|
||||
url: "https://example.com/",
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
|
||||
await cdp.detach(11);
|
||||
expect(cdp.dialogsSince(11, 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records Runtime console calls with bounded text and optional stack frames", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(21);
|
||||
onEvent.fire({ tabId: 21 }, "Runtime.consoleAPICalled", {
|
||||
type: "warn",
|
||||
args: [{ value: "abcdefghi" }, { description: "Object { ok: true }" }],
|
||||
timestamp: 1234.5,
|
||||
stackTrace: {
|
||||
callFrames: [
|
||||
{
|
||||
functionName: "render",
|
||||
url: "https://example.test/app.js",
|
||||
lineNumber: 4,
|
||||
columnNumber: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const withoutStack = cdp.consoleEntriesSince(21, 0, 50, 5, false);
|
||||
expect(withoutStack).toMatchObject({
|
||||
next_since: 1,
|
||||
truncated: true,
|
||||
entries: [
|
||||
{
|
||||
sequence: 1,
|
||||
kind: "console",
|
||||
level: "warn",
|
||||
text: "abcde",
|
||||
url: "https://example.test/app.js",
|
||||
line: 5,
|
||||
column: 9,
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(withoutStack.entries[0].stack_trace).toBeUndefined();
|
||||
|
||||
const withStack = cdp.consoleEntriesSince(21, 0, 50, 1000, true);
|
||||
expect(withStack.entries[0].stack_trace).toEqual([
|
||||
{
|
||||
function_name: "render",
|
||||
url: "https://example.test/app.js",
|
||||
line: 5,
|
||||
column: 9,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("records exceptions and engine log entries", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(22);
|
||||
onEvent.fire({ tabId: 22 }, "Runtime.exceptionThrown", {
|
||||
timestamp: 2000,
|
||||
exceptionDetails: {
|
||||
text: "Uncaught",
|
||||
url: "https://example.test/app.js",
|
||||
lineNumber: 40,
|
||||
columnNumber: 6,
|
||||
exception: { description: "TypeError: boom\n at render (app.js:41:7)" },
|
||||
},
|
||||
});
|
||||
onEvent.fire({ tabId: 22 }, "Log.entryAdded", {
|
||||
entry: {
|
||||
level: "error",
|
||||
text: "Failed to load resource: 404",
|
||||
url: "https://example.test/missing.png",
|
||||
lineNumber: 0,
|
||||
timestamp: 2100,
|
||||
},
|
||||
});
|
||||
|
||||
const result = cdp.consoleEntriesSince(22, 0, 50, 1000, false);
|
||||
expect(result.next_since).toBe(2);
|
||||
expect(result.entries).toMatchObject([
|
||||
{
|
||||
sequence: 1,
|
||||
kind: "exception",
|
||||
level: "error",
|
||||
text: "TypeError: boom",
|
||||
url: "https://example.test/app.js",
|
||||
line: 41,
|
||||
column: 7,
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
kind: "log",
|
||||
level: "error",
|
||||
text: "Failed to load resource: 404",
|
||||
url: "https://example.test/missing.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters console entries by cursor and caps result size", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(23);
|
||||
for (let i = 1; i <= 3; i += 1) {
|
||||
onEvent.fire({ tabId: 23 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: `message ${i}` }],
|
||||
});
|
||||
}
|
||||
|
||||
const result = cdp.consoleEntriesSince(23, 1, 1, 1000, false);
|
||||
expect(result).toMatchObject({
|
||||
next_since: 2,
|
||||
truncated: true,
|
||||
entries: [{ sequence: 2, text: "message 2" }],
|
||||
});
|
||||
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
|
||||
next_since: 3,
|
||||
truncated: true,
|
||||
entries: [{ sequence: 3, text: "message 3" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports dropped buffered entries only when they affect the cursor", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(25);
|
||||
for (let i = 1; i <= 201; i += 1) {
|
||||
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: `message ${i}` }],
|
||||
});
|
||||
}
|
||||
|
||||
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
|
||||
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
|
||||
next_since: 201,
|
||||
truncated: false,
|
||||
entries: [{ sequence: 201, text: "message 201" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears console state on detach", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(24);
|
||||
onEvent.fire({ tabId: 24 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: "x" }],
|
||||
});
|
||||
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(1);
|
||||
await cdp.detach(24);
|
||||
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates network request metadata by tab and consumes it", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(31);
|
||||
await cdp.ensureAttached(32);
|
||||
|
||||
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
|
||||
requestId: "shared-id",
|
||||
request: { url: "https://one.test/api", method: "GET" },
|
||||
type: "Fetch",
|
||||
});
|
||||
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
|
||||
requestId: "shared-id",
|
||||
request: { url: "https://two.test/script.js", method: "POST" },
|
||||
type: "Script",
|
||||
});
|
||||
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
|
||||
requestId: "shared-id",
|
||||
response: { status: 204 },
|
||||
type: "Fetch",
|
||||
});
|
||||
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
|
||||
requestId: "shared-id",
|
||||
errorText: "net::ERR_FAILED",
|
||||
type: "Script",
|
||||
});
|
||||
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
|
||||
requestId: "shared-id",
|
||||
errorText: "net::ERR_ABORTED",
|
||||
});
|
||||
|
||||
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
|
||||
{
|
||||
kind: "response",
|
||||
method: "GET",
|
||||
url: "https://one.test/api",
|
||||
status: 204,
|
||||
},
|
||||
{
|
||||
kind: "failure",
|
||||
url: undefined,
|
||||
error_text: "net::ERR_ABORTED",
|
||||
},
|
||||
]);
|
||||
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
|
||||
{
|
||||
kind: "failure",
|
||||
method: "POST",
|
||||
url: "https://two.test/script.js",
|
||||
error_text: "net::ERR_FAILED",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds request metadata and clears it on detach", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(33);
|
||||
const longUrl = `https://example.test/${"x".repeat(5000)}`;
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "long",
|
||||
request: { url: longUrl, method: "GET" },
|
||||
});
|
||||
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
|
||||
requestId: "long",
|
||||
errorText: "net::ERR_FAILED",
|
||||
});
|
||||
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
|
||||
expect(bounded.url).toHaveLength(4096);
|
||||
expect(bounded.truncated).toBe(true);
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "long-method",
|
||||
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
|
||||
});
|
||||
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
|
||||
requestId: "long-method",
|
||||
response: { url: "https://example.test/ok", status: 200 },
|
||||
});
|
||||
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
|
||||
expect(boundedResponse.method).toHaveLength(4096);
|
||||
expect(boundedResponse.truncated).toBe(true);
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "stale",
|
||||
request: { url: "https://example.test/stale", method: "GET" },
|
||||
});
|
||||
await cdp.detach(33);
|
||||
await cdp.ensureAttached(33);
|
||||
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
|
||||
requestId: "stale",
|
||||
errorText: "net::ERR_ABORTED",
|
||||
});
|
||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detachSession only detaches tabs no other session owns", async () => {
|
||||
const { api } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFrameGraph, type CdpFrameTreeNode, type CdpFrameTreeSource } from "../frame-graph";
|
||||
|
||||
describe("buildFrameGraph", () => {
|
||||
it("keeps sibling frames distinct and routes nested OOPIFs to their child sessions", () => {
|
||||
const graph = buildFrameGraph([
|
||||
{
|
||||
target: { tabId: 4 },
|
||||
tree: {
|
||||
frame: { id: "main", url: "https://app.test" },
|
||||
childFrames: [
|
||||
{ frame: { id: "left", parentId: "main", url: "https://left.test" } },
|
||||
{
|
||||
frame: { id: "right", parentId: "main", url: "https://right.test" },
|
||||
childFrames: [
|
||||
{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
target: { tabId: 4, sessionId: "right-session" },
|
||||
tree: {
|
||||
frame: { id: "right", url: "https://right.test" },
|
||||
childFrames: [{ frame: { id: "nested", parentId: "right", url: "https://nested.test" } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
target: { tabId: 4, sessionId: "nested-session" },
|
||||
tree: { frame: { id: "nested", url: "https://nested.test" } },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(graph?.frames).toHaveLength(4);
|
||||
expect(graph?.frames.find((frame) => frame.frameId === "left")?.target).toEqual({ tabId: 4 });
|
||||
expect(graph?.frames.find((frame) => frame.frameId === "right")).toMatchObject({
|
||||
parentFrameId: "main",
|
||||
target: { tabId: 4, sessionId: "right-session" },
|
||||
});
|
||||
expect(graph?.frames.find((frame) => frame.frameId === "nested")).toMatchObject({
|
||||
parentFrameId: "right",
|
||||
target: { tabId: 4, sessionId: "nested-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates each target boundary to its same-process descendants", () => {
|
||||
const sources: CdpFrameTreeSource[] = [
|
||||
{
|
||||
target: { tabId: 4 },
|
||||
tree: {
|
||||
frame: { id: "main" },
|
||||
childFrames: [
|
||||
{
|
||||
frame: { id: "oopif-a", parentId: "main" },
|
||||
childFrames: [
|
||||
{
|
||||
frame: { id: "same-process-b", parentId: "oopif-a" },
|
||||
childFrames: [
|
||||
{
|
||||
frame: { id: "oopif-c", parentId: "same-process-b" },
|
||||
childFrames: [{ frame: { id: "same-process-d", parentId: "oopif-c" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
target: { tabId: 4, sessionId: "session-a" },
|
||||
tree: {
|
||||
frame: { id: "oopif-a" },
|
||||
childFrames: [
|
||||
{
|
||||
frame: { id: "same-process-b", parentId: "oopif-a" },
|
||||
childFrames: [{ frame: { id: "oopif-c", parentId: "same-process-b" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
target: { tabId: 4, sessionId: "session-c" },
|
||||
tree: {
|
||||
frame: { id: "oopif-c" },
|
||||
childFrames: [{ frame: { id: "same-process-d", parentId: "oopif-c" } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const graph = buildFrameGraph(sources);
|
||||
|
||||
expect(graph?.frames.map((frame) => [frame.frameId, frame.target.sessionId])).toEqual([
|
||||
["main", undefined],
|
||||
["oopif-a", "session-a"],
|
||||
["same-process-b", "session-a"],
|
||||
["oopif-c", "session-c"],
|
||||
["same-process-d", "session-c"],
|
||||
]);
|
||||
expect(graph?.frames.find((frame) => frame.frameId === "oopif-a")?.parentFrameId).toBe("main");
|
||||
expect(graph?.frames.find((frame) => frame.frameId === "oopif-c")?.parentFrameId).toBe(
|
||||
"same-process-b",
|
||||
);
|
||||
|
||||
const reordered = buildFrameGraph([sources[2], sources[1], sources[0]]);
|
||||
expect(reordered).toEqual(graph);
|
||||
});
|
||||
|
||||
it("walks deeply nested frame trees without consuming the JavaScript call stack", () => {
|
||||
const depth = 5_000;
|
||||
const root: CdpFrameTreeNode = { frame: { id: "frame-0" } };
|
||||
let parent = root;
|
||||
for (let index = 1; index <= depth; index += 1) {
|
||||
const child: CdpFrameTreeNode = {
|
||||
frame: { id: `frame-${index}`, parentId: `frame-${index - 1}` },
|
||||
};
|
||||
parent.childFrames = [child];
|
||||
parent = child;
|
||||
}
|
||||
|
||||
const graph = buildFrameGraph([{ target: { tabId: 4 }, tree: root }]);
|
||||
|
||||
expect(graph?.frames).toHaveLength(depth + 1);
|
||||
expect(graph?.frames.at(-1)?.frameId).toBe(`frame-${depth}`);
|
||||
});
|
||||
|
||||
it("does not loop when an in-memory frame tree contains an object cycle", () => {
|
||||
const root: CdpFrameTreeNode = { frame: { id: "main" } };
|
||||
root.childFrames = [root];
|
||||
|
||||
expect(buildFrameGraph([{ target: { tabId: 4 }, tree: root }])?.frames).toEqual([
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,26 @@
|
||||
// record the payload for tool results, and auto-accept so automation
|
||||
// can continue.
|
||||
|
||||
import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/types";
|
||||
import type {
|
||||
ConsoleEntry,
|
||||
ConsoleEntryKind,
|
||||
ConsoleResult,
|
||||
ConsoleStackFrame,
|
||||
JavaScriptDialogInfo,
|
||||
JavaScriptDialogType,
|
||||
NetworkEntry,
|
||||
NetworkEntryKind,
|
||||
NetworkResult,
|
||||
} from "@/transport/types";
|
||||
import {
|
||||
buildFrameGraph,
|
||||
type CdpFrameGraph,
|
||||
type CdpFrameTreeNode,
|
||||
type CdpFrameTreeSource,
|
||||
type CdpTarget,
|
||||
} from "./frame-graph";
|
||||
|
||||
export type CdpDebuggee = chrome.debugger.Debuggee & { sessionId?: string };
|
||||
|
||||
/**
|
||||
* Minimal slice of `chrome.debugger` the rest of the extension
|
||||
@@ -28,21 +47,15 @@ import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/typ
|
||||
* fake without monkey-patching the real `chrome` global.
|
||||
*/
|
||||
export interface CdpDebuggerApi {
|
||||
attach(target: chrome.debugger.Debuggee, requiredVersion: string): Promise<void>;
|
||||
detach(target: chrome.debugger.Debuggee): Promise<void>;
|
||||
sendCommand(
|
||||
target: chrome.debugger.Debuggee,
|
||||
method: string,
|
||||
commandParams?: object,
|
||||
): Promise<unknown>;
|
||||
attach(target: CdpDebuggee, requiredVersion: string): Promise<void>;
|
||||
detach(target: CdpDebuggee): Promise<void>;
|
||||
sendCommand(target: CdpDebuggee, method: string, commandParams?: object): Promise<unknown>;
|
||||
/**
|
||||
* Fires for every CDP event (`Page.lifecycleEvent`, `DOM.documentUpdated`,
|
||||
* …). The first callback argument is the source debuggee; the second
|
||||
* is the CDP method name; the third is the payload.
|
||||
*/
|
||||
onEvent: chrome.events.Event<
|
||||
(source: chrome.debugger.Debuggee, method: string, params: unknown) => void
|
||||
>;
|
||||
onEvent: chrome.events.Event<(source: CdpDebuggee, method: string, params: unknown) => void>;
|
||||
/**
|
||||
* Fires when Chrome unilaterally detaches us — most commonly because
|
||||
* the tab navigated to a chrome:// URL or the user clicked
|
||||
@@ -75,8 +88,33 @@ export const CDP_PROTOCOL_VERSION = "1.3";
|
||||
/** Per-tab monotonic sequence returned by [`ChromiumCdp.dialogCursor`]. */
|
||||
export type DialogCursor = number;
|
||||
|
||||
/** CDP `Emulation.setDeviceMetricsOverride` payload. */
|
||||
export interface DeviceMetricsOverride {
|
||||
width: number;
|
||||
height: number;
|
||||
/** `0` asks Chrome to use the display's native factor. */
|
||||
deviceScaleFactor: number;
|
||||
mobile: boolean;
|
||||
}
|
||||
|
||||
/** CDP `Emulation.setUserAgentOverride` payload. */
|
||||
export interface UserAgentOverride {
|
||||
userAgent: string;
|
||||
acceptLanguage?: string;
|
||||
/** CDP `Emulation.UserAgentMetadata` (already camelCase). */
|
||||
userAgentMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_DIALOG_BUFFER = 32;
|
||||
const MAX_DIALOG_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_BUFFER = 200;
|
||||
const MAX_CONSOLE_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_STACK_FRAMES = 20;
|
||||
const MAX_NETWORK_BUFFER = 200;
|
||||
const MAX_NETWORK_FIELD_LENGTH = 4096;
|
||||
const MAX_NETWORK_REQUEST_META = 1024;
|
||||
const FRAME_DISCOVERY_TIMEOUT_MS = 1000;
|
||||
const FRAME_DISCOVERY_QUIET_MS = 20;
|
||||
|
||||
interface ParsedDialogOpening {
|
||||
type: JavaScriptDialogType;
|
||||
@@ -86,6 +124,39 @@ interface ParsedDialogOpening {
|
||||
hasBrowserHandler?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
|
||||
|
||||
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
|
||||
|
||||
/** Bounded request metadata remembered from `requestWillBeSent`. */
|
||||
interface NetworkRequestMeta {
|
||||
url: string;
|
||||
method?: string;
|
||||
resourceType?: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface FrameDiscoveryState {
|
||||
sessions: Set<string>;
|
||||
pending: Set<Promise<void>>;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
async function settleBeforeDeadline(promises: Promise<void>[], deadline: number): Promise<boolean> {
|
||||
if (promises.length === 0) return true;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
Promise.allSettled(promises).then(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), remaining);
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return !timedOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around `chrome.debugger` that owns the "attach once per
|
||||
* tabId" cache and exposes typed `send<T>()`.
|
||||
@@ -97,13 +168,27 @@ export class ChromiumCdp {
|
||||
private readonly tabOwners = new Map<number, Set<string>>();
|
||||
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
|
||||
private readonly dialogSequences = new Map<number, number>();
|
||||
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
|
||||
private readonly consoleSequences = new Map<number, number>();
|
||||
private readonly consoleDomainsEnabledTabs = new Set<number>();
|
||||
private readonly networkBuffers = new Map<number, NetworkEntry[]>();
|
||||
private readonly networkSequences = new Map<number, number>();
|
||||
private readonly networkDomainsEnabledTabs = new Set<number>();
|
||||
private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
|
||||
private readonly frameDiscovery = new Map<number, FrameDiscoveryState>();
|
||||
private detachSubscription: { dispose(): void } | null = null;
|
||||
private dialogSubscription: { dispose(): void } | null = null;
|
||||
private consoleSubscription: { dispose(): void } | null = null;
|
||||
private networkSubscription: { dispose(): void } | null = null;
|
||||
private frameTargetSubscription: { dispose(): void } | null = null;
|
||||
|
||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
||||
this.api = api;
|
||||
this.bindAutoDetach();
|
||||
this.bindDialogHandler();
|
||||
this.bindConsoleHandler();
|
||||
this.bindNetworkHandler();
|
||||
this.bindFrameTargetHandler();
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
@@ -116,8 +201,29 @@ export class ChromiumCdp {
|
||||
}
|
||||
const attach = (async () => {
|
||||
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
|
||||
await this.enablePageDomain(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
try {
|
||||
await this.enablePageDomain(tabId);
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomainBestEffort(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||
});
|
||||
} catch (err) {
|
||||
// A CDP domain enable failed after the raw attach succeeded
|
||||
// (e.g. `Page.enable` rejects because the tab just navigated to
|
||||
// a chrome:// URL or the Web Store). `attachedTabs` does not yet
|
||||
// hold `tabId`, so without rolling back the debugger would stay
|
||||
// attached and the next `ensureAttached` would re-attach and hit
|
||||
// "Another debugger is already attached" forever — the tab is
|
||||
// unusable until the extension is reloaded. Detach directly
|
||||
// (`this.detach()` is a no-op here because `attachedTabs` lacks
|
||||
// the id) so the next attempt starts from a clean slate.
|
||||
await this.api.detach({ tabId }).catch((detachErr) => {
|
||||
console.debug("[bsk cdp] rollback detach failed", { tabId, detachErr });
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
})()
|
||||
.catch((err) => {
|
||||
// Chrome surfaces "Another debugger is already attached" when
|
||||
@@ -148,23 +254,184 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
async sendToTarget<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T> {
|
||||
if (!this.attachedTabs.has(target.tabId)) {
|
||||
await this.ensureAttached(target.tabId);
|
||||
}
|
||||
try {
|
||||
return (await this.api.sendCommand(target, method, params ?? {})) as T;
|
||||
} catch (err) {
|
||||
throw normalizeError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async getFrameGraph(tabId: number): Promise<CdpFrameGraph> {
|
||||
await this.ensureAttached(tabId);
|
||||
await this.enableFrameDiscovery({ tabId }).catch(() => {});
|
||||
await this.drainFrameAttachTasks(tabId);
|
||||
|
||||
const sources: CdpFrameTreeSource[] = [];
|
||||
const root = await this.sendToTarget<{ frameTree?: CdpFrameTreeNode }>(
|
||||
{ tabId },
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
);
|
||||
if (root.frameTree) sources.push({ target: { tabId }, tree: root.frameTree });
|
||||
|
||||
const sessions = [...(this.frameDiscovery.get(tabId)?.sessions ?? [])];
|
||||
const childTrees = await Promise.all(
|
||||
sessions.map(async (sessionId): Promise<CdpFrameTreeSource | null> => {
|
||||
const target = { tabId, sessionId };
|
||||
try {
|
||||
const reply = await this.sendToTarget<{ frameTree?: CdpFrameTreeNode }>(
|
||||
target,
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
);
|
||||
return reply.frameTree ? { target, tree: reply.frameTree } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const source of childTrees) {
|
||||
if (source) sources.push(source);
|
||||
}
|
||||
|
||||
const graph = buildFrameGraph(sources);
|
||||
if (!graph) throw new Error("Page.getFrameTree returned no root frame");
|
||||
const frameById = new Map(graph.frames.map((frame) => [frame.frameId, frame]));
|
||||
await Promise.all(
|
||||
graph.frames.map(async (frame) => {
|
||||
if (!frame.parentFrameId) return;
|
||||
const parent = frameById.get(frame.parentFrameId);
|
||||
if (!parent) return;
|
||||
try {
|
||||
const owner = await this.sendToTarget<{ backendNodeId?: number }>(
|
||||
parent.target,
|
||||
"DOM.getFrameOwner",
|
||||
{ frameId: frame.frameId },
|
||||
);
|
||||
if (owner.backendNodeId !== undefined) frame.ownerBackendNodeId = owner.backendNodeId;
|
||||
} catch {
|
||||
// The frame may have navigated between tree capture and owner lookup.
|
||||
}
|
||||
}),
|
||||
);
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Return a cursor marking the current dialog sequence for `tabId`. */
|
||||
dialogCursor(tabId: number): DialogCursor {
|
||||
return this.dialogSequences.get(tabId) ?? 0;
|
||||
}
|
||||
|
||||
/** `Emulation.setDeviceMetricsOverride` — pin the tab's viewport metrics. */
|
||||
async setDeviceMetricsOverride(tabId: number, metrics: DeviceMetricsOverride): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setDeviceMetricsOverride", { ...metrics });
|
||||
}
|
||||
|
||||
/** `Emulation.clearDeviceMetricsOverride` — restore native viewport metrics. */
|
||||
async clearDeviceMetricsOverride(tabId: number): Promise<void> {
|
||||
await this.send(tabId, "Emulation.clearDeviceMetricsOverride", {});
|
||||
}
|
||||
|
||||
/** `Emulation.setUserAgentOverride` — an empty `userAgent` restores the real UA. */
|
||||
async setUserAgentOverride(tabId: number, override: UserAgentOverride): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setUserAgentOverride", { ...override });
|
||||
}
|
||||
|
||||
/** `Emulation.setTouchEmulationEnabled`. */
|
||||
async setTouchEmulationEnabled(
|
||||
tabId: number,
|
||||
enabled: boolean,
|
||||
maxTouchPoints?: number,
|
||||
): Promise<void> {
|
||||
await this.send(tabId, "Emulation.setTouchEmulationEnabled", {
|
||||
enabled,
|
||||
...(maxTouchPoints !== undefined ? { maxTouchPoints } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Dialogs observed on `tabId` with sequence strictly greater than `cursor`. */
|
||||
dialogsSince(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[] {
|
||||
const buf = this.dialogBuffers.get(tabId) ?? [];
|
||||
return buf.filter((entry) => entry.sequence > cursor);
|
||||
}
|
||||
|
||||
/** Ensure CDP domains for console capture are enabled for this tab. */
|
||||
async ensureConsoleCapture(tabId: number): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId)) {
|
||||
await this.ensureAttached(tabId);
|
||||
return;
|
||||
}
|
||||
await this.enableConsoleDomains(tabId);
|
||||
}
|
||||
|
||||
/** Console entries observed on `tabId`, bounded for agent context safety. */
|
||||
consoleEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleResult {
|
||||
const buf = this.consoleBuffers.get(tabId) ?? [];
|
||||
const { entries, nextSince, truncated } = readBufferedEntries(
|
||||
buf,
|
||||
this.consoleSequences.get(tabId) ?? 0,
|
||||
since,
|
||||
limit,
|
||||
(entry) => projectConsoleEntry(entry, maxTextChars, includeStack),
|
||||
);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
next_since: nextSince,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensure CDP domains for network capture are enabled for this tab. */
|
||||
async ensureNetworkCapture(tabId: number): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId)) {
|
||||
await this.ensureAttached(tabId);
|
||||
}
|
||||
await this.enableNetworkDomain(tabId);
|
||||
}
|
||||
|
||||
/** Network entries observed on `tabId`, bounded for agent context safety. */
|
||||
networkEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
): NetworkResult {
|
||||
const buf = this.networkBuffers.get(tabId) ?? [];
|
||||
const { entries, nextSince, truncated } = readBufferedEntries(
|
||||
buf,
|
||||
this.networkSequences.get(tabId) ?? 0,
|
||||
since,
|
||||
limit,
|
||||
(entry) => projectNetworkEntry(entry, maxTextChars),
|
||||
);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
next_since: nextSince,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Detach if attached; never throws. */
|
||||
async detach(tabId: number): Promise<void> {
|
||||
this.attachInFlight.delete(tabId);
|
||||
if (!this.attachedTabs.has(tabId)) return;
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
this.clearFrameState(tabId);
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
@@ -187,7 +454,7 @@ export class ChromiumCdp {
|
||||
}
|
||||
|
||||
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
||||
onEvent(handler: (source: chrome.debugger.Debuggee, method: string, params: unknown) => void): {
|
||||
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||
dispose(): void;
|
||||
} {
|
||||
this.api.onEvent.addListener(handler);
|
||||
@@ -204,6 +471,14 @@ export class ChromiumCdp {
|
||||
this.attachedTabs.clear();
|
||||
this.dialogBuffers.clear();
|
||||
this.dialogSequences.clear();
|
||||
this.consoleBuffers.clear();
|
||||
this.consoleSequences.clear();
|
||||
this.consoleDomainsEnabledTabs.clear();
|
||||
this.networkBuffers.clear();
|
||||
this.networkSequences.clear();
|
||||
this.networkDomainsEnabledTabs.clear();
|
||||
this.networkRequestMeta.clear();
|
||||
this.frameDiscovery.clear();
|
||||
await Promise.all(
|
||||
tabs.map(async (tabId) => {
|
||||
try {
|
||||
@@ -219,9 +494,139 @@ export class ChromiumCdp {
|
||||
await this.api.sendCommand({ tabId }, "Page.enable", {});
|
||||
}
|
||||
|
||||
private async enableConsoleDomains(tabId: number): Promise<void> {
|
||||
if (this.consoleDomainsEnabledTabs.has(tabId)) return;
|
||||
// Mark the tab only after both domains enable successfully — a
|
||||
// transient failure (e.g. restricted page during attach) must leave
|
||||
// the tab unmarked so a later `ensureConsoleCapture` can retry,
|
||||
// instead of silently returning no console output forever. Mirrors
|
||||
// `enableNetworkDomain`, which records the tab after success only.
|
||||
let failed = false;
|
||||
for (const method of ["Runtime.enable", "Log.enable"]) {
|
||||
try {
|
||||
await this.api.sendCommand({ tabId }, method, {});
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.debug("[bsk cdp] console domain enable failed", { tabId, method, err });
|
||||
}
|
||||
}
|
||||
if (!failed) {
|
||||
this.consoleDomainsEnabledTabs.add(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
private async enableNetworkDomain(tabId: number): Promise<void> {
|
||||
if (this.networkDomainsEnabledTabs.has(tabId)) return;
|
||||
await this.api.sendCommand({ tabId }, "Network.enable", {});
|
||||
this.networkDomainsEnabledTabs.add(tabId);
|
||||
}
|
||||
|
||||
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
|
||||
try {
|
||||
await this.enableNetworkDomain(tabId);
|
||||
} catch (err) {
|
||||
console.debug("[bsk cdp] network domain enable failed", { tabId, err });
|
||||
}
|
||||
}
|
||||
|
||||
private async enableFrameDiscovery(target: CdpTarget): Promise<void> {
|
||||
await this.api.sendCommand(target, "Target.setAutoAttach", {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: false,
|
||||
flatten: true,
|
||||
filter: [{ type: "iframe", exclude: false }],
|
||||
});
|
||||
}
|
||||
|
||||
private bindFrameTargetHandler(): void {
|
||||
if (this.frameTargetSubscription) return;
|
||||
const listener = (source: CdpDebuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : undefined;
|
||||
if (!sessionId) return;
|
||||
|
||||
if (method === "Target.detachedFromTarget") {
|
||||
const state = this.frameDiscovery.get(tabId);
|
||||
if (state?.sessions.delete(sessionId)) state.generation += 1;
|
||||
return;
|
||||
}
|
||||
if (method !== "Target.attachedToTarget") return;
|
||||
const targetInfo = raw.targetInfo as { type?: string } | undefined;
|
||||
if (targetInfo?.type && targetInfo.type !== "iframe") return;
|
||||
|
||||
const state = this.frameDiscoveryState(tabId);
|
||||
if (state.sessions.has(sessionId)) return;
|
||||
state.sessions.add(sessionId);
|
||||
state.generation += 1;
|
||||
|
||||
const task = this.initializeFrameTarget({ tabId, sessionId });
|
||||
state.pending.add(task);
|
||||
void task.finally(() => {
|
||||
state.pending.delete(task);
|
||||
});
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.frameTargetSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private async initializeFrameTarget(target: CdpTarget): Promise<void> {
|
||||
try {
|
||||
await this.enableFrameDiscovery(target);
|
||||
} catch (err) {
|
||||
const state = this.frameDiscovery.get(target.tabId);
|
||||
if (state?.sessions.delete(target.sessionId as string)) state.generation += 1;
|
||||
console.debug("[bsk cdp] child frame target initialization failed", { target, err });
|
||||
}
|
||||
}
|
||||
|
||||
private async drainFrameAttachTasks(tabId: number): Promise<void> {
|
||||
const deadline = Date.now() + FRAME_DISCOVERY_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const state = this.frameDiscovery.get(tabId);
|
||||
const generation = state?.generation ?? 0;
|
||||
if (state?.pending.size && !(await settleBeforeDeadline([...state.pending], deadline))) break;
|
||||
|
||||
// Target.setAutoAttach may enqueue the next attachedToTarget event after
|
||||
// its command promise settles. Wait for a short quiet window, then finish
|
||||
// only if neither the state object nor its generation changed.
|
||||
const quietTime = Math.min(FRAME_DISCOVERY_QUIET_MS, deadline - Date.now());
|
||||
if (quietTime <= 0) break;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, quietTime));
|
||||
const current = this.frameDiscovery.get(tabId);
|
||||
if (
|
||||
current === state &&
|
||||
(current?.generation ?? 0) === generation &&
|
||||
!current?.pending.size
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.debug("[bsk cdp] frame discovery did not reach quiescence before timeout", { tabId });
|
||||
}
|
||||
|
||||
private frameDiscoveryState(tabId: number): FrameDiscoveryState {
|
||||
const existing = this.frameDiscovery.get(tabId);
|
||||
if (existing) return existing;
|
||||
const created: FrameDiscoveryState = {
|
||||
sessions: new Set(),
|
||||
pending: new Set(),
|
||||
generation: 0,
|
||||
};
|
||||
this.frameDiscovery.set(tabId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private clearFrameState(tabId: number): void {
|
||||
this.frameDiscovery.delete(tabId);
|
||||
}
|
||||
|
||||
private bindDialogHandler(): void {
|
||||
if (this.dialogSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const listener = (source: CdpDebuggee, method: string, params: unknown) => {
|
||||
if (method !== "Page.javascriptDialogOpening") return;
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
@@ -272,6 +677,129 @@ export class ChromiumCdp {
|
||||
this.dialogSequences.delete(tabId);
|
||||
}
|
||||
|
||||
private bindConsoleHandler(): void {
|
||||
if (this.consoleSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
switch (method) {
|
||||
case "Runtime.consoleAPICalled":
|
||||
this.appendConsole(tabId, parseConsoleApiCalled(params));
|
||||
break;
|
||||
case "Runtime.exceptionThrown":
|
||||
this.appendConsole(tabId, parseExceptionThrown(params));
|
||||
break;
|
||||
case "Log.entryAdded":
|
||||
this.appendConsole(tabId, parseLogEntry(params));
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.consoleSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private appendConsole(tabId: number, entry: ParsedConsoleEntry | null): void {
|
||||
if (!entry) return;
|
||||
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
|
||||
this.consoleSequences.set(tabId, sequence);
|
||||
appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER);
|
||||
}
|
||||
|
||||
private clearConsoleState(tabId: number): void {
|
||||
this.consoleBuffers.delete(tabId);
|
||||
this.consoleSequences.delete(tabId);
|
||||
this.consoleDomainsEnabledTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindNetworkHandler(): void {
|
||||
if (this.networkSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
switch (method) {
|
||||
case "Network.requestWillBeSent":
|
||||
this.rememberNetworkRequest(tabId, params);
|
||||
break;
|
||||
case "Network.responseReceived":
|
||||
this.appendNetwork(
|
||||
tabId,
|
||||
parseNetworkResponse(this.takeNetworkRequest(tabId, params), params),
|
||||
);
|
||||
break;
|
||||
case "Network.loadingFailed":
|
||||
this.appendNetwork(
|
||||
tabId,
|
||||
parseNetworkFailure(this.takeNetworkRequest(tabId, params), params),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.networkSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private appendNetwork(tabId: number, entry: ParsedNetworkEntry | null): void {
|
||||
if (!entry) return;
|
||||
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
|
||||
this.networkSequences.set(tabId, sequence);
|
||||
appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER);
|
||||
}
|
||||
|
||||
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
|
||||
private rememberNetworkRequest(tabId: number, params: unknown): void {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const request = (raw.request ?? {}) as Record<string, unknown>;
|
||||
if (!id || typeof request.url !== "string") return;
|
||||
const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH);
|
||||
const method = truncateOptionalText(
|
||||
typeof request.method === "string" ? request.method : undefined,
|
||||
MAX_NETWORK_FIELD_LENGTH,
|
||||
);
|
||||
const resourceType = truncateOptionalText(
|
||||
typeof raw.type === "string" ? raw.type : undefined,
|
||||
MAX_NETWORK_FIELD_LENGTH,
|
||||
);
|
||||
const tabMeta = this.networkRequestMeta.get(tabId) ?? new Map<string, NetworkRequestMeta>();
|
||||
tabMeta.delete(id);
|
||||
tabMeta.set(id, {
|
||||
url: url.text,
|
||||
method: method?.text,
|
||||
resourceType: resourceType?.text,
|
||||
truncated:
|
||||
url.truncated || (method?.truncated ?? false) || (resourceType?.truncated ?? false),
|
||||
});
|
||||
while (tabMeta.size > MAX_NETWORK_REQUEST_META) {
|
||||
const oldest = tabMeta.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
tabMeta.delete(oldest);
|
||||
}
|
||||
this.networkRequestMeta.set(tabId, tabMeta);
|
||||
}
|
||||
|
||||
/** Consume request metadata once its response or failure has been observed. */
|
||||
private takeNetworkRequest(tabId: number, params: unknown): NetworkRequestMeta | undefined {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
if (!id) return undefined;
|
||||
const tabMeta = this.networkRequestMeta.get(tabId);
|
||||
const info = tabMeta?.get(id);
|
||||
tabMeta?.delete(id);
|
||||
if (tabMeta?.size === 0) this.networkRequestMeta.delete(tabId);
|
||||
return info;
|
||||
}
|
||||
|
||||
private clearNetworkState(tabId: number): void {
|
||||
this.networkBuffers.delete(tabId);
|
||||
this.networkSequences.delete(tabId);
|
||||
this.networkDomainsEnabledTabs.delete(tabId);
|
||||
this.networkRequestMeta.delete(tabId);
|
||||
}
|
||||
|
||||
private bindAutoDetach(): void {
|
||||
if (this.detachSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
@@ -280,6 +808,9 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.delete(source.tabId);
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
this.clearConsoleState(source.tabId);
|
||||
this.clearNetworkState(source.tabId);
|
||||
this.clearFrameState(source.tabId);
|
||||
}
|
||||
};
|
||||
this.api.onDetach.addListener(listener);
|
||||
@@ -294,6 +825,12 @@ export class ChromiumCdp {
|
||||
this.detachSubscription = null;
|
||||
this.dialogSubscription?.dispose();
|
||||
this.dialogSubscription = null;
|
||||
this.consoleSubscription?.dispose();
|
||||
this.consoleSubscription = null;
|
||||
this.networkSubscription?.dispose();
|
||||
this.networkSubscription = null;
|
||||
this.frameTargetSubscription?.dispose();
|
||||
this.frameTargetSubscription = null;
|
||||
}
|
||||
|
||||
/** Detach tabs only when no other live session has claimed them. */
|
||||
@@ -310,6 +847,45 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
function appendBoundedEntry<T>(
|
||||
buffers: Map<number, T[]>,
|
||||
tabId: number,
|
||||
entry: T,
|
||||
maxEntries: number,
|
||||
): void {
|
||||
const buffer = buffers.get(tabId) ?? [];
|
||||
buffer.push(entry);
|
||||
while (buffer.length > maxEntries) buffer.shift();
|
||||
buffers.set(tabId, buffer);
|
||||
}
|
||||
|
||||
function readBufferedEntries<
|
||||
TEntry extends { sequence: number },
|
||||
TProjected extends { sequence: number; truncated: boolean },
|
||||
>(
|
||||
buffer: TEntry[],
|
||||
currentSequence: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
project: (entry: TEntry) => TProjected,
|
||||
): { entries: TProjected[]; nextSince: number; truncated: boolean } {
|
||||
const hasCursor = typeof since === "number";
|
||||
const candidates = hasCursor ? buffer.filter((entry) => entry.sequence > since) : buffer;
|
||||
const limited = hasCursor ? candidates.slice(0, limit) : candidates.slice(-limit);
|
||||
const entries = limited.map(project);
|
||||
const oldestSequence = buffer[0]?.sequence ?? currentSequence + 1;
|
||||
const droppedEntries =
|
||||
currentSequence > buffer.length && (!hasCursor || (since ?? 0) < oldestSequence - 1);
|
||||
return {
|
||||
entries,
|
||||
nextSince: entries.at(-1)?.sequence ?? currentSequence,
|
||||
truncated:
|
||||
droppedEntries ||
|
||||
candidates.length > limited.length ||
|
||||
entries.some((entry) => entry.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const type = normalizeDialogType(raw.type);
|
||||
@@ -339,6 +915,257 @@ function truncateDialogField(value: string): string {
|
||||
return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`;
|
||||
}
|
||||
|
||||
function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const args = Array.isArray(raw.args) ? raw.args : [];
|
||||
const text = args.map(remoteObjectToText).filter(Boolean).join(" ");
|
||||
const stackTrace = parseStackTrace(raw.stackTrace);
|
||||
const top = stackTrace[0];
|
||||
return makeConsoleEntry({
|
||||
kind: "console",
|
||||
level: typeof raw.type === "string" ? raw.type : "log",
|
||||
text,
|
||||
url: top?.url,
|
||||
line: top?.line,
|
||||
column: top?.column,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
stack_trace: stackTrace,
|
||||
});
|
||||
}
|
||||
|
||||
function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const details = (raw.exceptionDetails ?? {}) as Record<string, unknown>;
|
||||
const exception = (details.exception ?? {}) as Record<string, unknown>;
|
||||
const text =
|
||||
firstLine(typeof exception.description === "string" ? exception.description : undefined) ||
|
||||
firstLine(typeof details.text === "string" ? details.text : undefined) ||
|
||||
"Uncaught (unknown error)";
|
||||
return makeConsoleEntry({
|
||||
kind: "exception",
|
||||
level: "error",
|
||||
text,
|
||||
url: typeof details.url === "string" ? details.url : undefined,
|
||||
line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined,
|
||||
column: typeof details.columnNumber === "number" ? details.columnNumber + 1 : undefined,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
stack_trace: parseStackTrace(details.stackTrace),
|
||||
});
|
||||
}
|
||||
|
||||
function parseLogEntry(params: unknown): ParsedConsoleEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const entry = (raw.entry ?? {}) as Record<string, unknown>;
|
||||
return makeConsoleEntry({
|
||||
kind: "log",
|
||||
level: typeof entry.level === "string" ? entry.level : "info",
|
||||
text: typeof entry.text === "string" ? entry.text : "",
|
||||
url: typeof entry.url === "string" ? entry.url : undefined,
|
||||
line: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
|
||||
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined,
|
||||
stack_trace: [],
|
||||
});
|
||||
}
|
||||
|
||||
function makeConsoleEntry(input: {
|
||||
kind: ConsoleEntryKind;
|
||||
level: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
timestamp?: number;
|
||||
stack_trace: ConsoleStackFrame[];
|
||||
}): ParsedConsoleEntry {
|
||||
const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH);
|
||||
return {
|
||||
kind: input.kind,
|
||||
level: input.level,
|
||||
text: projected.text,
|
||||
url: truncateOptionalConsoleField(input.url),
|
||||
line: input.line,
|
||||
column: input.column,
|
||||
timestamp: input.timestamp,
|
||||
stack_trace: input.stack_trace.slice(0, MAX_CONSOLE_STACK_FRAMES).map((frame) => ({
|
||||
function_name: truncateOptionalConsoleField(frame.function_name),
|
||||
url: truncateOptionalConsoleField(frame.url),
|
||||
line: frame.line,
|
||||
column: frame.column,
|
||||
})),
|
||||
truncated: projected.truncated || input.stack_trace.length > MAX_CONSOLE_STACK_FRAMES,
|
||||
};
|
||||
}
|
||||
|
||||
function projectConsoleEntry(
|
||||
entry: ConsoleEntry,
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleEntry {
|
||||
const projected = truncateText(entry.text, maxTextChars);
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
level: entry.level,
|
||||
text: projected.text,
|
||||
url: entry.url,
|
||||
line: entry.line,
|
||||
column: entry.column,
|
||||
timestamp: entry.timestamp,
|
||||
...(includeStack && entry.stack_trace && entry.stack_trace.length > 0
|
||||
? { stack_trace: entry.stack_trace }
|
||||
: {}),
|
||||
truncated: entry.truncated || projected.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteObjectToText(value: unknown): string {
|
||||
const raw = (value ?? {}) as Record<string, unknown>;
|
||||
if ("value" in raw && raw.value !== undefined) return String(raw.value);
|
||||
if (typeof raw.description === "string") return raw.description;
|
||||
if (typeof raw.unserializableValue === "string") return raw.unserializableValue;
|
||||
if (typeof raw.type === "string") return `[${raw.type}]`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseStackTrace(value: unknown): ConsoleStackFrame[] {
|
||||
const raw = (value ?? {}) as Record<string, unknown>;
|
||||
const frames = Array.isArray(raw.callFrames) ? raw.callFrames : [];
|
||||
return frames
|
||||
.filter(
|
||||
(frame): frame is Record<string, unknown> => frame !== null && typeof frame === "object",
|
||||
)
|
||||
.map((frame) => ({
|
||||
function_name:
|
||||
typeof frame.functionName === "string" && frame.functionName.length > 0
|
||||
? frame.functionName
|
||||
: undefined,
|
||||
url: typeof frame.url === "string" && frame.url.length > 0 ? frame.url : undefined,
|
||||
line: typeof frame.lineNumber === "number" ? frame.lineNumber + 1 : undefined,
|
||||
column: typeof frame.columnNumber === "number" ? frame.columnNumber + 1 : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function firstLine(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return value.split(/\r?\n/, 1)[0] || undefined;
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxChars: number): { text: string; truncated: boolean } {
|
||||
if (value.length <= maxChars) return { text: value, truncated: false };
|
||||
return { text: value.slice(0, maxChars), truncated: true };
|
||||
}
|
||||
|
||||
function truncateOptionalText(
|
||||
value: string | undefined,
|
||||
maxChars: number,
|
||||
): { text: string; truncated: boolean } | undefined {
|
||||
return value === undefined ? undefined : truncateText(value, maxChars);
|
||||
}
|
||||
|
||||
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
|
||||
return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text;
|
||||
}
|
||||
|
||||
function parseNetworkResponse(
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const response = (raw.response ?? {}) as Record<string, unknown>;
|
||||
const hasResponseUrl = typeof response.url === "string";
|
||||
const url = hasResponseUrl ? (response.url as string) : info?.url;
|
||||
if (!url) return null;
|
||||
return makeNetworkEntry({
|
||||
kind: "response",
|
||||
method: info?.method,
|
||||
url,
|
||||
status: typeof response.status === "number" ? response.status : undefined,
|
||||
status_text: typeof response.statusText === "string" ? response.statusText : undefined,
|
||||
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
truncated: info?.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function parseNetworkFailure(
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
return makeNetworkEntry({
|
||||
kind: "failure",
|
||||
method: info?.method,
|
||||
url: info?.url,
|
||||
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
truncated: info?.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeNetworkEntry(input: {
|
||||
kind: NetworkEntryKind;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
status_text?: string;
|
||||
mime_type?: string;
|
||||
resource_type?: string;
|
||||
error_text?: string;
|
||||
timestamp?: number;
|
||||
truncated?: boolean;
|
||||
}): ParsedNetworkEntry {
|
||||
const projectedMethod = truncateOptionalText(input.method, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedUrl = truncateOptionalText(input.url, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedStatusText = truncateOptionalText(input.status_text, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedMimeType = truncateOptionalText(input.mime_type, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedResourceType = truncateOptionalText(input.resource_type, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedError =
|
||||
input.error_text !== undefined
|
||||
? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
|
||||
: undefined;
|
||||
return {
|
||||
kind: input.kind,
|
||||
method: projectedMethod?.text,
|
||||
url: projectedUrl?.text,
|
||||
status: input.status,
|
||||
status_text: projectedStatusText?.text,
|
||||
mime_type: projectedMimeType?.text,
|
||||
resource_type: projectedResourceType?.text,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: input.timestamp,
|
||||
truncated:
|
||||
input.truncated === true ||
|
||||
(projectedMethod?.truncated ?? false) ||
|
||||
(projectedUrl?.truncated ?? false) ||
|
||||
(projectedStatusText?.truncated ?? false) ||
|
||||
(projectedMimeType?.truncated ?? false) ||
|
||||
(projectedResourceType?.truncated ?? false) ||
|
||||
(projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
|
||||
const projectedUrl = truncateOptionalText(entry.url, maxTextChars);
|
||||
const projectedError =
|
||||
entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined;
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
method: entry.method,
|
||||
url: projectedUrl?.text,
|
||||
status: entry.status,
|
||||
status_text: entry.status_text,
|
||||
mime_type: entry.mime_type,
|
||||
resource_type: entry.resource_type,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: entry.timestamp,
|
||||
truncated:
|
||||
entry.truncated || (projectedUrl?.truncated ?? false) || (projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown): Error {
|
||||
if (err instanceof Error) return err;
|
||||
if (typeof err === "string") return new Error(err);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export interface CdpTarget {
|
||||
tabId: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface CdpFrame {
|
||||
frameId: string;
|
||||
parentFrameId?: string;
|
||||
ownerBackendNodeId?: number;
|
||||
url?: string;
|
||||
target: CdpTarget;
|
||||
}
|
||||
|
||||
export interface CdpFrameGraph {
|
||||
rootFrameId: string;
|
||||
frames: CdpFrame[];
|
||||
}
|
||||
|
||||
export interface CdpFrameTreeNode {
|
||||
frame: {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
url?: string;
|
||||
};
|
||||
childFrames?: CdpFrameTreeNode[];
|
||||
}
|
||||
|
||||
export interface CdpFrameTreeSource {
|
||||
target: CdpTarget;
|
||||
tree: CdpFrameTreeNode;
|
||||
}
|
||||
|
||||
export function cdpTargetKey(target: CdpTarget): string {
|
||||
return `${target.tabId}:${target.sessionId ?? "root"}`;
|
||||
}
|
||||
|
||||
function mergeFrame(
|
||||
frames: Map<string, CdpFrame>,
|
||||
order: string[],
|
||||
node: CdpFrameTreeNode,
|
||||
target: CdpTarget,
|
||||
): void {
|
||||
const existing = frames.get(node.frame.id);
|
||||
if (!existing) order.push(node.frame.id);
|
||||
const parentFrameId = node.frame.parentId || existing?.parentFrameId;
|
||||
const url = node.frame.url || existing?.url;
|
||||
frames.set(node.frame.id, {
|
||||
frameId: node.frame.id,
|
||||
...(parentFrameId ? { parentFrameId } : {}),
|
||||
...(url ? { url } : {}),
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
function walkFrameTree(
|
||||
source: CdpFrameTreeSource,
|
||||
targetByRootFrameId: ReadonlyMap<string, CdpTarget>,
|
||||
frames: Map<string, CdpFrame>,
|
||||
order: string[],
|
||||
): void {
|
||||
const stack: Array<{ node: CdpFrameTreeNode; inheritedTarget: CdpTarget }> = [
|
||||
{ node: source.tree, inheritedTarget: source.target },
|
||||
];
|
||||
const expanded = new WeakSet<CdpFrameTreeNode>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || expanded.has(current.node)) continue;
|
||||
expanded.add(current.node);
|
||||
|
||||
const target = targetByRootFrameId.get(current.node.frame.id) ?? current.inheritedTarget;
|
||||
mergeFrame(frames, order, current.node, target);
|
||||
|
||||
const children = current.node.childFrames ?? [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
stack.push({ node: children[index], inheritedTarget: target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFrameGraph(sources: CdpFrameTreeSource[]): CdpFrameGraph | null {
|
||||
const rootSource = sources.find((source) => source.target.sessionId === undefined) ?? sources[0];
|
||||
if (!rootSource) return null;
|
||||
|
||||
const frames = new Map<string, CdpFrame>();
|
||||
const order: string[] = [];
|
||||
const targetByRootFrameId = new Map(
|
||||
sources.map((source) => [source.tree.frame.id, source.target] as const),
|
||||
);
|
||||
// The top-level source is authoritative if malformed input reports the same
|
||||
// frame as both a root target and a child target.
|
||||
targetByRootFrameId.set(rootSource.tree.frame.id, rootSource.target);
|
||||
|
||||
walkFrameTree(rootSource, targetByRootFrameId, frames, order);
|
||||
for (const source of sources) {
|
||||
if (source === rootSource) continue;
|
||||
walkFrameTree(source, targetByRootFrameId, frames, order);
|
||||
}
|
||||
|
||||
return {
|
||||
rootFrameId: rootSource.tree.frame.id,
|
||||
frames: order.flatMap((frameId) => {
|
||||
const frame = frames.get(frameId);
|
||||
return frame ? [frame] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiCloseLine } from "@remixicon/react";
|
||||
import { useEffect, useRef, useState, type TransitionEvent } from "react";
|
||||
import { type TransitionEvent, useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
export interface BorrowRequestData {
|
||||
@@ -62,33 +62,25 @@ function BorrowToastStack({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */
|
||||
const PROGRESS_TRANSITION_MS = 1000;
|
||||
|
||||
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
|
||||
const { t } = useTranslation("extension");
|
||||
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
|
||||
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
|
||||
const allowedRef = useRef(false);
|
||||
const [awaitingAutoDeny, setAwaitingAutoDeny] = useState(false);
|
||||
const settledRef = useRef(false);
|
||||
const onAllowRef = useRef(request.onAllow);
|
||||
const onDenyRef = useRef(request.onDeny);
|
||||
onAllowRef.current = request.onAllow;
|
||||
onDenyRef.current = request.onDeny;
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
if (!allowedRef.current) {
|
||||
setAwaitingAutoAllow(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [secondsLeft]);
|
||||
|
||||
function triggerAllow() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
@@ -98,13 +90,34 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
if (!settledRef.current) {
|
||||
setAwaitingAutoDeny(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [secondsLeft]);
|
||||
|
||||
// Prefer transitionend for visual sync, but fall back to a timer so
|
||||
// background tabs / suppressed CSS transitions still auto-deny.
|
||||
useEffect(() => {
|
||||
if (!awaitingAutoDeny || settledRef.current) return;
|
||||
const id = setTimeout(() => {
|
||||
handleDeny();
|
||||
}, PROGRESS_TRANSITION_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [awaitingAutoDeny]);
|
||||
|
||||
const progress = secondsLeft / totalSeconds;
|
||||
const truncatedTitle =
|
||||
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
|
||||
@@ -128,13 +141,13 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
progress={progress}
|
||||
seconds={secondsLeft}
|
||||
onProgressTransitionEnd={(propertyName) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (propertyName !== "stroke-dashoffset") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<span className="text-[13px] text-gray-500">
|
||||
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
|
||||
{t("borrowConfirmation.autoDeny", { count: secondsLeft })}
|
||||
</span>
|
||||
</div>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
|
||||
@@ -156,9 +169,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
<BorrowProgressBar
|
||||
progress={progress}
|
||||
onProgressTransitionEnd={(event) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (event.propertyName !== "width") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiStopCircleLine } from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
export interface ControlOverlayProps {
|
||||
@@ -18,6 +18,7 @@ export function ControlOverlay({
|
||||
}: ControlOverlayProps) {
|
||||
const { t } = useTranslation("extension");
|
||||
const [show, setShow] = useState(false);
|
||||
const blockerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -27,6 +28,36 @@ export function ControlOverlay({
|
||||
setShow(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
const blocker = blockerRef.current;
|
||||
if (!blocker) return;
|
||||
const stopScroll = (event: WheelEvent | TouchEvent) => {
|
||||
if (automationBypass) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
blocker.addEventListener("wheel", stopScroll, { passive: false });
|
||||
blocker.addEventListener("touchmove", stopScroll, { passive: false });
|
||||
return () => {
|
||||
blocker.removeEventListener("wheel", stopScroll);
|
||||
blocker.removeEventListener("touchmove", stopScroll);
|
||||
};
|
||||
}, [automationBypass]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || automationBypass) return;
|
||||
const stopScroll = (event: WheelEvent | TouchEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
window.addEventListener("wheel", stopScroll, { capture: true, passive: false });
|
||||
window.addEventListener("touchmove", stopScroll, { capture: true, passive: false });
|
||||
return () => {
|
||||
window.removeEventListener("wheel", stopScroll, { capture: true });
|
||||
window.removeEventListener("touchmove", stopScroll, { capture: true });
|
||||
};
|
||||
}, [visible, automationBypass]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const pointerEvents = automationBypass ? "none" : "auto";
|
||||
@@ -58,6 +89,8 @@ export function ControlOverlay({
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={blockerRef}
|
||||
data-slot="control-overlay-blocker"
|
||||
onPointerDown={(event) => {
|
||||
if (automationBypass) return;
|
||||
event.preventDefault();
|
||||
@@ -80,13 +113,14 @@ export function ControlOverlay({
|
||||
/>
|
||||
|
||||
<div
|
||||
data-slot="control-overlay-pill"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 2147483647,
|
||||
pointerEvents,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
@@ -122,7 +156,7 @@ export function ControlOverlay({
|
||||
disabled={interrupting}
|
||||
onClick={onInterrupt}
|
||||
style={{
|
||||
pointerEvents,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiArrowDownSLine } from "@remixicon/react";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import type { HelpHighlightRect } from "@/lib/help-bridge";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
export interface HelpRequestData {
|
||||
@@ -15,8 +16,14 @@ export interface HelpRequestData {
|
||||
prompt: string;
|
||||
/** Custom overlay title; falls back to i18n when omitted. */
|
||||
title?: string;
|
||||
/** Full task UI on the subject tab; compact status UI on related tabs. */
|
||||
displayMode?: "full" | "compact";
|
||||
/** CSS selectors to scroll to + flash-highlight. */
|
||||
selectors: string[];
|
||||
/** Pre-resolved top-viewport rectangles, used for cross-frame targets. */
|
||||
rects?: HelpHighlightRect[];
|
||||
/** Re-resolve cross-frame rectangles after scroll, resize, or layout changes. */
|
||||
refreshRects?: () => Promise<HelpHighlightRect[] | undefined>;
|
||||
onContinue: (note: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -46,6 +53,8 @@ const VIEWPORT_MARGIN = 16;
|
||||
const PANEL_WIDTH = 420;
|
||||
const FALLBACK_PANEL_H = 180;
|
||||
const FALLBACK_PANEL_H_COLLAPSED = 44;
|
||||
const EMPTY_SELECTORS: string[] = [];
|
||||
const EMPTY_RECTS: HelpHighlightRect[] = [];
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
@@ -174,6 +183,10 @@ function measure(selectors: string[]): Box[] {
|
||||
|
||||
export function HelpRequestOverlay({ request }: Props) {
|
||||
const { t } = useTranslation("extension");
|
||||
const isCompact = request?.displayMode === "compact";
|
||||
const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS);
|
||||
const [liveRects, setLiveRects] = useState<HelpHighlightRect[] | null>(null);
|
||||
const effectiveRects = isCompact ? EMPTY_RECTS : (liveRects ?? request?.rects ?? EMPTY_RECTS);
|
||||
const [note, setNote] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [boxes, setBoxes] = useState<Box[]>([]);
|
||||
@@ -196,6 +209,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
// Reset the note and collapse state whenever a new request appears.
|
||||
useEffect(() => {
|
||||
setNote("");
|
||||
setLiveRects(null);
|
||||
setCollapsed(false);
|
||||
placementRef.current = null;
|
||||
setDragPos(null);
|
||||
@@ -204,11 +218,38 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
dragStartRef.current = null;
|
||||
}, [request?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request || isCompact || !request.refreshRects) return;
|
||||
let disposed = false;
|
||||
let resolving = false;
|
||||
const refresh = async () => {
|
||||
if (resolving) return;
|
||||
resolving = true;
|
||||
try {
|
||||
const rects = await request.refreshRects?.();
|
||||
if (!disposed && rects) setLiveRects(rects);
|
||||
} finally {
|
||||
resolving = false;
|
||||
}
|
||||
};
|
||||
const onViewportChange = () => void refresh();
|
||||
window.addEventListener("scroll", onViewportChange, true);
|
||||
window.addEventListener("resize", onViewportChange);
|
||||
const interval = window.setInterval(onViewportChange, 500);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("scroll", onViewportChange, true);
|
||||
window.removeEventListener("resize", onViewportChange);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [request, isCompact]);
|
||||
|
||||
// Scroll the first matched target into view once per distinct request id.
|
||||
useLayoutEffect(() => {
|
||||
if (!request) return;
|
||||
if (isCompact) return;
|
||||
if (lastScrolledIdRef.current === request.id) return;
|
||||
for (const sel of request.selectors) {
|
||||
for (const sel of effectiveSelectors) {
|
||||
const el = querySelectorSafe(sel);
|
||||
if (el) {
|
||||
lastScrolledIdRef.current = request.id;
|
||||
@@ -216,16 +257,16 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [request]);
|
||||
}, [request, isCompact, effectiveSelectors]);
|
||||
|
||||
// Keep highlight boxes aligned with the page as it scrolls / resizes.
|
||||
useEffect(() => {
|
||||
if (!request) {
|
||||
setBoxes([]);
|
||||
setBoxes((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const nextBoxes = measure(request.selectors);
|
||||
const nextBoxes = [...measure(effectiveSelectors), ...effectiveRects];
|
||||
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
|
||||
};
|
||||
update();
|
||||
@@ -237,27 +278,31 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
window.removeEventListener("resize", update);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [request]);
|
||||
}, [request, effectiveSelectors, effectiveRects]);
|
||||
|
||||
const onHeaderPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return;
|
||||
const banner = bannerRef.current;
|
||||
if (!banner) return;
|
||||
const rect = banner.getBoundingClientRect();
|
||||
dragStartRef.current = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
panelW: banner.offsetWidth || PANEL_WIDTH,
|
||||
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
|
||||
};
|
||||
userMovedRef.current = true;
|
||||
setDragPos({ top: rect.top, left: rect.left });
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
}, [collapsed]);
|
||||
const onHeaderPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select"))
|
||||
return;
|
||||
const banner = bannerRef.current;
|
||||
if (!banner) return;
|
||||
const rect = banner.getBoundingClientRect();
|
||||
dragStartRef.current = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
panelW: banner.offsetWidth || PANEL_WIDTH,
|
||||
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
|
||||
};
|
||||
userMovedRef.current = true;
|
||||
setDragPos({ top: rect.top, left: rect.left });
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
},
|
||||
[collapsed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
@@ -290,8 +335,12 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
setPanelPos(null);
|
||||
return;
|
||||
}
|
||||
if (isCompact) {
|
||||
setPanelPos(null);
|
||||
return;
|
||||
}
|
||||
if (userMovedRef.current) return;
|
||||
const measured = boxes.length > 0 ? boxes : measure(request.selectors);
|
||||
const measured = boxes.length > 0 ? boxes : [...measure(effectiveSelectors), ...effectiveRects];
|
||||
const anchor = unionBox(measured);
|
||||
const banner = bannerRef.current;
|
||||
if (!anchor || !banner) {
|
||||
@@ -315,7 +364,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement);
|
||||
setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos));
|
||||
}, [request, boxes, collapsed]);
|
||||
}, [request, boxes, collapsed, isCompact, effectiveSelectors, effectiveRects]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
@@ -338,6 +387,8 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
width: var(--bsk-help-banner-width);
|
||||
max-width: calc(100vw - ${VIEWPORT_MARGIN * 2}px);
|
||||
@@ -355,16 +406,25 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
.bsk-help-banner[data-collapsed="true"] {
|
||||
gap: 0;
|
||||
width: var(--bsk-help-banner-width);
|
||||
padding: 10px 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] {
|
||||
width: min(360px, calc(100vw - ${VIEWPORT_MARGIN * 2}px));
|
||||
padding: 12px 14px;
|
||||
gap: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-drag-strip {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 12px;
|
||||
margin: -8px 0 -10px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
@@ -391,15 +451,20 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-collapsed="true"] .bsk-help-drag-strip {
|
||||
margin: -6px 0 -8px;
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.bsk-help-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bsk-help-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -410,31 +475,34 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.bsk-help-compact-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-collapsed="true"] .bsk-help-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bsk-help-header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
max-width var(--bsk-help-duration) var(--bsk-help-ease),
|
||||
opacity 220ms var(--bsk-help-ease),
|
||||
transform var(--bsk-help-duration) var(--bsk-help-ease);
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-collapsed="true"] .bsk-help-header-actions {
|
||||
display: flex;
|
||||
max-width: 280px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -467,15 +535,13 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
|
||||
.bsk-help-body {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
transition: grid-template-rows var(--bsk-help-duration) var(--bsk-help-ease);
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-collapsed="true"] .bsk-help-body {
|
||||
position: absolute;
|
||||
grid-template-rows: 0fr;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
@@ -524,6 +590,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
.bsk-help-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -535,7 +602,8 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.bsk-help-btn-continue {
|
||||
@@ -546,16 +614,30 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.bsk-help-header-actions .bsk-help-btn-cancel,
|
||||
.bsk-help-header-actions .bsk-help-btn-continue {
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bsk-help-header-actions .bsk-help-btn-continue {
|
||||
padding: 6px 14px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-cancel,
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-continue {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-footer-actions .bsk-help-btn-cancel {
|
||||
@@ -580,29 +662,31 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{boxes.map((b, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
|
||||
key={i}
|
||||
data-slot="help-highlight"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: b.top,
|
||||
left: b.left,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
borderRadius: 6,
|
||||
zIndex: 2147483646,
|
||||
pointerEvents: "none",
|
||||
animation: "bsk-help-flash 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!isCompact &&
|
||||
boxes.map((b, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
|
||||
key={i}
|
||||
data-slot="help-highlight"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: b.top,
|
||||
left: b.left,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
borderRadius: 6,
|
||||
zIndex: 2147483646,
|
||||
pointerEvents: "none",
|
||||
animation: "bsk-help-flash 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={bannerRef}
|
||||
data-slot="help-request-banner"
|
||||
data-collapsed={collapsed ? "true" : "false"}
|
||||
data-display-mode={isCompact ? "compact" : "full"}
|
||||
data-anchored={panelPos ? "true" : "false"}
|
||||
data-dragging={dragging ? "true" : "false"}
|
||||
data-placement={panelPos?.placement}
|
||||
@@ -625,89 +709,105 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="help-drag-handle"
|
||||
className="bsk-help-drag-strip"
|
||||
role="img"
|
||||
aria-label={t("helpRequest.dragHandle")}
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
>
|
||||
<span className="bsk-help-drag-pill" aria-hidden />
|
||||
</div>
|
||||
{!isCompact && (
|
||||
<div
|
||||
data-slot="help-drag-handle"
|
||||
className="bsk-help-drag-strip"
|
||||
role="img"
|
||||
aria-label={t("helpRequest.dragHandle")}
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
>
|
||||
<span className="bsk-help-drag-pill" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bsk-help-header">
|
||||
<img src={logoUrl} alt="browser-skill" style={{ width: 22, height: 22, borderRadius: 4 }} />
|
||||
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
|
||||
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="browser-skill"
|
||||
style={{ width: 22, height: 22, borderRadius: 4 }}
|
||||
/>
|
||||
{isCompact ? (
|
||||
<span className="bsk-help-compact-title">{t("helpRequest.compactStatus")}</span>
|
||||
) : (
|
||||
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-cancel-button" : undefined}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-continue-button" : undefined}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-cancel-button" : undefined}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onCancel()}
|
||||
data-slot="help-collapse-toggle"
|
||||
className="bsk-help-collapse-toggle"
|
||||
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
<span className="bsk-help-collapse-icon">
|
||||
<RiArrowDownSLine size={20} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-continue-button" : undefined}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="help-collapse-toggle"
|
||||
className="bsk-help-collapse-toggle"
|
||||
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
<span className="bsk-help-collapse-icon">
|
||||
<RiArrowDownSLine size={20} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bsk-help-body" aria-hidden={collapsed}>
|
||||
<div className="bsk-help-body-inner">
|
||||
<div className="bsk-help-body-content">
|
||||
<p className="bsk-help-prompt">{request.prompt}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="bsk-help-note-input"
|
||||
aria-label={t("helpRequest.noteLabel")}
|
||||
placeholder={t("helpRequest.notePlaceholder")}
|
||||
value={note}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<div className="bsk-help-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-cancel-button"}
|
||||
className="bsk-help-btn-cancel"
|
||||
{!isCompact && (
|
||||
<div className="bsk-help-body" aria-hidden={collapsed}>
|
||||
<div className="bsk-help-body-inner">
|
||||
<div className="bsk-help-body-content">
|
||||
<p className="bsk-help-prompt">{request.prompt}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="bsk-help-note-input"
|
||||
aria-label={t("helpRequest.noteLabel")}
|
||||
placeholder={t("helpRequest.notePlaceholder")}
|
||||
value={note}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-continue-button"}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<div className="bsk-help-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-cancel-button"}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-continue-button"}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import recordIconUrl from "../../assets/record-icon.png";
|
||||
|
||||
export interface RecordRequestData {
|
||||
id: string;
|
||||
/**
|
||||
* Epoch ms when the recording began. Supplied by the background so the timer
|
||||
* keeps counting the whole session across navigations; falls back to mount
|
||||
* time when the background could not report it.
|
||||
*/
|
||||
startedAtMs?: number;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
request: RecordRequestData | null;
|
||||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 16;
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
const INTRO_DURATION_MS = 10_000;
|
||||
const TIMER_REFRESH_MS = 250;
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function clampDragPos(
|
||||
top: number,
|
||||
left: number,
|
||||
panelW: number,
|
||||
panelH: number,
|
||||
): { top: number; left: number } {
|
||||
const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN;
|
||||
const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN;
|
||||
return {
|
||||
top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)),
|
||||
left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)),
|
||||
};
|
||||
}
|
||||
|
||||
export function RecordOverlay({ request }: Props) {
|
||||
const { t } = useTranslation("extension");
|
||||
const [show, setShow] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const pillRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartRef = useRef<{
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
panelW: number;
|
||||
panelH: number;
|
||||
armed: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
const raf = requestAnimationFrame(() => setShow(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
setShow(false);
|
||||
}, [request]);
|
||||
|
||||
const requestStartedAtMs = request?.startedAtMs;
|
||||
|
||||
useEffect(() => {
|
||||
setDragPos(null);
|
||||
setDragging(false);
|
||||
dragStartRef.current = null;
|
||||
if (!request) {
|
||||
setElapsedSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = requestStartedAtMs ?? Date.now();
|
||||
const updateElapsed = () => {
|
||||
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1_000)));
|
||||
};
|
||||
updateElapsed();
|
||||
const timer = window.setInterval(updateElapsed, TIMER_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [request?.id, requestStartedAtMs]);
|
||||
|
||||
const onPillPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target instanceof Element && e.target.closest("button, a, input, textarea, select")) {
|
||||
return;
|
||||
}
|
||||
const pill = pillRef.current;
|
||||
if (!pill) return;
|
||||
const rect = pill.getBoundingClientRect();
|
||||
dragStartRef.current = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
panelW: pill.offsetWidth || rect.width,
|
||||
panelH: pill.offsetHeight || rect.height,
|
||||
armed: false,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.pointerX;
|
||||
const dy = e.clientY - start.pointerY;
|
||||
if (!start.armed) {
|
||||
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
||||
start.armed = true;
|
||||
setDragging(true);
|
||||
setDragPos({ top: start.originTop, left: start.originLeft });
|
||||
}
|
||||
setDragPos(
|
||||
clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH),
|
||||
);
|
||||
};
|
||||
const onUp = () => {
|
||||
const start = dragStartRef.current;
|
||||
if (!start) return;
|
||||
if (start.armed) {
|
||||
setDragging(false);
|
||||
}
|
||||
dragStartRef.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS;
|
||||
const positioned = dragPos !== null;
|
||||
const defaultTransform = show
|
||||
? "translateX(-50%) translateY(0)"
|
||||
: "translateX(-50%) translateY(8px)";
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes bsk-rec-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.82); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={pillRef}
|
||||
data-slot="record-overlay-pill"
|
||||
data-phase={introVisible ? "intro" : "timer"}
|
||||
data-dragging={dragging ? "true" : "false"}
|
||||
onPointerDown={onPillPointerDown}
|
||||
style={{
|
||||
position: "fixed",
|
||||
...(positioned
|
||||
? { top: dragPos.top, left: dragPos.left, bottom: "auto" }
|
||||
: { bottom: 32, left: "50%" }),
|
||||
zIndex: 2147483647,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 9999,
|
||||
height: 50,
|
||||
boxSizing: "border-box",
|
||||
padding: "6px 8px 6px 16px",
|
||||
boxShadow: dragging
|
||||
? "0 12px 40px rgba(15,23,42,0.22), 0 2px 8px rgba(0,0,0,0.12)"
|
||||
: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
||||
opacity: show ? 1 : 0,
|
||||
transform: positioned ? "none" : defaultTransform,
|
||||
transition: dragging
|
||||
? "box-shadow 150ms ease-out"
|
||||
: "opacity 300ms ease-out, transform 300ms ease-out, box-shadow 150ms ease-out",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
maxWidth: "min(420px, calc(100vw - 32px))",
|
||||
cursor: dragging ? "grabbing" : "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="record-overlay-status"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
borderRadius: 9999,
|
||||
outline: "none",
|
||||
minHeight: 32,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="record-overlay-indicator"
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#ef4444",
|
||||
flexShrink: 0,
|
||||
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
data-slot="record-overlay-label"
|
||||
style={{
|
||||
display: "block",
|
||||
flex: "0 1 auto",
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: introVisible ? "auto" : 48,
|
||||
maxWidth: introVisible ? 280 : 48,
|
||||
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
|
||||
textAlign: introVisible ? "left" : "center",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
transition: "max-width 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="record-overlay-finish"
|
||||
aria-label={t("recordOverlay.finish")}
|
||||
onClick={request.onFinish}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "block",
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 9999,
|
||||
padding: 0,
|
||||
backgroundColor: "transparent",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={recordIconUrl}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
draggable={false}
|
||||
style={{ display: "block", margin: "auto" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
|
||||
it("auto-denies after countdown reaches zero and progress transition ends", async () => {
|
||||
const onAllow = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
|
||||
@@ -37,10 +37,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
}
|
||||
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
|
||||
const progressCircle = container.querySelectorAll("svg circle")[1];
|
||||
expect(progressCircle).toBeTruthy();
|
||||
await act(() => {
|
||||
@@ -54,8 +51,44 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
expect(onAllow).toHaveBeenCalledTimes(1);
|
||||
expect(onDeny).not.toHaveBeenCalled();
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(onAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-denies via timer fallback when progress transitionend never fires", async () => {
|
||||
const onAllow = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
|
||||
render(
|
||||
<BorrowConfirmationOverlay
|
||||
requests={[
|
||||
{
|
||||
id: "req-fallback",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
}
|
||||
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
|
||||
// No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(onAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ControlOverlay } from "../ControlOverlay";
|
||||
|
||||
describe("ControlOverlay", () => {
|
||||
@@ -7,7 +7,7 @@ describe("ControlOverlay", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("sets pointer-events none on blocker and pill when automationBypass is true", () => {
|
||||
it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => {
|
||||
const { container } = render(
|
||||
<ControlOverlay
|
||||
visible={true}
|
||||
@@ -17,13 +17,17 @@ describe("ControlOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
|
||||
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
|
||||
expect(blocker).toBeTruthy();
|
||||
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
|
||||
|
||||
const pill = container.querySelector("[data-slot='control-overlay-pill']");
|
||||
expect(pill).toBeTruthy();
|
||||
expect((pill as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
|
||||
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
|
||||
expect(stopBtn).toBeTruthy();
|
||||
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none");
|
||||
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
it("uses pointer-events auto on blocker when automationBypass is false", () => {
|
||||
@@ -36,8 +40,25 @@ describe("ControlOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
|
||||
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
|
||||
expect(blocker).toBeTruthy();
|
||||
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
it("calls onInterrupt from the stop button", () => {
|
||||
const onInterrupt = vi.fn();
|
||||
const { container } = render(
|
||||
<ControlOverlay
|
||||
visible={true}
|
||||
interrupting={false}
|
||||
automationBypass={false}
|
||||
onInterrupt={onInterrupt}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
|
||||
(stopBtn as HTMLButtonElement).click();
|
||||
|
||||
expect(onInterrupt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HelpRequestOverlay, type HelpRequestData } from "../HelpRequestOverlay";
|
||||
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
|
||||
|
||||
function renderOverlay(req: HelpRequestData) {
|
||||
function renderOverlay(req: HelpRequestData | null) {
|
||||
return render(
|
||||
createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: req })),
|
||||
);
|
||||
@@ -38,6 +38,75 @@ describe("HelpRequestOverlay", () => {
|
||||
expect(screen.getByText("Please complete the captcha")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders explicit viewport rectangles for cross-frame targets", () => {
|
||||
const { container } = renderOverlay(
|
||||
baseRequest({ rects: [{ top: 30, left: 40, width: 120, height: 50 }] }),
|
||||
);
|
||||
const highlight = container.querySelector<HTMLElement>("[data-slot='help-highlight']");
|
||||
expect(highlight?.style.top).toBe("30px");
|
||||
expect(highlight?.style.left).toBe("40px");
|
||||
expect(highlight?.style.width).toBe("120px");
|
||||
expect(highlight?.style.height).toBe("50px");
|
||||
});
|
||||
|
||||
it("re-resolves cross-frame rectangles after the viewport changes", async () => {
|
||||
const refreshRects = vi.fn(async () => [{ top: 80, left: 90, width: 140, height: 60 }]);
|
||||
const { container } = renderOverlay(
|
||||
baseRequest({
|
||||
rects: [{ top: 30, left: 40, width: 120, height: 50 }],
|
||||
refreshRects,
|
||||
}),
|
||||
);
|
||||
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
|
||||
await waitFor(() => {
|
||||
const highlight = container.querySelector<HTMLElement>("[data-slot='help-highlight']");
|
||||
expect(highlight?.style.top).toBe("80px");
|
||||
expect(highlight?.style.left).toBe("90px");
|
||||
});
|
||||
expect(refreshRects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the inactive render stable", () => {
|
||||
const { container, rerender } = renderOverlay(null);
|
||||
|
||||
rerender(
|
||||
createElement(
|
||||
I18nextProvider,
|
||||
{ i18n },
|
||||
createElement(HelpRequestOverlay, { request: null }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-slot='help-request-banner']")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a compact status without full request controls", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "login";
|
||||
el.getBoundingClientRect = () =>
|
||||
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
|
||||
document.body.append(el);
|
||||
|
||||
const { container } = renderOverlay(
|
||||
baseRequest({
|
||||
displayMode: "compact",
|
||||
selectors: ["#login"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t("helpRequest.compactStatus", { ns: "extension" }))).toBeTruthy();
|
||||
expect(screen.queryByText("Please complete the captcha")).toBeNull();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.queryByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }))).toBeNull();
|
||||
expect(container.querySelector("[data-slot='help-continue-button']")).toBeNull();
|
||||
expect(container.querySelector("[data-slot='help-cancel-button']")).toBeNull();
|
||||
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(0);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders a custom title when provided", () => {
|
||||
renderOverlay(baseRequest({ title: "Verify your identity" }));
|
||||
expect(screen.getByText("Verify your identity")).toBeTruthy();
|
||||
@@ -79,7 +148,7 @@ describe("HelpRequestOverlay", () => {
|
||||
renderOverlay(baseRequest({ selectors: ["#login", "#missing"] }));
|
||||
// One highlight box rendered for the matched selector.
|
||||
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(1);
|
||||
expect((el.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect(el.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
el.remove();
|
||||
});
|
||||
|
||||
@@ -115,8 +184,8 @@ describe("HelpRequestOverlay", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect((elA.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect((elB.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect(elA.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
expect(elB.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
elA.remove();
|
||||
elB.remove();
|
||||
});
|
||||
@@ -204,6 +273,15 @@ describe("HelpRequestOverlay", () => {
|
||||
expect(styles).toContain("width: 0");
|
||||
});
|
||||
|
||||
it("uses natural body height in the expanded layout", () => {
|
||||
const { container } = renderOverlay(baseRequest());
|
||||
const styles = overlayStyles(container);
|
||||
|
||||
expect(styles).toMatch(/\.bsk-help-body\s*\{[^}]*display:\s*block;/s);
|
||||
expect(styles).not.toMatch(/\.bsk-help-body\s*\{[^}]*grid-template-rows/s);
|
||||
expect(styles).toMatch(/\.bsk-help-banner\s*\{[^}]*justify-content:\s*flex-start;/s);
|
||||
});
|
||||
|
||||
it("keeps the same banner width when collapsed", () => {
|
||||
const { container } = renderOverlay(baseRequest());
|
||||
const styles = overlayStyles(container);
|
||||
@@ -286,9 +364,7 @@ describe("HelpRequestOverlay", () => {
|
||||
it("does not start drag when pointer down on a button in the header", () => {
|
||||
const { container } = renderOverlay(baseRequest());
|
||||
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
|
||||
const collapseBtn = screen.getByLabelText(
|
||||
i18n.t("helpRequest.collapse", { ns: "extension" }),
|
||||
);
|
||||
const collapseBtn = screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }));
|
||||
|
||||
fireEvent.pointerDown(collapseBtn, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RecordOverlay } from "../RecordOverlay";
|
||||
|
||||
function renderOverlay(request: { id: string; startedAtMs?: number; onFinish: () => void } | null) {
|
||||
return render(
|
||||
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request })),
|
||||
);
|
||||
}
|
||||
|
||||
function advanceRecording(ms: number) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
describe("RecordOverlay", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders the bottom pill with the icon-only finish button", () => {
|
||||
const onFinish = vi.fn();
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish });
|
||||
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']");
|
||||
expect(pill).toBeTruthy();
|
||||
expect((pill as HTMLElement).style.borderRadius).toBe("9999px");
|
||||
expect((pill as HTMLElement).style.backgroundColor).toBe("#fff");
|
||||
expect((pill as HTMLElement).style.gap).toBe("5px");
|
||||
expect((pill as HTMLElement).style.padding).toBe("6px 8px 6px 16px");
|
||||
expect((pill as HTMLElement).style.height).toBe("50px");
|
||||
expect((pill as HTMLElement).style.boxSizing).toBe("border-box");
|
||||
expect(container.querySelector("[data-slot='record-overlay-collapse']")).toBeNull();
|
||||
|
||||
const status = container.querySelector("[data-slot='record-overlay-status']") as HTMLElement;
|
||||
expect(status.style.minHeight).toBe("32px");
|
||||
const finish = screen.getByRole("button", {
|
||||
name: i18n.t("recordOverlay.finish", { ns: "extension" }),
|
||||
});
|
||||
expect(finish).toBeTruthy();
|
||||
expect(finish.textContent).toBe("");
|
||||
const icon = finish.querySelector("img");
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon?.getAttribute("src")).toContain("record-icon.png");
|
||||
expect((finish as HTMLElement).style.width).toBe("32px");
|
||||
expect((finish as HTMLElement).style.height).toBe("32px");
|
||||
expect(icon?.getAttribute("width")).toBe("24");
|
||||
expect(icon?.getAttribute("height")).toBe("24");
|
||||
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
|
||||
|
||||
fireEvent.click(finish);
|
||||
expect(onFinish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
expect(container.querySelector("[data-slot='record-overlay']")).toBeNull();
|
||||
|
||||
const indicator = container.querySelector("[data-slot='record-overlay-indicator']");
|
||||
expect(indicator).toBeTruthy();
|
||||
expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse");
|
||||
});
|
||||
|
||||
it("shows the recording message for ten seconds, then displays elapsed time", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||
|
||||
expect(pill.dataset.phase).toBe("intro");
|
||||
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
|
||||
|
||||
advanceRecording(9999);
|
||||
expect(pill.dataset.phase).toBe("intro");
|
||||
|
||||
advanceRecording(1);
|
||||
expect(pill.dataset.phase).toBe("timer");
|
||||
expect(label.textContent).toBe("00:10");
|
||||
});
|
||||
|
||||
it("formats elapsed time as zero-padded minutes and seconds", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||
|
||||
advanceRecording(125_000);
|
||||
expect(label.textContent).toBe("02:05");
|
||||
});
|
||||
|
||||
it("keeps a fixed timer label width with tabular numerals", () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||
|
||||
advanceRecording(10_000);
|
||||
expect(label.style.width).toBe("48px");
|
||||
expect(label.style.maxWidth).toBe("48px");
|
||||
expect(label.style.fontVariantNumeric).toBe("tabular-nums");
|
||||
expect(label.style.textAlign).toBe("center");
|
||||
|
||||
advanceRecording(1_000);
|
||||
expect(label.textContent).toBe("00:11");
|
||||
expect(label.style.width).toBe("48px");
|
||||
});
|
||||
|
||||
it("resets the intro and elapsed time for a new request", () => {
|
||||
vi.useFakeTimers();
|
||||
const first = { id: "rec-1", onFinish: vi.fn() };
|
||||
const second = { id: "rec-2", onFinish: vi.fn() };
|
||||
const { container, rerender } = renderOverlay(first);
|
||||
|
||||
advanceRecording(65_000);
|
||||
rerender(
|
||||
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request: second })),
|
||||
);
|
||||
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||
expect(pill.dataset.phase).toBe("intro");
|
||||
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
|
||||
|
||||
advanceRecording(10_000);
|
||||
expect(pill.dataset.phase).toBe("timer");
|
||||
expect(label.textContent).toBe("00:10");
|
||||
});
|
||||
|
||||
it("continues the session timer when the overlay remounts on a new page", () => {
|
||||
vi.useFakeTimers();
|
||||
const startedAtMs = Date.now() - 65_000;
|
||||
const { container } = renderOverlay({ id: "rec-1", startedAtMs, onFinish: vi.fn() });
|
||||
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||
expect(pill.dataset.phase).toBe("timer");
|
||||
expect(label.textContent).toBe("01:05");
|
||||
|
||||
advanceRecording(5_000);
|
||||
expect(label.textContent).toBe("01:10");
|
||||
});
|
||||
|
||||
it("does not start drag when pointer down on finish", () => {
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||
const finish = container.querySelector("[data-slot='record-overlay-finish']") as HTMLElement;
|
||||
|
||||
fireEvent.pointerDown(finish, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
|
||||
expect(pill.getAttribute("data-dragging")).toBe("false");
|
||||
});
|
||||
|
||||
it("moves the pill when dragging the non-button area without toggling collapse", () => {
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||
|
||||
pill.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 600,
|
||||
left: 200,
|
||||
width: 320,
|
||||
height: 48,
|
||||
right: 520,
|
||||
bottom: 648,
|
||||
}) as DOMRect;
|
||||
Object.defineProperty(pill, "offsetWidth", { value: 320, configurable: true });
|
||||
Object.defineProperty(pill, "offsetHeight", { value: 48, configurable: true });
|
||||
|
||||
fireEvent.pointerDown(pill, { button: 0, clientX: 300, clientY: 620, pointerId: 1 });
|
||||
fireEvent(window, new PointerEvent("pointermove", { clientX: 360, clientY: 580 }));
|
||||
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
|
||||
|
||||
expect(pill.getAttribute("data-phase")).toBe("intro");
|
||||
expect(pill.style.left).toBe("260px");
|
||||
expect(pill.style.top).toBe("560px");
|
||||
expect(pill.style.bottom).toBe("auto");
|
||||
expect(pill.style.transform).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAPTURE_SUPPRESS, type CaptureSuppressAck } from "@/lib/capture-suppress-bridge";
|
||||
import { CAPTURE_HIDDEN_ATTR, createCaptureSuppressController } from "../capture-suppress";
|
||||
|
||||
/**
|
||||
* rAF is stubbed with a manual queue so tests can drive the compositor
|
||||
* frame-by-frame and assert the ack only lands after the second frame.
|
||||
*/
|
||||
describe("capture-suppress controller", () => {
|
||||
let rafQueue: FrameRequestCallback[];
|
||||
let host: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
rafQueue.push(cb);
|
||||
return rafQueue.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
document.body.innerHTML = "";
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
});
|
||||
|
||||
/** Run every queued rAF callback, draining microtasks between rounds. */
|
||||
async function flushFrames(rounds = 4): Promise<void> {
|
||||
for (let round = 0; round < rounds && rafQueue.length > 0; round += 1) {
|
||||
const callbacks = rafQueue.splice(0);
|
||||
for (const cb of callbacks) cb(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
it("begin hides the host immediately but only acks after two frames", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "begin" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(true);
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// First frame commits the style change; the ack must still be pending.
|
||||
await flushFrames(1);
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
|
||||
// Second frame: the compositor has produced an overlay-free frame.
|
||||
await flushFrames();
|
||||
const expected: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
expect(sendResponse).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it("end removes the attribute once the count returns to zero", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
const sendResponse = vi.fn();
|
||||
const needsAsync = controller.handleMessage(
|
||||
{ type: CAPTURE_SUPPRESS, phase: "end" },
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("reference-counts concurrent begins so one end does not unhide early", async () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
await flushFrames();
|
||||
expect(controller.pendingCount).toBe(2);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(1);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, vi.fn());
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a stray end as a no-op", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "end" }, sendResponse);
|
||||
|
||||
expect(controller.pendingCount).toBe(0);
|
||||
expect(host.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
expect(sendResponse).toHaveBeenCalledWith({ type: CAPTURE_SUPPRESS, ok: true });
|
||||
});
|
||||
|
||||
it("re-applies the attribute when the host is remounted mid-capture", () => {
|
||||
let currentHost: HTMLElement | null = host;
|
||||
const controller = createCaptureSuppressController(() => currentHost);
|
||||
controller.handleMessage({ type: CAPTURE_SUPPRESS, phase: "begin" }, vi.fn());
|
||||
|
||||
// Host lost and rebuilt before `end` arrives.
|
||||
currentHost = null;
|
||||
const rebuilt = document.createElement("div");
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a remounted host visible once suppression has ended", () => {
|
||||
const controller = createCaptureSuppressController(() => host);
|
||||
const rebuilt = document.createElement("div");
|
||||
|
||||
controller.onHostMounted(rebuilt);
|
||||
|
||||
expect(rebuilt.hasAttribute(CAPTURE_HIDDEN_ATTR)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHelpRequestData } from "../help-request";
|
||||
|
||||
describe("createHelpRequestData", () => {
|
||||
it("gives live delivery and recovery the same rectangle refresh mechanism", async () => {
|
||||
const finish = vi.fn();
|
||||
const query = vi.fn(async () => ({
|
||||
active: true,
|
||||
request: {
|
||||
requestId: "help-1",
|
||||
prompt: "complete the challenge",
|
||||
selectors: [],
|
||||
rects: [{ top: 80, left: 90, width: 140, height: 60 }],
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
}));
|
||||
const request = createHelpRequestData(
|
||||
{
|
||||
requestId: "help-1",
|
||||
prompt: "complete the challenge",
|
||||
selectors: [],
|
||||
rects: [{ top: 10, left: 20, width: 30, height: 40 }],
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
{ finish, query },
|
||||
);
|
||||
|
||||
await expect(request.refreshRects?.()).resolves.toEqual([
|
||||
{ top: 80, left: 90, width: 140, height: 60 },
|
||||
]);
|
||||
request.onContinue(" done ");
|
||||
request.onCancel();
|
||||
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
expect(finish).toHaveBeenNthCalledWith(1, "help-1", "continued", " done ");
|
||||
expect(finish).toHaveBeenNthCalledWith(2, "help-1", "cancelled");
|
||||
});
|
||||
|
||||
it("ignores rectangle replies for a replaced request", async () => {
|
||||
const request = createHelpRequestData(
|
||||
{
|
||||
requestId: "help-1",
|
||||
prompt: "complete the challenge",
|
||||
selectors: [],
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
{
|
||||
finish: vi.fn(),
|
||||
query: vi.fn(async () => ({
|
||||
active: true,
|
||||
request: {
|
||||
requestId: "help-2",
|
||||
prompt: "new challenge",
|
||||
selectors: [],
|
||||
rects: [{ top: 1, left: 2, width: 3, height: 4 }],
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(request.refreshRects?.()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OverlayController } from "../overlay-controller";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "../overlay-controller";
|
||||
|
||||
describe("OverlayController", () => {
|
||||
it("resets agent overlays without clearing user-tab borrow requests", () => {
|
||||
@@ -31,6 +31,7 @@ describe("OverlayController", () => {
|
||||
expect(state.controlVisible).toBe(false);
|
||||
expect(state.activeSessionId).toBeNull();
|
||||
expect(state.activeHelp).toBeNull();
|
||||
expect(state.activeRecord).toBeNull();
|
||||
expect(state.automationBypassCount).toBe(0);
|
||||
});
|
||||
|
||||
@@ -69,4 +70,91 @@ describe("OverlayController", () => {
|
||||
expect(previous?.id).toBe("help-1");
|
||||
expect(controller.snapshot().activeHelp?.id).toBe("help-2");
|
||||
});
|
||||
|
||||
it("tracks active record request state", () => {
|
||||
const controller = new OverlayController();
|
||||
|
||||
controller.activateAgentSession("sess-1");
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
|
||||
expect(controller.snapshot().activeRecord?.id).toBe("rec-1");
|
||||
});
|
||||
|
||||
it("hides agent control overlay while recording is active", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
|
||||
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
expect(controller.snapshot().controlVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps control hidden after record ends until session overlays reset", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
controller.setAutomationBypass(true);
|
||||
controller.setAutomationBypass(true);
|
||||
|
||||
controller.clearAgentRecordRequest("rec-1");
|
||||
expect(controller.snapshot().activeRecord).toBeNull();
|
||||
expect(controller.snapshot().suppressControlAfterRecord).toBe(true);
|
||||
expect(controller.snapshot().automationBypassCount).toBe(0);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
|
||||
controller.resetAgentOverlays("sess-1");
|
||||
expect(controller.snapshot().suppressControlAfterRecord).toBe(false);
|
||||
expect(controller.snapshot().controlVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("hides control while paused and shows it again only after authoritative control mode", () => {
|
||||
const controller = new OverlayController();
|
||||
|
||||
controller.applyAgentControlMode("sess-1", "paused");
|
||||
expect(controller.snapshot().activeSessionId).toBe("sess-1");
|
||||
expect(controller.snapshot().controlVisible).toBe(false);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
|
||||
controller.applyAgentControlMode("sess-1", "control");
|
||||
expect(controller.snapshot().activeSessionId).toBe("sess-1");
|
||||
expect(controller.snapshot().controlVisible).toBe(true);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the control overlay when the user hides control hints", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
|
||||
|
||||
controller.setControlHintsHidden(true);
|
||||
expect(controller.snapshot().controlHintsHidden).toBe(true);
|
||||
// The session still owns the tab — only the chrome is hidden.
|
||||
expect(controller.snapshot().controlVisible).toBe(true);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
|
||||
controller.setControlHintsHidden(false);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the control-hints preference across session overlay resets", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
controller.setControlHintsHidden(true);
|
||||
|
||||
controller.resetAgentOverlays("sess-1");
|
||||
expect(controller.snapshot().controlHintsHidden).toBe(true);
|
||||
|
||||
controller.activateAgentSession("sess-2");
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isHoverSurfaceCandidateElement, isLikelyHoverSurfaceOwner } from "../record-hover-surface";
|
||||
|
||||
function mockRect(
|
||||
el: Element,
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
): void {
|
||||
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("record-hover-surface", () => {
|
||||
it("recognizes popover containers without treating topbar wrappers or menu items as surfaces", () => {
|
||||
document.body.innerHTML = `
|
||||
<ul class="header-menu tw-flex navbar-app__authentication-menu">
|
||||
<li class="tg-popover">
|
||||
<div class="tg-avatar tg-avatar--img"></div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tg-popover__popper">
|
||||
<div class="tg-popover__content">
|
||||
<div class="tg-popover__content-body">
|
||||
<li class="header-menu__item">
|
||||
<a class="header-dropdown-menu__link" href="/u/me">My profile</a>
|
||||
</li>
|
||||
<li class="header-menu__item">
|
||||
<a class="header-dropdown-menu__link" href="/dashboard/groups">My groups</a>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu")!)).toBe(false);
|
||||
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover")!)).toBe(false);
|
||||
expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu__item")!)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isHoverSurfaceCandidateElement(document.querySelector(".header-dropdown-menu__link")!),
|
||||
).toBe(false);
|
||||
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__popper")!)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content")!)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content-body")!),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat elements covered by an open surface as the surface owner", () => {
|
||||
document.body.innerHTML = `
|
||||
<button class="avatar">image</button>
|
||||
<a class="fork" href="/fork">Fork</a>
|
||||
<button class="clone">Clone & Download</button>
|
||||
<div class="tg-popover__content-body"></div>
|
||||
`;
|
||||
const avatar = document.querySelector(".avatar")!;
|
||||
const fork = document.querySelector(".fork")!;
|
||||
const clone = document.querySelector(".clone")!;
|
||||
const surface = document.querySelector(".tg-popover__content-body")!;
|
||||
mockRect(avatar, { left: 1340, top: 11, width: 26, height: 26 });
|
||||
mockRect(fork, { left: 1275, top: 56, width: 69, height: 28 });
|
||||
mockRect(clone, { left: 1159, top: 111, width: 180, height: 30 });
|
||||
mockRect(surface, { left: 1111, top: 44, width: 240, height: 496 });
|
||||
|
||||
expect(isLikelyHoverSurfaceOwner(avatar, surface)).toBe(true);
|
||||
expect(isLikelyHoverSurfaceOwner(fork, surface)).toBe(false);
|
||||
expect(isLikelyHoverSurfaceOwner(clone, surface)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat fixed navigation containers as plain floating surfaces", () => {
|
||||
document.body.innerHTML = `
|
||||
<header class="navbar-app">
|
||||
<a href="/fork">Fork</a>
|
||||
<button>image</button>
|
||||
</header>
|
||||
<div class="plain-floating"></div>
|
||||
`;
|
||||
const navbar = document.querySelector(".navbar-app")!;
|
||||
const floating = document.querySelector(".plain-floating")!;
|
||||
mockRect(navbar, { left: 0, top: 0, width: 1386, height: 51 });
|
||||
mockRect(floating, { left: 1111, top: 44, width: 240, height: 496 });
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((el) => {
|
||||
const style = {
|
||||
cursor: "",
|
||||
display: "block",
|
||||
pointerEvents: "auto",
|
||||
position: el === navbar ? "fixed" : "absolute",
|
||||
visibility: "visible",
|
||||
} as CSSStyleDeclaration;
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isHoverSurfaceCandidateElement(navbar)).toBe(false);
|
||||
expect(isHoverSurfaceCandidateElement(floating)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Content-script side of the capture-suppress bridge: hides the overlay
|
||||
* shadow host while the background captures a screenshot.
|
||||
*
|
||||
* `begin` sets `data-bsk-capture-hidden` on the host (overlay.css maps it
|
||||
* to `display: none !important`, which removes the whole shadow subtree
|
||||
* from compositing) and only acks after two animation frames — the first
|
||||
* frame commits the style change, the second guarantees the compositor
|
||||
* has produced a frame without the overlay, so the capture API cannot
|
||||
* pick up a stale frame that still contains it. `end` is
|
||||
* reference-counted so overlapping captures do not unhide the host early.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
|
||||
export const CAPTURE_HIDDEN_ATTR = "data-bsk-capture-hidden";
|
||||
|
||||
/**
|
||||
* Safety net for hidden/background tabs where rAF callbacks never fire:
|
||||
* the attribute is already set synchronously, so any frame the compositor
|
||||
* produces from now on excludes the overlay. Waiting longer would just
|
||||
* deadlock the capture.
|
||||
*/
|
||||
const FRAME_FALLBACK_MS = 500;
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
cancelAnimationFrame(raf);
|
||||
resolve();
|
||||
}, FRAME_FALLBACK_MS);
|
||||
});
|
||||
}
|
||||
|
||||
export interface CaptureSuppressController {
|
||||
/**
|
||||
* Handle one bridge message. Returns true when the ack is sent
|
||||
* asynchronously and the caller must keep the message channel open.
|
||||
*/
|
||||
handleMessage(
|
||||
message: CaptureSuppressMessage,
|
||||
sendResponse: (ack: CaptureSuppressAck) => void,
|
||||
): boolean;
|
||||
/** Re-apply the hidden attribute when the overlay host is (re)mounted. */
|
||||
onHostMounted(host: HTMLElement): void;
|
||||
/** Number of in-flight `begin` phases (exposed for tests). */
|
||||
readonly pendingCount: number;
|
||||
}
|
||||
|
||||
export function createCaptureSuppressController(
|
||||
getHost: () => HTMLElement | null,
|
||||
): CaptureSuppressController {
|
||||
let pending = 0;
|
||||
|
||||
const ack: CaptureSuppressAck = { type: CAPTURE_SUPPRESS, ok: true };
|
||||
|
||||
function setHidden(host: HTMLElement | null, hidden: boolean): void {
|
||||
if (!host) return;
|
||||
if (hidden) host.setAttribute(CAPTURE_HIDDEN_ATTR, "");
|
||||
else host.removeAttribute(CAPTURE_HIDDEN_ATTR);
|
||||
}
|
||||
|
||||
return {
|
||||
get pendingCount() {
|
||||
return pending;
|
||||
},
|
||||
handleMessage(message, sendResponse) {
|
||||
if (message.phase === "begin") {
|
||||
pending += 1;
|
||||
setHidden(getHost(), true);
|
||||
void (async () => {
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
sendResponse(ack);
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
// `end` without a matching `begin` (e.g. a retried message) must not
|
||||
// drive the counter negative or clear a concurrent capture's flag.
|
||||
pending = Math.max(0, pending - 1);
|
||||
if (pending === 0) setHidden(getHost(), false);
|
||||
sendResponse(ack);
|
||||
return false;
|
||||
},
|
||||
onHostMounted(host) {
|
||||
// Host lost + rebuilt mid-capture: the fresh host must stay hidden
|
||||
// until the last `end` arrives.
|
||||
setHidden(host, pending > 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HelpQueryResponse, HelpRequestMessage } from "@/lib/help-bridge";
|
||||
import type { HelpRequestData } from "./HelpRequestOverlay";
|
||||
|
||||
export interface HelpRequestDataDeps {
|
||||
finish(requestId: string, outcome: "continued" | "cancelled", note?: string): void;
|
||||
query(): Promise<HelpQueryResponse | undefined>;
|
||||
}
|
||||
|
||||
/** Build the content-side representation shared by live delivery and recovery. */
|
||||
export function createHelpRequestData(
|
||||
message: Omit<HelpRequestMessage, "type">,
|
||||
deps: HelpRequestDataDeps,
|
||||
): HelpRequestData {
|
||||
return {
|
||||
id: message.requestId,
|
||||
prompt: message.prompt,
|
||||
...(message.title ? { title: message.title } : {}),
|
||||
...(message.displayMode ? { displayMode: message.displayMode } : {}),
|
||||
selectors: message.selectors,
|
||||
rects: message.rects,
|
||||
refreshRects: async () => {
|
||||
const response = await deps.query();
|
||||
return response?.active && response.request?.requestId === message.requestId
|
||||
? response.request.rects
|
||||
: undefined;
|
||||
},
|
||||
onContinue: (note: string) =>
|
||||
deps.finish(message.requestId, "continued", note.trim() ? note : undefined),
|
||||
onCancel: () => deps.finish(message.requestId, "cancelled"),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import type { OverlayMode } from "@/lib/overlay-bridge";
|
||||
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
|
||||
import type { HelpRequestData } from "./HelpRequestOverlay";
|
||||
import type { RecordRequestData } from "./RecordOverlay";
|
||||
|
||||
export interface OverlayState {
|
||||
borrowRequests: BorrowRequestData[];
|
||||
activeHelp: HelpRequestData | null;
|
||||
activeRecord: RecordRequestData | null;
|
||||
controlVisible: boolean;
|
||||
interrupting: boolean;
|
||||
activeSessionId: string | null;
|
||||
controlMode: OverlayMode;
|
||||
automationBypassCount: number;
|
||||
/**
|
||||
* After record Finish/Stop clears `activeRecord`, the session is still
|
||||
* alive until CLI `session.stop`. Suppress the control mask for that gap
|
||||
* so 「Agent 正在控制」does not flash between RecordOverlay and teardown.
|
||||
*/
|
||||
suppressControlAfterRecord: boolean;
|
||||
/**
|
||||
* User preference (chrome.storage, toggled from the popup): hide the
|
||||
* control hints — status pill, orange glow, and the input blocker that
|
||||
* comes with them. Not session state; survives overlay resets.
|
||||
*/
|
||||
controlHintsHidden: boolean;
|
||||
}
|
||||
|
||||
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
|
||||
@@ -20,9 +36,13 @@ export class OverlayController {
|
||||
private state: MutableOverlayState = {
|
||||
borrowRequests: [],
|
||||
activeHelp: null,
|
||||
activeRecord: null,
|
||||
interrupting: false,
|
||||
activeSessionId: null,
|
||||
controlMode: "hidden",
|
||||
automationBypassCount: 0,
|
||||
suppressControlAfterRecord: false,
|
||||
controlHintsHidden: false,
|
||||
};
|
||||
|
||||
snapshot(): OverlayState {
|
||||
@@ -34,7 +54,7 @@ export class OverlayController {
|
||||
}
|
||||
|
||||
isControlVisible(): boolean {
|
||||
return this.state.activeSessionId !== null;
|
||||
return this.state.activeSessionId !== null && this.state.controlMode === "control";
|
||||
}
|
||||
|
||||
addBorrowRequest(request: BorrowRequestData): void {
|
||||
@@ -51,6 +71,25 @@ export class OverlayController {
|
||||
activateAgentSession(sessionId: string): void {
|
||||
this.state.activeSessionId = sessionId;
|
||||
this.state.interrupting = false;
|
||||
this.state.controlMode = "control";
|
||||
}
|
||||
|
||||
applyAgentControlMode(sessionId: string | null, mode: OverlayMode): void {
|
||||
if (mode === "control" && sessionId) {
|
||||
this.state.activeSessionId = sessionId;
|
||||
this.state.interrupting = false;
|
||||
this.state.controlMode = "control";
|
||||
return;
|
||||
}
|
||||
if ((mode === "interrupting" || mode === "paused") && sessionId) {
|
||||
this.state.activeSessionId = sessionId;
|
||||
this.state.interrupting = mode === "interrupting";
|
||||
this.state.controlMode = mode;
|
||||
return;
|
||||
}
|
||||
this.state.activeSessionId = null;
|
||||
this.state.interrupting = false;
|
||||
this.state.controlMode = "hidden";
|
||||
}
|
||||
|
||||
setInterrupting(interrupting: boolean): void {
|
||||
@@ -70,6 +109,25 @@ export class OverlayController {
|
||||
this.state.activeHelp = null;
|
||||
}
|
||||
|
||||
setAgentRecordRequest(request: RecordRequestData): RecordRequestData | null {
|
||||
const previous = this.state.activeRecord;
|
||||
this.state.activeRecord = request;
|
||||
this.state.suppressControlAfterRecord = false;
|
||||
return previous;
|
||||
}
|
||||
|
||||
clearAgentRecordRequest(requestId?: string): void {
|
||||
if (!this.state.activeRecord) return;
|
||||
if (requestId && this.state.activeRecord.id !== requestId) return;
|
||||
this.state.activeRecord = null;
|
||||
// Hide control until session teardown — see OverlayState.suppressControlAfterRecord.
|
||||
this.state.suppressControlAfterRecord = true;
|
||||
// Record start/rearm may have stacked automation-bypass refs; drop them so a
|
||||
// stray ControlOverlay cannot sit with pointer-events:none (page usable,
|
||||
// Interrupt unclickable).
|
||||
this.state.automationBypassCount = 0;
|
||||
}
|
||||
|
||||
setAutomationBypass(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.state.automationBypassCount += 1;
|
||||
@@ -78,6 +136,10 @@ export class OverlayController {
|
||||
}
|
||||
}
|
||||
|
||||
setControlHintsHidden(hidden: boolean): void {
|
||||
this.state.controlHintsHidden = hidden;
|
||||
}
|
||||
|
||||
resetAgentOverlays(sessionId: string): HelpRequestData | null {
|
||||
if (this.state.activeSessionId && this.state.activeSessionId !== sessionId) {
|
||||
return null;
|
||||
@@ -86,10 +148,24 @@ export class OverlayController {
|
||||
this.state = {
|
||||
...this.state,
|
||||
activeHelp: null,
|
||||
activeRecord: null,
|
||||
interrupting: false,
|
||||
activeSessionId: null,
|
||||
controlMode: "hidden",
|
||||
automationBypassCount: 0,
|
||||
suppressControlAfterRecord: false,
|
||||
};
|
||||
return previousHelp;
|
||||
}
|
||||
}
|
||||
|
||||
/** Control mask ("Agent 正在控制") must hide while help/record overlays own the chrome. */
|
||||
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
|
||||
return (
|
||||
state.controlVisible &&
|
||||
!state.controlHintsHidden &&
|
||||
!state.suppressControlAfterRecord &&
|
||||
state.activeHelp === null &&
|
||||
state.activeRecord === null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
@source "../content";
|
||||
|
||||
:host([data-bsk-overlay-surface]) {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
display: block !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 2147483647 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Screenshot suppression: the host lives in the main DOM, so hiding it
|
||||
removes the whole shadow subtree from compositing and captured frames
|
||||
only contain page content. */
|
||||
:host([data-bsk-capture-hidden]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
export interface HoverSurfaceDecision {
|
||||
related: boolean;
|
||||
reason?: string;
|
||||
surface?: Element;
|
||||
}
|
||||
|
||||
export interface HoverSurfaceContext {
|
||||
triggerElement: Element;
|
||||
}
|
||||
|
||||
export interface HoverSurfaceState {
|
||||
element: Element;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const HOVER_SURFACE_SELECTOR = [
|
||||
'[role="menu"]',
|
||||
'[role="menubar"]',
|
||||
'[role="listbox"]',
|
||||
'[role="dialog"]',
|
||||
'[role="tooltip"]',
|
||||
'[aria-modal="true"]',
|
||||
"[data-popper-placement]",
|
||||
"[data-floating-ui-placement]",
|
||||
"[data-headlessui-state]",
|
||||
"[data-radix-popper-content-wrapper]",
|
||||
].join(",");
|
||||
|
||||
const HOVER_SURFACE_WORD_RE =
|
||||
/(^|[^a-z0-9])(dropdown-menu|drop-down-menu|menu-list|popover__(popper|content|content-body)|popover-content|popper|popup|flyout|submenu|submenu-wrapper|context-menu|account-menu|user-menu|avatar-menu)(?=[^a-z0-9]|$)/i;
|
||||
const HOVER_SURFACE_ITEM_RE =
|
||||
/(^|[^a-z0-9])((dropdown|menu|submenu|context-menu)(-|__?)?(item|link)|item-(text|label|content)|item__?(text|label|content))(?=[^a-z0-9]|$)/i;
|
||||
const HOVER_SURFACE_MAX_AXIS_GAP = 96;
|
||||
const HOVER_SURFACE_MAX_DIAGONAL_GAP = 16;
|
||||
const HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO = 0.5;
|
||||
|
||||
function elementTokenText(el: Element): string {
|
||||
return [
|
||||
el.id,
|
||||
typeof el.className === "string" ? el.className : "",
|
||||
el.getAttribute("data-testid"),
|
||||
el.getAttribute("data-test"),
|
||||
el.getAttribute("data-cy"),
|
||||
el.getAttribute("aria-label"),
|
||||
el.getAttribute("role"),
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function isRecognizedHoverSurfaceElement(el: Element): boolean {
|
||||
if (el.matches(HOVER_SURFACE_SELECTOR)) return true;
|
||||
const tokenText = elementTokenText(el);
|
||||
if (HOVER_SURFACE_WORD_RE.test(tokenText) && !HOVER_SURFACE_ITEM_RE.test(tokenText)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVisibleSurfaceElement(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
|
||||
return false;
|
||||
}
|
||||
return rectFor(el) !== null;
|
||||
}
|
||||
|
||||
export function closestRecognizedHoverSurface(element: Element): Element | null {
|
||||
let node: Element | null = element;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isRecognizedHoverSurfaceElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPositionedFloatingElement(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position !== "absolute") return false;
|
||||
if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") {
|
||||
return false;
|
||||
}
|
||||
const rect = rectFor(el);
|
||||
if (!rect) return false;
|
||||
const viewportArea = window.innerWidth * window.innerHeight;
|
||||
const area = rect.width * rect.height;
|
||||
return (
|
||||
area > 0 && (!viewportArea || area <= viewportArea * HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO)
|
||||
);
|
||||
}
|
||||
|
||||
export function isHoverSurfaceCandidateElement(el: Element): boolean {
|
||||
return isRecognizedHoverSurfaceElement(el) || isPositionedFloatingElement(el);
|
||||
}
|
||||
|
||||
export function closestHoverSurfaceCandidate(element: Element): Element | null {
|
||||
let node: Element | null = element;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isHoverSurfaceCandidateElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function closestFloatingSurface(clickElement: Element): Element | null {
|
||||
let node: Element | null = clickElement;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 10) {
|
||||
if (isPositionedFloatingElement(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hoverSurfaceSignature(el: Element): string {
|
||||
const rect = rectFor(el);
|
||||
return [
|
||||
rect
|
||||
? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}`
|
||||
: "",
|
||||
(el.textContent ?? "").trim().slice(0, 512),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function collectHoverSurfaceStates(): HoverSurfaceState[] {
|
||||
const states: HoverSurfaceState[] = [];
|
||||
for (const el of document.querySelectorAll("*")) {
|
||||
if (isHoverSurfaceCandidateElement(el) && isVisibleSurfaceElement(el)) {
|
||||
states.push({ element: el, signature: hoverSurfaceSignature(el) });
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function rectFor(el: Element): DOMRect | null {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
return rect;
|
||||
}
|
||||
|
||||
function surfaceRelationMetrics(surface: Element, trigger: Element) {
|
||||
const surfaceRect = rectFor(surface);
|
||||
const triggerRect = rectFor(trigger);
|
||||
if (!surfaceRect || !triggerRect) return null;
|
||||
const horizontalOverlap =
|
||||
Math.min(triggerRect.right, surfaceRect.right) - Math.max(triggerRect.left, surfaceRect.left);
|
||||
const verticalOverlap =
|
||||
Math.min(triggerRect.bottom, surfaceRect.bottom) - Math.max(triggerRect.top, surfaceRect.top);
|
||||
const horizontalGap = Math.max(
|
||||
0,
|
||||
triggerRect.left - surfaceRect.right,
|
||||
surfaceRect.left - triggerRect.right,
|
||||
);
|
||||
const verticalGap = Math.max(
|
||||
0,
|
||||
triggerRect.top - surfaceRect.bottom,
|
||||
surfaceRect.top - triggerRect.bottom,
|
||||
);
|
||||
return {
|
||||
surfaceRect,
|
||||
triggerRect,
|
||||
horizontalOverlap,
|
||||
verticalOverlap,
|
||||
horizontalGap,
|
||||
verticalGap,
|
||||
};
|
||||
}
|
||||
|
||||
function isNearTrigger(surface: Element, trigger: Element): boolean {
|
||||
const metrics = surfaceRelationMetrics(surface, trigger);
|
||||
if (!metrics) return true;
|
||||
const { horizontalOverlap, verticalOverlap, horizontalGap, verticalGap } = metrics;
|
||||
if (horizontalOverlap > 0 && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
|
||||
if (verticalOverlap > 0 && horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true;
|
||||
return (
|
||||
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyHoverSurfaceOwner(trigger: Element, surface: Element): boolean {
|
||||
const metrics = surfaceRelationMetrics(surface, trigger);
|
||||
if (!metrics) return true;
|
||||
const {
|
||||
surfaceRect,
|
||||
triggerRect,
|
||||
horizontalOverlap,
|
||||
verticalOverlap,
|
||||
horizontalGap,
|
||||
verticalGap,
|
||||
} = metrics;
|
||||
const opensVertically =
|
||||
horizontalOverlap > 0 &&
|
||||
verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP &&
|
||||
(triggerRect.bottom <= surfaceRect.top + 8 || surfaceRect.bottom <= triggerRect.top + 8);
|
||||
const opensSideways =
|
||||
verticalOverlap > 0 &&
|
||||
horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP &&
|
||||
(triggerRect.right <= surfaceRect.left + 8 || surfaceRect.right <= triggerRect.left + 8);
|
||||
const opensFromCorner =
|
||||
horizontalGap > 0 &&
|
||||
verticalGap > 0 &&
|
||||
horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP &&
|
||||
verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP;
|
||||
return opensVertically || opensSideways || opensFromCorner;
|
||||
}
|
||||
|
||||
export function decideHoverSurfaceRelation(
|
||||
context: HoverSurfaceContext,
|
||||
clickElement: Element,
|
||||
): HoverSurfaceDecision {
|
||||
const clickSurface = closestRecognizedHoverSurface(clickElement);
|
||||
if (clickSurface && isNearTrigger(clickSurface, context.triggerElement)) {
|
||||
return { related: true, reason: "click-inside-hover-surface", surface: clickSurface };
|
||||
}
|
||||
|
||||
const floatingSurface = closestFloatingSurface(clickElement);
|
||||
if (floatingSurface && isNearTrigger(floatingSurface, context.triggerElement)) {
|
||||
return {
|
||||
related: true,
|
||||
reason: "click-inside-near-floating-surface",
|
||||
surface: floatingSurface,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.triggerElement.contains(clickElement) && clickElement !== context.triggerElement) {
|
||||
return { related: true, reason: "click-inside-trigger-contained-surface" };
|
||||
}
|
||||
|
||||
return { related: false, reason: "click-outside-hover-surface" };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
|
||||
// at module load. WXT injects that helper at build time via auto-imports;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import { ConnectionController } from "@/lib/connection-controller";
|
||||
import { startHeartbeat } from "@/lib/heartbeat";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
setConnectionEnabled as persistConnectionEnabled,
|
||||
@@ -8,14 +9,20 @@ import {
|
||||
} from "@/lib/instance-id";
|
||||
import { startKeepalive } from "@/lib/keepalive";
|
||||
import {
|
||||
OVERLAY_AGENT_STATE,
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
OVERLAY_MSG_READY,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayAgentStateMessage,
|
||||
type OverlayInterruptRequest,
|
||||
type OverlayInterruptResponse,
|
||||
type OverlayMessage,
|
||||
type OverlayMode,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
|
||||
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
|
||||
import { attachSessionEventHandler } from "@/session-manager/event-handler";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import {
|
||||
@@ -27,38 +34,130 @@ import {
|
||||
requestBorrowConfirmation,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { ToolDispatcher } from "@/tools/dispatcher";
|
||||
import {
|
||||
attachRecordFinishListener,
|
||||
attachRecordQueryListener,
|
||||
attachRecordStepListener,
|
||||
} from "@/tools/record";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import { WSTransport } from "@/transport/ws-transport";
|
||||
|
||||
export const DEFAULT_DAEMON_PORT = 52800;
|
||||
|
||||
export default defineBackground(() => {
|
||||
const port = DEFAULT_DAEMON_PORT;
|
||||
const controller = new ConnectionController();
|
||||
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
|
||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||
const sessions = new SessionManager();
|
||||
const cdp = new ChromiumCdp();
|
||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||
let overlayGeneration = 0;
|
||||
const controlModes = new Map<string, OverlayMode>();
|
||||
|
||||
function setControlMode(sessionId: string, mode: OverlayMode): void {
|
||||
if (controlModes.get(sessionId) === mode) return;
|
||||
controlModes.set(sessionId, mode);
|
||||
overlayGeneration += 1;
|
||||
const ctx = sessions.get(sessionId);
|
||||
if (ctx) void pushOverlayStateForWindow(ctx.agentWindowId);
|
||||
}
|
||||
|
||||
function overlayStateForWindow(windowId?: number): OverlayAgentStateMessage {
|
||||
const ctx = typeof windowId === "number" ? sessions.findByWindowId(windowId) : null;
|
||||
if (!ctx) {
|
||||
return {
|
||||
type: OVERLAY_AGENT_STATE,
|
||||
sessionId: null,
|
||||
mode: "hidden",
|
||||
generation: overlayGeneration,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: OVERLAY_AGENT_STATE,
|
||||
sessionId: ctx.sessionId,
|
||||
mode: controlModes.get(ctx.sessionId) ?? "control",
|
||||
generation: overlayGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
async function pushOverlayStateToTab(
|
||||
tabId: number,
|
||||
state: OverlayAgentStateMessage,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, state);
|
||||
} catch {
|
||||
// Restricted pages and not-yet-loaded content scripts cannot receive messages.
|
||||
}
|
||||
}
|
||||
|
||||
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
|
||||
const state = overlayStateForWindow(windowId);
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
await Promise.all(
|
||||
tabs.map((tab) =>
|
||||
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function pushAllAgentOverlayStates(): void {
|
||||
const windowIds = new Set(sessions.list().map((ctx) => ctx.agentWindowId));
|
||||
for (const windowId of windowIds) {
|
||||
void pushOverlayStateForWindow(windowId);
|
||||
}
|
||||
}
|
||||
|
||||
function onOverlaySessionStateChanged(): void {
|
||||
void sessionsLive.syncFromManager();
|
||||
const liveSessionIds = new Set(sessions.list().map((ctx) => ctx.sessionId));
|
||||
for (const sessionId of controlModes.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) controlModes.delete(sessionId);
|
||||
}
|
||||
overlayGeneration += 1;
|
||||
pushAllAgentOverlayStates();
|
||||
}
|
||||
|
||||
function onBrowserControlResumed(sessionId: string): void {
|
||||
const ctx = sessions.get(sessionId);
|
||||
if (!ctx) return;
|
||||
setControlMode(sessionId, "control");
|
||||
}
|
||||
|
||||
function pushOverlayStateForAgentWindow(windowId: number): void {
|
||||
if (!sessions.findByWindowId(windowId)) return;
|
||||
void pushOverlayStateForWindow(windowId);
|
||||
}
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
pushOverlayStateForAgentWindow(activeInfo.windowId);
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
|
||||
if (changeInfo.status !== "complete") return;
|
||||
if (typeof tab.windowId !== "number") return;
|
||||
pushOverlayStateForAgentWindow(tab.windowId);
|
||||
});
|
||||
// Re-sync the storage.session flag on SW startup so a previous SW's
|
||||
// stale `true` does not keep waking us on every page load until the
|
||||
// first mutation (review M4/M5 round 3 m-R3-1).
|
||||
void sessionsLive.refresh();
|
||||
const cleanupAfterDisconnect = createDisconnectCleanup({
|
||||
manager: sessions,
|
||||
sessionStopDeps: { cdp },
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
});
|
||||
const dispatcher = new ToolDispatcher({
|
||||
transport,
|
||||
sessions,
|
||||
cdp,
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
onSessionsChanged: onOverlaySessionStateChanged,
|
||||
onBrowserControlResumed,
|
||||
approveBorrow: (ctx) =>
|
||||
requestBorrowConfirmation(ctx.tabId, {
|
||||
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
||||
deps: {
|
||||
// Skip every Agent Window when choosing where to render the
|
||||
// overlay — Agent Windows boot on about:blank, which has no
|
||||
// content script, so sendMessage would fail-open and silently
|
||||
// allow the borrow without any UI shown.
|
||||
// content script, so they cannot surface an authorization decision.
|
||||
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
|
||||
// Resolve i18n strings per-borrow so language switches take effect
|
||||
// without re-creating the dispatcher.
|
||||
@@ -71,6 +170,27 @@ export default defineBackground(() => {
|
||||
}),
|
||||
});
|
||||
dispatcher.start();
|
||||
const recordDeps = {
|
||||
tabsApi: chrome.tabs,
|
||||
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
||||
chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
};
|
||||
// Message listeners stay up (cheap; fire only for record message types).
|
||||
// Tab / webNavigation observation attaches lazily while a recording is
|
||||
// active — see ensureBrowserObservationListeners in tools/record.ts.
|
||||
attachRecordStepListener();
|
||||
attachRecordFinishListener(recordDeps);
|
||||
attachRecordQueryListener(recordDeps);
|
||||
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
||||
attachBorrowNotificationClickHandler({
|
||||
onClicked: chrome.notifications.onClicked,
|
||||
@@ -85,8 +205,8 @@ export default defineBackground(() => {
|
||||
// The Allow / Deny buttons on the OS notification are the *explicit*
|
||||
// authorization fallback when every candidate user window's content
|
||||
// script was missing (extension just reloaded, page in BFCache, etc.).
|
||||
// Without this listener those button clicks would land nowhere and we'd
|
||||
// be back to fail-open-after-sendMessage-failure.
|
||||
// Without this listener those button clicks would land nowhere and the
|
||||
// request would only resolve via the fail-closed background timeout.
|
||||
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
|
||||
attachBorrowNotificationButtonHandler({
|
||||
onButtonClicked: chrome.notifications.onButtonClicked,
|
||||
@@ -100,9 +220,7 @@ export default defineBackground(() => {
|
||||
manager: sessions,
|
||||
transport,
|
||||
cdp,
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
onSessionsChanged: onOverlaySessionStateChanged,
|
||||
});
|
||||
|
||||
// MV3 service worker keepalive + reconnect supervisor (review M4/M5
|
||||
@@ -116,9 +234,61 @@ export default defineBackground(() => {
|
||||
shouldConnect: () => controller.isConnectionEnabled,
|
||||
});
|
||||
|
||||
// Application-level heartbeat (Chrome 116+): while the post-handshake
|
||||
// link is live, beat every 20s so WebSocket activity keeps the service
|
||||
// worker — and thus the daemon connection — alive during use, rather
|
||||
// than depending on the boundary-hugging 30s keepalive alarm. The
|
||||
// daemon also uses these beats to reap a silently-dead browser.
|
||||
startHeartbeat({
|
||||
send: (frame) => transport.send(frame),
|
||||
onActiveChange: (cb) =>
|
||||
controller.subscribe((snap) =>
|
||||
cb(snap.state === "connected" || snap.state === "version_skew"),
|
||||
),
|
||||
});
|
||||
|
||||
// Wake-driven reconnect (best effort). An MV3 service worker is killed
|
||||
// across OS sleep regardless of any keepalive, and the setTimeout-based
|
||||
// transport backoff dies with it. These Chrome lifecycle events revive
|
||||
// the worker and let us reconnect immediately instead of waiting for
|
||||
// the next 30s alarm tick. They only help when the daemon is actually
|
||||
// running; a cold daemon is (re)spawned by the next `bsk` command.
|
||||
const reconnectIfNeeded = () => {
|
||||
if (!controller.isConnectionEnabled) return;
|
||||
if (transport.state === "connected") return;
|
||||
void transport.connect().catch((err) => {
|
||||
console.debug("[browser-skill] wake reconnect attempt failed", err);
|
||||
});
|
||||
};
|
||||
if (typeof chrome.runtime?.onStartup?.addListener === "function") {
|
||||
chrome.runtime.onStartup.addListener(reconnectIfNeeded);
|
||||
}
|
||||
if (typeof chrome.idle?.onStateChanged?.addListener === "function") {
|
||||
chrome.idle.onStateChanged.addListener((state) => {
|
||||
// "active" fires when the user returns from idle/locked — the most
|
||||
// reliable "machine just woke" signal we get.
|
||||
if (state === "active") reconnectIfNeeded();
|
||||
});
|
||||
// Treat >60s of no input as idle so the active transition is timely.
|
||||
chrome.idle.setDetectionInterval?.(60);
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const connectionEnabled = await getConnectionEnabled();
|
||||
await controller.attach(transport, detectBrowserMeta(), connectionEnabled);
|
||||
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
|
||||
beforeDisconnect: async () => {
|
||||
const report = await cleanupAfterDisconnect();
|
||||
if (report.failures.length > 0) {
|
||||
console.warn("[browser-skill] session cleanup before disconnect was incomplete", report);
|
||||
}
|
||||
},
|
||||
onDisconnected: async () => {
|
||||
const report = await cleanupAfterDisconnect();
|
||||
if (report.failures.length > 0) {
|
||||
console.warn("[browser-skill] session cleanup after disconnect was incomplete", report);
|
||||
}
|
||||
},
|
||||
});
|
||||
})().catch((err) => {
|
||||
console.error("[browser-skill] controller failed to attach", err);
|
||||
});
|
||||
@@ -134,9 +304,19 @@ export default defineBackground(() => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.kind === OVERLAY_MSG_READY) {
|
||||
sendResponse(overlayStateForWindow(sender.tab?.windowId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (msg.kind === OVERLAY_MSG_INTERRUPT) {
|
||||
const req = msg as OverlayInterruptRequest;
|
||||
const ctx = sessions.get(req.sessionId);
|
||||
if (ctx) setControlMode(req.sessionId, "interrupting");
|
||||
void handleOverlayInterrupt(transport, req.sessionId).then((reply) => {
|
||||
if (reply.ok && sessions.get(req.sessionId)) {
|
||||
setControlMode(req.sessionId, "paused");
|
||||
}
|
||||
sendResponse(reply);
|
||||
});
|
||||
return true; // keep channel open
|
||||
@@ -167,9 +347,9 @@ export default defineBackground(() => {
|
||||
// silently doing nothing (review M4/M5 C2).
|
||||
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
|
||||
} else if (msg.kind === "set_connection_enabled") {
|
||||
void controller.setConnectionEnabled(msg.value).then(() =>
|
||||
persistConnectionEnabled(msg.value),
|
||||
);
|
||||
void controller
|
||||
.setConnectionEnabled(msg.value)
|
||||
.then(() => persistConnectionEnabled(msg.value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
|
||||
import { ControlOverlay } from "@/content/ControlOverlay";
|
||||
import { createCaptureSuppressController } from "@/content/capture-suppress";
|
||||
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
|
||||
import { createHelpRequestData } from "@/content/help-request";
|
||||
import overlayCss from "@/content/overlay.css?inline";
|
||||
import { OverlayController } from "@/content/overlay-controller";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||
import { RecordOverlay } from "@/content/RecordOverlay";
|
||||
import {
|
||||
HELP_RESPONSE,
|
||||
handleRecordContentMessage,
|
||||
isRecordContentMessage,
|
||||
type RecordCaptureController,
|
||||
} from "@/content/record-capture";
|
||||
import {
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
isCaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
import {
|
||||
HELP_ACK,
|
||||
HELP_FINISH,
|
||||
HELP_QUERY,
|
||||
type HelpAckMessage,
|
||||
type HelpCancelMessage,
|
||||
type HelpFinishMessage,
|
||||
type HelpQueryResponse,
|
||||
type HelpRequestMessage,
|
||||
type HelpResponseMessage,
|
||||
isHelpCancelMessage,
|
||||
isHelpRequestMessage,
|
||||
} from "@/lib/help-bridge";
|
||||
import { getControlHintsHidden, STORAGE_KEYS } from "@/lib/instance-id";
|
||||
import {
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayAgentOverlayResetMessage,
|
||||
type OverlayAutomationBypassMessage,
|
||||
type OverlayWhoAmIResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
isOverlayAgentStateMessage,
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_READY,
|
||||
type OverlayAgentOverlayResetMessage,
|
||||
type OverlayAgentStateMessage,
|
||||
type OverlayAutomationBypassMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
|
||||
import {
|
||||
RECORD_FINISH,
|
||||
RECORD_QUERY,
|
||||
type RecordQueryResponse,
|
||||
type RecordStartAck,
|
||||
type RecordStopAck,
|
||||
} from "@/lib/record-bridge";
|
||||
import type {
|
||||
BorrowCancelMessage,
|
||||
BorrowRequestMessage,
|
||||
BorrowResponseMessage,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Run at document_end so the overlay does not block first paint. Only attach
|
||||
// in the top-level frame so iframes do not double-render overlays.
|
||||
@@ -43,13 +69,25 @@ export default defineContentScript({
|
||||
if (window.top !== window) return;
|
||||
|
||||
const overlays = new OverlayController();
|
||||
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
|
||||
null;
|
||||
let recordCapture: RecordCaptureController | null = null;
|
||||
let activeRecordRequestId: string | null = null;
|
||||
let reactRoot: ReactDOM.Root | null = null;
|
||||
let overlayHost: HTMLElement | null = null;
|
||||
let overlayContainer: HTMLElement | null = null;
|
||||
let activeAgentState: OverlayAgentStateMessage | null = null;
|
||||
let hostLossReported = false;
|
||||
let remountInProgress = false;
|
||||
|
||||
// Load the user's control-hints preference up front so an already-active
|
||||
// Agent session does not flash the overlay before the stored value lands.
|
||||
try {
|
||||
overlays.setControlHintsHidden(await getControlHintsHidden());
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] control-hints preference read failed", err);
|
||||
}
|
||||
|
||||
const captureSuppress = createCaptureSuppressController(() => overlayHost);
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
name: "browser-skill-overlay",
|
||||
position: "inline",
|
||||
@@ -59,52 +97,141 @@ export default defineContentScript({
|
||||
shadowHost.setAttribute("aria-hidden", "true");
|
||||
shadowHost.setAttribute("data-bsk-overlay", "");
|
||||
overlayHost = shadowHost;
|
||||
overlayContainer = container;
|
||||
hostLossReported = false;
|
||||
// A host rebuilt mid-capture must stay hidden until `end` arrives.
|
||||
captureSuppress.onHostMounted(shadowHost);
|
||||
const app = document.createElement("div");
|
||||
app.className = "bsk-overlay-root";
|
||||
container.append(app);
|
||||
reactRoot = ReactDOM.createRoot(app);
|
||||
renderOverlay();
|
||||
renderAll();
|
||||
void requestOverlayState();
|
||||
return reactRoot;
|
||||
},
|
||||
onRemove(root) {
|
||||
overlayHost = null;
|
||||
overlayContainer = null;
|
||||
root?.unmount();
|
||||
reactRoot = null;
|
||||
},
|
||||
});
|
||||
|
||||
function renderOverlay() {
|
||||
function setOverlaySurfaceState(active: boolean, blocking: boolean): void {
|
||||
const host = overlayHost;
|
||||
const container = overlayContainer;
|
||||
if (!host || !container) return;
|
||||
|
||||
if (active) {
|
||||
host.setAttribute("data-bsk-overlay-surface", "");
|
||||
if (blocking) {
|
||||
host.setAttribute("data-bsk-overlay-blocking", "");
|
||||
} else {
|
||||
host.removeAttribute("data-bsk-overlay-blocking");
|
||||
}
|
||||
Object.assign(container.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
host.removeAttribute("data-bsk-overlay-surface");
|
||||
host.removeAttribute("data-bsk-overlay-blocking");
|
||||
container.style.removeProperty("position");
|
||||
container.style.removeProperty("inset");
|
||||
container.style.removeProperty("pointer-events");
|
||||
}
|
||||
|
||||
function setOverlayHostHiddenFromAccessibility(hidden: boolean): void {
|
||||
const host = overlayHost;
|
||||
if (!host) return;
|
||||
|
||||
if (!hidden) {
|
||||
host.removeAttribute("aria-hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const focusedInShadow = host.shadowRoot?.activeElement;
|
||||
if (focusedInShadow instanceof HTMLElement) {
|
||||
focusedInShadow.blur();
|
||||
}
|
||||
if (document.activeElement === host) {
|
||||
host.blur();
|
||||
}
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function renderReactOverlays(): void {
|
||||
const overlayState = overlays.snapshot();
|
||||
reactRoot?.render(
|
||||
React.createElement(
|
||||
I18nextProvider,
|
||||
{ i18n },
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(BorrowConfirmationOverlay, {
|
||||
requests: overlayState.borrowRequests,
|
||||
}),
|
||||
React.createElement(ControlOverlay, {
|
||||
visible: overlayState.controlVisible && overlayState.activeHelp === null,
|
||||
interrupting: overlayState.interrupting,
|
||||
automationBypass: overlayState.automationBypassCount > 0,
|
||||
onInterrupt: handleInterrupt,
|
||||
}),
|
||||
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
|
||||
),
|
||||
),
|
||||
const controlOverlayVisible = shouldShowAgentControlOverlay(overlayState);
|
||||
const interactiveOverlayVisible =
|
||||
overlayState.borrowRequests.length > 0 ||
|
||||
overlayState.activeHelp !== null ||
|
||||
overlayState.activeRecord !== null;
|
||||
setOverlayHostHiddenFromAccessibility(!interactiveOverlayVisible);
|
||||
setOverlaySurfaceState(
|
||||
controlOverlayVisible,
|
||||
controlOverlayVisible && overlayState.automationBypassCount === 0,
|
||||
);
|
||||
const root = reactRoot;
|
||||
if (!root) return;
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
I18nextProvider,
|
||||
{ i18n },
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(BorrowConfirmationOverlay, {
|
||||
requests: overlayState.borrowRequests,
|
||||
}),
|
||||
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
|
||||
React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
|
||||
React.createElement(ControlOverlay, {
|
||||
visible: controlOverlayVisible,
|
||||
interrupting: overlayState.interrupting,
|
||||
automationBypass: overlayState.automationBypassCount > 0,
|
||||
onInterrupt: handleInterrupt,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAll(): void {
|
||||
renderReactOverlays();
|
||||
}
|
||||
|
||||
async function waitForRenderedOverlayUpdate(): Promise<void> {
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function clearCurrentAgentSession(): void {
|
||||
const sessionId = overlays.snapshot().activeSessionId;
|
||||
if (!sessionId) return;
|
||||
resetAgentOverlayState(sessionId);
|
||||
}
|
||||
|
||||
function applyOverlayState(state: OverlayAgentStateMessage): void {
|
||||
activeAgentState = state;
|
||||
overlays.applyAgentControlMode(state.sessionId, state.mode);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function resetAgentOverlayState(sessionId: string) {
|
||||
const previousHelp = overlays.resetAgentOverlays(sessionId);
|
||||
if (previousHelp) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
activeHelpRespond = null;
|
||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||
}
|
||||
renderOverlay();
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function handleInterrupt() {
|
||||
@@ -116,15 +243,8 @@ export default defineContentScript({
|
||||
return;
|
||||
}
|
||||
overlays.setInterrupting(true);
|
||||
renderOverlay();
|
||||
void sendInterrupt(
|
||||
(msg) => chrome.runtime.sendMessage(msg),
|
||||
sessionId,
|
||||
).then((reply) => {
|
||||
// Always retract the mask after the round trip resolves
|
||||
// (success, failure, or timeout). Cancellation is fire-and-
|
||||
// forget on the daemon side; the user must not be stuck
|
||||
// behind a transient issue. The Agent Window stays open.
|
||||
renderAll();
|
||||
void sendInterrupt((msg) => chrome.runtime.sendMessage(msg), sessionId).then((reply) => {
|
||||
resetAgentOverlayState(sessionId);
|
||||
if (!reply.ok) {
|
||||
console.warn("[bsk overlay] interrupt did not get a clean ack from daemon");
|
||||
@@ -138,11 +258,54 @@ export default defineContentScript({
|
||||
| BorrowCancelMessage
|
||||
| HelpRequestMessage
|
||||
| HelpCancelMessage
|
||||
| CaptureSuppressMessage
|
||||
| OverlayAgentOverlayResetMessage
|
||||
| OverlayAgentStateMessage
|
||||
| OverlayAutomationBypassMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
|
||||
) => {
|
||||
if (isCaptureSuppressMessage(message)) {
|
||||
return captureSuppress.handleMessage(message, sendResponse);
|
||||
}
|
||||
|
||||
if (isRecordContentMessage(message)) {
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
message,
|
||||
{
|
||||
activeRequestId: activeRecordRequestId,
|
||||
capture: recordCapture,
|
||||
setActiveRequestId: (id) => {
|
||||
activeRecordRequestId = id;
|
||||
},
|
||||
setCapture: (capture) => {
|
||||
recordCapture = capture;
|
||||
},
|
||||
onStart: (requestId, startedAtMs) => {
|
||||
overlays.setAgentRecordRequest({
|
||||
id: requestId,
|
||||
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
renderAll();
|
||||
},
|
||||
onStop: () => {
|
||||
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
|
||||
renderAll();
|
||||
},
|
||||
},
|
||||
sendResponse as unknown as
|
||||
| ((response: RecordStartAck | RecordStopAck) => void)
|
||||
| undefined,
|
||||
);
|
||||
return needsAsync;
|
||||
}
|
||||
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
@@ -151,7 +314,12 @@ export default defineContentScript({
|
||||
) {
|
||||
const bypassMsg = message as OverlayAutomationBypassMessage;
|
||||
overlays.setAutomationBypass(bypassMsg.enabled);
|
||||
renderOverlay();
|
||||
renderAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isOverlayAgentStateMessage(message)) {
|
||||
applyOverlayState(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -162,48 +330,28 @@ export default defineContentScript({
|
||||
|
||||
if (message.type === "borrow-cancel") {
|
||||
overlays.removeBorrowRequest(message.requestId);
|
||||
renderOverlay();
|
||||
renderAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isHelpCancelMessage(message)) {
|
||||
const state = overlays.snapshot();
|
||||
if (state.activeHelp && state.activeHelp.id === message.requestId) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
overlays.clearAgentHelpRequest(message.requestId);
|
||||
renderAll();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isHelpRequestMessage(message)) {
|
||||
const helpMsg = message as HelpRequestMessage;
|
||||
let responded = false;
|
||||
const respond = (outcome: "continued" | "cancelled", note?: string) => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
const reply: HelpResponseMessage = {
|
||||
type: HELP_RESPONSE,
|
||||
outcome,
|
||||
...(note ? { note } : {}),
|
||||
};
|
||||
sendResponse(reply);
|
||||
activeHelpRespond = null;
|
||||
overlays.clearAgentHelpRequest(helpMsg.requestId);
|
||||
renderOverlay();
|
||||
};
|
||||
const previousHelp = overlays.setAgentHelpRequest({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) => respond("continued", note.trim() ? note : undefined),
|
||||
onCancel: () => respond("cancelled"),
|
||||
});
|
||||
if (previousHelp) {
|
||||
activeHelpRespond?.("cancelled");
|
||||
const previousHelp = mountHelpRequest(helpMsg);
|
||||
if (previousHelp && previousHelp.id !== helpMsg.requestId) {
|
||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||
}
|
||||
activeHelpRespond = respond;
|
||||
renderOverlay();
|
||||
return true; // async sendResponse
|
||||
renderAll();
|
||||
sendResponse({ type: HELP_ACK, ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "borrow-request") {
|
||||
@@ -213,7 +361,7 @@ export default defineContentScript({
|
||||
responded = true;
|
||||
sendResponse({ type: "borrow-response", allowed });
|
||||
overlays.removeBorrowRequest(message.requestId);
|
||||
renderOverlay();
|
||||
renderAll();
|
||||
};
|
||||
|
||||
overlays.addBorrowRequest({
|
||||
@@ -224,30 +372,133 @@ export default defineContentScript({
|
||||
onAllow: () => respond(true),
|
||||
onDeny: () => respond(false),
|
||||
});
|
||||
renderOverlay();
|
||||
renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function mountOverlayIfAgent(): Promise<void> {
|
||||
if (!(await anySessionLive())) return;
|
||||
try {
|
||||
const reply = (await chrome.runtime.sendMessage({
|
||||
kind: OVERLAY_MSG_WHO_AM_I,
|
||||
})) as OverlayWhoAmIResponse | undefined;
|
||||
if (!reply?.sessionId) return;
|
||||
overlays.activateAgentSession(reply.sessionId);
|
||||
renderOverlay();
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] who_am_i failed", err);
|
||||
async function sendHelpFinish(
|
||||
requestId: string,
|
||||
outcome: "continued" | "cancelled",
|
||||
note?: string,
|
||||
): Promise<void> {
|
||||
const msg: HelpFinishMessage = {
|
||||
type: HELP_FINISH,
|
||||
requestId,
|
||||
outcome,
|
||||
...(note ? { note } : {}),
|
||||
};
|
||||
overlays.clearAgentHelpRequest(requestId);
|
||||
renderAll();
|
||||
await waitForRenderedOverlayUpdate();
|
||||
await chrome.runtime.sendMessage(msg).catch((err) => {
|
||||
console.debug("[bsk overlay] help finish failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">) {
|
||||
return overlays.setAgentHelpRequest(
|
||||
createHelpRequestData(helpMsg, {
|
||||
finish: (requestId, outcome, note) => {
|
||||
void sendHelpFinish(requestId, outcome, note);
|
||||
},
|
||||
query: () =>
|
||||
chrome.runtime.sendMessage({ type: HELP_QUERY }) as Promise<
|
||||
HelpQueryResponse | undefined
|
||||
>,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function queryActiveHelpWithRetry(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
try {
|
||||
const helpQuery = (await chrome.runtime.sendMessage({
|
||||
type: HELP_QUERY,
|
||||
})) as HelpQueryResponse | undefined;
|
||||
if (helpQuery?.active && helpQuery.request) {
|
||||
mountHelpRequest(helpQuery.request);
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] help query failed", err);
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||
}
|
||||
}
|
||||
|
||||
async function queryActiveRecord(): Promise<void> {
|
||||
try {
|
||||
const recordQuery = (await chrome.runtime.sendMessage({
|
||||
type: RECORD_QUERY,
|
||||
})) as RecordQueryResponse | undefined;
|
||||
if (
|
||||
recordQuery?.active &&
|
||||
typeof recordQuery.requestId === "string" &&
|
||||
overlays.snapshot().activeRecord === null
|
||||
) {
|
||||
const requestId = recordQuery.requestId;
|
||||
const startedAtMs = recordQuery.startedAtMs;
|
||||
overlays.setAgentRecordRequest({
|
||||
id: requestId,
|
||||
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
renderAll();
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] record query failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuxiliaryOverlayState(): Promise<void> {
|
||||
await Promise.all([queryActiveHelpWithRetry(), queryActiveRecord()]);
|
||||
}
|
||||
|
||||
async function requestOverlayState(): Promise<void> {
|
||||
try {
|
||||
const state = (await chrome.runtime.sendMessage({
|
||||
kind: OVERLAY_MSG_READY,
|
||||
})) as OverlayAgentStateMessage | undefined;
|
||||
if (state && isOverlayAgentStateMessage(state)) {
|
||||
applyOverlayState(state);
|
||||
}
|
||||
void refreshAuxiliaryOverlayState();
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] overlay.ready failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void requestOverlayState();
|
||||
};
|
||||
|
||||
// Live-apply popup toggles of the control-hints preference.
|
||||
const onStorageChange = (
|
||||
changes: Record<string, chrome.storage.StorageChange>,
|
||||
areaName: string,
|
||||
) => {
|
||||
if (areaName !== "local") return;
|
||||
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
|
||||
if (!change) return;
|
||||
overlays.setControlHintsHidden(change.newValue === true);
|
||||
renderAll();
|
||||
};
|
||||
chrome.storage.onChanged.addListener(onStorageChange);
|
||||
|
||||
ui.mount();
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
void mountOverlayIfAgent();
|
||||
void requestOverlayState();
|
||||
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
|
||||
const hostObserver = new MutationObserver(() => {
|
||||
const connected = overlayHost?.isConnected ?? false;
|
||||
@@ -257,6 +508,8 @@ export default defineContentScript({
|
||||
remountInProgress = true;
|
||||
try {
|
||||
ui.mount();
|
||||
if (activeAgentState) applyOverlayState(activeAgentState);
|
||||
void requestOverlayState();
|
||||
} finally {
|
||||
remountInProgress = false;
|
||||
}
|
||||
@@ -271,19 +524,12 @@ export default defineContentScript({
|
||||
ctx.onInvalidated(() => {
|
||||
hostObserver.disconnect();
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
chrome.storage.onChanged.removeListener(onStorageChange);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
// Restore history hooks / remove capture listeners before the CS unloads.
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function anySessionLive(): Promise<boolean> {
|
||||
if (!chrome.storage?.session?.get) return true;
|
||||
try {
|
||||
const result = (await chrome.storage.session.get({
|
||||
[SESSIONS_LIVE_FLAG_KEY]: false,
|
||||
})) as Record<string, unknown> | undefined;
|
||||
return Boolean(result?.[SESSIONS_LIVE_FLAG_KEY]);
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] sessions-live flag read failed", err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SnapshotInfo } from "@/lib/connection-controller";
|
||||
import { STORAGE_KEYS } from "@/lib/instance-id";
|
||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
||||
import { App } from "./App";
|
||||
import { useConnectionState } from "./use-connection-state";
|
||||
@@ -24,6 +25,11 @@ const baseSnapshot: SnapshotInfo = {
|
||||
connectionEnabled: true,
|
||||
};
|
||||
|
||||
function openRecordView() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
const setLabel = vi.fn();
|
||||
const setConnectionEnabled = vi.fn();
|
||||
@@ -63,13 +69,35 @@ describe("App", () => {
|
||||
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses flex gap for label field spacing", () => {
|
||||
const { container } = render(<App />);
|
||||
it("does not render record UI on the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
const labelField = container.querySelector("[data-slot='popup-label-field']");
|
||||
expect(labelField?.className).toContain("flex");
|
||||
expect(labelField?.className).toContain("flex-col");
|
||||
expect(labelField?.className).toContain("gap-3");
|
||||
expect(screen.queryByRole("button", { name: "复制录制指令" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "操作录制" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the feature list from the launcher and navigates to record", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
|
||||
expect(screen.getByText("操作录制")).toBeTruthy();
|
||||
expect(screen.getByText("录制你的操作,供 Agent 参考")).toBeTruthy();
|
||||
expect(screen.queryByText("未连接")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "复制录制指令" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns from the feature list to the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "返回" }));
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows single-line compact metadata and copies the instance id", async () => {
|
||||
@@ -132,8 +160,179 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("连接已关闭")).toBeTruthy();
|
||||
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("copies the record prompt with instance id and --browser when connected", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("例如:发布一篇文章"), {
|
||||
target: { value: "发布 wiki 文档" },
|
||||
});
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("03c3e47f");
|
||||
expect(copied).toContain("--browser 03c3e47f");
|
||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||
// Button label stays static; a transient toast confirms the copy.
|
||||
expect(copyButton.textContent).toContain("复制录制指令");
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
||||
});
|
||||
|
||||
it("includes --url in record prompt when a start URL is provided", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://…"), {
|
||||
target: { value: "https://example.com/" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制录制指令" }));
|
||||
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("--url https://example.com/");
|
||||
expect(copied).not.toContain("--purpose");
|
||||
});
|
||||
|
||||
it("disables record copy when disconnected", () => {
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("control hints toggle", () => {
|
||||
function stubChromeStorage(initial: Record<string, unknown> = {}) {
|
||||
const store = { ...initial };
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { lastError: undefined },
|
||||
storage: {
|
||||
local: {
|
||||
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
|
||||
const items: Record<string, unknown> = {};
|
||||
for (const k of Array.isArray(keys) ? keys : [keys]) {
|
||||
if (k in store) items[k] = store[k];
|
||||
}
|
||||
cb(items);
|
||||
},
|
||||
set: (items: Record<string, unknown>, cb?: () => void) => {
|
||||
Object.assign(store, items);
|
||||
cb?.();
|
||||
},
|
||||
},
|
||||
onChanged: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the control hints toggle on when no preference is stored", async () => {
|
||||
stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "控制提示" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("reflects the stored hidden preference", async () => {
|
||||
stubChromeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
|
||||
|
||||
render(<App />);
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "控制提示" });
|
||||
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false"));
|
||||
});
|
||||
|
||||
it("persists the hidden preference when the toggle is turned off", async () => {
|
||||
const store = stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "控制提示" });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the hint copy in an accessible info tooltip", async () => {
|
||||
stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const info = await screen.findByRole("button", { name: "控制提示说明" });
|
||||
expect(info).toBeTruthy();
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
|
||||
// Hidden until the info button is hovered or focused.
|
||||
expect(tooltip.className).toContain("opacity-0");
|
||||
});
|
||||
|
||||
it("uses the same switch component and size for both settings rows", async () => {
|
||||
stubChromeStorage();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const hintsToggle = await screen.findByRole("switch", { name: "控制提示" });
|
||||
const connectionToggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
|
||||
// One shared Switch component, one size — hierarchy comes from copy and
|
||||
// the info icon, not control size. Both rows default to checked, so the
|
||||
// class strings must be identical.
|
||||
expect(hintsToggle.className).toContain("h-5 w-9");
|
||||
expect(hintsToggle.className).toBe(connectionToggle.className);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import { Badge, Button, Input, Label } from "@browser-skill/ui";
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckLine,
|
||||
RiFileCopyLine,
|
||||
RiInformationLine,
|
||||
} from "@remixicon/react";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
import { PROTOCOL_VERSION } from "@/transport/handshake";
|
||||
import functionIconUrl from "../../../assets/function.svg";
|
||||
import { ConnectionStatusIndicator } from "./connection-status-indicator";
|
||||
import { POPUP_FEATURES, type PopupView } from "./features";
|
||||
import { Switch } from "./switch";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
import { useControlHintsHidden } from "./use-control-hints-hidden";
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
disconnected: "popup.stateLabel.disconnected",
|
||||
@@ -29,9 +39,16 @@ function getLogoSrc() {
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
|
||||
const [labelDraft, setLabelDraft] = useState(snapshot.label);
|
||||
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
||||
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
|
||||
const [view, setView] = useState<PopupView>("main");
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
const [purposeDraft, setPurposeDraft] = useState("");
|
||||
const [startUrlDraft, setStartUrlDraft] = useState("");
|
||||
// Bumped on every successful copy so the "copied" toast re-shows (and its
|
||||
// auto-hide timer restarts) even when the copied content is unchanged.
|
||||
const [copiedTick, setCopiedTick] = useState(0);
|
||||
const showCopiedToast = copiedTick > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -44,17 +61,22 @@ export function App() {
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDraft(snapshot.label);
|
||||
}, [snapshot.label]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedInstanceId(false);
|
||||
setCopiedTick(0);
|
||||
}, [snapshot.instanceId]);
|
||||
|
||||
const onLabelBlur = () => {
|
||||
if (labelDraft !== snapshot.label) setLabel(labelDraft);
|
||||
};
|
||||
// Hide the copied toast when the command changes (topic / start URL edits).
|
||||
useEffect(() => {
|
||||
setCopiedTick(0);
|
||||
}, [purposeDraft, startUrlDraft]);
|
||||
|
||||
// Auto-hide the copied toast shortly after it appears.
|
||||
useEffect(() => {
|
||||
if (copiedTick === 0) return;
|
||||
const timer = window.setTimeout(() => setCopiedTick(0), 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
@@ -68,142 +90,328 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
? [
|
||||
`bsk record start --browser ${snapshot.instanceId}`,
|
||||
recordStartUrl ? `--url ${recordStartUrl}` : "",
|
||||
recordPurpose ? `--purpose "${recordPurpose}"` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: "";
|
||||
const recordPrompt = snapshot.instanceId
|
||||
? t("popup.record.promptTemplate", {
|
||||
command: recordCommand,
|
||||
})
|
||||
: "";
|
||||
|
||||
const copyRecordPrompt = async () => {
|
||||
if (!recordReady || !navigator.clipboard?.writeText) return;
|
||||
await navigator.clipboard.writeText(recordPrompt);
|
||||
setCopiedTick((tick) => tick + 1);
|
||||
};
|
||||
|
||||
const headerTitle =
|
||||
view === "features"
|
||||
? t("popup.launcher.title")
|
||||
: view === "record"
|
||||
? t("popup.record.sectionTitle")
|
||||
: t("popup.brandName");
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
|
||||
data-slot="popup-root"
|
||||
data-view={view}
|
||||
data-version-skew={isSkewed ? "true" : undefined}
|
||||
>
|
||||
<header className="flex items-center gap-2" data-slot="popup-brand-header">
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt=""
|
||||
className="size-7 rounded-lg"
|
||||
data-slot="popup-brand-logo"
|
||||
/>
|
||||
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-connection-card"
|
||||
<header
|
||||
className="flex items-center gap-2"
|
||||
data-slot={view === "main" ? "popup-brand-header" : "popup-subview-header"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ConnectionStatusIndicator state={statusState} />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
data-slot="popup-state-label"
|
||||
>
|
||||
{t(STATE_LABEL_KEYS[statusState])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[10px] font-medium uppercase"
|
||||
data-slot="popup-state-badge"
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={snapshot.connectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
>
|
||||
{snapshot.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3" data-slot="popup-label-field">
|
||||
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
|
||||
{t("popup.labelTitle")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-label"
|
||||
type="text"
|
||||
value={labelDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
|
||||
onBlur={onLabelBlur}
|
||||
placeholder={t("popup.labelPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-label-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
<code
|
||||
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
|
||||
data-slot="popup-instance-id"
|
||||
>
|
||||
{instanceId}
|
||||
</code>
|
||||
{view !== "main" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 rounded-md"
|
||||
disabled={!snapshot.instanceId}
|
||||
aria-label={t("popup.copyInstanceId")}
|
||||
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
|
||||
onClick={() => {
|
||||
void copyInstanceId();
|
||||
}}
|
||||
data-slot="popup-copy-instance-id"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
aria-label={t("popup.back")}
|
||||
onClick={() => setView(view === "record" ? "features" : "main")}
|
||||
data-slot="popup-back"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt=""
|
||||
className="size-7 rounded-lg"
|
||||
data-slot="popup-brand-logo"
|
||||
/>
|
||||
)}
|
||||
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold tracking-tight">
|
||||
{headerTitle}
|
||||
</h1>
|
||||
{view === "main" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group size-8 shrink-0 rounded-md"
|
||||
aria-label={t("popup.launcher.title")}
|
||||
onClick={() => setView("features")}
|
||||
data-slot="popup-launcher"
|
||||
>
|
||||
<img
|
||||
src={functionIconUrl}
|
||||
alt=""
|
||||
className="size-6 opacity-35 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
data-slot="popup-launcher-icon"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{view === "main" && (
|
||||
<>
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-connection-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ConnectionStatusIndicator state={statusState} />
|
||||
<span className="truncate text-sm font-medium" data-slot="popup-state-label">
|
||||
{t(STATE_LABEL_KEYS[statusState])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[10px] font-medium uppercase"
|
||||
data-slot="popup-state-badge"
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={snapshot.connectionEnabled}
|
||||
onCheckedChange={setConnectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-control-hints-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{t("popup.controlHintsToggleTitle")}
|
||||
</span>
|
||||
<span className="group relative inline-flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("popup.controlHintsInfoLabel")}
|
||||
data-slot="popup-control-hints-info"
|
||||
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<RiInformationLine className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
{t("popup.controlHintsToggleHint")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={!controlHintsHidden}
|
||||
onCheckedChange={(shown) => setControlHintsHidden(!shown)}
|
||||
aria-label={t("popup.controlHintsToggleTitle")}
|
||||
data-slot="popup-control-hints-toggle"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
>
|
||||
{snapshot.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
<code
|
||||
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
|
||||
data-slot="popup-instance-id"
|
||||
>
|
||||
{instanceId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 rounded-md"
|
||||
disabled={!snapshot.instanceId}
|
||||
aria-label={t("popup.copyInstanceId")}
|
||||
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
|
||||
onClick={() => {
|
||||
void copyInstanceId();
|
||||
}}
|
||||
data-slot="popup-copy-instance-id"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "features" && (
|
||||
<section className="space-y-2" data-slot="popup-feature-list">
|
||||
{POPUP_FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<button
|
||||
key={feature.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border/80 bg-card/60 px-3 py-2.5 text-left transition-colors hover:bg-card/80"
|
||||
onClick={() => setView(feature.id)}
|
||||
data-slot={`popup-feature-${feature.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
{t(feature.titleKey)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
|
||||
{t(feature.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === "record" && (
|
||||
<section className="space-y-2.5" data-slot="popup-record-body">
|
||||
<p
|
||||
className="text-[11px] leading-snug text-muted-foreground"
|
||||
data-slot="popup-record-hint-desc"
|
||||
>
|
||||
{t("popup.record.sectionHint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-purpose-field">
|
||||
<Label htmlFor="bh-record-purpose" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.topicLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-purpose"
|
||||
type="text"
|
||||
value={purposeDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPurposeDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("popup.record.topicPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-purpose-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-start-url-field">
|
||||
<Label htmlFor="bh-record-start-url" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.startUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-start-url"
|
||||
type="url"
|
||||
value={startUrlDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setStartUrlDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("popup.record.startUrlPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-start-url-input"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={recordPrompt}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-md border border-input bg-muted/40 px-2.5 py-2 font-mono text-[10px] leading-snug text-foreground/90 focus-visible:outline-none"
|
||||
data-slot="popup-record-prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{!recordReady && (
|
||||
<p className="text-[10px] text-muted-foreground" data-slot="popup-record-hint">
|
||||
{t("popup.record.hintDisconnected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative ml-auto shrink-0">
|
||||
{showCopiedToast && (
|
||||
<div
|
||||
role="status"
|
||||
className="absolute bottom-full right-0 mb-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium text-background shadow-md backdrop-blur-sm"
|
||||
data-slot="popup-record-copied-toast"
|
||||
>
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
{t("popup.record.copied")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={!recordReady}
|
||||
onClick={() => {
|
||||
void copyRecordPrompt();
|
||||
}}
|
||||
data-slot="popup-record-copy"
|
||||
>
|
||||
<RiFileCopyLine className="size-3.5" aria-hidden />
|
||||
{t("popup.record.copyButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RemixiconComponentType } from "@remixicon/react";
|
||||
import { RiRecordCircleLine } from "@remixicon/react";
|
||||
|
||||
export type PopupFeatureId = "record";
|
||||
|
||||
export type PopupView = "main" | "features" | PopupFeatureId;
|
||||
|
||||
export type PopupFeature = {
|
||||
id: PopupFeatureId;
|
||||
icon: RemixiconComponentType;
|
||||
titleKey: "popup.record.sectionTitle";
|
||||
descKey: "popup.record.cardDesc";
|
||||
};
|
||||
|
||||
export const POPUP_FEATURES: PopupFeature[] = [
|
||||
{
|
||||
id: "record",
|
||||
icon: RiRecordCircleLine,
|
||||
titleKey: "popup.record.sectionTitle",
|
||||
descKey: "popup.record.cardDesc",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,13 @@
|
||||
@import "@browser-skill/ui/styles";
|
||||
@source "../../";
|
||||
@source "../../../../../packages/ui/src";
|
||||
|
||||
/* The shared base sets html/body/#root to height:100%, which is meant for
|
||||
full-page surfaces. In a popup that pins the window to the tallest content
|
||||
it ever showed, so collapsing the record section leaves blank space. Size
|
||||
to content instead so Chrome can grow and shrink the popup to fit. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./switch";
|
||||
|
||||
describe("Switch", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders switch semantics with the checked state", () => {
|
||||
render(<Switch checked={true} onCheckedChange={vi.fn()} aria-label="开关" />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "开关" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
expect(toggle.className).toContain("h-5 w-9");
|
||||
expect(toggle.className).toContain("bg-primary");
|
||||
});
|
||||
|
||||
it("calls onCheckedChange with the negated state when clicked", () => {
|
||||
const onCheckedChange = vi.fn();
|
||||
render(<Switch checked={false} onCheckedChange={onCheckedChange} aria-label="开关" />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "开关" });
|
||||
expect(toggle.className).toContain("bg-muted");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from "@browser-skill/ui";
|
||||
|
||||
export interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
"aria-label": string;
|
||||
"data-slot"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The popup's single toggle switch — every settings row uses this same
|
||||
* component and size; hierarchy between primary and secondary settings is
|
||||
* conveyed by copy/iconography, not by control size.
|
||||
*/
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
"aria-label": ariaLabel,
|
||||
"data-slot": dataSlot,
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
data-slot={dataSlot}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
checked ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getControlHintsHidden, STORAGE_KEYS, setControlHintsHidden } from "@/lib/instance-id";
|
||||
|
||||
/**
|
||||
* Popup-side view of the "hide control hints" preference in
|
||||
* `chrome.storage.local`. Defaults to shown when storage is unavailable
|
||||
* (e.g. unit tests) so the popup still renders; writes go straight to
|
||||
* storage and content scripts pick them up via `storage.onChanged`.
|
||||
*/
|
||||
export function useControlHintsHidden(): [boolean, (hidden: boolean) => void] {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined;
|
||||
let cancelled = false;
|
||||
getControlHintsHidden()
|
||||
.then((value) => {
|
||||
if (!cancelled) setHidden(value);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.debug("[browser-skill] control-hints preference read failed", err);
|
||||
});
|
||||
const onChanged = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
|
||||
if (areaName !== "local") return;
|
||||
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
|
||||
if (change) setHidden(change.newValue === true);
|
||||
};
|
||||
chrome.storage.onChanged.addListener(onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
chrome.storage.onChanged.removeListener(onChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = (value: boolean) => {
|
||||
setHidden(value);
|
||||
if (typeof chrome === "undefined" || !chrome.storage?.local) return;
|
||||
setControlHintsHidden(value).catch((err) => {
|
||||
console.debug("[browser-skill] control-hints preference write failed", err);
|
||||
});
|
||||
};
|
||||
|
||||
return [hidden, update];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressMessage,
|
||||
withOverlaysHiddenForCapture,
|
||||
} from "../capture-suppress-bridge";
|
||||
|
||||
function recordingSendToTab(events: string[]) {
|
||||
return vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
|
||||
events.push(message.phase);
|
||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
describe("withOverlaysHiddenForCapture", () => {
|
||||
it("sends begin → fn → end and returns fn's result", async () => {
|
||||
const events: string[] = [];
|
||||
const sendToTab = recordingSendToTab(events);
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(
|
||||
7,
|
||||
async () => {
|
||||
events.push("capture");
|
||||
return "shot";
|
||||
},
|
||||
sendToTab,
|
||||
);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "begin" });
|
||||
expect(sendToTab).toHaveBeenCalledWith(7, { type: CAPTURE_SUPPRESS, phase: "end" });
|
||||
});
|
||||
|
||||
it("still sends end when fn throws, then rethrows", async () => {
|
||||
const events: string[] = [];
|
||||
const sendToTab = recordingSendToTab(events);
|
||||
|
||||
await expect(
|
||||
withOverlaysHiddenForCapture(
|
||||
7,
|
||||
async () => {
|
||||
events.push("capture");
|
||||
throw new Error("capture exploded");
|
||||
},
|
||||
sendToTab,
|
||||
),
|
||||
).rejects.toThrow("capture exploded");
|
||||
expect(events).toEqual(["begin", "capture", "end"]);
|
||||
});
|
||||
|
||||
it("captures directly when the tab has no content script", async () => {
|
||||
const sendToTab = vi.fn(async () => {
|
||||
throw new Error("Could not establish connection. Receiving end does not exist.");
|
||||
});
|
||||
const fn = vi.fn(async () => "shot");
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(7, fn, sendToTab);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
// begin failed → no matching end may be sent.
|
||||
expect(sendToTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows end failures so the capture result stands", async () => {
|
||||
const sendToTab = vi.fn(async (_tabId: number, message: CaptureSuppressMessage) => {
|
||||
if (message.phase === "end") throw new Error("tab navigated away");
|
||||
return { type: CAPTURE_SUPPRESS, ok: true };
|
||||
});
|
||||
|
||||
const result = await withOverlaysHiddenForCapture(7, async () => "shot", sendToTab);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(sendToTab).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ConnectionController, __testing__ } from "../connection-controller";
|
||||
import type { Transport } from "../../transport/transport";
|
||||
import type { ConnectionState } from "../../transport/types";
|
||||
import type { ConnectionStateHandler } from "../../transport/transport";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
|
||||
import type { HandshakeResult } from "../../transport/types";
|
||||
import type { ConnectionStateHandler, FrameHandler, Transport } from "../../transport/transport";
|
||||
import type { ConnectionState, HandshakeResult, ProtocolFrame } from "../../transport/types";
|
||||
import { __testing__, ConnectionController } from "../connection-controller";
|
||||
|
||||
vi.mock("../instance-id", () => ({
|
||||
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
|
||||
@@ -99,6 +97,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
function makeMockTransport(initialState: ConnectionState = "disconnected") {
|
||||
let state = initialState;
|
||||
const stateHandlers = new Set<ConnectionStateHandler>();
|
||||
const messageHandlers = new Set<FrameHandler>();
|
||||
const transport = {
|
||||
get state() {
|
||||
return state;
|
||||
@@ -112,7 +111,10 @@ function makeMockTransport(initialState: ConnectionState = "disconnected") {
|
||||
for (const h of stateHandlers) h("disconnected");
|
||||
}),
|
||||
send: vi.fn(),
|
||||
onMessage: vi.fn(() => ({ dispose: () => {} })),
|
||||
onMessage: vi.fn((handler: FrameHandler) => {
|
||||
messageHandlers.add(handler);
|
||||
return { dispose: () => messageHandlers.delete(handler) };
|
||||
}),
|
||||
onConnectionStateChange: vi.fn((handler: ConnectionStateHandler) => {
|
||||
stateHandlers.add(handler);
|
||||
return { dispose: () => stateHandlers.delete(handler) };
|
||||
@@ -121,6 +123,9 @@ function makeMockTransport(initialState: ConnectionState = "disconnected") {
|
||||
state = next;
|
||||
for (const h of stateHandlers) h(next);
|
||||
},
|
||||
emitMessage(frame: ProtocolFrame) {
|
||||
for (const handler of messageHandlers) handler(frame);
|
||||
},
|
||||
};
|
||||
return transport as typeof transport & Transport;
|
||||
}
|
||||
@@ -130,6 +135,10 @@ describe("ConnectionController connectionEnabled", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not connect on attach when connection is disabled", async () => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
@@ -154,6 +163,36 @@ describe("ConnectionController connectionEnabled", () => {
|
||||
expect(controller.snapshot().lastError).toBeNull();
|
||||
});
|
||||
|
||||
it("runs safe session cleanup before an intentional disconnect", async () => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
const order: string[] = [];
|
||||
transport.disconnect.mockImplementation(async () => {
|
||||
order.push("disconnect");
|
||||
});
|
||||
await controller.attach(transport, { name: "Chrome", version: "120" }, true, {
|
||||
beforeDisconnect: async () => {
|
||||
order.push("cleanup");
|
||||
},
|
||||
});
|
||||
|
||||
await controller.setConnectionEnabled(false);
|
||||
|
||||
expect(order).toEqual(["cleanup", "disconnect"]);
|
||||
});
|
||||
|
||||
it("runs session cleanup when the transport disconnects unexpectedly", async () => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
const onDisconnected = vi.fn(async () => {});
|
||||
await controller.attach(transport, { name: "Chrome", version: "120" }, true, {
|
||||
onDisconnected,
|
||||
});
|
||||
|
||||
transport.emitState("disconnected");
|
||||
await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("reconnects when connection is re-enabled", async () => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
@@ -173,4 +212,41 @@ describe("ConnectionController connectionEnabled", () => {
|
||||
|
||||
expect(controller.snapshot().state).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("disconnects and retries when handshake fails while the socket is still open", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
|
||||
const request = transport.send.mock.calls[0]?.[0] as { id: string };
|
||||
|
||||
transport.emitMessage({
|
||||
id: request.id,
|
||||
error: { code: "protocol_error", message: "bad handshake" },
|
||||
});
|
||||
await vi.waitFor(() => expect(transport.disconnect).toHaveBeenCalledTimes(1));
|
||||
expect(controller.snapshot().state).toBe("disconnected");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("binds each handshake to the connection that initiated it", async () => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = makeMockTransport();
|
||||
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
|
||||
const first = transport.send.mock.calls[0]?.[0] as { id: string };
|
||||
|
||||
transport.emitState("disconnected");
|
||||
transport.emitState("connected");
|
||||
const second = transport.send.mock.calls[1]?.[0] as { id: string };
|
||||
expect(second.id).not.toBe(first.id);
|
||||
|
||||
transport.emitMessage({ id: first.id, result: handshake("1.0", "1.0") });
|
||||
await Promise.resolve();
|
||||
expect(controller.snapshot().state).not.toBe("connected");
|
||||
|
||||
transport.emitMessage({ id: second.id, result: handshake("1.0", "1.0") });
|
||||
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { describeEventTarget, describeTarget, isMeaningfulClickTarget } from "../describe-target";
|
||||
|
||||
describe("describeTarget", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("prefers aria-label and button role", () => {
|
||||
document.body.innerHTML = `<button aria-label="发布">go</button>`;
|
||||
const el = document.querySelector("button")!;
|
||||
expect(describeTarget(el)).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses label text and name_attr for inputs", () => {
|
||||
document.body.innerHTML = `
|
||||
<label for="svc">服务名称</label>
|
||||
<input id="svc" name="serviceName" />
|
||||
`;
|
||||
const el = document.querySelector("input")!;
|
||||
expect(describeTarget(el)).toEqual({
|
||||
tag: "input",
|
||||
role: "textbox",
|
||||
name: "服务名称",
|
||||
name_attr: "serviceName",
|
||||
});
|
||||
});
|
||||
|
||||
it("never invents CSS selectors or @eN", () => {
|
||||
document.body.innerHTML = `<a href="/x">详情</a>`;
|
||||
const desc = describeTarget(document.querySelector("a")!);
|
||||
expect(JSON.stringify(desc)).not.toMatch(/@e\d+|nth-of-type|#/);
|
||||
expect(desc).toEqual({ tag: "a", role: "link", name: "详情" });
|
||||
});
|
||||
|
||||
it("prefers heading text inside SERP-style links", () => {
|
||||
document.body.innerHTML = `
|
||||
<a href="https://www.tencent.com">
|
||||
<h3>Tencent 腾讯</h3>
|
||||
<cite>https://www.tencent.com</cite>
|
||||
</a>
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("a")!).name).toBe("Tencent 腾讯");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LLM textbook click targets", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("rejects tag-only targets the LLM cannot find", () => {
|
||||
expect(isMeaningfulClickTarget({ tag: "div" })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a short visible name as the teaching signal", () => {
|
||||
expect(isMeaningfulClickTarget({ tag: "div", name: "发布" })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores anonymous layout chrome and lifts child clicks to the named button", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="noise">background</div>
|
||||
<button type="button"><span id="icon">发布</span></button>
|
||||
`;
|
||||
expect(describeEventTarget(document.querySelector("#noise"))).toBeNull();
|
||||
expect(describeEventTarget(document.querySelector("#icon"))).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach layout sibling text as nearby_label on buttons", () => {
|
||||
document.body.innerHTML = `
|
||||
<div>background</div>
|
||||
<button type="button" id="pub">发布</button>
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("#pub")!)).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps nearby_label for form fields from a preceding label-like sibling", () => {
|
||||
document.body.innerHTML = `
|
||||
<div>服务名称</div>
|
||||
<input id="svc" />
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("#svc")!)).toEqual(
|
||||
expect.objectContaining({
|
||||
tag: "input",
|
||||
nearby_label: "服务名称",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat plain text blocks as clicks", () => {
|
||||
document.body.innerHTML = `<p id="copy">说明文字</p>`;
|
||||
expect(describeEventTarget(document.querySelector("#copy"))).toBeNull();
|
||||
});
|
||||
|
||||
it("records custom button-like controls by their visible label", () => {
|
||||
document.body.innerHTML = `<div class="btn" id="pub" style="cursor: pointer">发布</div>`;
|
||||
expect(describeEventTarget(document.querySelector("#pub"))).toEqual(
|
||||
expect.objectContaining({ tag: "div", name: "发布" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
import { waitForDocumentSettled } from "../recording/document-settle";
|
||||
|
||||
function quietCdp(): { cdp: CdpRunner; send: ReturnType<typeof vi.fn> } {
|
||||
const send = vi.fn(async () => ({
|
||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
||||
}));
|
||||
return {
|
||||
cdp: { send: send as unknown as CdpRunner["send"] },
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
describe("waitForDocumentSettled", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits through the minimum observation floor before accepting a quiet page", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { cdp, send } = quietCdp();
|
||||
|
||||
const settled = waitForDocumentSettled(cdp, { target: { tabId: 7 } });
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
|
||||
await expect(settled).resolves.toBe("quiet");
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("cancels before probing a superseded observation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { cdp, send } = quietCdp();
|
||||
|
||||
const controller = new AbortController();
|
||||
const settled = waitForDocumentSettled(
|
||||
cdp,
|
||||
{ target: { tabId: 7 } },
|
||||
{
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
|
||||
await expect(settled).resolves.toBe("cancelled");
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("probes a same-process iframe through its isolated execution context", async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn(async (_tabId: number, method: string) => {
|
||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 91 };
|
||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
||||
});
|
||||
const cdp: CdpRunner = { send: send as unknown as CdpRunner["send"] };
|
||||
|
||||
const settled = waitForDocumentSettled(cdp, {
|
||||
target: { tabId: 7 },
|
||||
frameId: "child-frame",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
|
||||
await expect(settled).resolves.toBe("quiet");
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Page.createIsolatedWorld",
|
||||
expect.objectContaining({ frameId: "child-frame" }),
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Runtime.evaluate",
|
||||
expect.objectContaining({ contextId: 91 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes an OOPIF probe through its CDP target session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 27 };
|
||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: send as unknown as CdpRunner["send"],
|
||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
||||
};
|
||||
|
||||
const target = { tabId: 7, sessionId: "oopif-session" };
|
||||
const settled = waitForDocumentSettled(cdp, { target, frameId: "oopif-frame" });
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
|
||||
await expect(settled).resolves.toBe("quiet");
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(sendToTarget).toHaveBeenCalledWith(
|
||||
target,
|
||||
"Runtime.evaluate",
|
||||
expect.objectContaining({ contextId: 27 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ProtocolFrame } from "@/transport/types";
|
||||
import { HEARTBEAT_EVENT, HEARTBEAT_INTERVAL_MS, startHeartbeat } from "../heartbeat";
|
||||
|
||||
function makeTimers() {
|
||||
let nextId = 1;
|
||||
const intervals = new Map<number, { cb: () => void; ms: number }>();
|
||||
return {
|
||||
intervals,
|
||||
api: {
|
||||
setInterval: (cb: () => void, ms: number) => {
|
||||
const id = nextId++;
|
||||
intervals.set(id, { cb, ms });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id: number) => {
|
||||
intervals.delete(id);
|
||||
},
|
||||
},
|
||||
tick: (id: number) => intervals.get(id)?.cb(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives `onActiveChange` subscribers so a test can flip the link state. */
|
||||
function makeActiveSource() {
|
||||
const listeners = new Set<(active: boolean) => void>();
|
||||
return {
|
||||
subscribe: (cb: (active: boolean) => void) => {
|
||||
// Mirror the controller contract: fire immediately with the current
|
||||
// value (starts inactive), then on every change.
|
||||
cb(false);
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
set: (active: boolean) => {
|
||||
for (const cb of listeners) cb(active);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
describe("startHeartbeat", () => {
|
||||
it("does not beat while the link is inactive", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a 20s interval on activation and sends heartbeat events", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
const [id, info] = [...timers.intervals.entries()][0];
|
||||
expect(info.ms).toBe(HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
timers.tick(id);
|
||||
expect(send).toHaveBeenCalledWith({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
});
|
||||
|
||||
it("stops beating when the link goes inactive", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
active.set(false);
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not stack intervals on repeated activation", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
startHeartbeat({ send: vi.fn(), onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
});
|
||||
|
||||
it("swallows send errors so the interval keeps running", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn(() => {
|
||||
throw new Error("socket closed");
|
||||
});
|
||||
const handle = startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
expect(() => handle.beatForTest()).not.toThrow();
|
||||
});
|
||||
|
||||
it("dispose clears the interval and unsubscribes", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const handle = startHeartbeat({
|
||||
send: vi.fn(),
|
||||
onActiveChange: active.subscribe,
|
||||
timers: timers.api,
|
||||
});
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
|
||||
handle.dispose();
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
expect(active.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("sends a well-formed protocol frame", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
let captured: ProtocolFrame | null = null;
|
||||
const handle = startHeartbeat({
|
||||
send: (f) => {
|
||||
captured = f;
|
||||
},
|
||||
onActiveChange: active.subscribe,
|
||||
timers: timers.api,
|
||||
});
|
||||
handle.beatForTest();
|
||||
expect(captured).toEqual({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HELP_ACK,
|
||||
HELP_CANCEL,
|
||||
HELP_FINISH,
|
||||
HELP_QUERY,
|
||||
HELP_REQUEST,
|
||||
HELP_RESPONSE,
|
||||
isHelpAckMessage,
|
||||
isHelpCancelMessage,
|
||||
isHelpFinishMessage,
|
||||
isHelpQueryMessage,
|
||||
isHelpRequestMessage,
|
||||
isHelpResponseMessage,
|
||||
} from "../help-bridge";
|
||||
|
||||
describe("help-bridge", () => {
|
||||
@@ -12,6 +19,9 @@ describe("help-bridge", () => {
|
||||
expect(HELP_REQUEST).toBe("bsk-help-request");
|
||||
expect(HELP_RESPONSE).toBe("bsk-help-response");
|
||||
expect(HELP_CANCEL).toBe("bsk-help-cancel");
|
||||
expect(HELP_ACK).toBe("bsk-help-ack");
|
||||
expect(HELP_QUERY).toBe("bsk-help-query");
|
||||
expect(HELP_FINISH).toBe("bsk-help-finish");
|
||||
});
|
||||
|
||||
it("type-guards a help-request message", () => {
|
||||
@@ -20,7 +30,9 @@ describe("help-bridge", () => {
|
||||
type: HELP_REQUEST,
|
||||
requestId: "r1",
|
||||
prompt: "log in",
|
||||
displayMode: "compact",
|
||||
selectors: ["#login"],
|
||||
rects: [{ top: 10, left: 20, width: 100, height: 40 }],
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -51,12 +63,22 @@ describe("help-bridge", () => {
|
||||
});
|
||||
|
||||
it("type-guards a help-cancel message", () => {
|
||||
expect(
|
||||
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
|
||||
).toBe(true);
|
||||
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" })).toBe(true);
|
||||
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(false);
|
||||
expect(isHelpCancelMessage(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("type-guards lifecycle messages", () => {
|
||||
expect(isHelpAckMessage({ type: HELP_ACK, ok: true })).toBe(true);
|
||||
expect(isHelpQueryMessage({ type: HELP_QUERY })).toBe(true);
|
||||
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "continued" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isHelpResponseMessage({ type: HELP_RESPONSE, outcome: "cancelled", note: "no" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "weird" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateHoverTrigger } from "../hover-trigger-policy";
|
||||
|
||||
describe("evaluateHoverTrigger", () => {
|
||||
it("accepts explicit popup triggers", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "Open user navigation menu",
|
||||
attrs: { "aria-haspopup": "menu", "aria-expanded": "false" },
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(
|
||||
expect.arrayContaining(["aria-haspopup", "collapsed", "popup-signal"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts compact topbar image buttons without popup attributes", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "image",
|
||||
attrs: {},
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
pointerEvents: "auto",
|
||||
hasGraphicDescendant: true,
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
|
||||
});
|
||||
|
||||
it("accepts compact topbar image links without popup attributes", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "image",
|
||||
attrs: {},
|
||||
rect: { x: 900, y: 8, w: 32, h: 32 },
|
||||
pointerEvents: "auto",
|
||||
hasGraphicDescendant: true,
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(true);
|
||||
expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"]));
|
||||
});
|
||||
|
||||
it("rejects topbar menu links without surface trigger evidence", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "My profile",
|
||||
attrs: {},
|
||||
rect: { x: 820, y: 72, w: 96, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects text navigation items even when their attributes contain popup words", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "a",
|
||||
role: "link",
|
||||
label: "My profile",
|
||||
attrs: { class: "dropdown-item profile-link" },
|
||||
rect: { x: 820, y: 72, w: 96, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects labelled dropdown list items as hover triggers", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "li",
|
||||
label: "文档C+D",
|
||||
attrs: { class: "dropdown-item", tabindex: "0" },
|
||||
rect: { x: 820, y: 48, w: 160, h: 32 },
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ordinary controls without hover popup signals", () => {
|
||||
const decision = evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
label: "Search",
|
||||
attrs: {},
|
||||
rect: { x: 100, y: 240, w: 120, h: 36 },
|
||||
pointerEvents: "auto",
|
||||
});
|
||||
|
||||
expect(decision.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsafe or non-visible triggers", () => {
|
||||
expect(
|
||||
evaluateHoverTrigger({
|
||||
tag: "input",
|
||||
attrs: {},
|
||||
rect: { x: 10, y: 10, w: 80, h: 24 },
|
||||
pointerEvents: "auto",
|
||||
}).eligible,
|
||||
).toBe(false);
|
||||
expect(
|
||||
evaluateHoverTrigger({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
attrs: { hidden: "" },
|
||||
rect: { x: 10, y: 10, w: 80, h: 24 },
|
||||
pointerEvents: "auto",
|
||||
}).eligible,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
getControlHintsHidden,
|
||||
getLabel,
|
||||
getOrCreateInstanceId,
|
||||
STORAGE_KEYS,
|
||||
setConnectionEnabled,
|
||||
setControlHintsHidden,
|
||||
setLabel,
|
||||
} from "../instance-id";
|
||||
|
||||
@@ -93,4 +95,26 @@ describe("instance-id", () => {
|
||||
expect(store[STORAGE_KEYS.CONNECTION_ENABLED]).toBe(false);
|
||||
expect(await getConnectionEnabled(backend)).toBe(false);
|
||||
});
|
||||
|
||||
it("getControlHintsHidden returns false when storage is empty", async () => {
|
||||
const { backend } = fakeStorage();
|
||||
expect(await getControlHintsHidden(backend)).toBe(false);
|
||||
});
|
||||
|
||||
it("getControlHintsHidden returns persisted boolean values", async () => {
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
|
||||
expect(await getControlHintsHidden(backend)).toBe(true);
|
||||
});
|
||||
|
||||
it("getControlHintsHidden treats non-boolean stored values as shown", async () => {
|
||||
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: "true" });
|
||||
expect(await getControlHintsHidden(backend)).toBe(false);
|
||||
});
|
||||
|
||||
it("setControlHintsHidden persists the value retrievable by getControlHintsHidden", async () => {
|
||||
const { backend, store } = fakeStorage();
|
||||
await setControlHintsHidden(true, backend);
|
||||
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
|
||||
expect(await getControlHintsHidden(backend)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
OVERLAY_AGENT_OVERLAY_RESET,
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
type OverlayInterruptRequest,
|
||||
type OverlayInterruptResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
|
||||
describe("OVERLAY_MSG_INTERRUPT", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
|
||||
describe("sendInterrupt", () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
||||
import { RecordingObservationSession } from "../recording/observation-session";
|
||||
import { RecordingStateRegistry } from "../recording/state-registry";
|
||||
import { buildTraceV3 } from "../recording/trace-builder-v3";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
const URL = "https://example.com/login";
|
||||
|
||||
function sessionWithInput(redactValues = false): RecordingObservationSession {
|
||||
const session = new RecordingObservationSession({ redactValues });
|
||||
const state = session.registry.register({
|
||||
url: URL,
|
||||
rawVomText: '@vom 1\ntextbox "Password" value="••••••" [ref=e1]',
|
||||
});
|
||||
session.cursor.lastSettled = {
|
||||
stateId: state.id,
|
||||
rootFrameId: "root",
|
||||
index: new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
matchNodes: [
|
||||
{
|
||||
frameId: "root",
|
||||
backendNodeId: 42,
|
||||
tag: "input",
|
||||
rect: { x: 20, y: 40, w: 200, h: 30 },
|
||||
localRect: { x: 20, y: 40, w: 200, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [{ ref: "e1", backendNodeId: 42, role: "textbox", name: "Password", line: 1 }],
|
||||
}),
|
||||
url: URL,
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
function finalizedFillBody(value: string, redactValues: boolean): string {
|
||||
const session = sessionWithInput(redactValues);
|
||||
const draft: RecordingDraftStep = {
|
||||
op: "fill",
|
||||
captureTarget: { tag: "input", role: "textbox", name: "Password" },
|
||||
value,
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 20, y: 40, w: 200, h: 30 }, tag: "input" },
|
||||
},
|
||||
};
|
||||
session.bindDraft(draft, 1);
|
||||
draft.postStateId = draft.preStateId;
|
||||
return buildTraceV3({
|
||||
registry: session.registry,
|
||||
drafts: [draft],
|
||||
annotations: session.annotations,
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "test",
|
||||
}).states[0]!.body;
|
||||
}
|
||||
|
||||
describe("record observation annotations", () => {
|
||||
it("omits a fill literal when values are redacted", () => {
|
||||
const secret = "hunter2-private";
|
||||
const body = finalizedFillBody(secret, true);
|
||||
expect(body).toContain("step 1: fill");
|
||||
expect(body).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("keeps ordinary fill details", () => {
|
||||
expect(finalizedFillBody("ordinary text", false)).toContain('step 1: fill: "ordinary text"');
|
||||
});
|
||||
|
||||
it("encodes title and URL so line breaks cannot corrupt state metadata", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
const state = registry.register({
|
||||
url: "https://example.com/a\nb",
|
||||
title: "hello\nworld",
|
||||
rawVomText: "@vom 1",
|
||||
});
|
||||
const trace = buildTraceV3({
|
||||
registry,
|
||||
drafts: [{ op: "scroll", preStateId: state.id, postStateId: state.id }],
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "test",
|
||||
});
|
||||
expect(trace.states[0]?.body).toContain('url: "https://example.com/a\\nb"');
|
||||
expect(trace.states[0]?.body).toContain('title: "hello\\nworld"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("recording state ownership", () => {
|
||||
it("deduplicates within one recording and isolates ids between recordings", () => {
|
||||
const first = new RecordingStateRegistry();
|
||||
const second = new RecordingStateRegistry();
|
||||
expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1");
|
||||
expect(first.register({ url: URL, rawVomText: "same" }).id).toBe("s1");
|
||||
expect(second.register({ url: URL, rawVomText: "other" }).id).toBe("s1");
|
||||
});
|
||||
|
||||
it("enriches metadata when a deduplicated observation becomes more complete", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
registry.register({ url: URL, rawVomText: "same" });
|
||||
const state = registry.register({
|
||||
url: URL,
|
||||
title: "Login",
|
||||
rawVomText: "same",
|
||||
truncated: true,
|
||||
});
|
||||
expect(state).toMatchObject({ id: "s1", title: "Login", truncated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft binding", () => {
|
||||
it("keeps capture semantics when no observation exists", () => {
|
||||
const session = new RecordingObservationSession();
|
||||
const draft: RecordingDraftStep = {
|
||||
op: "hover",
|
||||
captureTarget: { tag: "button", role: "button", name: "新建" },
|
||||
};
|
||||
session.bindDraft(draft, 1);
|
||||
expect(draft.matchedTarget).toEqual({ role: "button", name: "新建", unmatched: true });
|
||||
expect(draft.preStateId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not bind an unmatched new action to a stale observation", () => {
|
||||
const session = sessionWithInput();
|
||||
const draft: RecordingDraftStep = {
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "Confirm" },
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 400, y: 300, w: 80, h: 30 }, tag: "button" },
|
||||
},
|
||||
};
|
||||
session.bindDraft(draft, 2, true);
|
||||
expect(draft.preStateId).toBeUndefined();
|
||||
expect("matchedTarget" in draft ? draft.matchedTarget : undefined).toEqual({
|
||||
role: "button",
|
||||
name: "Confirm",
|
||||
unmatched: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
import { ObservationNodeIndex, type RegisteredObservation } from "../recording/observation-capture";
|
||||
import { RecordingObservationSession } from "../recording/observation-session";
|
||||
import { SettleController } from "../recording/settle-controller";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
const OBSERVATION: RegisteredObservation = {
|
||||
stateId: "s-next",
|
||||
rootFrameId: "root",
|
||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
|
||||
url: "https://example.com/next",
|
||||
};
|
||||
|
||||
describe("SettleController", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("aborts superseded capture work and lands the prior action on the next origin", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async () => ({
|
||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
||||
})) as unknown as CdpRunner["send"],
|
||||
};
|
||||
const tabsApi: ChromeTabsApi = {
|
||||
get: vi.fn(async () => ({ id: 7, url: OBSERVATION.url }) as chrome.tabs.Tab),
|
||||
query: vi.fn(async () => []),
|
||||
};
|
||||
const session = new RecordingObservationSession();
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
vi.spyOn(session, "capture")
|
||||
.mockImplementationOnce(async (_cdp, _tabs, _tabId, signal) => {
|
||||
firstSignal = signal;
|
||||
return new Promise<RegisteredObservation>((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("observation aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
})
|
||||
.mockResolvedValueOnce(OBSERVATION);
|
||||
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{ op: "click", captureTarget: { tag: "button", name: "First" }, preStateId: "s1" },
|
||||
];
|
||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
||||
controller.schedule(drafts, 0);
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
|
||||
drafts.push({
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", name: "Second" },
|
||||
preStateId: "s-next",
|
||||
});
|
||||
controller.schedule(drafts, 1);
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
expect(drafts[0]?.postStateId).toBe("s-next");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await controller.flush();
|
||||
expect(drafts[1]?.postStateId).toBe("s-next");
|
||||
});
|
||||
|
||||
it("does not consume a newer redirect while reading the prior landing URL", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async () => ({
|
||||
result: { value: { idleMs: 1_000, readyState: "complete" } },
|
||||
})) as unknown as CdpRunner["send"],
|
||||
};
|
||||
let resolveFirstTab!: (tab: chrome.tabs.Tab) => void;
|
||||
const firstTab = new Promise<chrome.tabs.Tab>((resolve) => {
|
||||
resolveFirstTab = resolve;
|
||||
});
|
||||
const tabsApi: ChromeTabsApi = {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => firstTab)
|
||||
.mockResolvedValue({ id: 7, url: OBSERVATION.url }),
|
||||
query: vi.fn(async () => []),
|
||||
};
|
||||
const session = new RecordingObservationSession();
|
||||
session.cursor.lastSettled = OBSERVATION;
|
||||
const drafts: RecordingDraftStep[] = [];
|
||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
||||
|
||||
controller.scheduleRedirect(drafts, "https://example.com/intermediate");
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
expect(tabsApi.get).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.scheduleRedirect(drafts, OBSERVATION.url);
|
||||
resolveFirstTab({ id: 7, url: "https://example.com/intermediate" } as chrome.tabs.Tab);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await controller.flushRedirects();
|
||||
|
||||
expect(drafts).toEqual([]);
|
||||
expect(controller.hasPending).toBe(false);
|
||||
});
|
||||
|
||||
it("settles an action in its own OOPIF document scope", async () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
||||
if (method === "Page.createIsolatedWorld") return { executionContextId: 39 };
|
||||
return { result: { value: { idleMs: 1_000, readyState: "complete" } } };
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: send as unknown as CdpRunner["send"],
|
||||
sendToTarget: sendToTarget as unknown as NonNullable<CdpRunner["sendToTarget"]>,
|
||||
};
|
||||
const tabsApi: ChromeTabsApi = {
|
||||
get: vi.fn(async () => ({ id: 7, url: OBSERVATION.url }) as chrome.tabs.Tab),
|
||||
query: vi.fn(async () => []),
|
||||
};
|
||||
const session = new RecordingObservationSession();
|
||||
vi.spyOn(session, "capture").mockResolvedValue(OBSERVATION);
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{ op: "click", captureTarget: { tag: "button", name: "Inside frame" } },
|
||||
];
|
||||
const controller = new SettleController({ session, cdp, tabsApi, tabId: 7 });
|
||||
const target = { tabId: 7, sessionId: "oopif-session" };
|
||||
|
||||
controller.schedule(drafts, 0, { target, frameId: "oopif-frame" });
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
await controller.flush();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(sendToTarget).toHaveBeenCalledWith(
|
||||
target,
|
||||
"Runtime.evaluate",
|
||||
expect.objectContaining({ contextId: 39 }),
|
||||
);
|
||||
expect(drafts[0]?.postStateId).toBe("s-next");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendRecordedPayload, observeRecordedNavigation } from "../recording/step-buffer";
|
||||
|
||||
describe("recording-step-buffer", () => {
|
||||
it("stores semantic click without summary", () => {
|
||||
const buffer = { steps: [], pendingNavigation: false };
|
||||
appendRecordedPayload(buffer, {
|
||||
op: "click",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
expects_navigation: true,
|
||||
});
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
},
|
||||
]);
|
||||
expect(buffer.pendingNavigation).toBe(true);
|
||||
});
|
||||
|
||||
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
|
||||
const buffer = {
|
||||
steps: [
|
||||
{
|
||||
op: "click" as const,
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
},
|
||||
],
|
||||
currentUrl: "https://example.com/a",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/b", true);
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
navigatedTo: "https://example.com/b",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(buffer.steps)).not.toContain("wait_for_navigation");
|
||||
});
|
||||
|
||||
it("annotates navigated_to onto a select that auto-submits a navigation", () => {
|
||||
const buffer = {
|
||||
steps: [
|
||||
{
|
||||
op: "select" as const,
|
||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
},
|
||||
],
|
||||
currentUrl: "https://example.com/list",
|
||||
pendingNavigation: true,
|
||||
pendingNavigationDeadline: Date.now() + 5_000,
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "select",
|
||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
navigatedTo: "https://example.com/list?cat=tech",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits navigate for uncaused URL changes", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
currentUrl: "https://example.com/a",
|
||||
pendingNavigation: false,
|
||||
};
|
||||
observeRecordedNavigation(buffer, "https://example.com/b", false);
|
||||
expect(buffer.steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
url: "https://example.com/b",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { RenderedRef } from "@browser-skill/vom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureVomMatchNode } from "@/tools/capture-vom-observation";
|
||||
import { ObservationNodeIndex, type RegisteredObservation } from "../recording/observation-capture";
|
||||
import { matchObservationTarget } from "../recording/target-matcher";
|
||||
|
||||
function node(backendNodeId: number, frameId: string, x = 10): CaptureVomMatchNode {
|
||||
return {
|
||||
backendNodeId,
|
||||
frameId,
|
||||
tag: "button",
|
||||
rect: { x, y: 20, w: 100, h: 30 },
|
||||
localRect: { x, y: 20, w: 100, h: 30 },
|
||||
};
|
||||
}
|
||||
|
||||
function observation(
|
||||
matchNodes: CaptureVomMatchNode[],
|
||||
refs: RenderedRef[],
|
||||
): RegisteredObservation {
|
||||
return {
|
||||
stateId: "s1",
|
||||
rootFrameId: "root",
|
||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes, refs }),
|
||||
url: "https://example.com",
|
||||
};
|
||||
}
|
||||
|
||||
describe("matchObservationTarget", () => {
|
||||
it("indexes the safe geometry DTO without discarding frame-local geometry", () => {
|
||||
const geometry = node(42, "child");
|
||||
geometry.localRect = { x: 5, y: 6, w: 100, h: 30 };
|
||||
const ref: RenderedRef = {
|
||||
ref: "e1",
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
line: 1,
|
||||
};
|
||||
const candidate = observation([geometry], [ref]).index.candidates("child", "button")[0];
|
||||
|
||||
expect(candidate).toEqual({ frameId: "child", geometry, ref });
|
||||
});
|
||||
|
||||
it("matches a unique node using canonical top-level viewport geometry", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "root")],
|
||||
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
|
||||
),
|
||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
||||
});
|
||||
expect(target).toEqual({ ref: "e1", role: "button", name: "发布" });
|
||||
});
|
||||
|
||||
it("uses frame id with backend node id so sibling frames cannot collide", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "left"), node(42, "right")],
|
||||
[
|
||||
{ ref: "e1", backendNodeId: 42, frameId: "left", line: 1 },
|
||||
{ ref: "e2", backendNodeId: 42, frameId: "right", line: 2 },
|
||||
],
|
||||
),
|
||||
hint: {
|
||||
frameId: "right",
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
});
|
||||
expect(target.ref).toBe("e2");
|
||||
});
|
||||
|
||||
it("restricts a missing frame hint to the root frame", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "child")],
|
||||
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
||||
),
|
||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
||||
fallback: { tag: "button", name: "发布" },
|
||||
});
|
||||
expect(target).toEqual({ name: "发布", unmatched: true });
|
||||
});
|
||||
|
||||
it("returns unmatched for ambiguous geometry", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "root"), node(43, "root")],
|
||||
[
|
||||
{ ref: "e1", backendNodeId: 42, line: 1 },
|
||||
{ ref: "e2", backendNodeId: 43, line: 2 },
|
||||
],
|
||||
),
|
||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
||||
});
|
||||
expect(target.unmatched).toBe(true);
|
||||
});
|
||||
|
||||
it("uses semantics when geometry is unavailable without crossing frame boundaries", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(41, "root"), node(42, "child")],
|
||||
[
|
||||
{ ref: "e1", backendNodeId: 41, role: "button", name: "保存", line: 1 },
|
||||
{ ref: "e2", backendNodeId: 42, frameId: "child", role: "button", name: "保存", line: 2 },
|
||||
],
|
||||
),
|
||||
hint: { frameId: "child" },
|
||||
fallback: { tag: "button", role: "button", name: "保存" },
|
||||
});
|
||||
expect(target.ref).toBe("e2");
|
||||
});
|
||||
|
||||
it("uses semantics to disambiguate equal geometry in one frame", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "root"), node(43, "root")],
|
||||
[
|
||||
{ ref: "e1", backendNodeId: 42, role: "button", name: "保存", line: 1 },
|
||||
{ ref: "e2", backendNodeId: 43, role: "button", name: "取消", line: 2 },
|
||||
],
|
||||
),
|
||||
hint: { geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" } },
|
||||
fallback: { tag: "button", role: "button", name: "取消" },
|
||||
});
|
||||
expect(target.ref).toBe("e2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RecordingDraftStep } from "@/lib/recording/types";
|
||||
import { shouldRecordPress } from "../recording/draft-policy";
|
||||
import { buildTraceV2 } from "../recording/trace-reducer-v2";
|
||||
|
||||
function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string) {
|
||||
return buildTraceV2({
|
||||
steps,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
...(startUrl ? { startUrl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("shouldRecordPress", () => {
|
||||
it("keeps Enter and Escape", () => {
|
||||
expect(shouldRecordPress("Enter")).toBe(true);
|
||||
expect(shouldRecordPress("Escape")).toBe(true);
|
||||
});
|
||||
|
||||
it("drops modifiers, clipboard shortcuts, and bare typing", () => {
|
||||
expect(shouldRecordPress("Meta")).toBe(false);
|
||||
expect(shouldRecordPress("c", ["meta"])).toBe(false);
|
||||
expect(shouldRecordPress("a", ["ctrl"])).toBe(false);
|
||||
expect(shouldRecordPress("x")).toBe(false);
|
||||
expect(shouldRecordPress("中")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceTraceSteps", () => {
|
||||
it("builds steps with pages dictionary and page id references", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/search?q=hello&utm_source=x",
|
||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "fill",
|
||||
captureTarget: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
value: "browser skill",
|
||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
captureTarget: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
navigatedTo: "https://example.com/results/42",
|
||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
navigatedTo: "https://example.com/p/99",
|
||||
pageUrl: "https://example.com/results/42",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "a",
|
||||
pageUrl: "https://example.com/p/99",
|
||||
},
|
||||
];
|
||||
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
drafts,
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
);
|
||||
expect(JSON.stringify(steps)).not.toContain("parameters");
|
||||
expect(JSON.stringify(steps)).not.toContain("intent");
|
||||
expect(JSON.stringify(steps)).not.toContain("summary");
|
||||
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
"https://example.com/results/42",
|
||||
"https://example.com/p/99",
|
||||
]);
|
||||
|
||||
expect(steps[0]).toMatchObject({
|
||||
id: 1,
|
||||
op: "navigate",
|
||||
page: "p1",
|
||||
to: "https://example.com/search?q=hello&utm_source=x",
|
||||
});
|
||||
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "fill",
|
||||
page: "p1",
|
||||
value: "browser skill",
|
||||
});
|
||||
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
page: "p1",
|
||||
effect: { navigated_to: "p2" },
|
||||
});
|
||||
|
||||
expect(steps[3]).toMatchObject({
|
||||
op: "click",
|
||||
page: "p2",
|
||||
effect: { navigated_to: "p3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses consecutive navigations", () => {
|
||||
const { steps } = reduceTraceSteps([
|
||||
{ op: "navigate", url: "https://a.example/redirect1" },
|
||||
{ op: "navigate", url: "https://a.example/final" },
|
||||
]);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
to: "https://a.example/final",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps committed empty fill steps", () => {
|
||||
const { steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "fill",
|
||||
captureTarget: { tag: "input", role: "textbox", name: "Search query" },
|
||||
value: "",
|
||||
pageUrl: "https://example.com/search",
|
||||
},
|
||||
],
|
||||
"https://example.com/search",
|
||||
);
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
op: "fill",
|
||||
value: "",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps select navigatedTo onto effect.navigatedTo (page id)", () => {
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "select",
|
||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
labels: ["技术"],
|
||||
navigatedTo: "https://example.com/list?cat=tech",
|
||||
pageUrl: "https://example.com/list",
|
||||
},
|
||||
],
|
||||
"https://example.com/list",
|
||||
);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/list",
|
||||
"https://example.com/list?cat=tech",
|
||||
]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "select",
|
||||
page: "p1",
|
||||
selection: [{ value: "tech", label: "技术" }],
|
||||
effect: { navigated_to: "p2" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps hover steps before menu clicks", () => {
|
||||
const { steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "hover",
|
||||
captureTarget: { tag: "span", role: "button", name: "Account" },
|
||||
pageUrl: "https://example.com/app",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "a", role: "link", name: "Profile" },
|
||||
pageUrl: "https://example.com/app",
|
||||
},
|
||||
],
|
||||
"https://example.com/app",
|
||||
);
|
||||
expect(steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "hover",
|
||||
page: "p1",
|
||||
target: { name: "Account" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||
expect(
|
||||
buildTraceV2({
|
||||
steps: [{ op: "navigate", url: "https://example.com/other" }],
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
startUrl: "https://example.com/start",
|
||||
}).entry.start_url,
|
||||
).toBe("https://example.com/start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TRACE_VERSION_V3, VOM_FORMAT_VERSION } from "@/transport/types";
|
||||
import { RecordingStateRegistry } from "../recording/state-registry";
|
||||
import { buildTraceV3 } from "../recording/trace-builder-v3";
|
||||
import { reduceTraceStepsV3 } from "../recording/trace-reducer-v3";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
describe("trace reducer v3", () => {
|
||||
it("collapses redirect hops while retaining draft-to-step identity", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{ op: "navigate", url: "https://example.com/start", preStateId: "s1", postStateId: "s2" },
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/final",
|
||||
transitionQualifiers: ["server_redirect"],
|
||||
preStateId: "s2",
|
||||
postStateId: "s3",
|
||||
},
|
||||
];
|
||||
const before = structuredClone(drafts);
|
||||
const reduced = reduceTraceStepsV3(drafts);
|
||||
|
||||
expect(drafts).toEqual(before);
|
||||
expect(reduced.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
op: "navigate",
|
||||
state: "s1",
|
||||
to: "https://example.com/final",
|
||||
result: { state: "s3" },
|
||||
}),
|
||||
]);
|
||||
expect(reduced.stepIdByDraftId.get(1)).toBe(1);
|
||||
expect(reduced.stepIdByDraftId.get(2)).toBe(1);
|
||||
});
|
||||
|
||||
it("builds the wire model from protocol constants", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
const state = registry.register({ url: "https://example.com", rawVomText: "@vom 1" });
|
||||
const trace = buildTraceV3({
|
||||
registry,
|
||||
drafts: [
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "Save" },
|
||||
preStateId: state.id,
|
||||
postStateId: state.id,
|
||||
},
|
||||
],
|
||||
startedAt: "2026-08-12T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "test",
|
||||
});
|
||||
|
||||
expect(trace.version).toBe(TRACE_VERSION_V3);
|
||||
expect(trace.recorder.vom).toBe(VOM_FORMAT_VERSION);
|
||||
expect(trace.steps[0]).toMatchObject({
|
||||
op: "click",
|
||||
target: { role: "button", name: "Save", unmatched: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wire protocol and background-side helper for hiding the extension's
|
||||
* in-page overlay while a screenshot is captured, so captured frames only
|
||||
* contain the page itself.
|
||||
*
|
||||
* The background wraps every capture in `withOverlaysHiddenForCapture`,
|
||||
* which sends `begin` (the content script hides the overlay host and only
|
||||
* acks once the compositor has produced an overlay-free frame) and always
|
||||
* follows up with `end`, even when the capture throws. Tabs without the
|
||||
* content script (chrome://, the Web Store, ...) host no overlay, so a
|
||||
* failed `begin` falls through to capturing directly.
|
||||
*/
|
||||
|
||||
export const CAPTURE_SUPPRESS = "bsk/capture-suppress";
|
||||
|
||||
export type CaptureSuppressPhase = "begin" | "end";
|
||||
|
||||
export interface CaptureSuppressMessage {
|
||||
type: typeof CAPTURE_SUPPRESS;
|
||||
phase: CaptureSuppressPhase;
|
||||
}
|
||||
|
||||
export interface CaptureSuppressAck {
|
||||
type: typeof CAPTURE_SUPPRESS;
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export function isCaptureSuppressMessage(msg: unknown): msg is CaptureSuppressMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === CAPTURE_SUPPRESS && (m.phase === "begin" || m.phase === "end");
|
||||
}
|
||||
|
||||
/** Minimal `chrome.tabs.sendMessage` surface so tests can inject a fake. */
|
||||
export type CaptureSuppressSendToTab = (
|
||||
tabId: number,
|
||||
message: CaptureSuppressMessage,
|
||||
) => Promise<unknown>;
|
||||
|
||||
const defaultSendToTab: CaptureSuppressSendToTab = (tabId, message) =>
|
||||
chrome.tabs.sendMessage(tabId, message);
|
||||
|
||||
/**
|
||||
* Run `fn` with the target tab's overlay hidden. `end` is sent from a
|
||||
* `finally` so the overlay always reappears, and a missing content script
|
||||
* (or any other `begin` failure) simply skips suppression — there is no
|
||||
* overlay to hide in that tab.
|
||||
*/
|
||||
export async function withOverlaysHiddenForCapture<T>(
|
||||
tabId: number,
|
||||
fn: () => Promise<T>,
|
||||
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
|
||||
): Promise<T> {
|
||||
let began = false;
|
||||
try {
|
||||
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "begin" });
|
||||
began = true;
|
||||
} catch {
|
||||
// No content script in the target tab → no overlay can leak into the
|
||||
// capture; proceed without suppression.
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (began) {
|
||||
await sendToTab(tabId, { type: CAPTURE_SUPPRESS, phase: "end" }).catch((err) => {
|
||||
console.debug("[bsk capture] overlay restore failed", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type { ConnectionState, HandshakeResult } from "../transport/types";
|
||||
import { getLabel, getOrCreateInstanceId } from "./instance-id";
|
||||
import { compareProtocol, parseProtocolMajor } from "./semver";
|
||||
|
||||
const HANDSHAKE_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
export interface SnapshotInfo {
|
||||
state: ConnectionState;
|
||||
instanceId: string;
|
||||
@@ -21,6 +23,13 @@ export interface SnapshotInfo {
|
||||
|
||||
type Listener = (s: SnapshotInfo) => void;
|
||||
|
||||
export interface ConnectionLifecycleHooks {
|
||||
/** Safe local teardown that must finish before an intentional disconnect. */
|
||||
beforeDisconnect?: () => void | Promise<void>;
|
||||
/** Best-effort teardown after an unexpected transport loss. */
|
||||
onDisconnected?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates Transport + handshake lifecycle for the background SW.
|
||||
*
|
||||
@@ -36,7 +45,12 @@ export class ConnectionController {
|
||||
private lastError: string | null = null;
|
||||
private connectionEnabled = true;
|
||||
private listeners = new Set<Listener>();
|
||||
private handshakeInFlight = false;
|
||||
private lifecycleHooks: ConnectionLifecycleHooks = {};
|
||||
private disconnectRecovery: Promise<void> | null = null;
|
||||
private connectionGeneration = 0;
|
||||
private handshakeAbort: AbortController | null = null;
|
||||
private handshakeRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private suppressDisconnectRecovery = false;
|
||||
|
||||
get isConnectionEnabled(): boolean {
|
||||
return this.connectionEnabled;
|
||||
@@ -68,20 +82,36 @@ export class ConnectionController {
|
||||
transport: Transport,
|
||||
browser: { name: string; version: string },
|
||||
connectionEnabled = true,
|
||||
lifecycleHooks: ConnectionLifecycleHooks = {},
|
||||
): Promise<void> {
|
||||
this.transport = transport;
|
||||
this.connectionEnabled = connectionEnabled;
|
||||
this.lifecycleHooks = lifecycleHooks;
|
||||
this.instanceId = await getOrCreateInstanceId();
|
||||
this.label = await getLabel();
|
||||
|
||||
transport.onConnectionStateChange((s) => {
|
||||
if (!this.connectionEnabled) return;
|
||||
if (s === "connected") {
|
||||
void this.runHandshake(browser);
|
||||
if (s === "disconnected") {
|
||||
// An in-flight handshake belongs to the dead connection — cancel it so
|
||||
// a late resolution can never clobber the next attempt.
|
||||
this.cancelHandshake();
|
||||
this.handshake = null;
|
||||
if (this.connectionEnabled) {
|
||||
this.setState("disconnected");
|
||||
if (this.suppressDisconnectRecovery) {
|
||||
// Deliberate disconnect from runHandshake (rejected peer or failed
|
||||
// handshake bounce); the caller owns the reconnect policy there.
|
||||
this.suppressDisconnectRecovery = false;
|
||||
} else {
|
||||
void this.recoverFromDisconnect();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (s === "disconnected") {
|
||||
this.handshake = null;
|
||||
if (!this.connectionEnabled) return;
|
||||
if (s === "connected") {
|
||||
this.startHandshake(browser);
|
||||
return;
|
||||
}
|
||||
this.setState(s);
|
||||
});
|
||||
@@ -126,41 +156,69 @@ export class ConnectionController {
|
||||
this.fire();
|
||||
}
|
||||
|
||||
private async runHandshake(browser: { name: string; version: string }): Promise<void> {
|
||||
private startHandshake(browser: { name: string; version: string }): void {
|
||||
this.cancelHandshake();
|
||||
const generation = ++this.connectionGeneration;
|
||||
const abort = new AbortController();
|
||||
this.handshakeAbort = abort;
|
||||
void this.runHandshake(browser, generation, abort.signal);
|
||||
}
|
||||
|
||||
private async runHandshake(
|
||||
browser: { name: string; version: string },
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!this.transport) return;
|
||||
if (!this.connectionEnabled) return;
|
||||
if (this.handshakeInFlight) return;
|
||||
this.handshakeInFlight = true;
|
||||
this.setState("connecting");
|
||||
try {
|
||||
const outcome = await performHandshake(this.transport, {
|
||||
instanceId: this.instanceId,
|
||||
browser,
|
||||
label: this.label,
|
||||
});
|
||||
const outcome = await performHandshake(
|
||||
this.transport,
|
||||
{
|
||||
instanceId: this.instanceId,
|
||||
browser,
|
||||
label: this.label,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (generation !== this.connectionGeneration || signal.aborted) return;
|
||||
this.handshake = outcome.result;
|
||||
const verdict = computeConnectedState(outcome.result);
|
||||
if (verdict.kind === "rejected") {
|
||||
this.lastError = `version_too_old: ${verdict.reason}`;
|
||||
this.handshake = null;
|
||||
// Rejected peers must not be auto-retried: mark this disconnect as
|
||||
// deliberate so the listener skips unexpected-loss recovery.
|
||||
this.suppressDisconnectRecovery = true;
|
||||
await this.transport.disconnect().catch(() => {});
|
||||
this.setState("disconnected");
|
||||
return;
|
||||
}
|
||||
this.clearHandshakeRetry();
|
||||
this.lastError = null;
|
||||
this.setState(verdict.kind);
|
||||
} catch (err) {
|
||||
if (generation !== this.connectionGeneration || signal.aborted || isAbortError(err)) return;
|
||||
this.handshake = null;
|
||||
this.lastError = err instanceof Error ? err.message : String(err);
|
||||
// Bounce the half-open socket deliberately; the retry timer below owns
|
||||
// the reconnect, so skip unexpected-loss recovery for this disconnect.
|
||||
this.suppressDisconnectRecovery = true;
|
||||
await this.transport.disconnect().catch(() => {});
|
||||
this.setState("disconnected");
|
||||
this.scheduleHandshakeRetry(browser);
|
||||
} finally {
|
||||
this.handshakeInFlight = false;
|
||||
if (generation === this.connectionGeneration) this.handshakeAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async applyDisabledState(): Promise<void> {
|
||||
this.cancelHandshake();
|
||||
this.clearHandshakeRetry();
|
||||
this.handshake = null;
|
||||
this.lastError = null;
|
||||
await this.lifecycleHooks.beforeDisconnect?.();
|
||||
if (this.transport) {
|
||||
await this.transport.disconnect().catch(() => {});
|
||||
}
|
||||
@@ -169,6 +227,60 @@ export class ConnectionController {
|
||||
if (was === "disconnected") this.fire();
|
||||
}
|
||||
|
||||
private recoverFromDisconnect(): Promise<void> {
|
||||
if (this.disconnectRecovery) return this.disconnectRecovery;
|
||||
const recovery = (async () => {
|
||||
// WSTransport schedules its reconnect timer immediately after notifying
|
||||
// state listeners. Yield once, then disconnect explicitly so that timer
|
||||
// is cancelled before local session teardown begins.
|
||||
await Promise.resolve();
|
||||
if (!this.connectionEnabled || !this.transport) return;
|
||||
// Another path already reconnected while we yielded — nothing to do.
|
||||
if (this.currentState !== "disconnected") return;
|
||||
await this.transport.disconnect().catch(() => {});
|
||||
try {
|
||||
await this.lifecycleHooks.onDisconnected?.();
|
||||
} catch (err) {
|
||||
console.warn("[browser-skill] session cleanup after disconnect failed", err);
|
||||
}
|
||||
if (!this.connectionEnabled) return;
|
||||
try {
|
||||
await this.transport.connect();
|
||||
} catch (err) {
|
||||
this.lastError = err instanceof Error ? err.message : String(err);
|
||||
this.setState("disconnected");
|
||||
}
|
||||
})();
|
||||
this.disconnectRecovery = recovery.finally(() => {
|
||||
this.disconnectRecovery = null;
|
||||
});
|
||||
return this.disconnectRecovery;
|
||||
}
|
||||
|
||||
private cancelHandshake(): void {
|
||||
this.connectionGeneration += 1;
|
||||
this.handshakeAbort?.abort();
|
||||
this.handshakeAbort = null;
|
||||
}
|
||||
|
||||
private scheduleHandshakeRetry(browser: { name: string; version: string }): void {
|
||||
if (this.handshakeRetryTimer || !this.connectionEnabled) return;
|
||||
this.handshakeRetryTimer = setTimeout(() => {
|
||||
this.handshakeRetryTimer = null;
|
||||
if (!this.connectionEnabled || !this.transport) return;
|
||||
void this.transport.connect().catch((err) => {
|
||||
this.lastError = err instanceof Error ? err.message : String(err);
|
||||
this.setState("disconnected");
|
||||
});
|
||||
}, HANDSHAKE_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
private clearHandshakeRetry(): void {
|
||||
if (!this.handshakeRetryTimer) return;
|
||||
clearTimeout(this.handshakeRetryTimer);
|
||||
this.handshakeRetryTimer = null;
|
||||
}
|
||||
|
||||
private setState(next: ConnectionState): void {
|
||||
if (this.currentState === next) return;
|
||||
this.currentState = next;
|
||||
@@ -187,6 +299,12 @@ export class ConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verdict of the symmetric post-handshake compat check.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Build a semantic capture descriptor for an interacted element.
|
||||
*
|
||||
* Trace steps are an LLM *textbook*: each click must say what to look for
|
||||
* on screen (usually a short visible name). Tag-only noise like
|
||||
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
|
||||
*/
|
||||
|
||||
/** Content-script capture descriptor before VOM geometric matching. */
|
||||
export interface CaptureTargetDescriptor {
|
||||
role?: string;
|
||||
name?: string;
|
||||
tag: string;
|
||||
name_attr?: string;
|
||||
placeholder?: string;
|
||||
nearby_label?: string;
|
||||
}
|
||||
|
||||
/** Max length for a label that is still a useful “find this on screen” hint. */
|
||||
const ACTIONABLE_LABEL_MAX = 48;
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 80): string {
|
||||
const trimmed = normalizeWhitespace(value);
|
||||
if (trimmed.length <= max) return trimmed;
|
||||
return `${trimmed.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/** Short, human label an LLM can search for in a snapshot. */
|
||||
export function isActionableLabel(name: string): boolean {
|
||||
const trimmed = normalizeWhitespace(name);
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.length > ACTIONABLE_LABEL_MAX) return false;
|
||||
if (/^https?:\/\//i.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function labelledByText(el: Element): string | null {
|
||||
const ids = el.getAttribute("aria-labelledby");
|
||||
if (!ids) return null;
|
||||
const parts = ids
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.textContent ?? "")
|
||||
.map(normalizeWhitespace)
|
||||
.filter(Boolean);
|
||||
return parts.length ? truncate(parts.join(" ")) : null;
|
||||
}
|
||||
|
||||
function associatedLabelText(el: Element): string | null {
|
||||
if (!(el instanceof HTMLElement) || !el.id) return null;
|
||||
const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
||||
if (!label) return null;
|
||||
const text = normalizeWhitespace(label.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
function wrappingLabelText(el: Element): string | null {
|
||||
const label = el.closest("label");
|
||||
if (!label) return null;
|
||||
const clone = label.cloneNode(true) as HTMLElement;
|
||||
for (const control of clone.querySelectorAll("input,textarea,select,button")) {
|
||||
control.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
function placeholderOrTitle(el: Element): string | null {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
const ph = normalizeWhitespace(el.placeholder);
|
||||
if (ph) return truncate(ph);
|
||||
}
|
||||
const title = normalizeWhitespace(el.getAttribute("title") ?? "");
|
||||
return title ? truncate(title) : null;
|
||||
}
|
||||
|
||||
function ownText(el: Element): string | null {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return null;
|
||||
}
|
||||
const text = normalizeWhitespace(el.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
/** Prefer a compact title inside links (e.g. SERP result `<h3>`), not the whole blob. */
|
||||
function compactLinkName(el: Element): string | null {
|
||||
const heading = el.querySelector("h1, h2, h3, h4");
|
||||
if (heading) {
|
||||
const text = normalizeWhitespace(heading.textContent ?? "");
|
||||
if (text) return truncate(text);
|
||||
}
|
||||
const img = el.querySelector("img[alt]");
|
||||
if (img instanceof HTMLImageElement) {
|
||||
const alt = normalizeWhitespace(img.alt);
|
||||
if (alt) return truncate(alt);
|
||||
}
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
for (const junk of clone.querySelectorAll("cite, script, style, svg, noscript")) {
|
||||
junk.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
if (!text) return null;
|
||||
const withoutUrl = text.replace(/https?:\/\/\S+/g, "").trim();
|
||||
return truncate(withoutUrl || text);
|
||||
}
|
||||
|
||||
/** Visible accessible name preference order for LLM textbooks. */
|
||||
export function accessibleName(el: Element): string | undefined {
|
||||
const ariaLabel = normalizeWhitespace(el.getAttribute("aria-label") ?? "");
|
||||
if (ariaLabel) return truncate(ariaLabel);
|
||||
|
||||
const labelledBy = labelledByText(el);
|
||||
if (labelledBy) return labelledBy;
|
||||
|
||||
const forLabel = associatedLabelText(el);
|
||||
if (forLabel) return forLabel;
|
||||
|
||||
const wrapLabel = wrappingLabelText(el);
|
||||
if (wrapLabel) return wrapLabel;
|
||||
|
||||
const alt =
|
||||
el instanceof HTMLImageElement
|
||||
? normalizeWhitespace(el.alt)
|
||||
: normalizeWhitespace(el.getAttribute("alt") ?? "");
|
||||
if (alt) return truncate(alt);
|
||||
|
||||
const ph = placeholderOrTitle(el);
|
||||
if (ph) return ph;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "a" || el.getAttribute("role") === "link") {
|
||||
const linkName = compactLinkName(el);
|
||||
if (linkName) return linkName;
|
||||
}
|
||||
|
||||
const text = ownText(el);
|
||||
if (text) return text;
|
||||
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) {
|
||||
const value = normalizeWhitespace(el.value);
|
||||
if (
|
||||
value &&
|
||||
(el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")
|
||||
) {
|
||||
return truncate(value);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button",
|
||||
"summary",
|
||||
"select",
|
||||
'input:not([type="hidden"])',
|
||||
"textarea",
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="option"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="treeitem"]',
|
||||
'[role="combobox"]',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
].join(",");
|
||||
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
"button",
|
||||
"link",
|
||||
"tab",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"switch",
|
||||
"treeitem",
|
||||
"combobox",
|
||||
"textbox",
|
||||
"searchbox",
|
||||
"slider",
|
||||
]);
|
||||
|
||||
function looksClickable(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
if (el.matches(INTERACTIVE_SELECTOR)) return true;
|
||||
const role = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
|
||||
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
||||
if (el.tabIndex >= 0) return true;
|
||||
try {
|
||||
if (getComputedStyle(el).cursor === "pointer") return true;
|
||||
} catch {
|
||||
// happy-dom / detached nodes
|
||||
}
|
||||
const cls = typeof el.className === "string" ? el.className : "";
|
||||
if (/\b(btn|button|link|clickable|action)\b/i.test(cls)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element a click should describe for the LLM textbook.
|
||||
* Never falls back to an anonymous layout `div`.
|
||||
*/
|
||||
export function resolveClickableElement(target: Element): Element | null {
|
||||
const interactive = target.closest(INTERACTIVE_SELECTOR);
|
||||
if (interactive instanceof Element) return interactive;
|
||||
|
||||
let node: Element | null = target;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
|
||||
const name = accessibleName(node);
|
||||
if (name && isActionableLabel(name) && looksClickable(node)) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveHoverElement(target: Element): Element | null {
|
||||
const clickable = resolveClickableElement(target);
|
||||
if (clickable) return clickable;
|
||||
|
||||
let node: Element | null = target;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
|
||||
if (looksClickable(node)) return node;
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teachable clicks: the LLM must get a label it can later find via snapshot
|
||||
* (visible name), or at least a form `name_attr` for checkbox/radio.
|
||||
* Recording “点击div” with no name fails this bar.
|
||||
*/
|
||||
export function isMeaningfulClickTarget(target: CaptureTargetDescriptor): boolean {
|
||||
const name = target.name?.trim();
|
||||
if (name && isActionableLabel(name)) return true;
|
||||
if (
|
||||
(target.role === "checkbox" || target.role === "radio" || target.tag === "input") &&
|
||||
target.name_attr
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function inferRole(el: Element): string | undefined {
|
||||
const explicit = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "button") return "button";
|
||||
if (tag === "a" && el.hasAttribute("href")) return "link";
|
||||
if (tag === "select") return "combobox";
|
||||
if (tag === "textarea") return "textbox";
|
||||
if (tag === "img") return "img";
|
||||
if (tag === "summary") return "button";
|
||||
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
|
||||
return "textbox";
|
||||
}
|
||||
if (el instanceof HTMLInputElement) {
|
||||
const type = el.type.toLowerCase();
|
||||
if (type === "checkbox") return "checkbox";
|
||||
if (type === "radio") return "radio";
|
||||
if (type === "submit" || type === "button" || type === "image" || type === "reset") {
|
||||
return "button";
|
||||
}
|
||||
if (type === "range") return "slider";
|
||||
return "textbox";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearby labels help the LLM name form fields. Only apply to fillable
|
||||
* controls — never to buttons/links, where a previous layout sibling
|
||||
* (e.g. a chrome `<div>`) would pollute the textbook target.
|
||||
*/
|
||||
function nearbyLabelText(el: Element): string | undefined {
|
||||
if (
|
||||
!(
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (el.id) {
|
||||
const byFor = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
||||
const text = byFor ? normalizeWhitespace(byFor.textContent ?? "") : "";
|
||||
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
|
||||
}
|
||||
const wrapped = el.closest("label");
|
||||
if (wrapped) {
|
||||
const clone = wrapped.cloneNode(true) as HTMLLabelElement;
|
||||
for (const control of clone.querySelectorAll("input, textarea, select, button")) {
|
||||
control.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
|
||||
}
|
||||
const prev = el.previousElementSibling;
|
||||
if (prev && /^(LABEL|SPAN|DIV|P|DT)$/i.test(prev.tagName)) {
|
||||
const text = normalizeWhitespace(prev.textContent ?? "");
|
||||
if (text && text.length <= ACTIONABLE_LABEL_MAX) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function describeTarget(el: Element): CaptureTargetDescriptor {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = inferRole(el);
|
||||
const name = accessibleName(el);
|
||||
const nameAttr =
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement ||
|
||||
el instanceof HTMLButtonElement
|
||||
? normalizeWhitespace(el.name) || undefined
|
||||
: normalizeWhitespace(el.getAttribute("name") ?? "") || undefined;
|
||||
const placeholder =
|
||||
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
|
||||
? normalizeWhitespace(el.placeholder) || undefined
|
||||
: normalizeWhitespace(el.getAttribute("placeholder") ?? "") || undefined;
|
||||
const nearby = nearbyLabelText(el);
|
||||
|
||||
return {
|
||||
tag,
|
||||
...(role ? { role } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(nameAttr ? { name_attr: nameAttr } : {}),
|
||||
...(placeholder ? { placeholder } : {}),
|
||||
...(nearby && nearby !== name ? { nearby_label: nearby } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEventTarget(target: EventTarget | null): CaptureTargetDescriptor | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const clickable = resolveClickableElement(target);
|
||||
if (!clickable) return null;
|
||||
const desc = describeTarget(clickable);
|
||||
return isMeaningfulClickTarget(desc) ? desc : null;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Application-level WebSocket heartbeat.
|
||||
*
|
||||
* Since Chrome 116, sending or receiving a message over a WebSocket
|
||||
* resets the MV3 service-worker idle timer (see
|
||||
* https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets).
|
||||
* By emitting a tiny `system.heartbeat` event every 20s — comfortably
|
||||
* inside the 30s idle window — we keep the worker (and therefore the
|
||||
* daemon link) alive for as long as the connection is up, instead of
|
||||
* relying solely on the 30s keepalive alarm that sits right on the
|
||||
* eviction boundary.
|
||||
*
|
||||
* The daemon also treats the heartbeat as a liveness signal, so a
|
||||
* silently-dead browser can be reaped on its side.
|
||||
*
|
||||
* The heartbeat only runs while the *post-handshake* link is live: the
|
||||
* daemon rejects any first frame that is not `system.handshake`, so
|
||||
* beating before the handshake completes would get the connection
|
||||
* kicked. Callers wire {@link HeartbeatOptions.onActiveChange} to the
|
||||
* connection controller, which only reports `connected` / `version_skew`
|
||||
* after the handshake has landed.
|
||||
*/
|
||||
|
||||
import type { ProtocolFrame } from "@/transport/types";
|
||||
|
||||
export const HEARTBEAT_EVENT = "system.heartbeat";
|
||||
/** 20s: the interval Chrome's own WebSocket keepalive sample recommends. */
|
||||
export const HEARTBEAT_INTERVAL_MS = 20_000;
|
||||
|
||||
/** Subset of the timer API we use; lets tests inject fakes. */
|
||||
export interface HeartbeatTimers {
|
||||
setInterval(cb: () => void, ms: number): number;
|
||||
clearInterval(handle: number): void;
|
||||
}
|
||||
|
||||
export interface HeartbeatOptions {
|
||||
/** Send a frame over the live transport. */
|
||||
send: (frame: ProtocolFrame) => void;
|
||||
/**
|
||||
* Subscribe to "is the post-handshake link live" changes. Must invoke
|
||||
* the callback with the current value immediately and again on every
|
||||
* transition, and return an unsubscribe function.
|
||||
*/
|
||||
onActiveChange: (cb: (active: boolean) => void) => () => void;
|
||||
/** Override the beat interval (tests). */
|
||||
intervalMs?: number;
|
||||
/** Inject fake timers (tests). */
|
||||
timers?: HeartbeatTimers;
|
||||
}
|
||||
|
||||
export interface HeartbeatHandle {
|
||||
/** Stop beating and detach the active-state listener. */
|
||||
dispose(): void;
|
||||
/** Test hook: emit one heartbeat frame right now. */
|
||||
beatForTest(): void;
|
||||
}
|
||||
|
||||
function defaultTimers(): HeartbeatTimers {
|
||||
return {
|
||||
setInterval: (cb, ms) => setInterval(cb, ms) as unknown as number,
|
||||
clearInterval: (handle) => clearInterval(handle),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a heartbeat that beats every `intervalMs` while the link is
|
||||
* active and pauses entirely while it is not.
|
||||
*/
|
||||
export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
|
||||
const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
||||
const timers = options.timers ?? defaultTimers();
|
||||
let timer: number | null = null;
|
||||
|
||||
const beat = () => {
|
||||
try {
|
||||
options.send({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
} catch (err) {
|
||||
// The socket raced a close between the state change and this
|
||||
// tick. The transport's own reconnect path recovers; swallow so
|
||||
// the interval keeps running (it is cleared on the disconnect
|
||||
// notification anyway).
|
||||
console.debug("[bsk heartbeat] send failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== null) {
|
||||
timers.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (timer !== null) return;
|
||||
timer = timers.setInterval(beat, intervalMs);
|
||||
};
|
||||
|
||||
const unsubscribe = options.onActiveChange((active) => {
|
||||
if (active) start();
|
||||
else stop();
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
unsubscribe();
|
||||
stop();
|
||||
},
|
||||
beatForTest: beat,
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,22 @@
|
||||
* Mirrors the borrow-confirmation message style (see
|
||||
* `@/tools/borrow-confirmation`). The background asks a specific tab to
|
||||
* enter "help mode" (render HelpRequestOverlay + hide ControlOverlay);
|
||||
* the content script replies once the user clicks Continue / Cancel.
|
||||
* the content script acks display, then later reports Done / Cancel.
|
||||
*/
|
||||
|
||||
export const HELP_REQUEST = "bsk-help-request";
|
||||
export const HELP_RESPONSE = "bsk-help-response";
|
||||
export const HELP_CANCEL = "bsk-help-cancel";
|
||||
export const HELP_ACK = "bsk-help-ack";
|
||||
export const HELP_QUERY = "bsk-help-query";
|
||||
export const HELP_FINISH = "bsk-help-finish";
|
||||
|
||||
export interface HelpHighlightRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface HelpRequestMessage {
|
||||
type: typeof HELP_REQUEST;
|
||||
@@ -19,11 +29,20 @@ export interface HelpRequestMessage {
|
||||
prompt: string;
|
||||
/** Custom overlay title; omitted when the extension should use its default. */
|
||||
title?: string;
|
||||
/** Full task UI on the subject tab; compact status UI on related tabs. */
|
||||
displayMode?: "full" | "compact";
|
||||
/** CSS selectors to scroll to + flash-highlight (may be empty). */
|
||||
selectors: string[];
|
||||
/** Top-viewport rectangles for targets that cannot be represented by a root selector. */
|
||||
rects?: HelpHighlightRect[];
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface HelpAckMessage {
|
||||
type: typeof HELP_ACK;
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export interface HelpResponseMessage {
|
||||
type: typeof HELP_RESPONSE;
|
||||
outcome: "continued" | "cancelled";
|
||||
@@ -35,6 +54,22 @@ export interface HelpCancelMessage {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface HelpQueryMessage {
|
||||
type: typeof HELP_QUERY;
|
||||
}
|
||||
|
||||
export interface HelpQueryResponse {
|
||||
active: boolean;
|
||||
request?: Omit<HelpRequestMessage, "type">;
|
||||
}
|
||||
|
||||
export interface HelpFinishMessage {
|
||||
type: typeof HELP_FINISH;
|
||||
requestId: string;
|
||||
outcome: "continued" | "cancelled";
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
|
||||
if (typeof msg !== "object" || msg === null) {
|
||||
return false;
|
||||
@@ -45,8 +80,19 @@ export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
|
||||
typeof m.requestId === "string" &&
|
||||
typeof m.prompt === "string" &&
|
||||
(m.title === undefined || typeof m.title === "string") &&
|
||||
(m.displayMode === undefined || m.displayMode === "full" || m.displayMode === "compact") &&
|
||||
Array.isArray(m.selectors) &&
|
||||
m.selectors.every((selector) => typeof selector === "string") &&
|
||||
(m.rects === undefined ||
|
||||
(Array.isArray(m.rects) &&
|
||||
m.rects.every(
|
||||
(rect) =>
|
||||
typeof rect === "object" &&
|
||||
rect !== null &&
|
||||
["top", "left", "width", "height"].every((key) =>
|
||||
Number.isFinite((rect as Record<string, unknown>)[key]),
|
||||
),
|
||||
))) &&
|
||||
typeof m.timeoutMs === "number"
|
||||
);
|
||||
}
|
||||
@@ -58,3 +104,36 @@ export function isHelpCancelMessage(msg: unknown): msg is HelpCancelMessage {
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === HELP_CANCEL && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isHelpResponseMessage(msg: unknown): msg is HelpResponseMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return (
|
||||
m.type === HELP_RESPONSE &&
|
||||
(m.outcome === "continued" || m.outcome === "cancelled") &&
|
||||
(m.note === undefined || typeof m.note === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export function isHelpAckMessage(msg: unknown): msg is HelpAckMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === HELP_ACK && m.ok === true;
|
||||
}
|
||||
|
||||
export function isHelpQueryMessage(msg: unknown): msg is HelpQueryMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === HELP_QUERY;
|
||||
}
|
||||
|
||||
export function isHelpFinishMessage(msg: unknown): msg is HelpFinishMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return (
|
||||
m.type === HELP_FINISH &&
|
||||
typeof m.requestId === "string" &&
|
||||
(m.outcome === "continued" || m.outcome === "cancelled") &&
|
||||
(m.note === undefined || typeof m.note === "string")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
export interface HoverTriggerRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface HoverTriggerSignals {
|
||||
tag: string;
|
||||
role?: string;
|
||||
label?: string;
|
||||
attrs?: Record<string, string | undefined>;
|
||||
rect?: HoverTriggerRect | null;
|
||||
cursor?: string;
|
||||
pointerEvents?: string;
|
||||
hasGraphicDescendant?: boolean;
|
||||
cssHoverMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface HoverTriggerDecision {
|
||||
eligible: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const HOVER_TRIGGER_MIN_SCORE = 45;
|
||||
const HOVER_POPUP_SIGNAL_RE =
|
||||
/(menu|dropdown|popover|popup|avatar|profile|account|user|more|ellipsis|caret)/;
|
||||
const HOVER_TEXT_ITEM_RE = /(^|[\s_-])(dropdown-item|menu-item|context-menu-item|option)($|[\s_-])/;
|
||||
|
||||
function attr(signals: HoverTriggerSignals, name: string): string | undefined {
|
||||
return signals.attrs?.[name.toLowerCase()];
|
||||
}
|
||||
|
||||
export function isUnsafeHoverTrigger(signals: HoverTriggerSignals): boolean {
|
||||
const tag = signals.tag.toLowerCase();
|
||||
if (["input", "textarea", "select", "option"].includes(tag)) return true;
|
||||
if ((attr(signals, "contenteditable") ?? "").toLowerCase() === "true") return true;
|
||||
if (attr(signals, "disabled") !== undefined || attr(signals, "inert") !== undefined) return true;
|
||||
return (attr(signals, "aria-disabled") ?? "").toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function hasHoverPopupSignal(signals: HoverTriggerSignals): boolean {
|
||||
const attrs = signals.attrs ?? {};
|
||||
const haystack = [
|
||||
attrs.id,
|
||||
attrs.class,
|
||||
attrs["data-testid"],
|
||||
attrs["data-test"],
|
||||
attrs["data-cy"],
|
||||
attrs["aria-label"],
|
||||
attrs["aria-haspopup"],
|
||||
attrs["aria-controls"],
|
||||
attrs.title,
|
||||
signals.role === "button" ? signals.label : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return HOVER_POPUP_SIGNAL_RE.test(haystack);
|
||||
}
|
||||
|
||||
export function hasStrongHoverExpansionSignal(signals: HoverTriggerSignals): boolean {
|
||||
return (
|
||||
signals.cssHoverMatch === true ||
|
||||
attr(signals, "aria-haspopup") !== undefined ||
|
||||
(attr(signals, "aria-expanded") ?? "").toLowerCase() === "false"
|
||||
);
|
||||
}
|
||||
|
||||
export function hasDirectHoverInteractiveSignal(signals: HoverTriggerSignals): boolean {
|
||||
const tag = signals.tag.toLowerCase();
|
||||
const role = (signals.role ?? "").toLowerCase();
|
||||
return (
|
||||
["button", "a", "summary"].includes(tag) ||
|
||||
["button", "link", "menuitem", "tab", "combobox"].includes(role) ||
|
||||
attr(signals, "tabindex") !== undefined ||
|
||||
signals.cursor === "pointer" ||
|
||||
attr(signals, "onclick") !== undefined ||
|
||||
attr(signals, "onmouseenter") !== undefined ||
|
||||
attr(signals, "onmouseover") !== undefined ||
|
||||
attr(signals, "aria-haspopup") !== undefined ||
|
||||
attr(signals, "aria-expanded") === "false"
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateHoverTrigger(signals: HoverTriggerSignals): HoverTriggerDecision {
|
||||
const rect = signals.rect;
|
||||
if (
|
||||
!rect ||
|
||||
rect.w <= 0 ||
|
||||
rect.h <= 0 ||
|
||||
signals.pointerEvents === "none" ||
|
||||
attr(signals, "hidden") !== undefined ||
|
||||
attr(signals, "inert") !== undefined ||
|
||||
(attr(signals, "aria-hidden") ?? "").toLowerCase() === "true" ||
|
||||
isUnsafeHoverTrigger(signals)
|
||||
) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const area = rect.w * rect.h;
|
||||
if (area <= 0 || area > 160_000) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
const tag = signals.tag.toLowerCase();
|
||||
const role = (signals.role ?? "").toLowerCase();
|
||||
const label = signals.label?.trim();
|
||||
const graphic = signals.hasGraphicDescendant === true;
|
||||
const directInteractive = hasDirectHoverInteractiveSignal(signals);
|
||||
const popupSignal = hasHoverPopupSignal(signals);
|
||||
const isTextNavigationItem =
|
||||
(tag === "a" ||
|
||||
tag === "li" ||
|
||||
role === "link" ||
|
||||
role === "option" ||
|
||||
role.startsWith("menuitem") ||
|
||||
HOVER_TEXT_ITEM_RE.test(attr(signals, "class") ?? "")) &&
|
||||
!graphic &&
|
||||
!!label &&
|
||||
label.toLowerCase() !== "image";
|
||||
const compactTopbarIcon =
|
||||
(role === "button" || role === "link" || tag === "button" || tag === "a") &&
|
||||
graphic &&
|
||||
rect.y < 120 &&
|
||||
rect.w <= 96 &&
|
||||
rect.h <= 96 &&
|
||||
(!label || label.toLowerCase() === "image");
|
||||
const surfaceTriggerEvidence =
|
||||
(popupSignal && !isTextNavigationItem) ||
|
||||
compactTopbarIcon ||
|
||||
hasStrongHoverExpansionSignal(signals);
|
||||
if (!directInteractive || !surfaceTriggerEvidence) {
|
||||
return { eligible: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
if (attr(signals, "aria-haspopup") !== undefined) {
|
||||
score += 80;
|
||||
reasons.push("aria-haspopup");
|
||||
}
|
||||
if ((attr(signals, "aria-expanded") ?? "").toLowerCase() === "false") {
|
||||
score += 55;
|
||||
reasons.push("collapsed");
|
||||
}
|
||||
if (popupSignal) {
|
||||
score += 45;
|
||||
reasons.push("popup-signal");
|
||||
}
|
||||
if (tag === "button" || role === "button") {
|
||||
score += 30;
|
||||
reasons.push("button");
|
||||
}
|
||||
if (signals.cursor === "pointer") {
|
||||
score += 25;
|
||||
reasons.push("pointer");
|
||||
}
|
||||
if (graphic && (!label || label.toLowerCase() === "image")) {
|
||||
score += 40;
|
||||
reasons.push("icon-only");
|
||||
}
|
||||
if (rect.y < 120) {
|
||||
score += 25;
|
||||
reasons.push("topbar");
|
||||
}
|
||||
if (rect.w <= 80 && rect.h <= 80) {
|
||||
score += 20;
|
||||
reasons.push("compact");
|
||||
}
|
||||
if (signals.cssHoverMatch) {
|
||||
score += 40;
|
||||
reasons.push("css-hover");
|
||||
}
|
||||
|
||||
return { eligible: score >= HOVER_TRIGGER_MIN_SCORE, score, reasons };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const STORAGE_KEY = "bsk_instance_id";
|
||||
const LABEL_STORAGE_KEY = "bh_label";
|
||||
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
|
||||
const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden";
|
||||
|
||||
export interface StorageBackend {
|
||||
get(keys: string | string[]): Promise<Record<string, unknown>>;
|
||||
@@ -105,8 +106,30 @@ export async function setConnectionEnabled(
|
||||
await storage.set({ [CONNECTION_ENABLED_KEY]: enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* User preference for the in-page control hints (status pill + orange glow
|
||||
* shown while the Agent controls a tab). Defaults to shown when unset or
|
||||
* non-boolean; when hidden the whole control overlay — including its input
|
||||
* blocker — is skipped so the page looks and behaves normally.
|
||||
*/
|
||||
export async function getControlHintsHidden(
|
||||
storage: StorageBackend = defaultStorage(),
|
||||
): Promise<boolean> {
|
||||
const items = await storage.get(CONTROL_HINTS_HIDDEN_KEY);
|
||||
const raw = items[CONTROL_HINTS_HIDDEN_KEY];
|
||||
return typeof raw === "boolean" ? raw : false;
|
||||
}
|
||||
|
||||
export async function setControlHintsHidden(
|
||||
hidden: boolean,
|
||||
storage: StorageBackend = defaultStorage(),
|
||||
): Promise<void> {
|
||||
await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden });
|
||||
}
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
INSTANCE_ID: STORAGE_KEY,
|
||||
LABEL: LABEL_STORAGE_KEY,
|
||||
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
|
||||
CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_KEY,
|
||||
} as const;
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* between the content-script control overlay and the background SW.
|
||||
*
|
||||
* Content script → background:
|
||||
* - `{ kind: "overlay.who_am_i", tabId, windowId }` → background
|
||||
* replies `{ sessionId: string | null }` so the overlay knows
|
||||
* whether to render itself.
|
||||
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
|
||||
* overlay state for the sender's window.
|
||||
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
|
||||
* daemon (via a `session.user_interrupt` WS event) to cancel
|
||||
* every inflight + queued tool call for that session with
|
||||
@@ -14,8 +13,40 @@
|
||||
*/
|
||||
|
||||
export const OVERLAY_MSG_WHO_AM_I = "overlay.who_am_i";
|
||||
export const OVERLAY_MSG_READY = "overlay.ready";
|
||||
export const OVERLAY_MSG_INTERRUPT = "overlay.interrupt";
|
||||
|
||||
/**
|
||||
* WXT shadow-host element name (`createShadowRootUi({ name })`) and the marker
|
||||
* attribute set on the host for the agent's own overlay UI. The VOM capture
|
||||
* adapter uses these to skip the overlay host and its shadow subtree.
|
||||
*/
|
||||
export const OVERLAY_HOST_NAME = "browser-skill-overlay";
|
||||
export const OVERLAY_HOST_MARKER_ATTR = "data-bsk-overlay";
|
||||
|
||||
/** CSS selector for the WXT shadow host (tag + marker attribute). */
|
||||
export const OVERLAY_HOST_SELECTOR = `${OVERLAY_HOST_NAME}, [${OVERLAY_HOST_MARKER_ATTR}]`;
|
||||
|
||||
export function isOverlayHostElementName(tagName: string): boolean {
|
||||
return tagName.toLowerCase() === OVERLAY_HOST_NAME;
|
||||
}
|
||||
|
||||
export function isOverlayHostMarkerAttribute(attrName: string): boolean {
|
||||
return attrName.toLowerCase() === OVERLAY_HOST_MARKER_ATTR;
|
||||
}
|
||||
|
||||
export function isOverlayHostNode(tagName: string, attributeNames?: Iterable<string>): boolean {
|
||||
if (isOverlayHostElementName(tagName)) return true;
|
||||
if (!attributeNames) return false;
|
||||
for (const name of attributeNames) {
|
||||
if (isOverlayHostMarkerAttribute(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Page-world `document.querySelector(...)` for the overlay shadow host. */
|
||||
export const OVERLAY_HOST_LOOKUP_EXPR = `document.querySelector(${JSON.stringify(OVERLAY_HOST_SELECTOR)})`;
|
||||
|
||||
export interface OverlayWhoAmIRequest {
|
||||
kind: typeof OVERLAY_MSG_WHO_AM_I;
|
||||
tabId?: number;
|
||||
@@ -26,6 +57,22 @@ export interface OverlayWhoAmIResponse {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export interface OverlayReadyRequest {
|
||||
kind: typeof OVERLAY_MSG_READY;
|
||||
}
|
||||
|
||||
export type OverlayMode = "control" | "interrupting" | "paused" | "hidden";
|
||||
|
||||
/** Background → content: complete, authoritative control-overlay state. */
|
||||
export const OVERLAY_AGENT_STATE = "bh-agent-overlay-state";
|
||||
|
||||
export interface OverlayAgentStateMessage {
|
||||
type: typeof OVERLAY_AGENT_STATE;
|
||||
sessionId: string | null;
|
||||
mode: OverlayMode;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface OverlayInterruptRequest {
|
||||
kind: typeof OVERLAY_MSG_INTERRUPT;
|
||||
sessionId: string;
|
||||
@@ -59,4 +106,23 @@ export function isOverlayAgentOverlayResetMessage(
|
||||
return candidate.type === OVERLAY_AGENT_OVERLAY_RESET && typeof candidate.sessionId === "string";
|
||||
}
|
||||
|
||||
export type OverlayMessage = OverlayWhoAmIRequest | OverlayInterruptRequest;
|
||||
export function isOverlayAgentStateMessage(message: unknown): message is OverlayAgentStateMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const candidate = message as {
|
||||
type?: unknown;
|
||||
sessionId?: unknown;
|
||||
mode?: unknown;
|
||||
generation?: unknown;
|
||||
};
|
||||
return (
|
||||
candidate.type === OVERLAY_AGENT_STATE &&
|
||||
(typeof candidate.sessionId === "string" || candidate.sessionId === null) &&
|
||||
(candidate.mode === "control" ||
|
||||
candidate.mode === "interrupting" ||
|
||||
candidate.mode === "paused" ||
|
||||
candidate.mode === "hidden") &&
|
||||
typeof candidate.generation === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export type OverlayMessage = OverlayWhoAmIRequest | OverlayReadyRequest | OverlayInterruptRequest;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Wire protocol for user-action recording, sent between the background
|
||||
* service worker and a tab's content script.
|
||||
*/
|
||||
|
||||
import type { CaptureTargetDescriptor } from "./describe-target";
|
||||
|
||||
export const RECORD_START = "bsk-record-start";
|
||||
export const RECORD_STEP = "bsk-record-step";
|
||||
export const RECORD_STOP = "bsk-record-stop";
|
||||
export const RECORD_CANCEL = "bsk-record-cancel";
|
||||
export const RECORD_FINISH = "bsk-record-finish";
|
||||
export const RECORD_QUERY = "bsk-record-query";
|
||||
|
||||
export interface RecordStartAck {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface RecordQueryMessage {
|
||||
type: typeof RECORD_QUERY;
|
||||
}
|
||||
|
||||
export interface RecordQueryResponse {
|
||||
active: boolean;
|
||||
requestId?: string;
|
||||
/** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */
|
||||
startedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordStartMessage {
|
||||
type: typeof RECORD_START;
|
||||
requestId: string;
|
||||
/**
|
||||
* Epoch ms when the whole recording began, not when this tab was armed.
|
||||
* The overlay timer must span the session, so it survives navigations and
|
||||
* content-script remounts instead of restarting per page.
|
||||
*/
|
||||
startedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordStepPayload {
|
||||
op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
|
||||
target?: CaptureTargetDescriptor;
|
||||
value?: string;
|
||||
key?: string;
|
||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
|
||||
values?: string[];
|
||||
labels?: string[];
|
||||
url?: string;
|
||||
redacted?: boolean;
|
||||
/** Page URL when the step was captured. */
|
||||
page_url?: string;
|
||||
/** Capture-only hint; never persisted unless converted to navigated_to. */
|
||||
expects_navigation?: boolean;
|
||||
/** Whether an observed URL change was synchronously caused by the action. */
|
||||
navigation_caused_by_action?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordStepMessage {
|
||||
type: typeof RECORD_STEP;
|
||||
requestId: string;
|
||||
step: RecordStepPayload;
|
||||
}
|
||||
|
||||
export interface RecordStopMessage {
|
||||
type: typeof RECORD_STOP;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface RecordCancelMessage {
|
||||
type: typeof RECORD_CANCEL;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface RecordFinishMessage {
|
||||
type: typeof RECORD_FINISH;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_START && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordStopMessage(msg: unknown): msg is RecordStopMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_STOP && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordCancelMessage(msg: unknown): msg is RecordCancelMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_CANCEL && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordFinishMessage(msg: unknown): msg is RecordFinishMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_FINISH && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_QUERY;
|
||||
}
|
||||
|
||||
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
|
||||
const step = m.step;
|
||||
if (typeof step !== "object" || step === null) return false;
|
||||
return typeof (step as RecordStepPayload).op === "string";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { type CdpRunner, sendToCdpTarget } from "@/tools/shared";
|
||||
|
||||
const SETTLE_MIN_MS = 150;
|
||||
const SETTLE_QUIET_MS = 250;
|
||||
const SETTLE_MAX_MS = 2_000;
|
||||
const SETTLE_POLL_MS = 60;
|
||||
const SETTLE_WORLD_NAME = "__bsk_record_settle__";
|
||||
|
||||
const QUIET_PROBE = `(() => {
|
||||
const scope = window;
|
||||
let probe = scope.__bskRecordQuiet;
|
||||
if (!probe) {
|
||||
probe = { changedAt: Date.now() };
|
||||
const observer = new MutationObserver(() => {
|
||||
probe.changedAt = Date.now();
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
scope.__bskRecordQuiet = probe;
|
||||
}
|
||||
return { idleMs: Date.now() - probe.changedAt, readyState: document.readyState };
|
||||
})()`;
|
||||
|
||||
export interface DocumentSettleScope {
|
||||
target: CdpTarget;
|
||||
/** Omit only for the target's root document. */
|
||||
frameId?: string;
|
||||
}
|
||||
|
||||
type SettleOutcome = "quiet" | "timeout" | "cancelled";
|
||||
|
||||
interface QuietProbe {
|
||||
idleMs: number;
|
||||
readyState: string;
|
||||
}
|
||||
|
||||
interface ProbeContext {
|
||||
executionContextId?: number;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function executionContextForScope(
|
||||
cdp: CdpRunner,
|
||||
scope: DocumentSettleScope,
|
||||
): Promise<number | undefined> {
|
||||
if (!scope.frameId) return undefined;
|
||||
const result = await sendToCdpTarget<{ executionContextId?: number }>(
|
||||
cdp,
|
||||
scope.target,
|
||||
"Page.createIsolatedWorld",
|
||||
{
|
||||
frameId: scope.frameId,
|
||||
worldName: SETTLE_WORLD_NAME,
|
||||
grantUniveralAccess: false,
|
||||
},
|
||||
);
|
||||
return result.executionContextId;
|
||||
}
|
||||
|
||||
async function readQuietProbe(
|
||||
cdp: CdpRunner,
|
||||
scope: DocumentSettleScope,
|
||||
context: ProbeContext,
|
||||
): Promise<QuietProbe | null> {
|
||||
try {
|
||||
if (scope.frameId && context.executionContextId === undefined) {
|
||||
context.executionContextId = await executionContextForScope(cdp, scope);
|
||||
if (context.executionContextId === undefined) return null;
|
||||
}
|
||||
const reply = await sendToCdpTarget<{ result?: { value?: unknown } }>(
|
||||
cdp,
|
||||
scope.target,
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: QUIET_PROBE,
|
||||
returnByValue: true,
|
||||
...(context.executionContextId !== undefined
|
||||
? { contextId: context.executionContextId }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const value = reply.result?.value;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const { idleMs, readyState } = value as { idleMs?: unknown; readyState?: unknown };
|
||||
if (typeof idleMs !== "number" || typeof readyState !== "string") return null;
|
||||
return { idleMs, readyState };
|
||||
} catch {
|
||||
context.executionContextId = undefined;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDocumentSettled(
|
||||
cdp: CdpRunner,
|
||||
scope: DocumentSettleScope,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<SettleOutcome> {
|
||||
const startedAt = Date.now();
|
||||
const floor = startedAt + SETTLE_MIN_MS;
|
||||
const deadline = startedAt + SETTLE_MAX_MS;
|
||||
const context: ProbeContext = {};
|
||||
|
||||
for (;;) {
|
||||
if (options.signal?.aborted) return "cancelled";
|
||||
await sleep(SETTLE_POLL_MS, options.signal);
|
||||
if (options.signal?.aborted) return "cancelled";
|
||||
|
||||
const probe = await readQuietProbe(cdp, scope, context);
|
||||
const now = Date.now();
|
||||
if (now < floor) continue;
|
||||
if (now >= deadline) return "timeout";
|
||||
if (!probe || probe.readyState === "loading") continue;
|
||||
if (probe.idleMs >= SETTLE_QUIET_MS) return "quiet";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
|
||||
|
||||
export function shouldRecordPress(
|
||||
key: string,
|
||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
|
||||
): boolean {
|
||||
if (MODIFIER_ONLY_KEYS.has(key)) return false;
|
||||
const mods = modifiers ?? [];
|
||||
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
|
||||
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
|
||||
if (key === "Enter" || key === "Escape") return true;
|
||||
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIncludeDraft(step: RecordingDraftStep): boolean {
|
||||
return step.op !== "press" || shouldRecordPress(step.key, step.modifiers);
|
||||
}
|
||||
|
||||
export function resolveDraftStartUrl(
|
||||
drafts: RecordingDraftStep[],
|
||||
explicitStartUrl?: string,
|
||||
fallbackUrl?: string,
|
||||
): string {
|
||||
if (explicitStartUrl) return explicitStartUrl;
|
||||
const navigation = drafts.find(
|
||||
(step): step is Extract<RecordingDraftStep, { op: "navigate" }> => step.op === "navigate",
|
||||
);
|
||||
if (navigation) return navigation.url;
|
||||
return drafts.find((step) => step.pageUrl)?.pageUrl ?? fallbackUrl ?? "about:blank";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { RenderedRef } from "@browser-skill/vom";
|
||||
import {
|
||||
type CaptureVomMatchNode,
|
||||
type CaptureVomObservationResult,
|
||||
captureVomObservation,
|
||||
} from "@/tools/capture-vom-observation";
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
|
||||
export interface IndexedObservationNode {
|
||||
frameId: string;
|
||||
geometry: CaptureVomMatchNode;
|
||||
ref?: RenderedRef;
|
||||
}
|
||||
|
||||
export interface CapturedRecordingObservation {
|
||||
rootFrameId: string;
|
||||
index: ObservationNodeIndex;
|
||||
url: string;
|
||||
title?: string;
|
||||
vomText: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface RegisteredObservation {
|
||||
stateId: string;
|
||||
rootFrameId: string;
|
||||
index: ObservationNodeIndex;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function nodeKey(frameId: string, backendNodeId: number): string {
|
||||
return `${frameId}:${backendNodeId}`;
|
||||
}
|
||||
|
||||
function frameTagKey(frameId: string, tag: string): string {
|
||||
return `${frameId}:${tag.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export class ObservationNodeIndex {
|
||||
readonly #nodesByFrameTag = new Map<string, IndexedObservationNode[]>();
|
||||
readonly #refById = new Map<string, RenderedRef>();
|
||||
readonly #refsByFrame = new Map<string, RenderedRef[]>();
|
||||
|
||||
constructor(input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs">) {
|
||||
const refByNode = new Map<string, RenderedRef>();
|
||||
for (const ref of input.refs) {
|
||||
const frameId = ref.frameId ?? input.rootFrameId;
|
||||
refByNode.set(nodeKey(frameId, ref.backendNodeId), ref);
|
||||
this.#refById.set(ref.ref, ref);
|
||||
const frameRefs = this.#refsByFrame.get(frameId) ?? [];
|
||||
frameRefs.push(ref);
|
||||
this.#refsByFrame.set(frameId, frameRefs);
|
||||
}
|
||||
for (const geometry of input.matchNodes) {
|
||||
const { frameId } = geometry;
|
||||
const entry = {
|
||||
frameId,
|
||||
geometry,
|
||||
ref: refByNode.get(nodeKey(frameId, geometry.backendNodeId)),
|
||||
};
|
||||
const key = frameTagKey(frameId, geometry.tag);
|
||||
const bucket = this.#nodesByFrameTag.get(key) ?? [];
|
||||
bucket.push(entry);
|
||||
this.#nodesByFrameTag.set(key, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
candidates(frameId: string, tag: string): readonly IndexedObservationNode[] {
|
||||
return this.#nodesByFrameTag.get(frameTagKey(frameId, tag)) ?? [];
|
||||
}
|
||||
|
||||
ref(refId: string): RenderedRef | undefined {
|
||||
return this.#refById.get(refId);
|
||||
}
|
||||
|
||||
refs(frameId: string): readonly RenderedRef[] {
|
||||
return this.#refsByFrame.get(frameId) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readTabMeta(
|
||||
tabsApi: ChromeTabsApi,
|
||||
tabId: number,
|
||||
): Promise<{ url: string; title?: string }> {
|
||||
try {
|
||||
const tab = await tabsApi.get(tabId);
|
||||
return { url: tab.url ?? "about:blank", title: tab.title };
|
||||
} catch {
|
||||
return { url: "about:blank" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureRecordingObservation(input: {
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
tabId: number;
|
||||
maxTokens: number;
|
||||
redactValues: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CapturedRecordingObservation> {
|
||||
const { url, title } = await readTabMeta(input.tabsApi, input.tabId);
|
||||
const captured = await captureVomObservation(input.cdp, input.tabId, url, {
|
||||
maxTokens: input.maxTokens,
|
||||
redactValues: input.redactValues,
|
||||
conditionalSurfaceProbe: false,
|
||||
signal: input.signal,
|
||||
});
|
||||
return {
|
||||
rootFrameId: captured.rootFrameId,
|
||||
index: new ObservationNodeIndex(captured),
|
||||
url,
|
||||
title,
|
||||
vomText: captured.text,
|
||||
truncated: captured.truncated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
import { captureRecordingObservation, type RegisteredObservation } from "./observation-capture";
|
||||
import { RecordingStateRegistry } from "./state-registry";
|
||||
import { matchObservationTarget, unmatchedTarget } from "./target-matcher";
|
||||
import type { RecordingDraftStep, StepAnnotation, TargetedRecordingDraft } from "./types";
|
||||
|
||||
const DEFAULT_MAX_PAGE_TOKENS = 3_000;
|
||||
const MIN_CAPTURE_INTERVAL_MS = 200;
|
||||
|
||||
export interface TabObservationCursor {
|
||||
lastSettled: RegisteredObservation | null;
|
||||
lastCaptureAt: number;
|
||||
}
|
||||
|
||||
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isTargeted(draft: RecordingDraftStep): draft is TargetedRecordingDraft {
|
||||
return draft.op !== "navigate" && draft.op !== "scroll";
|
||||
}
|
||||
|
||||
export class RecordingObservationSession {
|
||||
readonly registry: RecordingStateRegistry;
|
||||
readonly cursor: TabObservationCursor;
|
||||
readonly annotations: StepAnnotation[];
|
||||
readonly #maxTokens: number;
|
||||
readonly #redactValues: boolean;
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
registry?: RecordingStateRegistry;
|
||||
cursor?: TabObservationCursor;
|
||||
annotations?: StepAnnotation[];
|
||||
maxTokens?: number;
|
||||
redactValues?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
this.registry = options.registry ?? new RecordingStateRegistry();
|
||||
this.cursor = options.cursor ?? { lastSettled: null, lastCaptureAt: 0 };
|
||||
this.annotations = options.annotations ?? [];
|
||||
this.#maxTokens = options.maxTokens ?? DEFAULT_MAX_PAGE_TOKENS;
|
||||
this.#redactValues = options.redactValues ?? false;
|
||||
}
|
||||
|
||||
async capture(
|
||||
cdp: CdpRunner,
|
||||
tabsApi: ChromeTabsApi,
|
||||
tabId: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RegisteredObservation> {
|
||||
const waitMs = Math.max(0, MIN_CAPTURE_INTERVAL_MS - (Date.now() - this.cursor.lastCaptureAt));
|
||||
if (waitMs > 0) await abortableDelay(waitMs, signal);
|
||||
if (signal?.aborted) throw new DOMException("observation aborted", "AbortError");
|
||||
const captured = await captureRecordingObservation({
|
||||
cdp,
|
||||
tabsApi,
|
||||
tabId,
|
||||
maxTokens: this.#maxTokens,
|
||||
redactValues: this.#redactValues,
|
||||
signal,
|
||||
});
|
||||
const state = this.registry.register({
|
||||
url: captured.url,
|
||||
title: captured.title,
|
||||
rawVomText: captured.vomText,
|
||||
truncated: captured.truncated,
|
||||
});
|
||||
const observation: RegisteredObservation = {
|
||||
stateId: state.id,
|
||||
rootFrameId: captured.rootFrameId,
|
||||
index: captured.index,
|
||||
url: captured.url,
|
||||
};
|
||||
this.cursor.lastSettled = observation;
|
||||
this.cursor.lastCaptureAt = Date.now();
|
||||
return observation;
|
||||
}
|
||||
|
||||
bindDraft(draft: RecordingDraftStep, draftId: number, previousActionPending = false): void {
|
||||
const observation = this.cursor.lastSettled;
|
||||
if (isTargeted(draft)) {
|
||||
draft.matchedTarget = observation
|
||||
? matchObservationTarget({
|
||||
observation,
|
||||
hint: draft.targetHint,
|
||||
fallback: draft.captureTarget,
|
||||
})
|
||||
: unmatchedTarget(draft.captureTarget);
|
||||
}
|
||||
if (!observation) return;
|
||||
|
||||
const unmatched = isTargeted(draft) && draft.matchedTarget?.unmatched === true;
|
||||
if (previousActionPending && unmatched) return;
|
||||
draft.preStateId = observation.stateId;
|
||||
this.registry.markStep(observation.stateId, draftId);
|
||||
|
||||
if (!isTargeted(draft) || !draft.matchedTarget?.ref) return;
|
||||
const ref = observation.index.ref(draft.matchedTarget.ref);
|
||||
if (!ref) return;
|
||||
this.annotations.push({
|
||||
draftId,
|
||||
op: draft.op,
|
||||
line: ref.line,
|
||||
stateId: observation.stateId,
|
||||
...(draft.op === "fill" && !this.#redactValues
|
||||
? { detail: JSON.stringify(draft.value) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
import { type DocumentSettleScope, waitForDocumentSettled } from "./document-settle";
|
||||
import { RecordingObservationSession } from "./observation-session";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
interface PendingSettle {
|
||||
abort: AbortController;
|
||||
scope: DocumentSettleScope;
|
||||
}
|
||||
|
||||
interface PendingRedirect {
|
||||
url: string;
|
||||
abort: AbortController;
|
||||
}
|
||||
|
||||
const CAPTURE_RETRY_DELAY_MS = 250;
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { name?: string }).name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
export function inferMissingPostStates(drafts: RecordingDraftStep[]): void {
|
||||
for (let index = 0; index < drafts.length - 1; index += 1) {
|
||||
const draft = drafts[index];
|
||||
const next = drafts[index + 1];
|
||||
if (draft && next && !draft.postStateId && next.preStateId) {
|
||||
draft.postStateId = next.preStateId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SettleController {
|
||||
readonly #session: RecordingObservationSession;
|
||||
readonly #cdp: CdpRunner;
|
||||
readonly #tabsApi: ChromeTabsApi;
|
||||
readonly #tabId: number;
|
||||
readonly #rootScope: DocumentSettleScope;
|
||||
readonly #pending = new Map<number, PendingSettle>();
|
||||
#queue = Promise.resolve();
|
||||
#pendingRedirect: PendingRedirect | null = null;
|
||||
#redirectQueue = Promise.resolve();
|
||||
|
||||
constructor(input: {
|
||||
session: RecordingObservationSession;
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
tabId: number;
|
||||
}) {
|
||||
this.#session = input.session;
|
||||
this.#cdp = input.cdp;
|
||||
this.#tabsApi = input.tabsApi;
|
||||
this.#tabId = input.tabId;
|
||||
this.#rootScope = { target: { tabId: input.tabId } };
|
||||
}
|
||||
|
||||
get hasPending(): boolean {
|
||||
return this.#pending.size > 0 || this.#pendingRedirect !== null;
|
||||
}
|
||||
|
||||
async #captureWithRetry(signal?: AbortSignal) {
|
||||
try {
|
||||
return await this.#session.capture(this.#cdp, this.#tabsApi, this.#tabId, signal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
await delay(CAPTURE_RETRY_DELAY_MS, signal);
|
||||
return this.#session.capture(this.#cdp, this.#tabsApi, this.#tabId, signal);
|
||||
}
|
||||
}
|
||||
|
||||
schedule(
|
||||
drafts: RecordingDraftStep[],
|
||||
draftIndex: number,
|
||||
scope: DocumentSettleScope = this.#rootScope,
|
||||
): void {
|
||||
if (scope.target.tabId !== this.#tabId) {
|
||||
throw new Error(
|
||||
`settle scope tab ${scope.target.tabId} does not belong to tab ${this.#tabId}`,
|
||||
);
|
||||
}
|
||||
const landing = drafts[draftIndex]?.preStateId;
|
||||
for (const [index, pending] of this.#pending) {
|
||||
if (index >= draftIndex) continue;
|
||||
pending.abort.abort();
|
||||
this.#pending.delete(index);
|
||||
if (landing && drafts[index] && !drafts[index].postStateId) {
|
||||
drafts[index].postStateId = landing;
|
||||
}
|
||||
}
|
||||
|
||||
this.#pending.get(draftIndex)?.abort.abort();
|
||||
const pending = { abort: new AbortController(), scope };
|
||||
this.#pending.set(draftIndex, pending);
|
||||
const task = async () => {
|
||||
if (pending.abort.signal.aborted) return;
|
||||
try {
|
||||
const outcome = await waitForDocumentSettled(this.#cdp, pending.scope, {
|
||||
signal: pending.abort.signal,
|
||||
});
|
||||
if (outcome === "cancelled") return;
|
||||
const observation = await this.#captureWithRetry(pending.abort.signal);
|
||||
if (!pending.abort.signal.aborted && drafts[draftIndex]) {
|
||||
drafts[draftIndex].postStateId = observation.stateId;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
`[bsk record] post-action observation failed for step ${draftIndex + 1}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (this.#pending.get(draftIndex) === pending) this.#pending.delete(draftIndex);
|
||||
}
|
||||
};
|
||||
this.#queue = this.#queue.then(task, task).catch(() => {});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
for (const pending of this.#pending.values()) pending.abort.abort();
|
||||
this.#pending.clear();
|
||||
this.clearRedirect();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const current = this.#queue;
|
||||
await current;
|
||||
if (current === this.#queue) return;
|
||||
}
|
||||
console.warn("[bsk record] settle queue kept growing; using completed observations");
|
||||
}
|
||||
|
||||
async settleTrailing(drafts: RecordingDraftStep[]): Promise<void> {
|
||||
const trailing: RecordingDraftStep[] = [];
|
||||
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
||||
const draft = drafts[index];
|
||||
if (!draft || draft.postStateId) break;
|
||||
trailing.push(draft);
|
||||
}
|
||||
if (trailing.length === 0) return;
|
||||
try {
|
||||
const observation = await this.#captureWithRetry();
|
||||
for (const draft of trailing) draft.postStateId = observation.stateId;
|
||||
} catch (error) {
|
||||
console.warn("[bsk record] final observation at stop failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearRedirect(): void {
|
||||
this.#pendingRedirect?.abort.abort();
|
||||
this.#pendingRedirect = null;
|
||||
}
|
||||
|
||||
scheduleRedirect(drafts: RecordingDraftStep[], url: string): void {
|
||||
this.#pendingRedirect?.abort.abort();
|
||||
const pending: PendingRedirect = { url, abort: new AbortController() };
|
||||
this.#pendingRedirect = pending;
|
||||
const task = () => this.#settleRedirect(drafts, pending);
|
||||
this.#redirectQueue = this.#redirectQueue.then(task, task).catch(() => {});
|
||||
}
|
||||
|
||||
async #settleRedirect(drafts: RecordingDraftStep[], pending: PendingRedirect): Promise<void> {
|
||||
const outcome = await waitForDocumentSettled(this.#cdp, this.#rootScope, {
|
||||
signal: pending.abort.signal,
|
||||
});
|
||||
if (outcome === "cancelled" || this.#pendingRedirect !== pending) return;
|
||||
|
||||
let finalUrl = pending.url;
|
||||
try {
|
||||
finalUrl = (await this.#tabsApi.get(this.#tabId)).url || finalUrl;
|
||||
} catch {
|
||||
// The navigation event URL remains the best available destination.
|
||||
}
|
||||
if (this.#pendingRedirect !== pending) return;
|
||||
this.#pendingRedirect = null;
|
||||
if (
|
||||
!finalUrl ||
|
||||
finalUrl === "about:blank" ||
|
||||
this.#session.cursor.lastSettled?.url === finalUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = drafts[drafts.length - 1];
|
||||
if (last?.op === "navigate" && last.url === finalUrl) {
|
||||
if (!last.postStateId) this.schedule(drafts, drafts.length - 1);
|
||||
await this.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
const draft: RecordingDraftStep = {
|
||||
op: "navigate",
|
||||
url: finalUrl,
|
||||
pageUrl: finalUrl,
|
||||
cause: "browser",
|
||||
preStateId: this.#session.cursor.lastSettled?.stateId,
|
||||
};
|
||||
drafts.push(draft);
|
||||
const draftIndex = drafts.length - 1;
|
||||
if (draft.preStateId) this.#session.registry.markStep(draft.preStateId, draftIndex + 1);
|
||||
this.schedule(drafts, draftIndex);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
async flushRedirects(): Promise<void> {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const current = this.#redirectQueue;
|
||||
await current;
|
||||
if (current === this.#redirectQueue) return;
|
||||
}
|
||||
console.warn("[bsk record] redirect queue kept growing; using the final completed landing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface RecordedStateEntry {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
rawVomText: string;
|
||||
truncated: boolean;
|
||||
stepsHere: number[];
|
||||
}
|
||||
|
||||
function stateIdentity(url: string, body: string): string {
|
||||
return `${url}\0${body}`;
|
||||
}
|
||||
|
||||
export class RecordingStateRegistry {
|
||||
readonly #entriesById = new Map<string, RecordedStateEntry>();
|
||||
readonly #idByIdentity = new Map<string, string>();
|
||||
#nextId = 1;
|
||||
|
||||
register(input: {
|
||||
url: string;
|
||||
title?: string;
|
||||
rawVomText: string;
|
||||
truncated?: boolean;
|
||||
}): RecordedStateEntry {
|
||||
const identity = stateIdentity(input.url, input.rawVomText);
|
||||
const existingId = this.#idByIdentity.get(identity);
|
||||
if (existingId) {
|
||||
const existing = this.#entriesById.get(existingId)!;
|
||||
if (!existing.title && input.title) existing.title = input.title;
|
||||
if (input.truncated) existing.truncated = true;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry: RecordedStateEntry = {
|
||||
id: `s${this.#nextId}`,
|
||||
url: input.url,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
rawVomText: input.rawVomText,
|
||||
truncated: input.truncated ?? false,
|
||||
stepsHere: [],
|
||||
};
|
||||
this.#nextId += 1;
|
||||
this.#entriesById.set(entry.id, entry);
|
||||
this.#idByIdentity.set(identity, entry.id);
|
||||
return entry;
|
||||
}
|
||||
|
||||
values(): RecordedStateEntry[] {
|
||||
return [...this.#entriesById.values()];
|
||||
}
|
||||
|
||||
markStep(stateId: string, draftId: number): void {
|
||||
const entry = this.#entriesById.get(stateId);
|
||||
if (entry && !entry.stepsHere.includes(draftId)) entry.stepsHere.push(draftId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { RecordStepPayload } from "../record-bridge";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
export interface RecordingStepBuffer {
|
||||
steps: RecordingDraftStep[];
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
}
|
||||
|
||||
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
||||
|
||||
function toDraftStep(payload: RecordStepPayload): RecordingDraftStep | null {
|
||||
const pageUrl = payload.page_url;
|
||||
switch (payload.op) {
|
||||
case "click":
|
||||
return payload.target
|
||||
? {
|
||||
op: "click",
|
||||
captureTarget: payload.target,
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "hover":
|
||||
return payload.target
|
||||
? {
|
||||
op: "hover",
|
||||
captureTarget: payload.target,
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "fill":
|
||||
return payload.target
|
||||
? {
|
||||
op: "fill",
|
||||
captureTarget: payload.target,
|
||||
value: payload.value ?? "",
|
||||
...(payload.redacted ? { redacted: true } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "press":
|
||||
return payload.key
|
||||
? {
|
||||
op: "press",
|
||||
key: payload.key,
|
||||
...(payload.target ? { captureTarget: payload.target } : {}),
|
||||
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "select":
|
||||
return payload.target && payload.values
|
||||
? {
|
||||
op: "select",
|
||||
captureTarget: payload.target,
|
||||
values: payload.values,
|
||||
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "navigate":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
|
||||
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
|
||||
const step = buffer.steps[i];
|
||||
if (!step) continue;
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select") {
|
||||
buffer.steps[i] = { ...step, navigatedTo: url };
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function observeRecordedNavigation(
|
||||
buffer: RecordingStepBuffer,
|
||||
url: string,
|
||||
causedByAction?: boolean,
|
||||
): void {
|
||||
if (!url || url === buffer.currentUrl) return;
|
||||
buffer.currentUrl = url;
|
||||
const pendingIsCurrent =
|
||||
buffer.pendingNavigation &&
|
||||
(buffer.pendingNavigationDeadline === undefined ||
|
||||
buffer.pendingNavigationDeadline >= Date.now());
|
||||
|
||||
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
|
||||
buffer.pendingNavigation = false;
|
||||
buffer.pendingNavigationDeadline = undefined;
|
||||
if (!annotateLastStepNavigation(buffer, url)) {
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
pageUrl: url,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buffer.pendingNavigation = false;
|
||||
buffer.pendingNavigationDeadline = undefined;
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
pageUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendRecordedPayload(
|
||||
buffer: RecordingStepBuffer,
|
||||
payload: RecordStepPayload,
|
||||
): void {
|
||||
if (payload.op === "navigate") {
|
||||
if (payload.url) {
|
||||
observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const step = toDraftStep({
|
||||
...payload,
|
||||
page_url: payload.page_url ?? buffer.currentUrl,
|
||||
});
|
||||
if (!step) return;
|
||||
buffer.steps.push(step);
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||
buffer.pendingNavigation = payload.expects_navigation === true;
|
||||
buffer.pendingNavigationDeadline = buffer.pendingNavigation
|
||||
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { RenderedRef } from "@browser-skill/vom";
|
||||
import type { TargetDescriptorV3 } from "@/transport/types";
|
||||
import type { CaptureTargetDescriptor } from "../describe-target";
|
||||
import type { IndexedObservationNode, RegisteredObservation } from "./observation-capture";
|
||||
import type { TargetGeometry, TargetMatchHint } from "./types";
|
||||
|
||||
const MATCH_TOLERANCE_PX = 2;
|
||||
|
||||
function close(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) <= MATCH_TOLERANCE_PX;
|
||||
}
|
||||
|
||||
function rectMatches(a: TargetGeometry["rect"], b: TargetGeometry["rect"]): boolean {
|
||||
return close(a.x, b.x) && close(a.y, b.y) && close(a.w, b.w) && close(a.h, b.h);
|
||||
}
|
||||
|
||||
function candidateMatches(candidate: IndexedObservationNode, geometry: TargetGeometry): boolean {
|
||||
return candidate.geometry.rect !== null && rectMatches(geometry.rect, candidate.geometry.rect);
|
||||
}
|
||||
|
||||
export function unmatchedTarget(fallback?: CaptureTargetDescriptor): TargetDescriptorV3 {
|
||||
return {
|
||||
...(fallback?.role ? { role: fallback.role } : {}),
|
||||
...(fallback?.name ? { name: fallback.name } : {}),
|
||||
unmatched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalized(value: string | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function matchesSemantics(ref: RenderedRef, fallback?: CaptureTargetDescriptor): boolean {
|
||||
const role = normalized(fallback?.role);
|
||||
const name = normalized(fallback?.name);
|
||||
if (!role && !name) return false;
|
||||
if (role && normalized(ref.role) !== role) return false;
|
||||
if (name && normalized(ref.name) !== name) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function descriptor(ref: RenderedRef): TargetDescriptorV3 {
|
||||
return {
|
||||
ref: ref.ref,
|
||||
...(ref.role ? { role: ref.role } : {}),
|
||||
...(ref.name ? { name: ref.name } : {}),
|
||||
...(ref.ctx ? { ctx: ref.ctx } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function matchObservationTarget(input: {
|
||||
observation: RegisteredObservation;
|
||||
hint?: TargetMatchHint;
|
||||
fallback?: CaptureTargetDescriptor;
|
||||
}): TargetDescriptorV3 {
|
||||
const frameId = input.hint?.frameId ?? input.observation.rootFrameId;
|
||||
const geometry = input.hint?.geometry;
|
||||
if (geometry) {
|
||||
const matches = input.observation.index
|
||||
.candidates(frameId, geometry.tag)
|
||||
.filter((candidate) => candidate.ref && candidateMatches(candidate, geometry));
|
||||
if (matches.length === 1) return descriptor(matches[0]!.ref!);
|
||||
const semanticMatches = matches.filter(
|
||||
(candidate) => candidate.ref && matchesSemantics(candidate.ref, input.fallback),
|
||||
);
|
||||
if (semanticMatches.length === 1) return descriptor(semanticMatches[0]!.ref!);
|
||||
return unmatchedTarget(input.fallback);
|
||||
}
|
||||
|
||||
const semanticMatches = input.observation.index
|
||||
.refs(frameId)
|
||||
.filter((ref) => matchesSemantics(ref, input.fallback));
|
||||
return semanticMatches.length === 1
|
||||
? descriptor(semanticMatches[0]!)
|
||||
: unmatchedTarget(input.fallback);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
type StepV3,
|
||||
type StopReason,
|
||||
TRACE_VERSION_V3,
|
||||
type TraceStateV3,
|
||||
type TraceV3,
|
||||
VOM_FORMAT_VERSION,
|
||||
} from "@/transport/types";
|
||||
import { resolveDraftStartUrl } from "./draft-policy";
|
||||
import type { RecordedStateEntry, RecordingStateRegistry } from "./state-registry";
|
||||
import { reduceTraceStepsV3 } from "./trace-reducer-v3";
|
||||
import { formatTraceStateBody } from "./trace-state-body";
|
||||
import type { RecordingDraftStep, StepAnnotation } from "./types";
|
||||
|
||||
function publishedEntries(registry: RecordingStateRegistry, steps: StepV3[]): RecordedStateEntry[] {
|
||||
const entries = registry.values();
|
||||
if (steps.length === 0) return entries.slice(0, 1);
|
||||
const referenced = new Set(steps.flatMap((step) => [step.state, step.result.state]));
|
||||
return entries.filter((entry) => referenced.has(entry.id));
|
||||
}
|
||||
|
||||
function remapDraftIds(draftIds: number[], stepIdByDraftId: Map<number, number>): number[] {
|
||||
return [
|
||||
...new Set(
|
||||
draftIds.flatMap((id) => {
|
||||
const stepId = stepIdByDraftId.get(id);
|
||||
return stepId === undefined ? [] : [stepId];
|
||||
}),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function buildTraceV3(input: {
|
||||
registry: RecordingStateRegistry;
|
||||
drafts: RecordingDraftStep[];
|
||||
annotations?: StepAnnotation[];
|
||||
startedAt: string;
|
||||
purpose?: string;
|
||||
startUrl?: string;
|
||||
stoppedBy: StopReason;
|
||||
bskVersion: string;
|
||||
}): TraceV3 {
|
||||
const reduced = reduceTraceStepsV3(input.drafts);
|
||||
const entries = publishedEntries(input.registry, reduced.steps);
|
||||
const publishedId = new Map(entries.map((entry, index) => [entry.id, `s${index + 1}`]));
|
||||
const annotationsByState = new Map<string, StepAnnotation[]>();
|
||||
for (const annotation of input.annotations ?? []) {
|
||||
const bucket = annotationsByState.get(annotation.stateId) ?? [];
|
||||
bucket.push(annotation);
|
||||
annotationsByState.set(annotation.stateId, bucket);
|
||||
}
|
||||
const steps = reduced.steps.map((step) => ({
|
||||
...step,
|
||||
state: publishedId.get(step.state) ?? step.state,
|
||||
result: { state: publishedId.get(step.result.state) ?? step.result.state },
|
||||
}));
|
||||
const states: TraceStateV3[] = entries.map((entry) => {
|
||||
const id = publishedId.get(entry.id) ?? entry.id;
|
||||
return {
|
||||
id,
|
||||
url: entry.url,
|
||||
...(entry.title ? { title: entry.title } : {}),
|
||||
body: formatTraceStateBody({
|
||||
stateId: id,
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
stepIds: remapDraftIds(entry.stepsHere, reduced.stepIdByDraftId),
|
||||
vomText: entry.rawVomText,
|
||||
annotations: annotationsByState.get(entry.id) ?? [],
|
||||
stepIdByDraftId: reduced.stepIdByDraftId,
|
||||
}),
|
||||
...(entry.truncated ? { truncated: true } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
version: TRACE_VERSION_V3,
|
||||
...(input.purpose ? { purpose: input.purpose } : {}),
|
||||
recorded_at: new Date().toISOString(),
|
||||
started_at: input.startedAt,
|
||||
stopped_by: input.stoppedBy,
|
||||
entry: { start_url: resolveDraftStartUrl(input.drafts, input.startUrl, states[0]?.url) },
|
||||
recorder: { bsk: input.bskVersion, vom: VOM_FORMAT_VERSION },
|
||||
states,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PageRefV2, SelectedOptionV2, StepV2, TraceV2 } from "@/transport/types";
|
||||
import { resolveDraftStartUrl, shouldIncludeDraft } from "./draft-policy";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
/** Collapse consecutive navigations to the last hop. */
|
||||
function collapseNavigations(steps: RecordingDraftStep[]): RecordingDraftStep[] {
|
||||
const out: RecordingDraftStep[] = [];
|
||||
for (const step of steps) {
|
||||
const prev = out[out.length - 1];
|
||||
if (step.op === "navigate" && prev?.op === "navigate") {
|
||||
out[out.length - 1] = step;
|
||||
continue;
|
||||
}
|
||||
out.push(step);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectUrls(steps: RecordingDraftStep[], startUrl?: string): string[] {
|
||||
const urls: string[] = [];
|
||||
if (startUrl) urls.push(startUrl);
|
||||
for (const step of steps) {
|
||||
if (step.op === "navigate") {
|
||||
urls.push(step.url);
|
||||
continue;
|
||||
}
|
||||
if (step.pageUrl) urls.push(step.pageUrl);
|
||||
if ("navigatedTo" in step && step.navigatedTo) urls.push(step.navigatedTo);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
unique.push(url);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildPageRegistry(
|
||||
steps: RecordingDraftStep[],
|
||||
startUrl?: string,
|
||||
): { pages: PageRefV2[]; urlToId: Map<string, string> } {
|
||||
const urls = collectUrls(steps, startUrl);
|
||||
const urlToId = new Map<string, string>();
|
||||
const pages = urls.map((url, index) => {
|
||||
const id = `p${index + 1}`;
|
||||
urlToId.set(url, id);
|
||||
return { id, url };
|
||||
});
|
||||
return { pages, urlToId };
|
||||
}
|
||||
|
||||
function pageIdFor(
|
||||
url: string | undefined,
|
||||
urlToId: Map<string, string>,
|
||||
fallbackUrl?: string,
|
||||
): string {
|
||||
if (url && urlToId.has(url)) return urlToId.get(url)!;
|
||||
if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!;
|
||||
return urlToId.values().next().value ?? "p1";
|
||||
}
|
||||
|
||||
function pageUrlForDraft(step: RecordingDraftStep, fallbackUrl?: string): string | undefined {
|
||||
if (step.op === "navigate") return step.pageUrl ?? step.url;
|
||||
if (step.pageUrl) return step.pageUrl;
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
function effectForNavigation(
|
||||
navigatedTo: string | undefined,
|
||||
urlToId: Map<string, string>,
|
||||
): StepV2["effect"] {
|
||||
if (!navigatedTo) return undefined;
|
||||
const pageId = urlToId.get(navigatedTo);
|
||||
if (!pageId) return undefined;
|
||||
return { navigated_to: pageId };
|
||||
}
|
||||
|
||||
function withEffect(step: StepV2, effect: StepV2["effect"]): StepV2 {
|
||||
if (!effect) return step;
|
||||
return { ...step, effect };
|
||||
}
|
||||
|
||||
function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] {
|
||||
return values.map((value, index) => ({
|
||||
value,
|
||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function toV2Step(
|
||||
step: RecordingDraftStep,
|
||||
id: number,
|
||||
urlToId: Map<string, string>,
|
||||
fallbackUrl?: string,
|
||||
): StepV2 | null {
|
||||
if (!shouldIncludeDraft(step)) return null;
|
||||
|
||||
const pageUrl = pageUrlForDraft(step, fallbackUrl);
|
||||
const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
|
||||
|
||||
switch (step.op) {
|
||||
case "navigate":
|
||||
return {
|
||||
op: "navigate",
|
||||
id,
|
||||
page: pageIdFor(step.url, urlToId, fallbackUrl),
|
||||
to: step.url,
|
||||
};
|
||||
case "click":
|
||||
if (!step.captureTarget) return null;
|
||||
return withEffect(
|
||||
{
|
||||
op: "click",
|
||||
id,
|
||||
page,
|
||||
target: step.captureTarget,
|
||||
},
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "hover":
|
||||
if (!step.captureTarget) return null;
|
||||
return {
|
||||
op: "hover",
|
||||
id,
|
||||
page,
|
||||
target: step.captureTarget,
|
||||
};
|
||||
case "fill":
|
||||
if (!step.captureTarget) return null;
|
||||
return {
|
||||
op: "fill",
|
||||
id,
|
||||
page,
|
||||
target: step.captureTarget,
|
||||
value: step.value,
|
||||
...(step.redacted ? { redacted: true } : {}),
|
||||
};
|
||||
case "press":
|
||||
return withEffect(
|
||||
{
|
||||
op: "press",
|
||||
id,
|
||||
page,
|
||||
key: step.key,
|
||||
...(step.captureTarget ? { target: step.captureTarget } : {}),
|
||||
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
|
||||
},
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "select":
|
||||
if (!step.captureTarget) return null;
|
||||
return withEffect(
|
||||
{
|
||||
op: "select",
|
||||
id,
|
||||
page,
|
||||
target: step.captureTarget,
|
||||
selection: toSelection(step.values, step.labels),
|
||||
},
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "scroll":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ReducedTrace {
|
||||
pages: PageRefV2[];
|
||||
steps: StepV2[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile capture drafts into record-only trace v2 steps.
|
||||
* Variable inputs are NOT classified here — executing agents infer that at run time.
|
||||
*/
|
||||
function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string): ReducedTrace {
|
||||
const collapsed = collapseNavigations(steps);
|
||||
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
|
||||
const out: StepV2[] = [];
|
||||
let id = 1;
|
||||
let lastUrl = startUrl;
|
||||
for (const draft of collapsed) {
|
||||
if (draft.op === "navigate") lastUrl = draft.url;
|
||||
else if ("navigatedTo" in draft && draft.navigatedTo) lastUrl = draft.navigatedTo;
|
||||
else if (draft.pageUrl) lastUrl = draft.pageUrl;
|
||||
const step = toV2Step(draft, id, urlToId, lastUrl);
|
||||
if (!step) continue;
|
||||
out.push(step);
|
||||
id += 1;
|
||||
}
|
||||
return { pages, steps: out };
|
||||
}
|
||||
|
||||
function resolveTraceStartUrl(
|
||||
drafts: RecordingDraftStep[],
|
||||
startUrl?: string,
|
||||
pages?: PageRefV2[],
|
||||
): string {
|
||||
return resolveDraftStartUrl(drafts, startUrl, pages?.[0]?.url);
|
||||
}
|
||||
|
||||
export function buildTraceV2(input: {
|
||||
steps: RecordingDraftStep[];
|
||||
startedAt: string;
|
||||
startUrl?: string;
|
||||
purpose?: string;
|
||||
}): TraceV2 {
|
||||
const { pages, steps } = reduceTraceSteps(input.steps, input.startUrl);
|
||||
return {
|
||||
recorded_at: new Date().toISOString(),
|
||||
started_at: input.startedAt,
|
||||
...(input.purpose ? { purpose: input.purpose } : {}),
|
||||
entry: { start_url: resolveTraceStartUrl(input.steps, input.startUrl, pages) },
|
||||
pages,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { NavigationCause, StepV3 } from "@/transport/types";
|
||||
import { shouldIncludeDraft } from "./draft-policy";
|
||||
import { unmatchedTarget } from "./target-matcher";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
interface CollapsedDraft {
|
||||
draft: RecordingDraftStep;
|
||||
draftIds: number[];
|
||||
}
|
||||
|
||||
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
|
||||
const TRANSITION_CAUSES: Record<string, NavigationCause> = {
|
||||
typed: "user_typed",
|
||||
generated: "user_typed",
|
||||
keyword: "user_typed",
|
||||
keyword_generated: "user_typed",
|
||||
link: "link",
|
||||
form_submit: "form_submit",
|
||||
reload: "reload",
|
||||
auto_bookmark: "browser",
|
||||
start_page: "browser",
|
||||
};
|
||||
|
||||
function isRedirect(step: Extract<RecordingDraftStep, { op: "navigate" }>): boolean {
|
||||
return (step.transitionQualifiers ?? []).some((qualifier) => REDIRECT_QUALIFIERS.has(qualifier));
|
||||
}
|
||||
|
||||
function collapseRedirects(steps: RecordingDraftStep[]): CollapsedDraft[] {
|
||||
const output: CollapsedDraft[] = [];
|
||||
steps.forEach((step, index) => {
|
||||
const previous = output[output.length - 1];
|
||||
if (step.op === "navigate" && previous?.draft.op === "navigate" && isRedirect(step)) {
|
||||
previous.draft = {
|
||||
...previous.draft,
|
||||
url: step.url,
|
||||
postStateId: step.postStateId ?? previous.draft.postStateId,
|
||||
};
|
||||
previous.draftIds.push(index + 1);
|
||||
return;
|
||||
}
|
||||
output.push({ draft: { ...step }, draftIds: [index + 1] });
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function navigationCause(step: Extract<RecordingDraftStep, { op: "navigate" }>): NavigationCause {
|
||||
if (step.cause) return step.cause;
|
||||
const qualifiers = step.transitionQualifiers ?? [];
|
||||
if (qualifiers.includes("forward_back")) return "history";
|
||||
if (qualifiers.includes("from_address_bar")) return "user_typed";
|
||||
return TRANSITION_CAUSES[step.transitionType ?? ""] ?? "browser";
|
||||
}
|
||||
|
||||
function selection(values: string[], labels?: string[]): Array<{ value: string; label?: string }> {
|
||||
return values.map((value, index) => ({
|
||||
value,
|
||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function reduceDraft(draft: RecordingDraftStep, id: number): StepV3 | null {
|
||||
if (!shouldIncludeDraft(draft)) return null;
|
||||
const state = draft.preStateId ?? draft.postStateId;
|
||||
const resultState = draft.postStateId ?? draft.preStateId;
|
||||
if (!state || !resultState) return null;
|
||||
const common = { id, state, result: { state: resultState } };
|
||||
|
||||
switch (draft.op) {
|
||||
case "navigate":
|
||||
return { op: "navigate", ...common, to: draft.url, cause: navigationCause(draft) };
|
||||
case "click":
|
||||
return {
|
||||
op: "click",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
};
|
||||
case "hover":
|
||||
return {
|
||||
op: "hover",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
};
|
||||
case "fill":
|
||||
return {
|
||||
op: "fill",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
value: draft.value,
|
||||
commit: draft.commit ?? "blur",
|
||||
...(draft.redacted ? { redacted: true } : {}),
|
||||
};
|
||||
case "press":
|
||||
return {
|
||||
op: "press",
|
||||
...common,
|
||||
key: draft.key,
|
||||
...(draft.captureTarget || draft.matchedTarget
|
||||
? { target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget) }
|
||||
: {}),
|
||||
...(draft.modifiers?.length ? { modifiers: draft.modifiers } : {}),
|
||||
};
|
||||
case "select":
|
||||
return {
|
||||
op: "select",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
selection: selection(draft.values, draft.labels),
|
||||
};
|
||||
case "scroll":
|
||||
return { op: "scroll", ...common };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReducedTraceV3 {
|
||||
steps: StepV3[];
|
||||
stepIdByDraftId: Map<number, number>;
|
||||
}
|
||||
|
||||
export function reduceTraceStepsV3(steps: RecordingDraftStep[]): ReducedTraceV3 {
|
||||
const output: StepV3[] = [];
|
||||
const stepIdByDraftId = new Map<number, number>();
|
||||
for (const { draft, draftIds } of collapseRedirects(steps)) {
|
||||
const step = reduceDraft(draft, output.length + 1);
|
||||
if (!step) continue;
|
||||
output.push(step);
|
||||
for (const draftId of draftIds) stepIdByDraftId.set(draftId, step.id);
|
||||
}
|
||||
return { steps: output, stepIdByDraftId };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StepAnnotation } from "./types";
|
||||
|
||||
function annotationText(annotation: StepAnnotation, stepId: number): string {
|
||||
return ` ⟵ step ${stepId}: ${annotation.op}${annotation.detail ? `: ${annotation.detail}` : ""}`;
|
||||
}
|
||||
|
||||
export function formatTraceStateBody(input: {
|
||||
stateId: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
stepIds: number[];
|
||||
vomText: string;
|
||||
annotations: StepAnnotation[];
|
||||
stepIdByDraftId: Map<number, number>;
|
||||
}): string {
|
||||
const lines = ["# bsk-observation 1", `state: ${JSON.stringify(input.stateId)}`];
|
||||
lines.push(`url: ${JSON.stringify(input.url)}`);
|
||||
if (input.title) lines.push(`title: ${JSON.stringify(input.title)}`);
|
||||
if (input.stepIds.length > 0) lines.push(`steps_here: [${input.stepIds.join(", ")}]`);
|
||||
lines.push("---");
|
||||
|
||||
const byLine = new Map<number, Array<{ annotation: StepAnnotation; stepId: number }>>();
|
||||
for (const annotation of input.annotations) {
|
||||
const stepId = input.stepIdByDraftId.get(annotation.draftId);
|
||||
if (stepId === undefined) continue;
|
||||
const bucket = byLine.get(annotation.line) ?? [];
|
||||
bucket.push({ annotation, stepId });
|
||||
byLine.set(annotation.line, bucket);
|
||||
}
|
||||
|
||||
input.vomText.split("\n").forEach((bodyLine, lineIndex) => {
|
||||
let line = bodyLine;
|
||||
for (const item of byLine.get(lineIndex) ?? []) {
|
||||
line += annotationText(item.annotation, item.stepId);
|
||||
}
|
||||
lines.push(line);
|
||||
});
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { CaptureTargetDescriptor } from "@/lib/describe-target";
|
||||
import type {
|
||||
FillCommit,
|
||||
KeyModifier,
|
||||
NavigationCause,
|
||||
StepV3,
|
||||
TargetDescriptorV3,
|
||||
} from "@/transport/types";
|
||||
|
||||
export interface TargetGeometry {
|
||||
/** Top-level viewport-relative CSS pixels, as defined by the geometry module. */
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface TargetMatchHint {
|
||||
geometry?: TargetGeometry;
|
||||
/** Missing means the current top frame, never an unrestricted frame search. */
|
||||
frameId?: string;
|
||||
}
|
||||
|
||||
export interface StepAnnotation {
|
||||
draftId: number;
|
||||
op: StepV3["op"];
|
||||
line: number;
|
||||
stateId: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface DraftStateLink {
|
||||
pageUrl?: string;
|
||||
preStateId?: string;
|
||||
postStateId?: string;
|
||||
}
|
||||
|
||||
interface DraftTarget {
|
||||
captureTarget?: CaptureTargetDescriptor;
|
||||
targetHint?: TargetMatchHint;
|
||||
matchedTarget?: TargetDescriptorV3;
|
||||
}
|
||||
|
||||
interface DraftNavigationEffect {
|
||||
navigatedTo?: string;
|
||||
}
|
||||
|
||||
export type RecordingDraftStep =
|
||||
| ({ op: "click" } & DraftStateLink & DraftTarget & DraftNavigationEffect)
|
||||
| ({ op: "hover" } & DraftStateLink & DraftTarget)
|
||||
| ({
|
||||
op: "fill";
|
||||
value: string;
|
||||
commit?: FillCommit;
|
||||
redacted?: boolean;
|
||||
} & DraftStateLink &
|
||||
DraftTarget &
|
||||
DraftNavigationEffect)
|
||||
| ({
|
||||
op: "press";
|
||||
key: string;
|
||||
modifiers?: KeyModifier[];
|
||||
} & DraftStateLink &
|
||||
DraftTarget &
|
||||
DraftNavigationEffect)
|
||||
| ({
|
||||
op: "select";
|
||||
values: string[];
|
||||
labels?: string[];
|
||||
} & DraftStateLink &
|
||||
DraftTarget &
|
||||
DraftNavigationEffect)
|
||||
| ({ op: "scroll" } & DraftStateLink)
|
||||
| ({
|
||||
op: "navigate";
|
||||
url: string;
|
||||
cause?: NavigationCause;
|
||||
transitionType?: string;
|
||||
transitionQualifiers?: string[];
|
||||
} & DraftStateLink);
|
||||
|
||||
export type TargetedRecordingDraft = Extract<
|
||||
RecordingDraftStep,
|
||||
{ op: "click" | "hover" | "fill" | "press" | "select" }
|
||||
>;
|
||||
@@ -44,3 +44,53 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => {
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chromeAgentWindowApi.create", () => {
|
||||
const create = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("chrome", {
|
||||
windows: { create },
|
||||
});
|
||||
create.mockReset();
|
||||
create.mockResolvedValue({ id: 100 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("focuses Agent Windows by default", async () => {
|
||||
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
type: "normal",
|
||||
focused: true,
|
||||
url: AGENT_WINDOW_HOME,
|
||||
});
|
||||
});
|
||||
|
||||
it("can create an Agent Window without stealing focus", async () => {
|
||||
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, { focused: false });
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
type: "normal",
|
||||
focused: false,
|
||||
url: AGENT_WINDOW_HOME,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an optional window size through the options object", async () => {
|
||||
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, {
|
||||
size: { width: 1280, height: 800 },
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
type: "normal",
|
||||
focused: true,
|
||||
url: AGENT_WINDOW_HOME,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDisconnectCleanup } from "../disconnect-cleanup";
|
||||
import { SessionManager } from "../manager";
|
||||
|
||||
describe("disconnect session cleanup", () => {
|
||||
it("stops every local session through the safe session-stop path", async () => {
|
||||
const remove = vi.fn(async () => {});
|
||||
let nextWindowId = 100;
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => nextWindowId++),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
remove,
|
||||
},
|
||||
});
|
||||
await manager.start("aa11");
|
||||
await manager.start("bb22");
|
||||
const detachSession = vi.fn(async () => {});
|
||||
const onSessionsChanged = vi.fn();
|
||||
const cleanup = createDisconnectCleanup({
|
||||
manager,
|
||||
sessionStopDeps: { cdp: { detachSession } },
|
||||
onSessionsChanged,
|
||||
});
|
||||
|
||||
const report = await cleanup();
|
||||
|
||||
expect(report).toEqual({ stoppedSessionIds: ["aa11", "bb22"], failures: [] });
|
||||
expect(manager.list()).toEqual([]);
|
||||
expect(remove).toHaveBeenCalledTimes(2);
|
||||
expect(detachSession).toHaveBeenCalledWith("aa11");
|
||||
expect(detachSession).toHaveBeenCalledWith("bb22");
|
||||
expect(onSessionsChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("coalesces overlapping disconnect notifications", async () => {
|
||||
let releaseRemove: () => void = () => {};
|
||||
const removeGate = new Promise<void>((resolve) => {
|
||||
releaseRemove = resolve;
|
||||
});
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 100),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
remove: vi.fn(() => removeGate),
|
||||
},
|
||||
});
|
||||
await manager.start("aa11");
|
||||
const cleanup = createDisconnectCleanup({ manager });
|
||||
|
||||
const first = cleanup();
|
||||
const second = cleanup();
|
||||
expect(second).toBe(first);
|
||||
releaseRemove();
|
||||
|
||||
await expect(first).resolves.toEqual({ stoppedSessionIds: ["aa11"], failures: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentWindowApi } from "../agent-window";
|
||||
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
||||
import { SessionManager } from "../manager";
|
||||
|
||||
function fakeAgentWindow(): AgentWindowApi & {
|
||||
@@ -8,7 +8,7 @@ function fakeAgentWindow(): AgentWindowApi & {
|
||||
ensureActiveTabMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let nextId = 100;
|
||||
const createMock = vi.fn(async (_url: string) => {
|
||||
const createMock = vi.fn(async (_url: string, _opts?: AgentWindowCreateOptions) => {
|
||||
const id = nextId++;
|
||||
return id;
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe("SessionManager", () => {
|
||||
const sm = new SessionManager({ agentWindow: aw, now: () => 1700000000000 });
|
||||
const ctx = await sm.start("aa11");
|
||||
expect(aw.createMock).toHaveBeenCalledOnce();
|
||||
expect(aw.createMock).toHaveBeenCalledWith("about:blank");
|
||||
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {});
|
||||
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
|
||||
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
|
||||
expect(ctx.sessionId).toBe("aa11");
|
||||
@@ -39,6 +39,24 @@ describe("SessionManager", () => {
|
||||
expect(ctx.refStore.isEmpty()).toBe(true);
|
||||
expect(ctx.borrowedTabs.size).toBe(0);
|
||||
});
|
||||
it("forwards an optional window size when starting a session", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
const ctx = await sm.start("aa11", { size: { width: 1280, height: 800 } });
|
||||
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {
|
||||
size: { width: 1280, height: 800 },
|
||||
});
|
||||
expect(ctx.agentWindowId).toBe(100);
|
||||
});
|
||||
|
||||
it("forwards an explicit unfocused start to the Agent Window", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
|
||||
await sm.start("aa11", { focused: false });
|
||||
|
||||
expect(aw.createMock).toHaveBeenCalledWith("about:blank", { focused: false });
|
||||
});
|
||||
|
||||
it("indexes the session by sessionId and agent window id", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
@@ -57,6 +75,52 @@ describe("SessionManager", () => {
|
||||
await expect(sm.start("aa11")).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("removes a newly created Agent Window when startup is aborted", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
let resolveCreate: (windowId: number) => void = () => {};
|
||||
aw.createMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
const controller = new AbortController();
|
||||
const pending = sm.start("aa11", { signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
resolveCreate(777);
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(aw.removeMock).toHaveBeenCalledWith(777);
|
||||
expect(sm.has("aa11")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes an incomplete Agent Window when active-tab setup fails", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
|
||||
await expect(sm.start("aa11")).rejects.toThrow("tab setup failed");
|
||||
|
||||
expect(aw.removeMock).toHaveBeenCalledWith(100);
|
||||
expect(sm.has("aa11")).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces the orphan Agent Window id when startup cleanup fails", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
|
||||
aw.removeMock.mockRejectedValueOnce(new Error("window removal denied"));
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
|
||||
await expect(sm.start("aa11")).rejects.toMatchObject({
|
||||
name: "SessionStartCleanupError",
|
||||
windowId: 100,
|
||||
message: expect.stringMatching(/cleanup of Agent Window 100 failed.*window removal denied/),
|
||||
});
|
||||
expect(sm.has("aa11")).toBe(false);
|
||||
});
|
||||
|
||||
it("stop() closes the Agent Window and forgets the session", async () => {
|
||||
const aw = fakeAgentWindow();
|
||||
const sm = new SessionManager({ agentWindow: aw });
|
||||
|
||||
@@ -33,4 +33,19 @@ describe("RefStore", () => {
|
||||
expect(s.resolve("@e11")).toBe(11);
|
||||
expect(s.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves the child CDP session as part of ref identity", () => {
|
||||
const s = new RefStore();
|
||||
s.set("e1", 42, {
|
||||
tabId: 7,
|
||||
frameId: "child-frame",
|
||||
cdpSessionId: "child-session",
|
||||
});
|
||||
expect(s.resolveEntry("e1")).toMatchObject({
|
||||
backendNodeId: 42,
|
||||
tabId: 7,
|
||||
frameId: "child-frame",
|
||||
cdpSessionId: "child-session",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
export interface AgentWindowApi {
|
||||
create(url: string): Promise<number>;
|
||||
create(url: string, opts?: AgentWindowCreateOptions): Promise<number>;
|
||||
remove(windowId: number): Promise<void>;
|
||||
/**
|
||||
* Guarantee the Agent Window has an active, CDP-navigable tab.
|
||||
@@ -18,15 +18,24 @@ export interface AgentWindowApi {
|
||||
ensureActiveTab(windowId: number, url: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Creation hints for a new Agent Window. */
|
||||
export interface AgentWindowCreateOptions {
|
||||
/** Optional outer size in CSS pixels. */
|
||||
size?: { width: number; height: number };
|
||||
/** Defaults to true so existing sessions keep visible Agent Windows. */
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
/** Initial tab URL for every new session's Agent Window. */
|
||||
export const AGENT_WINDOW_HOME = "about:blank";
|
||||
|
||||
export const chromeAgentWindowApi: AgentWindowApi = {
|
||||
async create(url: string): Promise<number> {
|
||||
async create(url: string, opts: AgentWindowCreateOptions = {}): Promise<number> {
|
||||
const win = await chrome.windows.create({
|
||||
type: "normal",
|
||||
focused: true,
|
||||
focused: opts.focused ?? true,
|
||||
url,
|
||||
...(opts.size ? { width: opts.size.width, height: opts.size.height } : {}),
|
||||
});
|
||||
if (typeof win?.id !== "number") {
|
||||
throw new Error("[bh] chrome.windows.create returned no window id");
|
||||
@@ -34,12 +43,11 @@ export const chromeAgentWindowApi: AgentWindowApi = {
|
||||
return win.id;
|
||||
},
|
||||
async remove(windowId: number): Promise<void> {
|
||||
try {
|
||||
await chrome.windows.remove(windowId);
|
||||
} catch (err) {
|
||||
// Window may have been closed by the user already; ignore.
|
||||
console.debug("[bh] chrome.windows.remove failed", err);
|
||||
}
|
||||
// Callers decide whether a missing/failed removal is benign. In
|
||||
// particular, transactional session-start cleanup must be able to
|
||||
// surface a window it could not remove instead of reporting a false
|
||||
// cancellation success while the Agent Window remains open.
|
||||
await chrome.windows.remove(windowId);
|
||||
},
|
||||
async ensureActiveTab(windowId: number, url: string): Promise<void> {
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { handleSessionStop, type SessionStopDeps } from "@/tools/session";
|
||||
import type { SessionManager } from "./manager";
|
||||
|
||||
export interface DisconnectCleanupFailure {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DisconnectCleanupReport {
|
||||
stoppedSessionIds: string[];
|
||||
failures: DisconnectCleanupFailure[];
|
||||
}
|
||||
|
||||
export interface DisconnectCleanupOptions {
|
||||
manager: SessionManager;
|
||||
sessionStopDeps?: SessionStopDeps;
|
||||
onSessionsChanged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a coalesced cleanup operation for transport loss.
|
||||
*
|
||||
* The daemon purges its registry when the extension socket disappears. To
|
||||
* keep the extension-side mirror consistent, every local session must follow
|
||||
* the normal safe stop path as well: return borrowed tabs, clear refs, detach
|
||||
* CDP, and only then close the Agent Window.
|
||||
*/
|
||||
export function createDisconnectCleanup(options: DisconnectCleanupOptions) {
|
||||
let inFlight: Promise<DisconnectCleanupReport> | null = null;
|
||||
|
||||
return (): Promise<DisconnectCleanupReport> => {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = cleanupSessions(options).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupSessions(
|
||||
options: DisconnectCleanupOptions,
|
||||
): Promise<DisconnectCleanupReport> {
|
||||
const stoppedSessionIds: string[] = [];
|
||||
const failures: DisconnectCleanupFailure[] = [];
|
||||
|
||||
for (const ctx of options.manager.list()) {
|
||||
try {
|
||||
const result = await handleSessionStop(
|
||||
options.manager,
|
||||
{ session_id: ctx.sessionId },
|
||||
options.sessionStopDeps,
|
||||
);
|
||||
if ("code" in result) {
|
||||
failures.push({ sessionId: ctx.sessionId, message: result.message });
|
||||
continue;
|
||||
}
|
||||
if (result.return_failures?.length) {
|
||||
failures.push({
|
||||
sessionId: ctx.sessionId,
|
||||
message: result.return_failures.map((failure) => failure.message).join("; "),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
stoppedSessionIds.push(ctx.sessionId);
|
||||
} catch (err) {
|
||||
failures.push({
|
||||
sessionId: ctx.sessionId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
options.onSessionsChanged?.();
|
||||
return { stoppedSessionIds, failures };
|
||||
}
|
||||
@@ -25,6 +25,46 @@ export interface SessionManagerOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Options for starting a session's Agent Window. */
|
||||
export interface SessionStartOptions {
|
||||
/** Optional Agent Window outer size in CSS pixels. */
|
||||
size?: { width: number; height: number };
|
||||
/** Defaults to true so existing clients keep visible Agent Windows. */
|
||||
focused?: boolean;
|
||||
/** Cancellation for the transactional Agent Window startup sequence. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class SessionStartCleanupError extends Error {
|
||||
readonly windowId: number;
|
||||
readonly startupError: unknown;
|
||||
readonly cleanupError: unknown;
|
||||
|
||||
constructor(windowId: number, startupError: unknown, cleanupError: unknown) {
|
||||
const startupMessage =
|
||||
startupError instanceof Error ? startupError.message : String(startupError);
|
||||
const cleanupMessage =
|
||||
cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
||||
super(
|
||||
`session_start failed (${startupMessage}) and cleanup of Agent Window ${windowId} failed: ${cleanupMessage}`,
|
||||
);
|
||||
this.name = "SessionStartCleanupError";
|
||||
this.windowId = windowId;
|
||||
this.startupError = startupError;
|
||||
this.cleanupError = cleanupError;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStartAbortError(): Error {
|
||||
const error = new Error("session_start aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfSessionStartAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw sessionStartAbortError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner of all live agent sessions inside the extension.
|
||||
*
|
||||
@@ -128,22 +168,40 @@ export class SessionManager {
|
||||
* Returns the created window id so callers can echo it back to the
|
||||
* daemon in the `tool.session_start` reply.
|
||||
*/
|
||||
async start(sessionId: string): Promise<SessionContext> {
|
||||
async start(sessionId: string, opts: SessionStartOptions = {}): Promise<SessionContext> {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
throw new Error(`[bh] session ${sessionId} already exists`);
|
||||
}
|
||||
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME);
|
||||
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
||||
const ctx: SessionContext = {
|
||||
sessionId,
|
||||
agentWindowId: windowId,
|
||||
refStore: new RefStore(),
|
||||
borrowedTabs: new Map(),
|
||||
createdAtMs: this.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, ctx);
|
||||
this.windowIndex.set(windowId, sessionId);
|
||||
return ctx;
|
||||
throwIfSessionStartAborted(opts.signal);
|
||||
|
||||
let windowId: number | null = null;
|
||||
try {
|
||||
const { signal: _signal, ...createOptions } = opts;
|
||||
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
|
||||
throwIfSessionStartAborted(opts.signal);
|
||||
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
|
||||
throwIfSessionStartAborted(opts.signal);
|
||||
|
||||
const ctx: SessionContext = {
|
||||
sessionId,
|
||||
agentWindowId: windowId,
|
||||
refStore: new RefStore(),
|
||||
borrowedTabs: new Map(),
|
||||
createdAtMs: this.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, ctx);
|
||||
this.windowIndex.set(windowId, sessionId);
|
||||
return ctx;
|
||||
} catch (startupError) {
|
||||
if (windowId !== null) {
|
||||
try {
|
||||
await this.agentWindow.remove(windowId);
|
||||
} catch (cleanupError) {
|
||||
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
|
||||
}
|
||||
}
|
||||
throw startupError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Per-session map from `@e<N>` snapshot refs to CDP `backendNodeId`.
|
||||
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
|
||||
*
|
||||
* Each fresh `tool.snapshot` resets the store: M6 will call
|
||||
* `replace(...)` with the new ref → backendNodeId pairs. Tools like
|
||||
* `tool.click` consume the store via `resolve("@e1")`.
|
||||
* `replace(...)` with the new ref → node address pairs. A node address
|
||||
* includes the owning flat CDP session/frame when the element lives in
|
||||
* an OOPIF; tools resolve live geometry from that identity at call time.
|
||||
*
|
||||
* Refs are session-scoped (§7): looking up a ref in the wrong session
|
||||
* returns `null`, never silently leaks. Storing values in different
|
||||
@@ -15,10 +16,19 @@ export type BackendNodeId = number;
|
||||
export interface RefEntry {
|
||||
backendNodeId: BackendNodeId;
|
||||
tabId: number | null;
|
||||
frameId?: string;
|
||||
cdpSessionId?: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
type RefInput = BackendNodeId | { backendNodeId: BackendNodeId; tabId: number };
|
||||
export type RefInput =
|
||||
| BackendNodeId
|
||||
| {
|
||||
backendNodeId: BackendNodeId;
|
||||
tabId: number;
|
||||
frameId?: string;
|
||||
cdpSessionId?: string;
|
||||
};
|
||||
|
||||
export class RefStore {
|
||||
private readonly map = new Map<string, RefEntry>();
|
||||
@@ -44,7 +54,7 @@ export class RefStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire store with a new ref → backendNodeId mapping.
|
||||
* Replace the entire store with a new ref → CDP node identity mapping.
|
||||
* Used after every fresh `tool.snapshot`.
|
||||
*/
|
||||
replace(entries: Iterable<readonly [string, RefInput]>): void {
|
||||
@@ -53,10 +63,20 @@ export class RefStore {
|
||||
for (const [ref, input] of entries) this.map.set(normaliseRef(ref), this.entry(input));
|
||||
}
|
||||
|
||||
set(ref: string, id: BackendNodeId, opts: { tabId?: number } = {}): void {
|
||||
set(
|
||||
ref: string,
|
||||
id: BackendNodeId,
|
||||
opts: {
|
||||
tabId?: number;
|
||||
frameId?: string;
|
||||
cdpSessionId?: string;
|
||||
} = {},
|
||||
): void {
|
||||
this.map.set(normaliseRef(ref), {
|
||||
backendNodeId: id,
|
||||
tabId: opts.tabId ?? null,
|
||||
...(opts.frameId ? { frameId: opts.frameId } : {}),
|
||||
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
|
||||
generation: this.generation,
|
||||
});
|
||||
}
|
||||
@@ -80,6 +100,8 @@ export class RefStore {
|
||||
return {
|
||||
backendNodeId: input.backendNodeId,
|
||||
tabId: input.tabId,
|
||||
...(input.frameId ? { frameId: input.frameId } : {}),
|
||||
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
|
||||
generation: this.generation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +162,22 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("denies malformed or missing confirmation responses", async () => {
|
||||
tabs.get.mockResolvedValue({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValue(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
);
|
||||
|
||||
for (const response of [undefined, {}, { allowed: true }, { type: "borrow-response" }]) {
|
||||
tabs.sendMessage.mockResolvedValueOnce(response);
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the Agent Window when it is lastFocusedWindow and falls back to a real user window", async () => {
|
||||
tabs.get.mockResolvedValueOnce({
|
||||
id: 42,
|
||||
@@ -250,7 +266,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(tabs.sendMessage.mock.calls[1]?.[0]).toBe(301);
|
||||
});
|
||||
|
||||
it("does NOT fail-open immediately when every candidate's sendMessage fails — waits for notification button or timeout", async () => {
|
||||
it("waits for a notification decision and denies when every UI path times out", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
@@ -282,13 +298,13 @@ describe("requestBorrowConfirmation", () => {
|
||||
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
// CRITICAL: previously this would already be settled(true). Now we
|
||||
// require an explicit user choice (notification button) or timeout.
|
||||
// require an explicit user choice (notification button) or safe timeout.
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
// Allow the BACKGROUND_TIMEOUT_MS fail-open to fire so the test resolves.
|
||||
// The background timeout must resolve the request without authorizing it.
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(resolvedValue).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(resolvedValue).toBe(false);
|
||||
|
||||
const exhaustedWarn = warnSpy.mock.calls.find(
|
||||
(call) =>
|
||||
@@ -375,7 +391,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("fail-opens when no injectable user window exists at all", async () => {
|
||||
it("denies when no injectable user window exists at all", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Stranded Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
|
||||
@@ -394,7 +410,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).not.toHaveBeenCalled();
|
||||
// No notification either — there's no actionable target window.
|
||||
expect(notifications.create).not.toHaveBeenCalled();
|
||||
@@ -421,6 +437,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(notificationId.startsWith(BORROW_NOTIFICATION_PREFIX)).toBe(true);
|
||||
expect(options.type).toBe("basic");
|
||||
expect(options.title).toMatch(/borrow/i);
|
||||
expect(options.requireInteraction).toBe(true);
|
||||
expect(notifications.clear).toHaveBeenCalledWith(notificationId);
|
||||
});
|
||||
|
||||
@@ -447,10 +464,12 @@ describe("requestBorrowConfirmation", () => {
|
||||
userWindowWithActiveTab({ windowId: 77, tabId: 4242, url: "https://docs.example/" }),
|
||||
);
|
||||
// Keep sendMessage pending so the request stays live while we click.
|
||||
// A second call may arrive if the background timeout dismisses the overlay.
|
||||
let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined;
|
||||
tabs.sendMessage.mockImplementationOnce(
|
||||
() => new Promise((res) => (resolveSend = res as never)),
|
||||
);
|
||||
tabs.sendMessage.mockResolvedValue(undefined);
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
@@ -460,7 +479,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
|
||||
// Simulate user clicking the OS notification.
|
||||
for (const l of listeners) l(notificationId);
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
|
||||
|
||||
// Finish the borrow so the test resolves cleanly.
|
||||
@@ -469,21 +488,29 @@ describe("requestBorrowConfirmation", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("fail-opens after background timeout when content script never responds", async () => {
|
||||
it("denies after background timeout when content script never responds", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
);
|
||||
tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
tabs.sendMessage.mockResolvedValueOnce(undefined);
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
// Timeout must dismiss any in-flight overlay so Allow cannot linger.
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
|
||||
type: string;
|
||||
requestId: string;
|
||||
};
|
||||
expect(cancelMessage.type).toBe("borrow-cancel");
|
||||
});
|
||||
|
||||
it("dismisses pending overlay and fail-opens when aborted", async () => {
|
||||
it("dismisses the pending overlay and denies when aborted", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 7, url: "https://app.example/" }),
|
||||
@@ -500,7 +527,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const requestMessage = tabs.sendMessage.mock.calls[0][1] as { requestId: string };
|
||||
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
|
||||
@@ -510,6 +537,48 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(cancelMessage.type).toBe("borrow-cancel");
|
||||
expect(cancelMessage.requestId).toBe(requestMessage.requestId);
|
||||
});
|
||||
|
||||
it("does not fail-open when tabs.get rejects — still surfaces confirmation to a candidate", async () => {
|
||||
// Regression: a transient tabs.get failure (SW teardown / page hiccup)
|
||||
// used to return true immediately, approving the borrow with no
|
||||
// confirmation UI. The tab was already fetched by the borrow pipeline
|
||||
// moments earlier, so the failure is transient — we must fall back to a
|
||||
// generic title and still ask the user.
|
||||
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
|
||||
);
|
||||
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: false });
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
const allowed = await pending;
|
||||
|
||||
// The user's explicit deny must be honoured — NOT auto-allowed by the
|
||||
// earlier tabs.get rejection.
|
||||
expect(allowed).toBe(false);
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still allows an explicit overlay allow after a tabs.get rejection", async () => {
|
||||
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
|
||||
);
|
||||
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
const allowed = await pending;
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInjectableContentScriptUrl", () => {
|
||||
@@ -543,4 +612,14 @@ describe("isInjectableContentScriptUrl", () => {
|
||||
).toBe(false);
|
||||
expect(isInjectableContentScriptUrl("https://chromewebstore.google.com/")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the Edge Add-ons store", () => {
|
||||
expect(
|
||||
isInjectableContentScriptUrl(
|
||||
"https://microsoftedge.microsoft.com/addons/detail/browserskill/emacgiaaaiojkkpkddmmdfhmokgmnikg",
|
||||
),
|
||||
).toBe(false);
|
||||
// Non-store paths on the same host stay injectable.
|
||||
expect(isInjectableContentScriptUrl("https://microsoftedge.microsoft.com/")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
import type { ConsoleResult } from "@/transport/types";
|
||||
import { handleConsole } from "../console";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
opts: {
|
||||
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
|
||||
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
|
||||
result?: ConsoleResult;
|
||||
} = {},
|
||||
) {
|
||||
const result =
|
||||
opts.result ??
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
} satisfies ConsoleResult);
|
||||
const ensureConsoleCapture = vi.fn(async () => {});
|
||||
const consoleEntriesSince = vi.fn(() => result);
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
ensureConsoleCapture,
|
||||
consoleEntriesSince,
|
||||
} as unknown as CdpRunner;
|
||||
const tabsApi = {
|
||||
get:
|
||||
opts.get ??
|
||||
vi.fn(
|
||||
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query:
|
||||
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||
};
|
||||
return { cdp, tabsApi, ensureConsoleCapture, consoleEntriesSince };
|
||||
}
|
||||
|
||||
describe("handleConsole", () => {
|
||||
it("reads the Agent Window active tab with safe defaults", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [{ sequence: 3, kind: "console", level: "log", text: "hello", truncated: false }],
|
||||
next_since: 3,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleConsole(sm, { session_id: "aa11" }, deps);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.entries[0].text).toBe("hello");
|
||||
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(7);
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
|
||||
});
|
||||
|
||||
it("reads an explicit tab and forwards cursor options", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
await handleConsole(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
tab_id: 9,
|
||||
since: 12,
|
||||
limit: 75,
|
||||
max_text_chars: 1500,
|
||||
include_stack: true,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
|
||||
});
|
||||
|
||||
it("hides other sessions' Agent Window tabs", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
|
||||
await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
const res = await handleConsole(sm, { session_id: "aa11", tab_id: 9 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "not_found" });
|
||||
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
|
||||
import type { ConnectionState, ProtocolFrame, RequestFrame } from "@/transport/types";
|
||||
import type {
|
||||
ConnectionState,
|
||||
ConsoleResult,
|
||||
ProtocolFrame,
|
||||
RequestFrame,
|
||||
} from "@/transport/types";
|
||||
import { ToolDispatcher } from "../dispatcher";
|
||||
|
||||
type TestDispatcherCdp = NonNullable<ConstructorParameters<typeof ToolDispatcher>[0]["cdp"]>;
|
||||
|
||||
function fakeTransport() {
|
||||
const handlers = new Set<FrameHandler>();
|
||||
const stateHandlers = new Set<ConnectionStateHandler>();
|
||||
@@ -36,6 +43,10 @@ function makeRequest(method: string, params: unknown): RequestFrame {
|
||||
}
|
||||
|
||||
describe("ToolDispatcher", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -58,6 +69,25 @@ describe("ToolDispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards an unfocused session start to the Agent Window", async () => {
|
||||
const { transport, deliver } = fakeTransport();
|
||||
const create = vi.fn(async () => 4242);
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create,
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.session_start", { session_id: "aa11", focused: false }));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(create).toHaveBeenCalledWith("about:blank", { focused: false });
|
||||
});
|
||||
|
||||
it("routes tool.session_stop and replies with empty result", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -78,6 +108,66 @@ describe("ToolDispatcher", () => {
|
||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||
});
|
||||
|
||||
it("routes tool.console through the CDP console buffer", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
|
||||
},
|
||||
});
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await sessions.start("aa11");
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureConsoleCapture: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
consoleEntriesSince: vi.fn(
|
||||
() =>
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [
|
||||
{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false },
|
||||
],
|
||||
next_since: 1,
|
||||
truncated: false,
|
||||
}) satisfies ConsoleResult,
|
||||
),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.console", { session_id: "aa11" }));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(cdp.ensureConsoleCapture).toHaveBeenCalledWith(7);
|
||||
expect(sent[0]).toEqual({
|
||||
id: "r-1",
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }],
|
||||
next_since: 1,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("detaches CDP state before stopping a session", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -91,8 +181,19 @@ describe("ToolDispatcher", () => {
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
|
||||
@@ -102,6 +203,55 @@ describe("ToolDispatcher", () => {
|
||||
expect(sent[0]).toEqual({ id: "r-1", result: {} });
|
||||
});
|
||||
|
||||
it("forwards daemon cancellation into an in-flight session stop", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const remove = vi.fn(async () => {});
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove,
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await sessions.start("aa11");
|
||||
let finishDetach: () => void = () => {};
|
||||
const detach = new Promise<void>((resolve) => {
|
||||
finishDetach = resolve;
|
||||
});
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(() => detach),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
|
||||
await vi.waitFor(() => expect(cdp.detachSession).toHaveBeenCalledWith("aa11"));
|
||||
deliver({ id: "cancel-stop", method: "cancel", params: { rpc_id: "r-1" } });
|
||||
await flushMicrotasks();
|
||||
finishDetach();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(sent).toContainEqual({ id: "cancel-stop", result: { cancelled: true } });
|
||||
expect(sent).toContainEqual({
|
||||
id: "r-1",
|
||||
error: expect.objectContaining({ code: "cancelled" }),
|
||||
});
|
||||
expect(sessions.has("aa11")).toBe(true);
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns not_found when stopping an unknown session", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -202,6 +352,306 @@ describe("ToolDispatcher", () => {
|
||||
expect(onSessionsChanged).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const onBrowserControlResumed = vi.fn();
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, onBrowserControlResumed });
|
||||
dispatcher.start();
|
||||
|
||||
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
|
||||
await flushMicrotasks();
|
||||
sent.length = 0;
|
||||
|
||||
deliver({ ...makeRequest("tool.snapshot", { session_id: "aa11" }), id: "r-passive" });
|
||||
await flushMicrotasks();
|
||||
expect(onBrowserControlResumed).not.toHaveBeenCalled();
|
||||
|
||||
deliver({
|
||||
...makeRequest("tool.tab_create", { session_id: "aa11", url: "https://example.test/" }),
|
||||
id: "r-control",
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
||||
});
|
||||
|
||||
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const { transport } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const order: string[] = [];
|
||||
const cdp = {
|
||||
send: vi.fn(async <T>() => {
|
||||
order.push("hover");
|
||||
return {} as T;
|
||||
}),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
|
||||
(
|
||||
dispatcher as unknown as { rememberHover: (sessionId: string, result: unknown) => unknown }
|
||||
).rememberHover("aa11", {
|
||||
tab_id: 7,
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
const helpers = dispatcher as unknown as {
|
||||
withHoverReassert: <T>(
|
||||
params: { session_id: string; tab_id?: number },
|
||||
work: () => Promise<T>,
|
||||
options?: { releaseAfter?: boolean },
|
||||
) => Promise<T>;
|
||||
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => {
|
||||
order.push("observe");
|
||||
return {};
|
||||
});
|
||||
expect(order).toEqual(["hover", "observe"]);
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
|
||||
await helpers.setHoverBypass("aa11", 7, true);
|
||||
await helpers.withHoverReassert(
|
||||
{ session_id: "aa11", tab_id: 7 },
|
||||
async () => {
|
||||
order.push("click");
|
||||
return {};
|
||||
},
|
||||
{ releaseAfter: true },
|
||||
);
|
||||
|
||||
expect(cdp.send).toHaveBeenLastCalledWith(7, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reassert or disable hover latches from another session", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const { transport } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const cdp = {
|
||||
send: vi.fn(async <T>() => ({}) as T),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
const helpers = dispatcher as unknown as {
|
||||
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||
withHoverReassert: <T>(
|
||||
params: { session_id: string; tab_id?: number },
|
||||
work: () => Promise<T>,
|
||||
options?: { releaseAfter?: boolean },
|
||||
) => Promise<T>;
|
||||
releaseHoverLatch: (sessionId?: string, tabId?: number) => Promise<void>;
|
||||
};
|
||||
|
||||
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||
await helpers.setHoverBypass("aa11", 7, true);
|
||||
|
||||
await helpers.withHoverReassert({ session_id: "bb22", tab_id: 7 }, async () => ({}), {
|
||||
releaseAfter: true,
|
||||
});
|
||||
|
||||
expect(cdp.send).not.toHaveBeenCalled();
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
|
||||
await helpers.releaseHoverLatch("aa11", 7);
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("transfers hover overlay bypass ownership between sessions", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const { transport } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||
const helpers = dispatcher as unknown as {
|
||||
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
await helpers.setHoverBypass("aa11", 7, true);
|
||||
await helpers.setHoverBypass("bb22", 7, true);
|
||||
await helpers.setHoverBypass("aa11", 7, false);
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
|
||||
await helpers.setHoverBypass("bb22", 7, false);
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("limits default-target hover reassertion to the active tab", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
query: vi.fn(async () => [{ id: 9, windowId: 4242, active: true }]),
|
||||
get: vi.fn(async (tabId: number) => ({ id: tabId, windowId: 4242, active: tabId === 9 })),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const { transport } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
await sessions.start("aa11");
|
||||
const cdp = {
|
||||
send: vi.fn(async <T>() => ({}) as T),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 9,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
setDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
clearDeviceMetricsOverride: vi.fn(async () => {}),
|
||||
setUserAgentOverride: vi.fn(async () => {}),
|
||||
setTouchEmulationEnabled: vi.fn(async () => {}),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
|
||||
const helpers = dispatcher as unknown as {
|
||||
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||
withHoverReassert: <T>(
|
||||
params: { session_id: string; tab_id?: number },
|
||||
work: () => Promise<T>,
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||
helpers.rememberHover("aa11", { tab_id: 9, x: 30, y: 40 });
|
||||
|
||||
await helpers.withHoverReassert({ session_id: "aa11" }, async () => ({}));
|
||||
|
||||
expect(cdp.send).toHaveBeenCalledTimes(1);
|
||||
expect(cdp.send).toHaveBeenCalledWith(9, "Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: 30,
|
||||
y: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the current session hover latch before navigation-style work", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const { transport } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions });
|
||||
const helpers = dispatcher as unknown as {
|
||||
rememberHover: (sessionId: string, result: unknown) => unknown;
|
||||
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||
withHoverReleaseForRequest: <T>(
|
||||
params: { session_id: string; tab_id?: number },
|
||||
work: () => Promise<T>,
|
||||
) => Promise<T>;
|
||||
withHoverReassert: <T>(
|
||||
params: { session_id: string; tab_id?: number },
|
||||
work: () => Promise<T>,
|
||||
) => Promise<T>;
|
||||
};
|
||||
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||
await helpers.setHoverBypass("aa11", 7, true);
|
||||
|
||||
await helpers.withHoverReleaseForRequest({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("disconnects the transport when send() fails so keepalive can rebuild", async () => {
|
||||
const { transport, deliver } = fakeTransport();
|
||||
const sessions = new SessionManager({
|
||||
@@ -276,23 +726,31 @@ describe("ToolDispatcher", () => {
|
||||
await flushMicrotasks();
|
||||
expect(ac?.signal.aborted).toBe(true);
|
||||
|
||||
// Cancel ack arrived synchronously; the slow tool replies with
|
||||
// `cancelled` once the dispatcher's race observes the abort.
|
||||
// Cancel ack arrives synchronously, but the original RPC must not reply
|
||||
// until the in-progress window creation has completed and been rolled back.
|
||||
const ack = sent.find(
|
||||
(m) =>
|
||||
typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "cancel-1",
|
||||
);
|
||||
expect(ack).toEqual({ id: "cancel-1", result: { cancelled: true } });
|
||||
|
||||
expect(
|
||||
sent.find(
|
||||
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(true);
|
||||
|
||||
resolveCreate(9999);
|
||||
await flushMicrotasks();
|
||||
|
||||
const slow = sent.find(
|
||||
(m) => typeof (m as { id?: string }).id === "string" && (m as { id: string }).id === "r-1",
|
||||
);
|
||||
expect(slow).toMatchObject({ id: "r-1", error: { code: "cancelled" } });
|
||||
expect(dispatcher.inflightAbortControllers.has("r-1")).toBe(false);
|
||||
|
||||
// Drain the dangling create promise so vitest does not warn.
|
||||
resolveCreate(9999);
|
||||
await flushMicrotasks();
|
||||
expect(sessions.has("aa44")).toBe(false);
|
||||
expect(sessions.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancel for an unknown rpc_id replies with cancelled=false", async () => {
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nodeBoundingRect, quadBoundingRect } from "../element-geometry";
|
||||
import { nodeContentRegion } from "../element-geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
describe("quadBoundingRect", () => {
|
||||
it("computes axis-aligned bounds from an 8-double quad", () => {
|
||||
expect(quadBoundingRect([10, 20, 110, 20, 110, 60, 10, 60])).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 40,
|
||||
describe("nodeContentRegion", () => {
|
||||
it("rejects invalid content quads before using the box model fallback", async () => {
|
||||
const send = async (_tabId: number, method: string) => {
|
||||
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 0, 0, 0, 0, 0, 0]] };
|
||||
if (method === "DOM.getBoxModel") {
|
||||
return { model: { content: [5, 10, 25, 10, 25, 30, 5, 30] } };
|
||||
}
|
||||
throw new Error(`unexpected CDP call ${method}`);
|
||||
};
|
||||
|
||||
await expect(nodeContentRegion({ send: send as CdpRunner["send"] }, 7, 555)).resolves.toEqual([
|
||||
[
|
||||
{ x: 5, y: 10 },
|
||||
{ x: 25, y: 10 },
|
||||
{ x: 25, y: 30 },
|
||||
{ x: 5, y: 30 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves every visible content fragment", async () => {
|
||||
const send = async () => ({
|
||||
quads: [
|
||||
[10, 20, 60, 20, 60, 40, 10, 40],
|
||||
[15, 50, 90, 50, 90, 80, 15, 80],
|
||||
],
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: send as CdpRunner["send"],
|
||||
};
|
||||
|
||||
await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
|
||||
[
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 60, y: 20 },
|
||||
{ x: 60, y: 40 },
|
||||
{ x: 10, y: 40 },
|
||||
],
|
||||
[
|
||||
{ x: 15, y: 50 },
|
||||
{ x: 90, y: 50 },
|
||||
{ x: 90, y: 80 },
|
||||
{ x: 15, y: 80 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for degenerate quads", () => {
|
||||
expect(quadBoundingRect([0, 0, 0, 0, 0, 0, 0, 0])).toBeNull();
|
||||
expect(quadBoundingRect([1, 2, 3])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeBoundingRect", () => {
|
||||
it("falls back to visible descendant bounds for zero-size containers", async () => {
|
||||
const calls: Array<{ method: string; params?: object }> = [];
|
||||
const send = async (_tabId: number, method: string, params?: object) => {
|
||||
@@ -38,12 +68,14 @@ describe("nodeBoundingRect", () => {
|
||||
};
|
||||
const cdp = { send: send as CdpRunner["send"] };
|
||||
|
||||
await expect(nodeBoundingRect(cdp, 7, 555)).resolves.toEqual({
|
||||
x: 242,
|
||||
y: 468,
|
||||
width: 769,
|
||||
height: 180,
|
||||
});
|
||||
await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
|
||||
[
|
||||
{ x: 242, y: 468 },
|
||||
{ x: 1011, y: 468 },
|
||||
{ x: 1011, y: 648 },
|
||||
{ x: 242, y: 648 },
|
||||
],
|
||||
]);
|
||||
expect(calls.map((c) => c.method)).toEqual([
|
||||
"DOM.getContentQuads",
|
||||
"DOM.getBoxModel",
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DeviceMetricsOverride, UserAgentOverride } from "@/browser-driver/chromium-cdp";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { EmulateOverrides, RpcError } from "@/transport/types";
|
||||
import {
|
||||
EMULATE_SCOPE_NOTE,
|
||||
type EmulateCdpRunner,
|
||||
handleEmulate,
|
||||
resetEmulateStatesForTests,
|
||||
toCdpUserAgentMetadata,
|
||||
validateOverrides,
|
||||
} from "../emulate";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface CdpCalls {
|
||||
metrics: Array<{ tabId: number; metrics: DeviceMetricsOverride }>;
|
||||
clearedMetrics: number[];
|
||||
ua: Array<{ tabId: number; override: UserAgentOverride }>;
|
||||
touch: Array<{ tabId: number; enabled: boolean; maxTouchPoints?: number }>;
|
||||
}
|
||||
|
||||
function makeDeps(opts: { throwOn?: string; tabWindowId?: number } = {}) {
|
||||
const calls: CdpCalls = { metrics: [], clearedMetrics: [], ua: [], touch: [] };
|
||||
const maybeThrow = (kind: string) => {
|
||||
if (opts.throwOn === kind) throw new Error(`simulated ${kind} failure`);
|
||||
};
|
||||
const cdp: EmulateCdpRunner = {
|
||||
setDeviceMetricsOverride: vi.fn(async (tabId, metrics) => {
|
||||
maybeThrow("metrics");
|
||||
calls.metrics.push({ tabId, metrics });
|
||||
}),
|
||||
clearDeviceMetricsOverride: vi.fn(async (tabId) => {
|
||||
maybeThrow("metrics");
|
||||
calls.clearedMetrics.push(tabId);
|
||||
}),
|
||||
setUserAgentOverride: vi.fn(async (tabId, override) => {
|
||||
maybeThrow("ua");
|
||||
calls.ua.push({ tabId, override });
|
||||
}),
|
||||
setTouchEmulationEnabled: vi.fn(async (tabId, enabled, maxTouchPoints) => {
|
||||
maybeThrow("touch");
|
||||
calls.touch.push({ tabId, enabled, maxTouchPoints });
|
||||
}),
|
||||
trackSessionTab: vi.fn(),
|
||||
};
|
||||
const tabsApi = {
|
||||
get: vi.fn(
|
||||
async (tabId: number) =>
|
||||
({ id: tabId, windowId: opts.tabWindowId ?? 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query: vi.fn(async () => [
|
||||
{ id: 7, windowId: opts.tabWindowId ?? 100, active: true } as chrome.tabs.Tab,
|
||||
]),
|
||||
};
|
||||
return { cdp, tabsApi, calls };
|
||||
}
|
||||
|
||||
async function makeManager(): Promise<SessionManager> {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
return sm;
|
||||
}
|
||||
|
||||
const fullOverrides: EmulateOverrides = {
|
||||
width: 390,
|
||||
height: 844,
|
||||
device_scale_factor: 3,
|
||||
mobile: true,
|
||||
user_agent: "Mozilla/5.0 (iPhone)",
|
||||
accept_language: "zh-CN",
|
||||
touch: true,
|
||||
max_touch_points: 5,
|
||||
};
|
||||
|
||||
describe("handleEmulate", () => {
|
||||
beforeEach(() => {
|
||||
resetEmulateStatesForTests();
|
||||
});
|
||||
|
||||
it("applies viewport, UA and touch overrides to the active tab", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
expect(result).toEqual({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: fullOverrides,
|
||||
note: EMULATE_SCOPE_NOTE,
|
||||
});
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 390, height: 844, deviceScaleFactor: 3, mobile: true } },
|
||||
]);
|
||||
expect(deps.calls.ua).toEqual([
|
||||
{
|
||||
tabId: 7,
|
||||
override: { userAgent: "Mozilla/5.0 (iPhone)", acceptLanguage: "zh-CN" },
|
||||
},
|
||||
]);
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: true, maxTouchPoints: 5 }]);
|
||||
expect(deps.cdp.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||
});
|
||||
|
||||
it("defaults deviceScaleFactor to 0 and mobile to false when omitted", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 800, height: 1280 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 800, height: 1280, deviceScaleFactor: 0, mobile: false } },
|
||||
]);
|
||||
expect(deps.calls.ua).toEqual([]);
|
||||
expect(deps.calls.touch).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies a UA-only override", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.metrics).toEqual([]);
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 7, override: { userAgent: "custom-ua" } }]);
|
||||
});
|
||||
|
||||
it("converts user_agent_metadata to CDP camelCase", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
overrides: {
|
||||
user_agent: "custom-ua",
|
||||
user_agent_metadata: {
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
full_version: "120.0.0.0",
|
||||
platform: "Android",
|
||||
platform_version: "14",
|
||||
mobile: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7 });
|
||||
expect(deps.calls.ua[0].override.userAgentMetadata).toEqual({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
fullVersion: "120.0.0.0",
|
||||
platform: "Android",
|
||||
platformVersion: "14",
|
||||
mobile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("max_touch_points without touch enables touch emulation", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { max_touch_points: 5 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: true, maxTouchPoints: 5 }]);
|
||||
});
|
||||
|
||||
it("touch: false only disables touch emulation", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { touch: false } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 7, cleared: false });
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: false, maxTouchPoints: undefined }]);
|
||||
});
|
||||
|
||||
it("targets an explicit tab_id", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", tab_id: 9, overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ tab_id: 9 });
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 9, override: { userAgent: "custom-ua" } }]);
|
||||
});
|
||||
|
||||
it("off clears metrics, touch and UA overrides", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
expect(result).toEqual({ tab_id: 7, cleared: true, note: EMULATE_SCOPE_NOTE });
|
||||
expect(deps.calls.clearedMetrics).toEqual([7]);
|
||||
expect(deps.calls.touch).toEqual([{ tabId: 7, enabled: false, maxTouchPoints: undefined }]);
|
||||
expect(deps.calls.ua).toEqual([{ tabId: 7, override: { userAgent: "" } }]);
|
||||
});
|
||||
|
||||
it("rejects off combined with overrides", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", off: true, overrides: { touch: true } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ code: "invalid_params" });
|
||||
expect(deps.calls.clearedMetrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects missing overrides and missing off", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "aa11" }, deps);
|
||||
expect(result).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects an unknown session", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
const result = await handleEmulate(sm, { session_id: "zz99", off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
it("rejects a tab outside the Agent Window", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ tabWindowId: 200 });
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", tab_id: 9, off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "permission_denied" });
|
||||
expect(deps.calls.clearedMetrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges onto the tab's stored state: later width/height keep the earlier dpr/mobile", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: { width: 500, height: 900, device_scale_factor: 3, mobile: true },
|
||||
});
|
||||
expect(deps.calls.metrics).toEqual([
|
||||
{ tabId: 7, metrics: { width: 390, height: 844, deviceScaleFactor: 3, mobile: true } },
|
||||
{ tabId: 7, metrics: { width: 500, height: 900, deviceScaleFactor: 3, mobile: true } },
|
||||
]);
|
||||
// The merged state is applied as a whole: UA and touch are re-applied too.
|
||||
expect(deps.calls.ua).toHaveLength(2);
|
||||
expect(deps.calls.touch).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps emulation state per tab", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", tab_id: 9, overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
// Tab 9 has no stored state, so dpr/mobile fall back to the CDP defaults.
|
||||
expect(deps.calls.metrics.at(-1)).toEqual({
|
||||
tabId: 9,
|
||||
metrics: { width: 500, height: 900, deviceScaleFactor: 0, mobile: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("off clears the stored state: a later request starts from scratch", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps();
|
||||
await handleEmulate(sm, { session_id: "aa11", overrides: fullOverrides }, deps);
|
||||
await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { width: 500, height: 900 } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
tab_id: 7,
|
||||
cleared: false,
|
||||
applied: { width: 500, height: 900 },
|
||||
note: EMULATE_SCOPE_NOTE,
|
||||
});
|
||||
// dpr/mobile fell back to the CDP defaults instead of the cleared values,
|
||||
// and the cleared UA/touch were not re-applied.
|
||||
expect(deps.calls.metrics.at(-1)).toEqual({
|
||||
tabId: 7,
|
||||
metrics: { width: 500, height: 900, deviceScaleFactor: 0, mobile: false },
|
||||
});
|
||||
expect(deps.calls.ua).toHaveLength(2); // preset + the clearing empty UA
|
||||
expect(deps.calls.touch).toHaveLength(2); // preset + the disabling call
|
||||
});
|
||||
|
||||
it("maps CDP failures to cdp_failed and points at --off", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ throwOn: "ua" });
|
||||
const result = await handleEmulate(
|
||||
sm,
|
||||
{ session_id: "aa11", overrides: { user_agent: "custom-ua" } },
|
||||
deps,
|
||||
);
|
||||
expect(result).toMatchObject({ code: "cdp_failed" });
|
||||
const message = (result as RpcError).message;
|
||||
expect(message).toContain("simulated ua failure");
|
||||
expect(message).toContain("not rolled back");
|
||||
expect(message).toContain("--off");
|
||||
});
|
||||
|
||||
it("reports an off-branch CDP failure with the same not-rolled-back note", async () => {
|
||||
const sm = await makeManager();
|
||||
const deps = makeDeps({ throwOn: "metrics" });
|
||||
const result = await handleEmulate(sm, { session_id: "aa11", off: true }, deps);
|
||||
expect(result).toMatchObject({ code: "cdp_failed" });
|
||||
const message = (result as RpcError).message;
|
||||
expect(message).toContain("simulated metrics failure");
|
||||
expect(message).toContain("not rolled back");
|
||||
expect(message).toContain("--off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOverrides", () => {
|
||||
it("accepts a full override set", () => {
|
||||
expect(validateOverrides(fullOverrides)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects width without height and vice versa", () => {
|
||||
expect(validateOverrides({ width: 390 })).toMatchObject({ code: "invalid_params" });
|
||||
expect(validateOverrides({ height: 844 })).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects non-positive or non-integer dimensions", () => {
|
||||
expect(validateOverrides({ width: 0, height: 844 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ width: 390.5, height: 844 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects metrics extras without dimensions", () => {
|
||||
expect(validateOverrides({ device_scale_factor: 3 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ mobile: true })).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects a non-positive device_scale_factor", () => {
|
||||
expect(validateOverrides({ width: 390, height: 844, device_scale_factor: 0 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(
|
||||
validateOverrides({ width: 390, height: 844, device_scale_factor: Number.NaN }),
|
||||
).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
|
||||
it("rejects accept_language / user_agent_metadata without user_agent", () => {
|
||||
expect(validateOverrides({ accept_language: "zh-CN" })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(validateOverrides({ user_agent_metadata: { platform: "Android" } })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid max_touch_points", () => {
|
||||
expect(validateOverrides({ touch: true, max_touch_points: 0 })).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an empty override set", () => {
|
||||
expect(validateOverrides({})).toMatchObject({ code: "invalid_params" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCdpUserAgentMetadata", () => {
|
||||
it("maps snake_case fields to camelCase and drops absent ones", () => {
|
||||
expect(
|
||||
toCdpUserAgentMetadata({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
platform: "Android",
|
||||
mobile: true,
|
||||
}),
|
||||
).toEqual({
|
||||
brands: [{ brand: "Chromium", version: "120" }],
|
||||
platform: "Android",
|
||||
mobile: true,
|
||||
});
|
||||
expect(toCdpUserAgentMetadata({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,9 @@ describe("handleEvaluate", () => {
|
||||
{ session_id: "aa11", expression: "1+1", tab_id: 11 },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry";
|
||||
import {
|
||||
clipPolygon,
|
||||
polygonArea,
|
||||
polygonBounds,
|
||||
projectAndClipRegion,
|
||||
projectUnitPoint,
|
||||
type Quad,
|
||||
rectPolygon,
|
||||
regionBounds,
|
||||
} from "../geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
describe("frame geometry projection", () => {
|
||||
it("keeps region bounds separate from polygon area", () => {
|
||||
const region = [
|
||||
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
|
||||
rectPolygon({ x: 20, y: 5, w: 5, h: 5 }),
|
||||
];
|
||||
expect(regionBounds(region)).toEqual({ x: 0, y: 0, width: 25, height: 10 });
|
||||
expect(polygonArea(region[0])).toBe(100);
|
||||
});
|
||||
|
||||
it("maps through the iframe content box instead of its border box", () => {
|
||||
const contentQuad: Quad = [
|
||||
{ x: 204, y: 306 },
|
||||
{ x: 604, y: 306 },
|
||||
{ x: 604, y: 506 },
|
||||
{ x: 204, y: 506 },
|
||||
];
|
||||
const projected = projectAndClipRegion(
|
||||
[rectPolygon({ x: 10, y: 20, w: 100, h: 40 })],
|
||||
[{ sourceViewport: { width: 200, height: 100 }, destinationQuad: contentQuad }],
|
||||
{ width: 1000, height: 800 },
|
||||
);
|
||||
expect(polygonBounds(projected.flat())).toEqual({ x: 224, y: 346, width: 200, height: 80 });
|
||||
});
|
||||
|
||||
it("uses a projective mapping for perspective-transformed iframe quads", () => {
|
||||
const quad: Quad = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 200, y: 20 },
|
||||
{ x: 180, y: 140 },
|
||||
{ x: 20, y: 100 },
|
||||
];
|
||||
expect(projectUnitPoint({ x: 0, y: 0 }, quad)).toEqual(quad[0]);
|
||||
expect(projectUnitPoint({ x: 1, y: 0 }, quad)).toEqual(quad[1]);
|
||||
expect(projectUnitPoint({ x: 1, y: 1 }, quad)).toEqual(quad[2]);
|
||||
expect(projectUnitPoint({ x: 0, y: 1 }, quad)).toEqual(quad[3]);
|
||||
});
|
||||
|
||||
it("clips against the iframe polygon rather than its bounding box", () => {
|
||||
const frameQuad: Quad = [
|
||||
{ x: 50, y: 0 },
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 50, y: 100 },
|
||||
{ x: 0, y: 50 },
|
||||
];
|
||||
expect(clipPolygon(rectPolygon({ x: 0, y: 0, w: 10, h: 10 }), frameQuad)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reapply same-target frame transforms around an OOPIF", async () => {
|
||||
const send = vi.fn(async (_tabId, method, params) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
|
||||
}
|
||||
if (method === "DOM.getBoxModel") {
|
||||
const backendNodeId = (params as { backendNodeId?: number }).backendNodeId;
|
||||
return backendNodeId === 20
|
||||
? { model: { content: [110, 70, 310, 70, 310, 170, 110, 170] } }
|
||||
: { model: { content: [100, 50, 400, 50, 400, 250, 100, 250] } };
|
||||
}
|
||||
throw new Error(`unexpected root command ${method}`);
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: send as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
throw new Error(`unexpected child command ${method}`);
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
};
|
||||
const projection = await resolveFrameProjection(
|
||||
cdp,
|
||||
{
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "same-process-parent",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4 },
|
||||
},
|
||||
{
|
||||
frameId: "oopif",
|
||||
parentFrameId: "same-process-parent",
|
||||
ownerBackendNodeId: 20,
|
||||
target: { tabId: 4, sessionId: "oopif-session" },
|
||||
},
|
||||
],
|
||||
},
|
||||
"oopif",
|
||||
);
|
||||
|
||||
expect(projection?.edges).toEqual([
|
||||
{
|
||||
sourceViewport: { width: 200, height: 100 },
|
||||
destinationQuad: [
|
||||
{ x: 110, y: 70 },
|
||||
{ x: 310, y: 70 },
|
||||
{ x: 310, y: 170 },
|
||||
{ x: 110, y: 170 },
|
||||
],
|
||||
destinationClips: [
|
||||
[
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 400, y: 50 },
|
||||
{ x: 400, y: 250 },
|
||||
{ x: 100, y: 250 },
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clips live OOPIF geometry at every frame boundary", async () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId, method) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
|
||||
}
|
||||
if (method === "DOM.getBoxModel") {
|
||||
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
|
||||
}
|
||||
throw new Error(`unexpected root command ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[-50, -20, 250, -20, 250, 120, -50, 120]] };
|
||||
}
|
||||
throw new Error(`unexpected child command ${method}`);
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
getFrameGraph: vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
|
||||
const geometry = await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
});
|
||||
|
||||
expect(geometry).toMatchObject({
|
||||
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
||||
actionPoint: { x: 200, y: 150 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses one frame graph while scrolling and projecting live geometry", async () => {
|
||||
const getFrameGraph = vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId, method) => {
|
||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
|
||||
}
|
||||
if (method === "DOM.getBoxModel") {
|
||||
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
|
||||
}
|
||||
throw new Error(`unexpected root command ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
|
||||
}
|
||||
throw new Error(`unexpected child command ${method}`);
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
getFrameGraph,
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveNodeGeometry(
|
||||
cdp,
|
||||
4,
|
||||
{
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
},
|
||||
{ scrollIntoView: true },
|
||||
),
|
||||
).resolves.not.toMatchObject({ code: expect.any(String) });
|
||||
expect(getFrameGraph).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clips live geometry to same-target iframe ancestors", async () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(async (_tabId, method) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
|
||||
}
|
||||
if (method === "DOM.getBoxModel") {
|
||||
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
|
||||
}
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[50, 50, 350, 50, 350, 250, 50, 250]] };
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
getFrameGraph: vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4 },
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
|
||||
const geometry = await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4 },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
});
|
||||
|
||||
expect(geometry).toMatchObject({
|
||||
topBounds: { x: 100, y: 100, width: 200, height: 100 },
|
||||
actionPoint: { x: 200, y: 150 },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when an OOPIF frame graph is unavailable", async () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: vi.fn(),
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
}) as CdpRunner["sendToTarget"],
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
}),
|
||||
).resolves.toMatchObject({ code: "cdp_failed" });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user