mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
254
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5dc148d5 | ||
|
|
3c5f838c56 | ||
|
|
9337bd557b | ||
|
|
162a31ae92 | ||
|
|
2f84234a31 | ||
|
|
72c10aab75 | ||
|
|
1eeb55af02 | ||
|
|
4992a54864 | ||
|
|
8b195eb0df | ||
|
|
eae4db1729 | ||
|
|
e961395be9 | ||
|
|
a6d1d02acc | ||
|
|
5ebe609c40 | ||
|
|
321494d93f | ||
|
|
f2184e74c5 | ||
|
|
eeb633d68e | ||
|
|
6d181105f2 | ||
|
|
ebad0ed1b5 | ||
|
|
498f8fb4a7 | ||
|
|
cef59137e4 | ||
|
|
f70978bfcf | ||
|
|
1498cd425c | ||
|
|
e831d65db3 | ||
|
|
47ac947f18 | ||
|
|
69d64c4eb1 | ||
|
|
99c997d6d5 | ||
|
|
93853ebc65 | ||
|
|
248bb90a2d | ||
|
|
6e6c178434 | ||
|
|
386ef91c24 | ||
|
|
0dc11b30ea | ||
|
|
759eebd4b6 | ||
|
|
fd3ce202a1 | ||
|
|
618bd6a0df | ||
|
|
c455a09fc3 | ||
|
|
b6be356e40 | ||
|
|
945bf1523d | ||
|
|
a928a9fb76 | ||
|
|
0b2d7e95cc | ||
|
|
556bea1645 | ||
|
|
0302d5592b | ||
|
|
eb34ec0dbf | ||
|
|
43067b6bfe | ||
|
|
6cc0d38489 | ||
|
|
0b71963344 | ||
|
|
59e2c3224e | ||
|
|
d70df753f6 | ||
|
|
66ae555e40 | ||
|
|
fee7c0983c | ||
|
|
51473c01ff | ||
|
|
49744233f7 | ||
|
|
95571f8b70 | ||
|
|
8344e311fa | ||
|
|
63338cd98e | ||
|
|
bd193ceb5d | ||
|
|
5ed828736f | ||
|
|
ee620acf64 | ||
|
|
05a36075b8 | ||
|
|
1bd13d5c3c | ||
|
|
59d6533eff | ||
|
|
536d3a9844 | ||
|
|
a77353bce8 | ||
|
|
91621b2545 | ||
|
|
641d934337 | ||
|
|
a4fed136e3 | ||
|
|
3c77ef7b01 | ||
|
|
3b21f2f0a7 | ||
|
|
405433ab65 | ||
|
|
58c7f38a05 | ||
|
|
e0c88295c7 | ||
|
|
dc36e38cb9 | ||
|
|
d5de9b0154 | ||
|
|
995061514c | ||
|
|
406bb21b7e | ||
|
|
f06a514bd2 | ||
|
|
1e53ee63ae | ||
|
|
120dc441bb | ||
|
|
710b1219f4 | ||
|
|
177ecd3ebf | ||
|
|
0e6b9c4542 | ||
|
|
506adbe708 | ||
|
|
984d017b0f | ||
|
|
5df93143c5 | ||
|
|
7163f2f9b0 | ||
|
|
e9f0584af3 | ||
|
|
2997f495db | ||
|
|
f96bd72c3c | ||
|
|
329c68054b | ||
|
|
5cd43553ed | ||
|
|
9ad33801c7 | ||
|
|
7aaa5a2d01 | ||
|
|
210b21b637 | ||
|
|
48f64ec0df | ||
|
|
c48a53269e | ||
|
|
3bbd6ab19f | ||
|
|
1a70d2200e | ||
|
|
554861d6b1 | ||
|
|
0d7f976335 | ||
|
|
d1d3eb6a94 | ||
|
|
38fd7d3f4c | ||
|
|
b5062ac19b | ||
|
|
41cb576256 | ||
|
|
f060a7c074 | ||
|
|
60b62d5420 | ||
|
|
70b851a3e8 | ||
|
|
da3534d066 | ||
|
|
a004291848 | ||
|
|
b1b1ae1d48 | ||
|
|
8822bba9a5 | ||
|
|
c74c119a82 | ||
|
|
60fbf30e24 | ||
|
|
b801bc1f62 | ||
|
|
bf59753aa3 | ||
|
|
a86cbf724f | ||
|
|
76d447caa0 | ||
|
|
14e5bbe4fc | ||
|
|
bef722107f | ||
|
|
6800f8fdfe | ||
|
|
4b6cdde168 | ||
|
|
4aade4b94f | ||
|
|
616af1099f | ||
|
|
3656f0dd3c | ||
|
|
9742d83213 | ||
|
|
615e866608 | ||
|
|
c08fed5de3 | ||
|
|
887d1ed0b9 | ||
|
|
1a3d3b7de8 | ||
|
|
72834980d2 | ||
|
|
38b0debba9 | ||
|
|
4677149e5e | ||
|
|
8ea5477e11 | ||
|
|
17af1af65a | ||
|
|
6300eda0aa | ||
|
|
87cdcfe5ca | ||
|
|
21476c3884 | ||
|
|
c2a225377b | ||
|
|
e64f4cac9f | ||
|
|
368507b07d | ||
|
|
b750eed5d4 | ||
|
|
3d159c0254 | ||
|
|
524516cb56 | ||
|
|
f98198949a | ||
|
|
d05279f991 | ||
|
|
a4b6a61d29 | ||
|
|
cad0b5cbc9 | ||
|
|
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 | ||
|
|
aab3b7a8e5 | ||
|
|
2dad162a87 | ||
|
|
063b0bbc91 | ||
|
|
0de67c537a |
@@ -37,6 +37,27 @@ jobs:
|
||||
- name: Run Rust tests
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
windows-daemon:
|
||||
name: Windows daemon and update regression tests
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run Windows named-pipe tests
|
||||
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
|
||||
|
||||
- name: Run Windows process liveness tests
|
||||
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
||||
|
||||
- name: Run Windows update tests
|
||||
run: cargo test -p bsk --lib --locked cli::update::tests
|
||||
|
||||
- name: Run Windows update process tests
|
||||
run: cargo test -p bsk --test windows_update --locked
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, tests, build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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,379 @@ 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"
|
||||
|
||||
publish-edge-addons:
|
||||
needs: [resolve, build-extension]
|
||||
runs-on: ubuntu-latest
|
||||
environment: edge-addons
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: extension-zip
|
||||
path: dist
|
||||
|
||||
- name: Validate Microsoft Edge Add-ons configuration
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "::error::Environment secret EDGE_ADDONS_API_KEY is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CLIENT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_CLIENT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$PRODUCT_ID" ]; then
|
||||
echo "::error::Environment variable EDGE_ADDONS_PRODUCT_ID is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$PRODUCT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
||||
echo "::error::EDGE_ADDONS_PRODUCT_ID must be the Partner Center product GUID, not the public Edge Add-ons extension ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE="$(find dist -maxdepth 1 -name '*.zip' -type f -print -quit)"
|
||||
if [ -z "$ARCHIVE" ]; then
|
||||
echo "::error::No extension zip found in the downloaded artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload and publish Microsoft Edge Add-ons extension
|
||||
shell: bash
|
||||
env:
|
||||
API_KEY: ${{ secrets.EDGE_ADDONS_API_KEY }}
|
||||
CLIENT_ID: ${{ vars.EDGE_ADDONS_CLIENT_ID }}
|
||||
PRODUCT_ID: ${{ vars.EDGE_ADDONS_PRODUCT_ID }}
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API_ROOT="https://api.addons.microsoftedge.microsoft.com/v1"
|
||||
UPLOAD_HEADERS="$RUNNER_TEMP/edge-upload-headers.txt"
|
||||
UPLOAD_RESPONSE="$RUNNER_TEMP/edge-upload-response.json"
|
||||
STATUS_RESPONSE="$RUNNER_TEMP/edge-status-response.json"
|
||||
PUBLISH_HEADERS="$RUNNER_TEMP/edge-publish-headers.txt"
|
||||
PUBLISH_RESPONSE="$RUNNER_TEMP/edge-publish-response.json"
|
||||
|
||||
operation_id_from_headers() {
|
||||
local headers="$1"
|
||||
local location
|
||||
location="$(awk '
|
||||
tolower($1) == "location:" {
|
||||
sub(/\r$/, "")
|
||||
sub(/^[^:]*:[[:space:]]*/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$headers")"
|
||||
location="${location%%\?*}"
|
||||
printf '%s' "${location##*/}"
|
||||
}
|
||||
|
||||
poll_operation() {
|
||||
local operation_name="$1"
|
||||
local status_url="$2"
|
||||
local status="InProgress"
|
||||
local http_status
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
sleep 5
|
||||
http_status="$(curl --silent --show-error \
|
||||
--output "$STATUS_RESPONSE" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
"$status_url")"
|
||||
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name status check failed with HTTP $http_status"
|
||||
jq . "$STATUS_RESPONSE" || cat "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status="$(jq -r '.status // empty' "$STATUS_RESPONSE")"
|
||||
echo "$operation_name status check $attempt/30: $status"
|
||||
case "$status" in
|
||||
Succeeded)
|
||||
return 0
|
||||
;;
|
||||
InProgress)
|
||||
;;
|
||||
Failed)
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name failed"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::Microsoft Edge Add-ons returned an unknown $operation_name status: $status"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "::error::Microsoft Edge Add-ons $operation_name did not complete after 30 checks"
|
||||
jq . "$STATUS_RESPONSE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$UPLOAD_RESPONSE" \
|
||||
--dump-header "$UPLOAD_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/zip" \
|
||||
--data-binary "@$ARCHIVE" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload failed with HTTP $HTTP_STATUS"
|
||||
jq . "$UPLOAD_RESPONSE" || cat "$UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_OPERATION_ID="$(operation_id_from_headers "$UPLOAD_HEADERS")"
|
||||
if [ -z "$UPLOAD_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons upload response has no Location operation ID"
|
||||
cat "$UPLOAD_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"package upload" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/draft/package/operations/$UPLOAD_OPERATION_ID"
|
||||
|
||||
HTTP_STATUS="$(curl --silent --show-error \
|
||||
--output "$PUBLISH_RESPONSE" \
|
||||
--dump-header "$PUBLISH_HEADERS" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: ApiKey $API_KEY" \
|
||||
--header "X-ClientID: $CLIENT_ID" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"notes\":\"Automated BrowserSkill extension v${RELEASE_VERSION} release from GitHub Actions.\"}" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions")"
|
||||
if [ "$HTTP_STATUS" != "202" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish failed with HTTP $HTTP_STATUS"
|
||||
jq . "$PUBLISH_RESPONSE" || cat "$PUBLISH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLISH_OPERATION_ID="$(operation_id_from_headers "$PUBLISH_HEADERS")"
|
||||
if [ -z "$PUBLISH_OPERATION_ID" ]; then
|
||||
echo "::error::Microsoft Edge Add-ons publish response has no Location operation ID"
|
||||
cat "$PUBLISH_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
poll_operation \
|
||||
"submission" \
|
||||
"$API_ROOT/products/$PRODUCT_ID/submissions/operations/$PUBLISH_OPERATION_ID"
|
||||
|
||||
echo "Microsoft Edge Add-ons submission created successfully:"
|
||||
jq '{status, message, errorCode, errors}' "$STATUS_RESPONSE"
|
||||
|
||||
@@ -7,10 +7,18 @@ dist/
|
||||
.output/
|
||||
.wxt/
|
||||
.web-extension-id
|
||||
.pnpm-store/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local browser evaluation artifacts
|
||||
evals/browser/results/
|
||||
evals/browser/agents.local.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to BrowserSkill will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-09-02
|
||||
|
||||
### Added
|
||||
- File transfer: upload and download support across CLI, Extension, and DSH Plugin
|
||||
- File transfer: drag-and-drop upload (`drop-to-upload`)
|
||||
- VOM semantic graph, name enrichment, and hover perception modules
|
||||
- VOM hover probing (opt-in via `observe` parameter)
|
||||
- DSH Plugin: browser tool parity with CLI commands
|
||||
- Edge Add-ons automated publishing in CI
|
||||
- Protocol upgrade reminder when CLI / Extension protocol versions differ
|
||||
- Browser evaluation harness (`evals/browser/`)
|
||||
- Unified release script (`scripts/release.mjs`)
|
||||
|
||||
### Changed
|
||||
- **Version scheme**: all three components now share the same semver
|
||||
- VOM rendering algorithm optimizations
|
||||
- Leaner SKILL.md agent instructions
|
||||
- DSH Plugin: simplified browser commands
|
||||
- Borrow confirmation UX — proactive focus and longer timeout
|
||||
- PiP window now has a close button
|
||||
|
||||
### Fixed
|
||||
- Screenshot media type detection (was hard-coded to `image/png`)
|
||||
- DSH Plugin session lifecycle stability
|
||||
- DSH Plugin Cordis package ID mismatch
|
||||
- Observation thumbnail media type sniffing
|
||||
- Upload/download race conditions and layout bypass issues
|
||||
- VOM repeated name and safety policy issues
|
||||
|
||||
---
|
||||
|
||||
*Previous releases used independent version numbers per component.*
|
||||
|
||||
## CLI 0.1.11 / Extension 0.1.7 / DSH Plugin 0.1.2 — 2026-08-26 ~ 2026-08-29
|
||||
|
||||
### Added
|
||||
- DSH Plugin sidebar integration, session lifecycle, and archive cleanup
|
||||
- Recorder iframe and OOPIF support
|
||||
|
||||
### Changed
|
||||
- VOM functional refactor
|
||||
|
||||
### Fixed
|
||||
- Recorder safety policy and bug fixes
|
||||
|
||||
## CLI 0.1.10 / Extension 0.1.6 — 2026-08-08
|
||||
|
||||
### Added
|
||||
- VOM observation recording and settled-state detection
|
||||
- Record overlay timer
|
||||
|
||||
### Fixed
|
||||
- Browser keepalive disconnect handling
|
||||
|
||||
## CLI 0.1.9 / Extension 0.1.5 — 2026-07-29
|
||||
|
||||
### Added
|
||||
- CLI auto-update mechanism
|
||||
- Trace v3 protocol and recorder
|
||||
|
||||
### Fixed
|
||||
- MV3 keepalive disconnect
|
||||
|
||||
## CLI 0.1.8 / Extension 0.1.4 — 2026-07-22
|
||||
|
||||
### Added
|
||||
- More browser interaction actions
|
||||
|
||||
### Fixed
|
||||
- Windows named-pipe hash-only path issue
|
||||
|
||||
## CLI 0.1.7 / Extension 0.1.3 — 2026-07-07
|
||||
|
||||
Initial public release pair.
|
||||
|
||||
## CLI 0.1.6 — 2026-06-30
|
||||
|
||||
### Fixed
|
||||
- Minor CLI fixes
|
||||
|
||||
## CLI 0.1.5 / Extension 0.1.2 — 2026-06-22
|
||||
|
||||
First tagged releases.
|
||||
Generated
+3
-2
@@ -134,9 +134,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk"
|
||||
version = "0.1.8"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bsk-protocol",
|
||||
"clap",
|
||||
"console",
|
||||
@@ -168,7 +169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bsk-protocol"
|
||||
version = "0.1.8"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"semver",
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.8"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
@@ -36,13 +36,14 @@ flate2 = "1.1.9"
|
||||
tar = "0.4.46"
|
||||
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
||||
sha2 = "0.11.0"
|
||||
base64 = "0.22"
|
||||
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.7", path = "crates/bsk-protocol" }
|
||||
bsk-protocol = { version = "0.2.0", 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,8 @@ 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`)
|
||||
- [`evals/browser`](evals/browser/README.md) — deterministic local pages and agent-neutral browser capability evaluation
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+33
-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,8 @@ 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`)
|
||||
- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BrowserSkill — Privacy Policy
|
||||
|
||||
**Last updated:** May 25, 2026
|
||||
**Last updated:** August 20, 2026
|
||||
|
||||
This Privacy Policy describes how the **BrowserSkill** browser extension (the "Extension") handles information when you install and use it. BrowserSkill is published as part of the open-source [BrowserSkill](https://github.com/Tencent/BrowserSkill) project. The source code is publicly auditable.
|
||||
|
||||
@@ -14,7 +14,7 @@ BrowserSkill is a local automation bridge that lets AI coding agents (such as Cu
|
||||
|
||||
## 2. Single Purpose
|
||||
|
||||
The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf.
|
||||
The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, task-scoped file transfer, and tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf.
|
||||
|
||||
## 3. Data the Extension Accesses
|
||||
|
||||
@@ -26,6 +26,7 @@ Depending on the commands the user (via their AI agent) sends to the local daemo
|
||||
| **User input simulated by the agent** | Mouse clicks, keystrokes, and form values that the AI agent dispatches through the Chrome DevTools Protocol (CDP). | Required to perform automation actions the user has asked the agent to do. |
|
||||
| **Tab and window metadata** | Tab IDs, URLs, titles, window IDs of the Agent Window and any tabs the user explicitly authorizes. | Required to target automation commands at the correct tab/window. |
|
||||
| **Local extension storage** | A randomly generated 8-character instance ID and an optional user-supplied label. | Used so the local daemon can recognize this browser instance across reconnects. No personal data is stored. |
|
||||
| **File transfers requested by the agent** | Local files explicitly supplied to `bsk upload`, and the file created by a single `bsk download` action. | Required to attach a task file to a web page or return a browser-generated download to the invoking local agent. |
|
||||
| **OS notifications** | Permission to display a system notification when the agent requests to "borrow" one of the user's existing tabs. | Required to obtain explicit, per-tab user consent before the agent touches any pre-existing tab. |
|
||||
|
||||
## 4. Data the Extension Does **Not** Collect
|
||||
@@ -34,7 +35,7 @@ BrowserSkill does **not**:
|
||||
|
||||
- Send any data to remote servers, the Extension's authors, or any third party.
|
||||
- Call any LLM, AI, or cloud API. The Extension contains no API keys, model identifiers, or remote endpoints.
|
||||
- Read or transmit cookies, browsing history, bookmarks, downloads, saved passwords, or autofill data.
|
||||
- Read or transmit cookies, browsing history, bookmarks, saved passwords, or autofill data. It observes only the download initiated by an active `bsk download` call, not download history generally.
|
||||
- Use webcam, microphone, geolocation, or any device sensor.
|
||||
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
|
||||
- Track users across websites or across sessions.
|
||||
@@ -50,6 +51,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
|
||||
- **`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.
|
||||
- **`downloads`** — Correlate and route the one browser download initiated by an active `bsk download` command. If that claimed transaction fails, BrowserSkill cancels an in-progress file or removes its completed temporary browser file. It is not used to enumerate download history or alter unclaimed downloads.
|
||||
- **`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.
|
||||
|
||||
@@ -61,6 +63,7 @@ All Extension activity stays on the user's local device. The only network traffi
|
||||
|
||||
- The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage.
|
||||
- Page content, screenshots, DOM snapshots, and other observed data are returned to the local daemon in response to commands and are **not retained by the Extension**. They live only as long as the agent's tool call.
|
||||
- Upload and download bytes are staged by the local daemon in a private, session-scoped directory. Download staging is removed after it is copied to the requested destination. Upload staging is retained until the session ends so a later form submission can still read the attached file. Remaining staging is removed when the session ends or disconnects, or when the daemon next starts after a crash.
|
||||
|
||||
## 8. User Control
|
||||
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>BrowserSkill file-transfer fixture</title>
|
||||
<style>
|
||||
body { font: 16px system-ui; max-width: 760px; margin: 48px auto; }
|
||||
.actions { display: flex; gap: 12px; align-items: center; margin: 24px 0; }
|
||||
button, a { padding: 10px 16px; border: 1px solid #777; border-radius: 8px; }
|
||||
a[aria-disabled="true"] { opacity: .45; pointer-events: none; }
|
||||
#drop { padding: 36px; border: 3px dashed #777; border-radius: 12px; text-align: center; }
|
||||
#drop.dragging { border-color: #2563eb; background: #eff6ff; }
|
||||
img { display: block; max-width: 480px; max-height: 360px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Upload then download the same image</h1>
|
||||
<p id="status">No image selected</p>
|
||||
<div class="actions">
|
||||
<button id="upload" type="button">Upload through file input</button>
|
||||
<input id="file" type="file" accept="image/png" hidden />
|
||||
<a id="download" aria-disabled="true">Download uploaded image</a>
|
||||
</div>
|
||||
<div id="drop" role="button" tabindex="0">Drop an image here</div>
|
||||
<img id="preview" alt="Uploaded image preview" hidden />
|
||||
<script>
|
||||
const input = document.querySelector('#file');
|
||||
const upload = document.querySelector('#upload');
|
||||
const drop = document.querySelector('#drop');
|
||||
const download = document.querySelector('#download');
|
||||
const preview = document.querySelector('#preview');
|
||||
const status = document.querySelector('#status');
|
||||
let objectUrl;
|
||||
|
||||
window.__bskTransferFixture = { events: [], result: null };
|
||||
|
||||
function accept(file, mechanism) {
|
||||
if (!file) return;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
preview.src = objectUrl;
|
||||
preview.hidden = false;
|
||||
download.href = objectUrl;
|
||||
download.download = file.name;
|
||||
download.removeAttribute('aria-disabled');
|
||||
status.textContent = `Ready via ${mechanism}: ${file.name} (${file.size} bytes)`;
|
||||
window.__bskTransferFixture.result = {
|
||||
mechanism,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
hasPathProperty: 'path' in file,
|
||||
};
|
||||
}
|
||||
|
||||
upload.addEventListener('click', () => input.click());
|
||||
input.addEventListener('change', () => accept(input.files[0], 'input'));
|
||||
|
||||
for (const type of ['dragenter', 'dragover']) {
|
||||
drop.addEventListener(type, event => {
|
||||
const types = [...event.dataTransfer.types];
|
||||
window.__bskTransferFixture.events.push({ type, types });
|
||||
if (!types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
drop.classList.add('dragging');
|
||||
});
|
||||
}
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('dragging'));
|
||||
drop.addEventListener('drop', event => {
|
||||
window.__bskTransferFixture.events.push({
|
||||
type: 'drop',
|
||||
types: [...event.dataTransfer.types],
|
||||
fileCount: event.dataTransfer.files.length,
|
||||
});
|
||||
event.preventDefault();
|
||||
drop.classList.remove('dragging');
|
||||
accept(event.dataTransfer.files[0], 'drop');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(
|
||||
@@ -101,6 +254,69 @@ describe("ChromiumCdp", () => {
|
||||
expect(cdp.isAttached(7)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
|
||||
const { api } = fakeApi();
|
||||
const attached = new Set<number>();
|
||||
let finishDetach!: () => void;
|
||||
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
|
||||
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
|
||||
attached.add(tabId!);
|
||||
});
|
||||
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
|
||||
attached.delete(tabId!);
|
||||
});
|
||||
vi.mocked(api.detach).mockImplementationOnce(
|
||||
({ tabId }) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
finishDetach = () => {
|
||||
attached.delete(tabId!);
|
||||
if (rejectDetach) reject(new Error("tab already detached"));
|
||||
else resolve();
|
||||
};
|
||||
}),
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
cdp.trackSessionTab("aa11", 7);
|
||||
await cdp.ensureAttached(7);
|
||||
const returning = cdp.releaseSessionTab("aa11", 7);
|
||||
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
|
||||
let duplicateDetachFinished = false;
|
||||
const duplicateDetach = cdp.detach(7).then(() => {
|
||||
duplicateDetachFinished = true;
|
||||
});
|
||||
|
||||
cdp.trackSessionTab("bb22", 7);
|
||||
// Register rejection handlers immediately; the pre-fix driver rejects
|
||||
// both commands while the browser still has the previous attachment.
|
||||
const pending = Promise.allSettled([
|
||||
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
|
||||
cdp.send(7, "DOM.getDocument"),
|
||||
]);
|
||||
await cdp.send(8, "DOM.getDocument");
|
||||
const finishedEarly = duplicateDetachFinished;
|
||||
finishDetach();
|
||||
await Promise.all([returning, duplicateDetach]);
|
||||
|
||||
expect(await pending).toEqual([
|
||||
{ status: "fulfilled", value: { ok: true } },
|
||||
{ status: "fulfilled", value: { ok: true } },
|
||||
]);
|
||||
expect(finishedEarly).toBe(false);
|
||||
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(cdp.isAttached(7)).toBe(true);
|
||||
expect(cdp.isAttached(8)).toBe(true);
|
||||
await cdp.detachSession("bb22");
|
||||
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||
expect(cdp.isAttached(7)).toBe(false);
|
||||
expect(attached.has(7)).toBe(false);
|
||||
expect(cdp.isAttached(8)).toBe(true);
|
||||
});
|
||||
|
||||
it("detachAll() iterates every cached tab", async () => {
|
||||
const { api } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
@@ -132,11 +348,33 @@ describe("ChromiumCdp", () => {
|
||||
expect(cdp.isAttached(12)).toBe(true);
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {});
|
||||
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {});
|
||||
await cdp.ensureConsoleCapture(12);
|
||||
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
});
|
||||
|
||||
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(enableCalls).toHaveLength(2);
|
||||
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 () => {
|
||||
@@ -476,6 +714,155 @@ describe("ChromiumCdp", () => {
|
||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||
});
|
||||
|
||||
it("releases only the returning session's claim on a tab", async () => {
|
||||
const { api } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(1);
|
||||
await cdp.ensureAttached(2);
|
||||
cdp.trackSessionTab("aa11", 1);
|
||||
cdp.trackSessionTab("bb22", 1);
|
||||
cdp.trackSessionTab("aa11", 2);
|
||||
|
||||
await cdp.releaseSessionTab("unknown", 1);
|
||||
await cdp.releaseSessionTab("aa11", 1);
|
||||
expect(api.detach).not.toHaveBeenCalled();
|
||||
|
||||
await cdp.releaseSessionTab("bb22", 1);
|
||||
await cdp.releaseSessionTab("bb22", 1);
|
||||
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
|
||||
expect(cdp.isAttached(2)).toBe(true);
|
||||
|
||||
await cdp.detachSession("aa11");
|
||||
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
|
||||
const { api } = fakeApi();
|
||||
let finishAttach!: () => void;
|
||||
vi.mocked(api.attach).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishAttach = resolve;
|
||||
}),
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
cdp.trackSessionTab("aa11", 1);
|
||||
const attaching = cdp.ensureAttached(1);
|
||||
const releasing = cdp.releaseSessionTab("aa11", 1);
|
||||
if (newOwner) cdp.trackSessionTab("bb22", 1);
|
||||
finishAttach();
|
||||
await Promise.all([attaching, releasing]);
|
||||
|
||||
expect(cdp.isAttached(1)).toBe(newOwner);
|
||||
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
|
||||
});
|
||||
|
||||
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
let controlled = true;
|
||||
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
|
||||
cdp.trackSessionTab("aa11", 1);
|
||||
cdp.trackSessionTab("bb22", 1);
|
||||
await cdp.ensureAttached(1);
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||
type: "confirm",
|
||||
message: "before return",
|
||||
});
|
||||
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
|
||||
vi.mocked(api.sendCommand).mockClear();
|
||||
|
||||
controlled = false;
|
||||
await cdp.releaseSessionTab("aa11", 1);
|
||||
expect(cdp.isAttached(1)).toBe(true);
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||
type: "confirm",
|
||||
message: "after return",
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
|
||||
|
||||
controlled = true;
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||
type: "alert",
|
||||
message: "borrowed again",
|
||||
});
|
||||
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
|
||||
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
|
||||
{ tabId: 1 },
|
||||
"Page.handleJavaScriptDialog",
|
||||
{ accept: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores dialog events after detach, including a pending eligibility check", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
let resolveEligibility!: (value: boolean) => void;
|
||||
const shouldAutoAcceptDialog = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveEligibility = resolve;
|
||||
}),
|
||||
);
|
||||
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||
await cdp.ensureAttached(1);
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||
await cdp.detach(1);
|
||||
resolveEligibility(true);
|
||||
await Promise.resolve();
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||
|
||||
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
|
||||
expect(api.sendCommand).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"Page.handleJavaScriptDialog",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const shouldAutoAcceptDialog = vi.fn(async () => {
|
||||
throw new Error("tab is gone");
|
||||
});
|
||||
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||
await cdp.ensureAttached(1);
|
||||
vi.mocked(api.sendCommand).mockClear();
|
||||
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
|
||||
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not restore dialog state when an acceptance completes after detach", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(1);
|
||||
let finishAccept!: () => void;
|
||||
vi.mocked(api.sendCommand).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishAccept = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||
await cdp.detach(1);
|
||||
finishAccept();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||
expect(cdp.dialogCursor(1)).toBe(0);
|
||||
});
|
||||
|
||||
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 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,15 @@ import type {
|
||||
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
|
||||
@@ -38,21 +47,15 @@ import type {
|
||||
* 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
|
||||
@@ -85,6 +88,23 @@ 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;
|
||||
@@ -93,6 +113,8 @@ 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;
|
||||
@@ -114,6 +136,27 @@ interface NetworkRequestMeta {
|
||||
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>()`.
|
||||
@@ -122,31 +165,45 @@ export class ChromiumCdp {
|
||||
private readonly api: CdpDebuggerApi;
|
||||
private readonly attachedTabs = new Set<number>();
|
||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||
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 consoleDomainsAttemptedTabs = new Set<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) {
|
||||
constructor(
|
||||
api: CdpDebuggerApi = chromeDebuggerApi,
|
||||
private readonly options: {
|
||||
/** CDP observation alone does not authorize dismissing user dialogs. */
|
||||
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
|
||||
} = {},
|
||||
) {
|
||||
this.api = api;
|
||||
this.bindAutoDetach();
|
||||
this.bindDialogHandler();
|
||||
this.bindConsoleHandler();
|
||||
this.bindNetworkHandler();
|
||||
this.bindFrameTargetHandler();
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
async ensureAttached(tabId: number): Promise<void> {
|
||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||
// New observers must wait before opening the next connection to that tab.
|
||||
const detaching = this.detachInFlight.get(tabId);
|
||||
if (detaching) await detaching;
|
||||
if (this.attachedTabs.has(tabId)) return;
|
||||
const existing = this.attachInFlight.get(tabId);
|
||||
if (existing) {
|
||||
@@ -155,10 +212,29 @@ export class ChromiumCdp {
|
||||
}
|
||||
const attach = (async () => {
|
||||
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
|
||||
await this.enablePageDomain(tabId);
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomainBestEffort(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
|
||||
@@ -189,11 +265,105 @@ 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) ?? [];
|
||||
@@ -266,19 +436,31 @@ export class ChromiumCdp {
|
||||
|
||||
/** Detach if attached; never throws. */
|
||||
async detach(tabId: number): Promise<void> {
|
||||
const existing = this.detachInFlight.get(tabId);
|
||||
if (existing) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
this.attachInFlight.delete(tabId);
|
||||
if (!this.attachedTabs.has(tabId)) return;
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
// Tab may already be gone — Chrome auto-detaches on close. Log
|
||||
// at debug so production builds aren't noisy.
|
||||
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
||||
}
|
||||
this.clearFrameState(tabId);
|
||||
const detach = (async () => {
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
// Tab may already be gone — Chrome auto-detaches on close. Log
|
||||
// at debug so production builds aren't noisy.
|
||||
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
|
||||
}
|
||||
})().finally(() => {
|
||||
this.detachInFlight.delete(tabId);
|
||||
});
|
||||
this.detachInFlight.set(tabId, detach);
|
||||
await detach;
|
||||
}
|
||||
|
||||
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
|
||||
@@ -293,8 +475,19 @@ export class ChromiumCdp {
|
||||
this.tabOwners.set(tabId, owners);
|
||||
}
|
||||
|
||||
/** Release one session's claim, preserving attachments still used by another. */
|
||||
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
|
||||
const owners = this.tabOwners.get(tabId);
|
||||
if (!owners?.delete(sessionId) || owners.size > 0) return;
|
||||
this.tabOwners.delete(tabId);
|
||||
// An observation may still be attaching when the tab is returned. Wait
|
||||
// for it so detach cannot miss the attachment or remove a new owner's claim.
|
||||
await this.attachInFlight.get(tabId)?.catch(() => {});
|
||||
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -313,11 +506,12 @@ export class ChromiumCdp {
|
||||
this.dialogSequences.clear();
|
||||
this.consoleBuffers.clear();
|
||||
this.consoleSequences.clear();
|
||||
this.consoleDomainsAttemptedTabs.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 {
|
||||
@@ -334,15 +528,24 @@ export class ChromiumCdp {
|
||||
}
|
||||
|
||||
private async enableConsoleDomains(tabId: number): Promise<void> {
|
||||
if (this.consoleDomainsAttemptedTabs.has(tabId)) return;
|
||||
this.consoleDomainsAttemptedTabs.add(tabId);
|
||||
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> {
|
||||
@@ -359,9 +562,104 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -374,13 +672,23 @@ export class ChromiumCdp {
|
||||
}
|
||||
|
||||
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||
const parsed = parseDialogOpeningParams(params);
|
||||
try {
|
||||
if (
|
||||
this.options.shouldAutoAcceptDialog &&
|
||||
!(await this.options.shouldAutoAcceptDialog(tabId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The tab may have been returned while its current scope was checked.
|
||||
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
||||
if (parsed.type === "prompt") {
|
||||
handleParams.promptText = parsed.defaultPrompt ?? "";
|
||||
}
|
||||
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
||||
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
||||
this.dialogSequences.set(tabId, sequence);
|
||||
this.appendDialog(tabId, {
|
||||
@@ -445,7 +753,7 @@ export class ChromiumCdp {
|
||||
private clearConsoleState(tabId: number): void {
|
||||
this.consoleBuffers.delete(tabId);
|
||||
this.consoleSequences.delete(tabId);
|
||||
this.consoleDomainsAttemptedTabs.delete(tabId);
|
||||
this.consoleDomainsEnabledTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindNetworkHandler(): void {
|
||||
@@ -545,6 +853,7 @@ export class ChromiumCdp {
|
||||
this.clearDialogState(source.tabId);
|
||||
this.clearConsoleState(source.tabId);
|
||||
this.clearNetworkState(source.tabId);
|
||||
this.clearFrameState(source.tabId);
|
||||
}
|
||||
};
|
||||
this.api.onDetach.addListener(listener);
|
||||
@@ -563,19 +872,15 @@ export class ChromiumCdp {
|
||||
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. */
|
||||
async detachSession(sessionId: string): Promise<void> {
|
||||
const tabsToDetach: number[] = [];
|
||||
for (const [tabId, owners] of this.tabOwners) {
|
||||
owners.delete(sessionId);
|
||||
if (owners.size === 0) {
|
||||
this.tabOwners.delete(tabId);
|
||||
tabsToDetach.push(tabId);
|
||||
}
|
||||
}
|
||||
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
|
||||
await Promise.all(
|
||||
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { HelpHighlightRect } from "@/lib/help-bridge";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
export interface HelpRequestData {
|
||||
@@ -19,6 +20,10 @@ export interface HelpRequestData {
|
||||
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;
|
||||
}
|
||||
@@ -49,6 +54,7 @@ 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));
|
||||
@@ -179,6 +185,8 @@ 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[]>([]);
|
||||
@@ -201,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);
|
||||
@@ -209,6 +218,32 @@ 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;
|
||||
@@ -231,7 +266,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const nextBoxes = measure(effectiveSelectors);
|
||||
const nextBoxes = [...measure(effectiveSelectors), ...effectiveRects];
|
||||
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
|
||||
};
|
||||
update();
|
||||
@@ -243,7 +278,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
window.removeEventListener("resize", update);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [request, effectiveSelectors]);
|
||||
}, [request, effectiveSelectors, effectiveRects]);
|
||||
|
||||
const onHeaderPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -305,7 +340,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
return;
|
||||
}
|
||||
if (userMovedRef.current) return;
|
||||
const measured = boxes.length > 0 ? boxes : measure(effectiveSelectors);
|
||||
const measured = boxes.length > 0 ? boxes : [...measure(effectiveSelectors), ...effectiveRects];
|
||||
const anchor = unionBox(measured);
|
||||
const banner = bannerRef.current;
|
||||
if (!anchor || !banner) {
|
||||
@@ -329,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, isCompact, effectiveSelectors]);
|
||||
}, [request, boxes, collapsed, isCompact, effectiveSelectors, effectiveRects]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiStopCircleLine } from "@remixicon/react";
|
||||
import { useEffect, useState } from "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;
|
||||
}
|
||||
|
||||
@@ -11,9 +23,51 @@ 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) {
|
||||
@@ -23,8 +77,87 @@ export function RecordOverlay({ request }: Props) {
|
||||
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>{`
|
||||
@@ -35,78 +168,118 @@ export function RecordOverlay({ request }: Props) {
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={pillRef}
|
||||
data-slot="record-overlay-pill"
|
||||
data-phase={introVisible ? "intro" : "timer"}
|
||||
data-dragging={dragging ? "true" : "false"}
|
||||
onPointerDown={onPillPointerDown}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
...(positioned
|
||||
? { top: dragPos.top, left: dragPos.left, bottom: "auto" }
|
||||
: { bottom: 32, left: "50%" }),
|
||||
zIndex: 2147483647,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
gap: 5,
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 9999,
|
||||
padding: "10px 10px 10px 20px",
|
||||
boxShadow: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
||||
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: show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)",
|
||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
<div
|
||||
data-slot="record-overlay-status"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
borderRadius: 9999,
|
||||
outline: "none",
|
||||
minHeight: 32,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{t("recordOverlay.recording")}
|
||||
</span>
|
||||
<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: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
display: "block",
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 9999,
|
||||
padding: "8px 20px 8px 16px",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
backgroundColor: "#f97316",
|
||||
padding: 0,
|
||||
backgroundColor: "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RiStopCircleLine size={18} color="#fff" />
|
||||
{t("recordOverlay.finish")}
|
||||
<img
|
||||
src={recordIconUrl}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
draggable={false}
|
||||
style={{ display: "block", margin: "auto" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
id: "req-1",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
timeoutMs: 60_000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
@@ -32,7 +32,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
id: "req-fallback",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
timeoutMs: 60_000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
@@ -74,7 +74,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
|
||||
@@ -38,6 +38,36 @@ 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);
|
||||
|
||||
|
||||
@@ -1,37 +1,181 @@
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
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 orange finish CTA and no logo", () => {
|
||||
it("renders the bottom pill with the icon-only finish button", () => {
|
||||
const onFinish = vi.fn();
|
||||
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish }} />);
|
||||
const { container } = renderOverlay({ id: "rec-1", onFinish });
|
||||
|
||||
const pill = container.querySelector("[data-slot='record-overlay-pill']");
|
||||
expect(pill).toBeTruthy();
|
||||
expect(pill?.querySelector("img")).toBeNull();
|
||||
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 finish = container.querySelector("[data-slot='record-overlay-finish']");
|
||||
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 as HTMLElement).style.backgroundColor).toBe("#f97316");
|
||||
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!);
|
||||
fireEvent.click(finish);
|
||||
expect(onFinish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
|
||||
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish: vi.fn() }} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -130,4 +130,31 @@ describe("OverlayController", () => {
|
||||
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,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RecordFramePortMessage } from "@/lib/recording/frame-bridge";
|
||||
import { RECORD_FRAME_START } from "@/lib/recording/frame-bridge";
|
||||
import { RECORD_DOCUMENT_ATTRIBUTE } from "@/shared/recording-document-identity";
|
||||
import { RecordFrameAgent } from "../recording/frame-agent";
|
||||
|
||||
class PortListeners<T extends (...args: never[]) => unknown> {
|
||||
readonly values = new Set<T>();
|
||||
addListener = (listener: T) => this.values.add(listener);
|
||||
removeListener = (listener: T) => this.values.delete(listener);
|
||||
}
|
||||
|
||||
function portHarness() {
|
||||
const onMessage = new PortListeners<(message: unknown) => void>();
|
||||
const onDisconnect = new PortListeners<() => void>();
|
||||
const outbound: RecordFramePortMessage[] = [];
|
||||
const port = {
|
||||
name: "bsk-record-frame",
|
||||
onMessage,
|
||||
onDisconnect,
|
||||
postMessage(message: RecordFramePortMessage) {
|
||||
outbound.push(message);
|
||||
if (message.type === "ready") {
|
||||
queueMicrotask(() => {
|
||||
for (const listener of onMessage.values) {
|
||||
listener({
|
||||
type: "ready_ack",
|
||||
requestId: message.requestId,
|
||||
producerId: message.producerId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as chrome.runtime.Port;
|
||||
return {
|
||||
port,
|
||||
outbound,
|
||||
receive(message: RecordFramePortMessage) {
|
||||
for (const listener of onMessage.values) listener(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RecordFrameAgent", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("starts when crypto.randomUUID is unavailable", async () => {
|
||||
const harness = portHarness();
|
||||
const getRandomValues = vi.fn((bytes: Uint8Array) => {
|
||||
bytes.fill(0x11);
|
||||
return bytes;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
connect: vi.fn(() => harness.port),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
});
|
||||
const agent = new RecordFrameAgent();
|
||||
|
||||
await expect(
|
||||
agent.start({ type: RECORD_FRAME_START, requestId: "rec-http", startedAtMs: 10 }),
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(getRandomValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
|
||||
const harness = portHarness();
|
||||
const sendMessage = vi
|
||||
.fn<(message: { sequence: number }) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockRejectedValueOnce(new Error("still offline"))
|
||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
connect: vi.fn(() => harness.port),
|
||||
sendMessage,
|
||||
},
|
||||
});
|
||||
document.body.innerHTML = `<label for="draft">Draft</label><input id="draft" />`;
|
||||
const agent = new RecordFrameAgent();
|
||||
|
||||
await expect(
|
||||
agent.start({ type: RECORD_FRAME_START, requestId: "rec-1", startedAtMs: 10 }),
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
||||
|
||||
const input = document.querySelector("input")!;
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.value = "final value";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-1" });
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.outbound).toContainEqual({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: "stop-1",
|
||||
ok: false,
|
||||
error: "failed to deliver one or more recorded steps",
|
||||
}),
|
||||
);
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
|
||||
|
||||
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-2" });
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.outbound).toContainEqual({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: "stop-2",
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RecordStepMessage } from "@/lib/record-bridge";
|
||||
import { RecordStepDelivery } from "../record-step-delivery";
|
||||
|
||||
describe("RecordStepDelivery", () => {
|
||||
it("retries from the first unacknowledged sequence before sending later steps", async () => {
|
||||
const sent: RecordStepMessage[] = [];
|
||||
const send = vi.fn(async (message: RecordStepMessage) => {
|
||||
sent.push(message);
|
||||
if (sent.length === 1) throw new Error("service worker unavailable");
|
||||
return { ok: true, sequence: message.sequence };
|
||||
});
|
||||
const delivery = new RecordStepDelivery("rec-ordered", send, "document-1");
|
||||
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "First" } });
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Second" } });
|
||||
|
||||
await expect(delivery.flush()).resolves.toBe(true);
|
||||
expect(sent.map((message) => message.sequence)).toEqual([1, 1, 2]);
|
||||
expect(sent.map((message) => message.step.target?.name)).toEqual(["First", "First", "Second"]);
|
||||
});
|
||||
|
||||
it("keeps unacknowledged steps pending for a later flush", async () => {
|
||||
const send = vi
|
||||
.fn<(message: RecordStepMessage) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockRejectedValueOnce(new Error("still offline"))
|
||||
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
|
||||
const delivery = new RecordStepDelivery("rec-retry", send, "document-1");
|
||||
delivery.enqueue({ op: "click", target: { tag: "button", name: "Save" } });
|
||||
|
||||
await expect(delivery.flush()).resolves.toBe(false);
|
||||
await expect(delivery.flush()).resolves.toBe(true);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export interface OverlayState {
|
||||
* 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">;
|
||||
@@ -36,6 +42,7 @@ export class OverlayController {
|
||||
controlMode: "hidden",
|
||||
automationBypassCount: 0,
|
||||
suppressControlAfterRecord: false,
|
||||
controlHintsHidden: false,
|
||||
};
|
||||
|
||||
snapshot(): OverlayState {
|
||||
@@ -129,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;
|
||||
@@ -152,6 +163,7 @@ export class OverlayController {
|
||||
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
|
||||
return (
|
||||
state.controlVisible &&
|
||||
!state.controlHintsHidden &&
|
||||
!state.suppressControlAfterRecord &&
|
||||
state.activeHelp === null &&
|
||||
state.activeRecord === null
|
||||
|
||||
@@ -14,3 +14,10 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,27 @@
|
||||
import { describeEventTarget, describeTarget, type TargetDescriptor } from "@/lib/describe-target";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
isRecordStopMessage,
|
||||
RECORD_CANCEL,
|
||||
RECORD_START,
|
||||
RECORD_STEP,
|
||||
RECORD_STOP,
|
||||
type RecordCancelMessage,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStepPayload,
|
||||
type RecordStopAck,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { shouldRecordPress } from "@/lib/trace-reducer";
|
||||
|
||||
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
|
||||
const failedStepDeliveries = new Set<string>();
|
||||
const knownRecordRequests = new Set<string>();
|
||||
|
||||
function sendRecordStep(requestId: string, step: RecordStepPayload): void {
|
||||
const payload = { type: RECORD_STEP, requestId, step };
|
||||
const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then(
|
||||
() => true,
|
||||
() => {
|
||||
failedStepDeliveries.add(requestId);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>();
|
||||
sends.add(pending);
|
||||
pendingStepSends.set(requestId, sends);
|
||||
void pending.then(() => {
|
||||
sends.delete(pending);
|
||||
if (sends.size === 0) pendingStepSends.delete(requestId);
|
||||
});
|
||||
}
|
||||
type CaptureTargetDescriptor,
|
||||
describeEventTarget,
|
||||
describeTarget,
|
||||
resolveClickableElement,
|
||||
resolveHoverElement,
|
||||
} from "@/lib/describe-target";
|
||||
import {
|
||||
evaluateHoverTrigger,
|
||||
type HoverTriggerDecision,
|
||||
type HoverTriggerRect,
|
||||
hasDirectHoverInteractiveSignal,
|
||||
hasStrongHoverExpansionSignal,
|
||||
} from "@/lib/hover-trigger-policy";
|
||||
import type { RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { shouldRecordPress } from "@/lib/recording/draft-policy";
|
||||
import {
|
||||
closestHoverSurfaceCandidate,
|
||||
collectHoverSurfaceStates,
|
||||
decideHoverSurfaceRelation,
|
||||
type HoverSurfaceState,
|
||||
isHoverSurfaceCandidateElement,
|
||||
isLikelyHoverSurfaceOwner,
|
||||
} from "./record-hover-surface";
|
||||
|
||||
export interface RecordCaptureController {
|
||||
dispose(): void;
|
||||
@@ -44,13 +29,35 @@ export interface RecordCaptureController {
|
||||
|
||||
interface FillSession {
|
||||
element: FillableElement;
|
||||
target: TargetDescriptor;
|
||||
target: CaptureTargetDescriptor;
|
||||
baselineValue: string;
|
||||
lastValue: string;
|
||||
pendingCommit?: "enter" | "suggestion" | "blur";
|
||||
}
|
||||
|
||||
interface HoverCandidate {
|
||||
element: Element;
|
||||
target: CaptureTargetDescriptor;
|
||||
recordedAt: number;
|
||||
score: number;
|
||||
eligible: boolean;
|
||||
}
|
||||
|
||||
interface HoverSurfaceNode {
|
||||
element: Element;
|
||||
signature: string;
|
||||
owner: HoverCandidate;
|
||||
parent?: HoverSurfaceNode;
|
||||
}
|
||||
|
||||
type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
|
||||
|
||||
const HOVER_BEFORE_CLICK_MAX_MS = 10_000;
|
||||
const HOVER_SURFACE_CONTEXT_MAX_MS = 30_000;
|
||||
const HOVER_REPLACE_SCORE_MARGIN = 50;
|
||||
const HOVER_CANDIDATE_LIMIT = 24;
|
||||
const HOVER_TRIGGER_LABEL_MAX = 48;
|
||||
|
||||
function eventTarget(event: Event): EventTarget | null {
|
||||
return event.composedPath()[0] ?? event.target;
|
||||
}
|
||||
@@ -115,6 +122,24 @@ function nearbyFillableFromSearchChrome(target: Element): FillableElement | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function captureGeometry(el: Element): RecordStepPayload["geometry"] {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
tag: el.tagName.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function geometryForEventTarget(
|
||||
target: EventTarget | null,
|
||||
): RecordStepPayload["geometry"] | undefined {
|
||||
if (!(target instanceof Element)) return undefined;
|
||||
const clickable = target.closest(
|
||||
'a,button,input,select,textarea,[role="button"],[role="link"],[role="menuitem"],[contenteditable="true"]',
|
||||
);
|
||||
return captureGeometry(clickable ?? target);
|
||||
}
|
||||
|
||||
function fillableValue(el: FillableElement): string {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return el.value;
|
||||
@@ -122,6 +147,176 @@ function fillableValue(el: FillableElement): string {
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
function hoverTriggerAttrs(el: Element): Record<string, string | undefined> {
|
||||
return {
|
||||
id: el.id || undefined,
|
||||
class: typeof el.className === "string" ? el.className || undefined : undefined,
|
||||
"data-testid": el.getAttribute("data-testid") ?? undefined,
|
||||
"data-test": el.getAttribute("data-test") ?? undefined,
|
||||
"data-cy": el.getAttribute("data-cy") ?? undefined,
|
||||
"aria-label": el.getAttribute("aria-label") ?? undefined,
|
||||
"aria-haspopup": el.getAttribute("aria-haspopup") ?? undefined,
|
||||
"aria-controls": el.getAttribute("aria-controls") ?? undefined,
|
||||
"aria-expanded": el.getAttribute("aria-expanded") ?? undefined,
|
||||
"aria-hidden": el.getAttribute("aria-hidden") ?? undefined,
|
||||
"aria-disabled": el.getAttribute("aria-disabled") ?? undefined,
|
||||
contenteditable: el.getAttribute("contenteditable") ?? undefined,
|
||||
disabled: el.hasAttribute("disabled") ? "" : undefined,
|
||||
hidden: el.hasAttribute("hidden") ? "" : undefined,
|
||||
inert: el.hasAttribute("inert") ? "" : undefined,
|
||||
onclick: el.getAttribute("onclick") ?? undefined,
|
||||
onmouseenter: el.getAttribute("onmouseenter") ?? undefined,
|
||||
onmouseover: el.getAttribute("onmouseover") ?? undefined,
|
||||
role: el.getAttribute("role") ?? undefined,
|
||||
tabindex: el.getAttribute("tabindex") ?? undefined,
|
||||
title: el.getAttribute("title") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function hoverTriggerRect(el: Element): HoverTriggerRect {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function hoverTriggerStyle(el: Element): { cursor?: string; pointerEvents?: string } {
|
||||
if (!(el instanceof HTMLElement)) return {};
|
||||
const style = getComputedStyle(el);
|
||||
return { cursor: style.cursor, pointerEvents: style.pointerEvents };
|
||||
}
|
||||
|
||||
function hasHoverGraphicDescendant(el: Element): boolean {
|
||||
return el.querySelector("img,svg,use,path,i") !== null;
|
||||
}
|
||||
|
||||
function normalizeLabelText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncateLabel(value: string, max = HOVER_TRIGGER_LABEL_MAX): string {
|
||||
const normalized = normalizeLabelText(value);
|
||||
if (normalized.length <= max) return normalized;
|
||||
return `${normalized.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function collectHoverTriggerLabelText(root: Element): string {
|
||||
let text = "";
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += ` ${node.textContent ?? ""}`;
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof Element)) return;
|
||||
if (node !== root && isHoverSurfaceCandidateElement(node)) {
|
||||
return;
|
||||
}
|
||||
for (const child of node.childNodes) visit(child);
|
||||
};
|
||||
for (const child of root.childNodes) visit(child);
|
||||
return normalizeLabelText(text);
|
||||
}
|
||||
|
||||
function compactHoverTargetName(
|
||||
el: Element,
|
||||
desc: CaptureTargetDescriptor,
|
||||
): CaptureTargetDescriptor {
|
||||
if (!desc.name) return desc;
|
||||
const fullText = normalizeLabelText(el.textContent ?? "");
|
||||
const compactName = collectHoverTriggerLabelText(el);
|
||||
if (!compactName || compactName === desc.name) return desc;
|
||||
const compactComparable = compactName.replace(/\s+/g, "");
|
||||
const descComparable = desc.name.replace(/…$/, "").replace(/\s+/g, "");
|
||||
const fullTextComparable = fullText.replace(/\s+/g, "");
|
||||
if (
|
||||
descComparable.startsWith(compactComparable) &&
|
||||
fullTextComparable.startsWith(descComparable) &&
|
||||
compactName.length < desc.name.length
|
||||
) {
|
||||
return { ...desc, name: truncateLabel(compactName) };
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
function isWeakHoverTarget(target: CaptureTargetDescriptor): boolean {
|
||||
return !target.role && !target.name && target.tag === "div";
|
||||
}
|
||||
|
||||
function looksLikeAvatarElement(el: Element): boolean {
|
||||
const className = typeof el.className === "string" ? el.className : "";
|
||||
return /\b(avatar|user-avatar)\b/i.test(className);
|
||||
}
|
||||
|
||||
function normalizeHoverTarget(
|
||||
el: Element,
|
||||
desc: CaptureTargetDescriptor,
|
||||
decision: HoverTriggerDecision,
|
||||
): CaptureTargetDescriptor {
|
||||
if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) {
|
||||
return { ...desc, name: "image" };
|
||||
}
|
||||
if (
|
||||
isWeakHoverTarget(desc) &&
|
||||
(looksLikeAvatarElement(el) ||
|
||||
(hasHoverGraphicDescendant(el) && decision.reasons.includes("icon-only")))
|
||||
) {
|
||||
return { tag: "img", role: "img", name: "image" };
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
function hoverTriggerSignals(el: Element, desc: CaptureTargetDescriptor) {
|
||||
if (!(el instanceof HTMLElement)) return null;
|
||||
const style = hoverTriggerStyle(el);
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
role: desc.role,
|
||||
label: desc.name,
|
||||
attrs: hoverTriggerAttrs(el),
|
||||
rect: hoverTriggerRect(el),
|
||||
cursor: style.cursor,
|
||||
pointerEvents: style.pointerEvents,
|
||||
hasGraphicDescendant: hasHoverGraphicDescendant(el),
|
||||
};
|
||||
}
|
||||
|
||||
function hoverCandidateFromEvent(target: EventTarget | null): HoverCandidate | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const element = resolveHoverElement(target);
|
||||
if (!element) return null;
|
||||
const desc = describeTarget(element);
|
||||
const signals = hoverTriggerSignals(element, desc);
|
||||
if (!signals) return null;
|
||||
const decision = evaluateHoverTrigger(signals);
|
||||
const hasExpansionSignal = hasStrongHoverExpansionSignal(signals);
|
||||
const hasDirectSignal = hasDirectHoverInteractiveSignal(signals);
|
||||
if (!decision.eligible && !hasExpansionSignal && !hasDirectSignal) return null;
|
||||
if (!decision.eligible && !desc.name && !desc.role) return null;
|
||||
const normalizedTarget = normalizeHoverTarget(
|
||||
element,
|
||||
compactHoverTargetName(element, desc),
|
||||
decision,
|
||||
);
|
||||
const now = Date.now();
|
||||
return {
|
||||
element,
|
||||
target: normalizedTarget,
|
||||
recordedAt: now,
|
||||
score: decision.score,
|
||||
eligible: decision.eligible,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldReplaceHoverCandidate(
|
||||
current: HoverCandidate,
|
||||
next: HoverCandidate,
|
||||
now: number,
|
||||
): boolean {
|
||||
if (next.element === current.element) return false;
|
||||
if (now - current.recordedAt > HOVER_BEFORE_CLICK_MAX_MS) return true;
|
||||
if (current.element.contains(next.element)) return false;
|
||||
if (next.element.contains(current.element)) return true;
|
||||
return next.score >= current.score + HOVER_REPLACE_SCORE_MARGIN;
|
||||
}
|
||||
|
||||
/** Clicks that only pick an autocomplete/suggestion value — not a semantic submit action. */
|
||||
function isInputCompletionClick(target: EventTarget | null, session: FillSession | null): boolean {
|
||||
if (!session || !(target instanceof Element)) return false;
|
||||
@@ -164,14 +359,22 @@ function scheduleInputCompletionCommit(
|
||||
export function startRecordCapture(
|
||||
_requestId: string,
|
||||
sendStep: (step: RecordStepPayload) => void,
|
||||
options: { captureNavigation?: boolean } = {},
|
||||
): RecordCaptureController {
|
||||
const emitStep = (step: RecordStepPayload) => {
|
||||
sendStep({ page_url: location.href, ...step });
|
||||
};
|
||||
const hoverSurfaceStateMap = (states: HoverSurfaceState[]): Map<Element, string> =>
|
||||
new Map(states.map((state) => [state.element, state.signature]));
|
||||
let fillSession: FillSession | null = null;
|
||||
let composing = false;
|
||||
let lastUrl = location.href;
|
||||
let keyboardActivation: { target: EventTarget | null; recordedAt: number } | undefined;
|
||||
let pendingHover: HoverCandidate | null = null;
|
||||
const recentHoverCandidates: HoverCandidate[] = [];
|
||||
const emittedHoverElements = new WeakSet<Element>();
|
||||
let hoverSurfaceStates = hoverSurfaceStateMap(collectHoverSurfaceStates());
|
||||
const hoverSurfaceNodes = new Map<Element, HoverSurfaceNode>();
|
||||
let generatedControlClick: Element | null = null;
|
||||
let navigationActionPending = false;
|
||||
let navigationActionVersion = 0;
|
||||
@@ -195,14 +398,17 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "fill",
|
||||
target: session.target,
|
||||
geometry: captureGeometry(session.element),
|
||||
value,
|
||||
commit: session.pendingCommit ?? "blur",
|
||||
...(isPassword ? { redacted: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const commitFillSession = () => {
|
||||
const commitFillSession = (commit: "enter" | "suggestion" | "blur" = "blur") => {
|
||||
if (!fillSession || composing) return;
|
||||
const session = fillSession;
|
||||
session.pendingCommit = commit;
|
||||
fillSession = null;
|
||||
emitFill(session);
|
||||
committedValues.set(session.element, session.lastValue);
|
||||
@@ -237,6 +443,224 @@ export function startRecordCapture(
|
||||
});
|
||||
};
|
||||
|
||||
const rememberHoverCandidate = (hover: HoverCandidate) => {
|
||||
const duplicate = recentHoverCandidates.findIndex(
|
||||
(candidate) => candidate.element === hover.element,
|
||||
);
|
||||
if (duplicate >= 0) recentHoverCandidates.splice(duplicate, 1);
|
||||
recentHoverCandidates.push(hover);
|
||||
if (recentHoverCandidates.length > HOVER_CANDIDATE_LIMIT) {
|
||||
recentHoverCandidates.splice(0, recentHoverCandidates.length - HOVER_CANDIDATE_LIMIT);
|
||||
}
|
||||
};
|
||||
|
||||
const surfaceArea = (el: Element): number => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width * rect.height;
|
||||
};
|
||||
|
||||
const smallestContaining = <T>(
|
||||
target: Element,
|
||||
items: Iterable<T>,
|
||||
elementFor: (item: T) => Element,
|
||||
): T | undefined => {
|
||||
let best: T | undefined;
|
||||
for (const item of items) {
|
||||
const element = elementFor(item);
|
||||
if (!element.contains(target)) continue;
|
||||
if (!best || surfaceArea(element) < surfaceArea(elementFor(best))) {
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const ownedSurfaceContaining = (target: Element): HoverSurfaceNode | undefined =>
|
||||
smallestContaining(target, hoverSurfaceNodes.values(), (node) => node.element);
|
||||
|
||||
const surfaceStateContaining = (
|
||||
target: Element,
|
||||
states: HoverSurfaceState[],
|
||||
): HoverSurfaceState | undefined => smallestContaining(target, states, (state) => state.element);
|
||||
|
||||
const inferOwnerForSurface = (
|
||||
surface: Element,
|
||||
now: number,
|
||||
before?: HoverCandidate,
|
||||
): HoverCandidate | undefined => {
|
||||
for (let index = recentHoverCandidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = recentHoverCandidates[index];
|
||||
if (!candidate) continue;
|
||||
if (before && candidate.element === before.element) continue;
|
||||
if (before && candidate.recordedAt > before.recordedAt) continue;
|
||||
if (surface.contains(candidate.element)) continue;
|
||||
if (now - candidate.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
if (isLikelyHoverSurfaceOwner(candidate.element, surface)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const nodeForUnownedSurface = (
|
||||
surfaceState: HoverSurfaceState,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
): HoverSurfaceNode | undefined => {
|
||||
const owner = inferOwnerForSurface(surfaceState.element, now);
|
||||
if (!owner) return undefined;
|
||||
const parent = parentSurfaceNodeForOwner(owner, currentStates, now);
|
||||
const node: HoverSurfaceNode = {
|
||||
element: surfaceState.element,
|
||||
signature: surfaceState.signature,
|
||||
owner,
|
||||
...(parent ? { parent } : {}),
|
||||
};
|
||||
hoverSurfaceNodes.set(surfaceState.element, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const ownedSurfaceForAction = (target: Element): HoverSurfaceNode | undefined => {
|
||||
const closestSurface = closestHoverSurfaceCandidate(target);
|
||||
if (closestSurface) {
|
||||
const owned = hoverSurfaceNodes.get(closestSurface);
|
||||
if (owned) return owned;
|
||||
}
|
||||
const ownedContaining = ownedSurfaceContaining(target);
|
||||
if (ownedContaining) return ownedContaining;
|
||||
const now = Date.now();
|
||||
const currentStates = collectHoverSurfaceStates();
|
||||
pruneGoneHoverSurfaces(currentStates);
|
||||
const surfaceState = closestSurface
|
||||
? currentStates.find((state) => state.element === closestSurface)
|
||||
: surfaceStateContaining(target, currentStates);
|
||||
hoverSurfaceStates = hoverSurfaceStateMap(currentStates);
|
||||
if (!surfaceState) return undefined;
|
||||
return nodeForUnownedSurface(surfaceState, currentStates, now);
|
||||
};
|
||||
|
||||
const pruneGoneHoverSurfaces = (currentStates: HoverSurfaceState[]) => {
|
||||
const current = new Set(currentStates.map((state) => state.element));
|
||||
for (const element of hoverSurfaceNodes.keys()) {
|
||||
if (!current.has(element)) hoverSurfaceNodes.delete(element);
|
||||
}
|
||||
};
|
||||
|
||||
const parentSurfaceNodeForOwner = (
|
||||
owner: HoverCandidate,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
): HoverSurfaceNode | undefined => {
|
||||
const ownedParent = ownedSurfaceContaining(owner.element);
|
||||
if (ownedParent) return ownedParent;
|
||||
const parentState = surfaceStateContaining(owner.element, currentStates);
|
||||
if (!parentState) return undefined;
|
||||
const parentOwner = inferOwnerForSurface(parentState.element, now, owner);
|
||||
if (!parentOwner) return undefined;
|
||||
const parentNode: HoverSurfaceNode = {
|
||||
element: parentState.element,
|
||||
signature: parentState.signature,
|
||||
owner: parentOwner,
|
||||
};
|
||||
hoverSurfaceNodes.set(parentState.element, parentNode);
|
||||
return parentNode;
|
||||
};
|
||||
|
||||
const createSurfaceNode = (
|
||||
state: HoverSurfaceState,
|
||||
owner: HoverCandidate,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
): HoverSurfaceNode | undefined => {
|
||||
const parent = parentSurfaceNodeForOwner(owner, currentStates, now);
|
||||
if (parent?.element === state.element) return undefined;
|
||||
if (now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) return undefined;
|
||||
if (!isLikelyHoverSurfaceOwner(owner.element, state.element)) return undefined;
|
||||
const node: HoverSurfaceNode = {
|
||||
element: state.element,
|
||||
signature: state.signature,
|
||||
owner,
|
||||
...(parent ? { parent } : {}),
|
||||
};
|
||||
hoverSurfaceNodes.set(state.element, node);
|
||||
return node;
|
||||
};
|
||||
|
||||
const bindChangedHoverSurfaces = (
|
||||
previousStates: Map<Element, string>,
|
||||
currentStates: HoverSurfaceState[],
|
||||
now: number,
|
||||
) => {
|
||||
for (const state of currentStates) {
|
||||
if (previousStates.get(state.element) === state.signature) continue;
|
||||
const owner = inferOwnerForSurface(state.element, now);
|
||||
if (!owner) continue;
|
||||
createSurfaceNode(state, owner, currentStates, now);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshHoverSurfaces = (previousStates = hoverSurfaceStates) => {
|
||||
const now = Date.now();
|
||||
const currentStates = collectHoverSurfaceStates();
|
||||
pruneGoneHoverSurfaces(currentStates);
|
||||
bindChangedHoverSurfaces(previousStates, currentStates, now);
|
||||
hoverSurfaceStates = hoverSurfaceStateMap(currentStates);
|
||||
};
|
||||
|
||||
const scheduleHoverSurfaceRefresh = (previousStates: Map<Element, string>) => {
|
||||
setTimeout(() => refreshHoverSurfaces(previousStates), 0);
|
||||
};
|
||||
|
||||
const emitHoverStep = (hover: HoverCandidate) => {
|
||||
if (emittedHoverElements.has(hover.element)) return;
|
||||
emitStep({
|
||||
op: "hover",
|
||||
target: hover.target,
|
||||
geometry: captureGeometry(hover.element),
|
||||
});
|
||||
emittedHoverElements.add(hover.element);
|
||||
};
|
||||
|
||||
const surfaceOwnerChain = (
|
||||
surface: HoverSurfaceNode,
|
||||
actionElement: Element,
|
||||
now: number,
|
||||
): HoverCandidate[] => {
|
||||
const chain: HoverCandidate[] = [];
|
||||
const selected = new WeakSet<Element>();
|
||||
const surfaces: HoverSurfaceNode[] = [];
|
||||
let node: HoverSurfaceNode | undefined = surface;
|
||||
while (node && surfaces.length < 4) {
|
||||
surfaces.unshift(node);
|
||||
node = node.parent;
|
||||
}
|
||||
for (const owned of surfaces) {
|
||||
const owner = owned.owner;
|
||||
if (owner.element === actionElement || actionElement.contains(owner.element)) {
|
||||
continue;
|
||||
}
|
||||
if (selected.has(owner.element)) continue;
|
||||
if (emittedHoverElements.has(owner.element)) continue;
|
||||
if (now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
selected.add(owner.element);
|
||||
chain.push(owner);
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
const containedHoverOwnerForAction = (
|
||||
actionElement: Element,
|
||||
now: number,
|
||||
): HoverCandidate | undefined => {
|
||||
for (let index = recentHoverCandidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = recentHoverCandidates[index];
|
||||
if (!candidate) continue;
|
||||
if (candidate.element === actionElement) continue;
|
||||
if (!candidate.element.contains(actionElement)) continue;
|
||||
if (now - candidate.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue;
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const emitClick = (event: MouseEvent) => {
|
||||
// Only record clicks an LLM can re-identify (named interactive controls).
|
||||
const target = describeEventTarget(eventTarget(event));
|
||||
@@ -245,10 +669,59 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "click",
|
||||
target,
|
||||
geometry: geometryForEventTarget(eventTarget(event)),
|
||||
expects_navigation: true,
|
||||
});
|
||||
};
|
||||
|
||||
const emitHoverCandidateBeforeAction = (actionTarget: EventTarget | null) => {
|
||||
if (!(actionTarget instanceof Element)) return;
|
||||
const actionElement = resolveClickableElement(actionTarget) ?? actionTarget;
|
||||
const surface = ownedSurfaceForAction(actionElement);
|
||||
const now = Date.now();
|
||||
const containedOwner = surface ? undefined : containedHoverOwnerForAction(actionElement, now);
|
||||
const hoverChain = surface
|
||||
? surfaceOwnerChain(surface, actionElement, now)
|
||||
: containedOwner
|
||||
? [containedOwner]
|
||||
: [];
|
||||
if (hoverChain.length === 0) {
|
||||
pendingHover = null;
|
||||
return;
|
||||
}
|
||||
for (const hover of hoverChain) emitHoverStep(hover);
|
||||
pendingHover = null;
|
||||
};
|
||||
|
||||
const onMouseOver = (event: MouseEvent) => {
|
||||
const target = eventTarget(event);
|
||||
if (isOverlayTarget(target)) return;
|
||||
const now = Date.now();
|
||||
if (pendingHover && target instanceof Element && pendingHover.element.contains(target)) {
|
||||
if (now - pendingHover.recordedAt <= HOVER_BEFORE_CLICK_MAX_MS) return;
|
||||
pendingHover = null;
|
||||
}
|
||||
const candidate = hoverCandidateFromEvent(target);
|
||||
if (candidate) {
|
||||
const previousSurfaceStates = new Map(hoverSurfaceStates);
|
||||
rememberHoverCandidate(candidate);
|
||||
refreshHoverSurfaces(previousSurfaceStates);
|
||||
scheduleHoverSurfaceRefresh(previousSurfaceStates);
|
||||
}
|
||||
const hover = candidate?.eligible ? candidate : null;
|
||||
if (pendingHover && target instanceof Element) {
|
||||
const relation = decideHoverSurfaceRelation({ triggerElement: pendingHover.element }, target);
|
||||
if (relation.related && now - pendingHover.recordedAt <= HOVER_BEFORE_CLICK_MAX_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!hover) return;
|
||||
if (pendingHover && !shouldReplaceHoverCandidate(pendingHover, hover, now)) {
|
||||
return;
|
||||
}
|
||||
pendingHover = hover;
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = eventTarget(event);
|
||||
@@ -278,6 +751,7 @@ export function startRecordCapture(
|
||||
|
||||
const fillable = fillableFromTarget(target);
|
||||
if (fillable) {
|
||||
emitHoverCandidateBeforeAction(fillable);
|
||||
ensureFillSession(fillable);
|
||||
return;
|
||||
}
|
||||
@@ -285,6 +759,7 @@ export function startRecordCapture(
|
||||
if (target instanceof Element) {
|
||||
const nearbyFillable = nearbyFillableFromSearchChrome(target);
|
||||
if (nearbyFillable) {
|
||||
emitHoverCandidateBeforeAction(nearbyFillable);
|
||||
ensureFillSession(nearbyFillable);
|
||||
return;
|
||||
}
|
||||
@@ -296,7 +771,7 @@ export function startRecordCapture(
|
||||
scheduleInputCompletionCommit(
|
||||
sessionElement,
|
||||
syncFillSessionValue,
|
||||
commitFillSession,
|
||||
() => commitFillSession("suggestion"),
|
||||
(el) => fillSession?.element === el,
|
||||
);
|
||||
return;
|
||||
@@ -304,6 +779,7 @@ export function startRecordCapture(
|
||||
|
||||
commitFillSession();
|
||||
if (target instanceof Element && target.closest("select")) return;
|
||||
emitHoverCandidateBeforeAction(target);
|
||||
emitClick(event);
|
||||
};
|
||||
|
||||
@@ -345,6 +821,7 @@ export function startRecordCapture(
|
||||
commitFillSession();
|
||||
const target = eventTarget(event);
|
||||
if (target instanceof HTMLSelectElement) {
|
||||
emitHoverCandidateBeforeAction(target);
|
||||
const values = Array.from(target.selectedOptions).map((opt) => opt.value);
|
||||
const labels = Array.from(target.selectedOptions).map((opt) =>
|
||||
(opt.label || opt.textContent || opt.value).trim(),
|
||||
@@ -354,6 +831,7 @@ export function startRecordCapture(
|
||||
emitStep({
|
||||
op: "select",
|
||||
target: desc,
|
||||
geometry: captureGeometry(target),
|
||||
values,
|
||||
labels,
|
||||
expects_navigation: true,
|
||||
@@ -394,20 +872,22 @@ export function startRecordCapture(
|
||||
};
|
||||
}
|
||||
if (fillable) {
|
||||
commitFillSession();
|
||||
commitFillSession(event.key === "Enter" ? "enter" : "blur");
|
||||
}
|
||||
const desc = describeEventTarget(target);
|
||||
if (!desc && !event.key) return;
|
||||
emitHoverCandidateBeforeAction(target);
|
||||
emitStep({
|
||||
op: "press",
|
||||
key: event.key,
|
||||
...(desc ? { target: desc } : {}),
|
||||
...(desc ? { target: desc, geometry: geometryForEventTarget(target) } : {}),
|
||||
...(modifiers.length ? { modifiers } : {}),
|
||||
expects_navigation: event.key === "Enter",
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("mouseover", onMouseOver, true);
|
||||
document.addEventListener("focusin", onFocusIn, true);
|
||||
document.addEventListener("focusout", onFocusOut, true);
|
||||
document.addEventListener("input", onInput, true);
|
||||
@@ -416,28 +896,39 @@ export function startRecordCapture(
|
||||
document.addEventListener("change", onChange, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
|
||||
const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
|
||||
urlObserver.observe(document, { subtree: true, childList: true });
|
||||
const captureNavigation = options.captureNavigation ?? true;
|
||||
const urlObserver = captureNavigation
|
||||
? new MutationObserver(() => emitNavigateIfChanged())
|
||||
: null;
|
||||
urlObserver?.observe(document, { subtree: true, childList: true });
|
||||
const onUrlEvent = () => emitNavigateIfChanged();
|
||||
window.addEventListener("hashchange", onUrlEvent);
|
||||
window.addEventListener("popstate", onUrlEvent);
|
||||
window.addEventListener("pagehide", commitFillSession);
|
||||
// Wrap: passing commitFillSession directly would forward the DOM event as
|
||||
// the `commit` argument and stamp it onto the recorded fill step.
|
||||
const onPageHide = () => commitFillSession();
|
||||
if (captureNavigation) {
|
||||
window.addEventListener("hashchange", onUrlEvent);
|
||||
window.addEventListener("popstate", onUrlEvent);
|
||||
}
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
||||
originalPushState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
|
||||
originalReplaceState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
if (captureNavigation) {
|
||||
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
||||
originalPushState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
|
||||
originalReplaceState.apply(this, args);
|
||||
emitNavigateIfChanged(navigationActionPending ? true : undefined);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
commitFillSession();
|
||||
document.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("mouseover", onMouseOver, true);
|
||||
document.removeEventListener("focusin", onFocusIn, true);
|
||||
document.removeEventListener("focusout", onFocusOut, true);
|
||||
document.removeEventListener("input", onInput, true);
|
||||
@@ -445,93 +936,16 @@ export function startRecordCapture(
|
||||
document.removeEventListener("compositionend", onCompositionEnd, true);
|
||||
document.removeEventListener("change", onChange, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
urlObserver.disconnect();
|
||||
window.removeEventListener("hashchange", onUrlEvent);
|
||||
window.removeEventListener("popstate", onUrlEvent);
|
||||
window.removeEventListener("pagehide", commitFillSession);
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
urlObserver?.disconnect();
|
||||
if (captureNavigation) {
|
||||
window.removeEventListener("hashchange", onUrlEvent);
|
||||
window.removeEventListener("popstate", onUrlEvent);
|
||||
}
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
if (captureNavigation) {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RecordContentMessage = RecordStartMessage | RecordStopMessage | RecordCancelMessage;
|
||||
|
||||
export function isRecordContentMessage(msg: unknown): msg is RecordContentMessage {
|
||||
return isRecordStartMessage(msg) || isRecordStopMessage(msg) || isRecordCancelMessage(msg);
|
||||
}
|
||||
|
||||
export function handleRecordContentMessage(
|
||||
message: RecordContentMessage,
|
||||
state: {
|
||||
activeRequestId: string | null;
|
||||
capture: RecordCaptureController | null;
|
||||
setActiveRequestId(id: string | null): void;
|
||||
setCapture(capture: RecordCaptureController | null): void;
|
||||
onStart(requestId: string): void;
|
||||
onStop(): void;
|
||||
},
|
||||
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
|
||||
): boolean {
|
||||
if (isRecordStartMessage(message)) {
|
||||
if (!knownRecordRequests.has(message.requestId)) {
|
||||
knownRecordRequests.add(message.requestId);
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
}
|
||||
state.capture?.dispose();
|
||||
state.setCapture(
|
||||
startRecordCapture(message.requestId, (step) => {
|
||||
sendRecordStep(message.requestId, step);
|
||||
}),
|
||||
);
|
||||
state.setActiveRequestId(message.requestId);
|
||||
state.onStart(message.requestId);
|
||||
sendResponse?.({ ok: true });
|
||||
return sendResponse !== undefined;
|
||||
}
|
||||
|
||||
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
||||
// Require an active recording that matches this requestId — otherwise a
|
||||
// stray STOP/CANCEL (e.g. after teardown) would still run finishStop and
|
||||
// clear overlay state even though nothing was capturing.
|
||||
if (!state.activeRequestId || state.activeRequestId !== message.requestId) {
|
||||
return false;
|
||||
}
|
||||
state.capture?.dispose();
|
||||
state.setCapture(null);
|
||||
const finishStop = () => {
|
||||
state.onStop();
|
||||
state.setActiveRequestId(null);
|
||||
};
|
||||
if (isRecordStopMessage(message) && sendResponse) {
|
||||
const pending = [...(pendingStepSends.get(message.requestId) ?? [])];
|
||||
void Promise.all(pending).then((delivered) => {
|
||||
finishStop();
|
||||
const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId);
|
||||
if (succeeded) {
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
knownRecordRequests.delete(message.requestId);
|
||||
}
|
||||
sendResponse(
|
||||
succeeded
|
||||
? { ok: true }
|
||||
: {
|
||||
ok: false,
|
||||
error: "failed to deliver one or more recorded steps",
|
||||
},
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (isRecordCancelMessage(message)) {
|
||||
failedStepDeliveries.delete(message.requestId);
|
||||
knownRecordRequests.delete(message.requestId);
|
||||
}
|
||||
finishStop();
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export { RECORD_CANCEL, RECORD_START, RECORD_STEP, RECORD_STOP };
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createRandomUuid } from "@/lib/random-uuid";
|
||||
import {
|
||||
isAcceptedRecordStepAck,
|
||||
RECORD_STEP,
|
||||
type RecordStepMessage,
|
||||
type RecordStepPayload,
|
||||
} from "@/lib/record-bridge";
|
||||
|
||||
export type SendRecordStepMessage = (message: RecordStepMessage) => Promise<unknown>;
|
||||
|
||||
export class RecordStepDelivery {
|
||||
readonly #requestId: string;
|
||||
readonly #producerId: string;
|
||||
readonly #send: SendRecordStepMessage;
|
||||
readonly #pending = new Map<number, RecordStepPayload>();
|
||||
#nextSequence = 1;
|
||||
#tail = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
requestId: string,
|
||||
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
|
||||
producerId: string = createRandomUuid(),
|
||||
) {
|
||||
this.#requestId = requestId;
|
||||
this.#producerId = producerId;
|
||||
this.#send = send;
|
||||
}
|
||||
|
||||
enqueue(step: RecordStepPayload): void {
|
||||
const sequence = this.#nextSequence;
|
||||
this.#nextSequence += 1;
|
||||
this.#pending.set(sequence, step);
|
||||
void this.#schedule();
|
||||
}
|
||||
|
||||
async flush(): Promise<boolean> {
|
||||
await this.#tail;
|
||||
await this.#schedule();
|
||||
return this.#pending.size === 0;
|
||||
}
|
||||
|
||||
#schedule(): Promise<void> {
|
||||
const drain = () => this.#drain();
|
||||
this.#tail = this.#tail.then(drain, drain);
|
||||
return this.#tail;
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
for (const [sequence, step] of this.#pending) {
|
||||
const message: RecordStepMessage = {
|
||||
type: RECORD_STEP,
|
||||
requestId: this.#requestId,
|
||||
producerId: this.#producerId,
|
||||
sequence,
|
||||
step,
|
||||
};
|
||||
try {
|
||||
const response = await this.#send(message);
|
||||
if (!isAcceptedRecordStepAck(response, sequence)) return;
|
||||
this.#pending.delete(sequence);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { createRandomUuid } from "@/lib/random-uuid";
|
||||
import {
|
||||
markRecordingDocument,
|
||||
type RecordingDocumentMarker,
|
||||
waitForDocumentElement,
|
||||
} from "@/lib/recording/document-marker";
|
||||
import {
|
||||
isRecordFrameStartMessage,
|
||||
RECORD_FRAME_PORT,
|
||||
RECORD_FRAME_QUERY,
|
||||
RECORD_FRAME_START,
|
||||
type RecordFramePortMessage,
|
||||
type RecordFrameQueryResponse,
|
||||
type RecordFrameStartMessage,
|
||||
} from "@/lib/recording/frame-bridge";
|
||||
import { type RecordCaptureController, startRecordCapture } from "../record-capture";
|
||||
import { RecordStepDelivery } from "../record-step-delivery";
|
||||
|
||||
interface ActiveFrameRecording {
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
port: chrome.runtime.Port;
|
||||
delivery: RecordStepDelivery;
|
||||
capture: RecordCaptureController;
|
||||
marker: RecordingDocumentMarker;
|
||||
}
|
||||
|
||||
const RECORD_FRAME_REGISTER_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class RecordFrameAgent {
|
||||
#active: ActiveFrameRecording | null = null;
|
||||
#startPromise: Promise<{ ok: true } | { ok: false; error: string }> | null = null;
|
||||
#disposed = false;
|
||||
|
||||
async start(
|
||||
message: RecordFrameStartMessage,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (this.#active?.requestId === message.requestId) return { ok: true };
|
||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
||||
if (this.#startPromise) {
|
||||
await this.#startPromise;
|
||||
return this.start(message);
|
||||
}
|
||||
if (this.#active) this.cancel(this.#active.requestId);
|
||||
const starting = this.#start(message);
|
||||
this.#startPromise = starting;
|
||||
try {
|
||||
return await starting;
|
||||
} finally {
|
||||
if (this.#startPromise === starting) this.#startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
const active = this.#active;
|
||||
if (!active || active.requestId !== requestId) return;
|
||||
active.capture.dispose();
|
||||
active.marker.restore();
|
||||
active.port.disconnect();
|
||||
this.#active = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#disposed = true;
|
||||
if (this.#active) this.cancel(this.#active.requestId);
|
||||
}
|
||||
|
||||
async #start(
|
||||
message: RecordFrameStartMessage,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const producerId = createRandomUuid();
|
||||
const root = await waitForDocumentElement();
|
||||
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
|
||||
const marker = markRecordingDocument(producerId, root);
|
||||
const port = chrome.runtime.connect({ name: RECORD_FRAME_PORT });
|
||||
try {
|
||||
await registerPort(port, message.requestId, producerId);
|
||||
} catch {
|
||||
marker.restore();
|
||||
port.disconnect();
|
||||
return { ok: false, error: "failed to register recording document" };
|
||||
}
|
||||
if (this.#disposed) {
|
||||
marker.restore();
|
||||
port.disconnect();
|
||||
return { ok: false, error: "recording document is disposed" };
|
||||
}
|
||||
|
||||
const delivery = new RecordStepDelivery(message.requestId, undefined, producerId);
|
||||
const capture = startRecordCapture(
|
||||
message.requestId,
|
||||
(step) => {
|
||||
marker.ensure();
|
||||
delivery.enqueue(step);
|
||||
},
|
||||
{ captureNavigation: window.top === window },
|
||||
);
|
||||
const active: ActiveFrameRecording = {
|
||||
requestId: message.requestId,
|
||||
producerId,
|
||||
port,
|
||||
delivery,
|
||||
capture,
|
||||
marker,
|
||||
};
|
||||
this.#active = active;
|
||||
port.onMessage.addListener((raw: unknown) => {
|
||||
const command = raw as Partial<RecordFramePortMessage>;
|
||||
if (command.type === "stop" && command.requestId === active.requestId) {
|
||||
void this.#stop(active, command.commandId);
|
||||
} else if (command.type === "cancel" && command.requestId === active.requestId) {
|
||||
this.cancel(active.requestId);
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (this.#active !== active) return;
|
||||
active.capture.dispose();
|
||||
active.marker.restore();
|
||||
this.#active = null;
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async #stop(active: ActiveFrameRecording, commandId: string | undefined): Promise<void> {
|
||||
if (!commandId || this.#active !== active) return;
|
||||
active.capture.dispose();
|
||||
const succeeded = await active.delivery.flush();
|
||||
try {
|
||||
active.port.postMessage({
|
||||
type: "stopped",
|
||||
requestId: active.requestId,
|
||||
commandId,
|
||||
ok: succeeded,
|
||||
...(succeeded ? {} : { error: "failed to deliver one or more recorded steps" }),
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!succeeded || this.#active !== active) return;
|
||||
active.marker.restore();
|
||||
this.#active = null;
|
||||
active.port.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function registerPort(
|
||||
port: chrome.runtime.Port,
|
||||
requestId: string,
|
||||
producerId: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("recording document registration timed out"));
|
||||
}, RECORD_FRAME_REGISTER_TIMEOUT_MS);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
port.onMessage.removeListener(onMessage);
|
||||
port.onDisconnect.removeListener(onDisconnect);
|
||||
};
|
||||
const onMessage = (raw: unknown) => {
|
||||
const message = raw as Partial<RecordFramePortMessage>;
|
||||
if (
|
||||
message.type !== "ready_ack" ||
|
||||
message.requestId !== requestId ||
|
||||
message.producerId !== producerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onDisconnect = () => {
|
||||
cleanup();
|
||||
reject(new Error("recording document port disconnected"));
|
||||
};
|
||||
port.onMessage.addListener(onMessage);
|
||||
port.onDisconnect.addListener(onDisconnect);
|
||||
port.postMessage({ type: "ready", requestId, producerId } satisfies RecordFramePortMessage);
|
||||
});
|
||||
}
|
||||
|
||||
export function attachRecordFrameAgent(): () => void {
|
||||
const agent = new RecordFrameAgent();
|
||||
const onMessage = (
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: { ok: true } | { ok: false; error: string }) => void,
|
||||
) => {
|
||||
if (!isRecordFrameStartMessage(message)) return false;
|
||||
void agent.start(message).then(sendResponse);
|
||||
return true;
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
void chrome.runtime
|
||||
.sendMessage({ type: RECORD_FRAME_QUERY })
|
||||
.then((response: RecordFrameQueryResponse | undefined) => {
|
||||
if (!response?.active || !response.requestId || response.startedAtMs === undefined) return;
|
||||
return agent.start({
|
||||
type: RECORD_FRAME_START,
|
||||
requestId: response.requestId,
|
||||
startedAtMs: response.startedAtMs,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
agent.dispose();
|
||||
};
|
||||
}
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
type OverlayMode,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
|
||||
import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
|
||||
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 { isAgentControlledTab, SessionManager } from "@/session-manager/manager";
|
||||
import {
|
||||
attachBorrowNotificationButtonHandler,
|
||||
attachBorrowNotificationClickHandler,
|
||||
@@ -37,7 +39,10 @@ import {
|
||||
attachRecordFinishListener,
|
||||
attachRecordQueryListener,
|
||||
attachRecordStepListener,
|
||||
type RecordRuntimeDeps,
|
||||
} from "@/tools/record";
|
||||
import { chromeTabsApi } from "@/tools/shared";
|
||||
import { chromeTabMutationApi } from "@/tools/tabs";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import { WSTransport } from "@/transport/ws-transport";
|
||||
@@ -46,7 +51,12 @@ export default defineBackground(() => {
|
||||
const controller = new ConnectionController();
|
||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||
const sessions = new SessionManager();
|
||||
const cdp = new ChromiumCdp();
|
||||
const cdp = new ChromiumCdp(undefined, {
|
||||
shouldAutoAcceptDialog: async (tabId) => {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return sessions.findByWindowId(tab.windowId) !== null;
|
||||
},
|
||||
});
|
||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||
let overlayGeneration = 0;
|
||||
const controlModes = new Map<string, OverlayMode>();
|
||||
@@ -77,6 +87,24 @@ export default defineBackground(() => {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative overlay state for a specific tab. Agent Window tabs are
|
||||
* free by default; only tabs explicitly claimed through session startup,
|
||||
* `tab_create`, or `tab_borrow` receive the control overlay.
|
||||
*/
|
||||
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
|
||||
if (typeof tabId === "number" && typeof windowId === "number") {
|
||||
const ctx = sessions.findByWindowId(windowId);
|
||||
if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId);
|
||||
}
|
||||
return {
|
||||
type: OVERLAY_AGENT_STATE,
|
||||
sessionId: null,
|
||||
mode: "hidden",
|
||||
generation: overlayGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
async function pushOverlayStateToTab(
|
||||
tabId: number,
|
||||
state: OverlayAgentStateMessage,
|
||||
@@ -88,13 +116,18 @@ export default defineBackground(() => {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
|
||||
const state = overlayStateForTab(tabId, windowId);
|
||||
await pushOverlayStateToTab(tabId, state);
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
tabs.map((tab) => {
|
||||
if (typeof tab.id !== "number") return Promise.resolve();
|
||||
return pushOverlayStateForTab(tab.id, windowId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,16 +166,66 @@ export default defineBackground(() => {
|
||||
if (typeof tab.windowId !== "number") return;
|
||||
pushOverlayStateForAgentWindow(tab.windowId);
|
||||
});
|
||||
// Newly observed tabs are free unless the agent's own creation path has
|
||||
// already claimed their concrete id. This listener only renders that
|
||||
// state; it never infers or mutates ownership from event ordering.
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
|
||||
if (!sessions.findByWindowId(tab.windowId)) return;
|
||||
void pushOverlayStateForTab(tab.id, tab.windowId);
|
||||
});
|
||||
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
|
||||
sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing });
|
||||
});
|
||||
// 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,
|
||||
// Same contract as the dispatcher's `tool.session_stop`: without these
|
||||
// deps the agent-tab cleanup and the window-release path are dead code.
|
||||
sessionStopDeps: {
|
||||
cdp,
|
||||
tabManagement: { tabs: chromeTabMutationApi },
|
||||
tabsQuery: chromeTabsApi,
|
||||
},
|
||||
onSessionsChanged: () => {
|
||||
void sessionsLive.syncFromManager();
|
||||
},
|
||||
});
|
||||
const recordDeps = {
|
||||
tabsApi: chrome.tabs,
|
||||
cdp,
|
||||
frameCoordinator: recordFrameCoordinator,
|
||||
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.
|
||||
}
|
||||
},
|
||||
} satisfies RecordRuntimeDeps;
|
||||
recordFrameCoordinator.attach();
|
||||
attachRecordStepListener(recordDeps);
|
||||
attachRecordFinishListener(recordDeps);
|
||||
attachRecordQueryListener(recordDeps);
|
||||
|
||||
const dispatcher = new ToolDispatcher({
|
||||
transport,
|
||||
sessions,
|
||||
cdp,
|
||||
recording: recordDeps,
|
||||
onSessionsChanged: onOverlaySessionStateChanged,
|
||||
onBrowserControlResumed,
|
||||
onAgentTabClaimed: (tabId, windowId) => {
|
||||
void pushOverlayStateForTab(tabId, windowId);
|
||||
},
|
||||
approveBorrow: (ctx) =>
|
||||
requestBorrowConfirmation(ctx.tabId, {
|
||||
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
||||
@@ -162,27 +245,6 @@ 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,
|
||||
@@ -267,7 +329,20 @@ export default defineBackground(() => {
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -284,7 +359,11 @@ export default defineBackground(() => {
|
||||
}
|
||||
|
||||
if (msg.kind === OVERLAY_MSG_READY) {
|
||||
sendResponse(overlayStateForWindow(sender.tab?.windowId));
|
||||
// Resolve from explicit tab ownership before sending so free tabs never
|
||||
// flash the control mask.
|
||||
if (typeof sender.tab?.id === "number") {
|
||||
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@ 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, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||
import { RecordOverlay } from "@/content/RecordOverlay";
|
||||
import {
|
||||
handleRecordContentMessage,
|
||||
isRecordContentMessage,
|
||||
type RecordCaptureController,
|
||||
} from "@/content/record-capture";
|
||||
type CaptureSuppressAck,
|
||||
type CaptureSuppressMessage,
|
||||
isCaptureSuppressMessage,
|
||||
} from "@/lib/capture-suppress-bridge";
|
||||
import {
|
||||
HELP_ACK,
|
||||
HELP_FINISH,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
isHelpCancelMessage,
|
||||
isHelpRequestMessage,
|
||||
} from "@/lib/help-bridge";
|
||||
import { getControlHintsHidden, STORAGE_KEYS } from "@/lib/instance-id";
|
||||
import {
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
isOverlayAgentStateMessage,
|
||||
@@ -37,10 +40,17 @@ import {
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
isRecordStopMessage,
|
||||
RECORD_FINISH,
|
||||
RECORD_QUERY,
|
||||
type RecordCancelMessage,
|
||||
type RecordQueryResponse,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStopAck,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import type {
|
||||
BorrowCancelMessage,
|
||||
@@ -60,7 +70,6 @@ export default defineContentScript({
|
||||
if (window.top !== window) return;
|
||||
|
||||
const overlays = new OverlayController();
|
||||
let recordCapture: RecordCaptureController | null = null;
|
||||
let activeRecordRequestId: string | null = null;
|
||||
let reactRoot: ReactDOM.Root | null = null;
|
||||
let overlayHost: HTMLElement | null = null;
|
||||
@@ -69,6 +78,16 @@ export default defineContentScript({
|
||||
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",
|
||||
@@ -80,6 +99,8 @@ export default defineContentScript({
|
||||
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);
|
||||
@@ -207,8 +228,6 @@ export default defineContentScript({
|
||||
if (previousHelp) {
|
||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||
}
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
renderAll();
|
||||
}
|
||||
@@ -237,46 +256,46 @@ export default defineContentScript({
|
||||
| BorrowCancelMessage
|
||||
| HelpRequestMessage
|
||||
| HelpCancelMessage
|
||||
| CaptureSuppressMessage
|
||||
| RecordStartMessage
|
||||
| RecordStopMessage
|
||||
| RecordCancelMessage
|
||||
| OverlayAgentOverlayResetMessage
|
||||
| OverlayAgentStateMessage
|
||||
| OverlayAutomationBypassMessage,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpAckMessage) => void,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
|
||||
) => {
|
||||
if (isRecordContentMessage(message)) {
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
message,
|
||||
{
|
||||
activeRequestId: activeRecordRequestId,
|
||||
capture: recordCapture,
|
||||
setActiveRequestId: (id) => {
|
||||
activeRecordRequestId = id;
|
||||
},
|
||||
setCapture: (capture) => {
|
||||
recordCapture = capture;
|
||||
},
|
||||
onStart: (requestId) => {
|
||||
overlays.setAgentRecordRequest({
|
||||
id: requestId,
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
renderAll();
|
||||
},
|
||||
onStop: () => {
|
||||
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
|
||||
renderAll();
|
||||
},
|
||||
if (isCaptureSuppressMessage(message)) {
|
||||
return captureSuppress.handleMessage(message, sendResponse);
|
||||
}
|
||||
|
||||
if (isRecordStartMessage(message)) {
|
||||
activeRecordRequestId = message.requestId;
|
||||
overlays.setAgentRecordRequest({
|
||||
id: message.requestId,
|
||||
...(typeof message.startedAtMs === "number" ? { startedAtMs: message.startedAtMs } : {}),
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId: message.requestId,
|
||||
});
|
||||
},
|
||||
sendResponse as unknown as
|
||||
| ((response: RecordStartAck | RecordStopAck) => void)
|
||||
| undefined,
|
||||
);
|
||||
return needsAsync;
|
||||
});
|
||||
renderAll();
|
||||
(sendResponse as unknown as (response: RecordStartAck) => void)({ ok: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
|
||||
if (activeRecordRequestId !== message.requestId) return false;
|
||||
overlays.clearAgentRecordRequest(message.requestId);
|
||||
activeRecordRequestId = null;
|
||||
renderAll();
|
||||
if (isRecordStopMessage(message)) {
|
||||
(sendResponse as unknown as (response: RecordStopAck) => void)({ ok: true });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -318,16 +337,7 @@ export default defineContentScript({
|
||||
|
||||
if (isHelpRequestMessage(message)) {
|
||||
const helpMsg = message as HelpRequestMessage;
|
||||
const previousHelp = overlays.setAgentHelpRequest({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) =>
|
||||
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
|
||||
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"),
|
||||
});
|
||||
const previousHelp = mountHelpRequest(helpMsg);
|
||||
if (previousHelp && previousHelp.id !== helpMsg.requestId) {
|
||||
void sendHelpFinish(previousHelp.id, "cancelled");
|
||||
}
|
||||
@@ -380,17 +390,18 @@ export default defineContentScript({
|
||||
});
|
||||
}
|
||||
|
||||
function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">): void {
|
||||
overlays.setAgentHelpRequest({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) =>
|
||||
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
|
||||
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"),
|
||||
});
|
||||
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> {
|
||||
@@ -415,15 +426,17 @@ export default defineContentScript({
|
||||
try {
|
||||
const recordQuery = (await chrome.runtime.sendMessage({
|
||||
type: RECORD_QUERY,
|
||||
})) as { active?: boolean; requestId?: string } | undefined;
|
||||
})) 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,
|
||||
@@ -460,6 +473,19 @@ export default defineContentScript({
|
||||
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 requestOverlayState();
|
||||
@@ -490,10 +516,8 @@ 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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -121,8 +122,8 @@ describe("App", () => {
|
||||
|
||||
expect(screen.queryByText(/^扩展 v/)).toBeNull();
|
||||
expect(screen.queryByText(/^daemon v/)).toBeNull();
|
||||
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
|
||||
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
|
||||
expect(screen.getByTitle("扩展版本").textContent).toBe(`Ext ${EXTENSION_VERSION}`);
|
||||
expect(screen.getByTitle("bsk 版本").textContent).toBe(`CLI ${mockDaemonVersion}`);
|
||||
expect(screen.getByText("03c3e47f")).toBeTruthy();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
|
||||
@@ -199,6 +200,9 @@ describe("App", () => {
|
||||
expect(copied).toContain("--browser 03c3e47f");
|
||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||
expect(copied).toContain("./trace");
|
||||
expect(copied).toContain("trace.json");
|
||||
expect(copied).toContain("states/");
|
||||
// Button label stays static; a transient toast confirms the copy.
|
||||
expect(copyButton.textContent).toContain("复制录制指令");
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
||||
@@ -243,4 +247,130 @@ describe("App", () => {
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats protocol drift as still connected and prompts an upgrade", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "version_skew",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "version_skew",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("已连接")).toBeTruthy();
|
||||
expect(screen.getByText("可升级")).toBeTruthy();
|
||||
expect(screen.queryByText("协议不一致")).toBeNull();
|
||||
expect(screen.queryByText("Action needed")).toBeNull();
|
||||
expect(screen.queryByText("兼容")).toBeNull();
|
||||
const warning = screen.getByText(/协议版本不同,请及时升级/);
|
||||
expect(warning.textContent).toContain("CLI 协议");
|
||||
expect(warning.textContent).toContain("扩展协议");
|
||||
|
||||
openRecordView();
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
fireEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control hints toggle", () => {
|
||||
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,17 +1,20 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
|
||||
import { Badge, Button, Input, Label } from "@browser-skill/ui";
|
||||
import {
|
||||
RiApps2Line,
|
||||
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",
|
||||
@@ -37,6 +40,7 @@ function getLogoSrc() {
|
||||
export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
||||
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
|
||||
const [view, setView] = useState<PopupView>("main");
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
const [purposeDraft, setPurposeDraft] = useState("");
|
||||
@@ -75,6 +79,7 @@ export function App() {
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const connectionLive = statusState === "connected" || isSkewed;
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
||||
const extensionVersion = snapshot.extensionVersion || "—";
|
||||
@@ -86,7 +91,7 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordReady = connectionLive && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
@@ -156,12 +161,17 @@ export function App() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
className="group size-8 shrink-0 rounded-md"
|
||||
aria-label={t("popup.launcher.title")}
|
||||
onClick={() => setView("features")}
|
||||
data-slot="popup-launcher"
|
||||
>
|
||||
<RiApps2Line className="size-4" aria-hidden />
|
||||
<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>
|
||||
@@ -187,41 +197,62 @@ export function App() {
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={snapshot.connectionEnabled}
|
||||
<Switch
|
||||
checked={snapshot.connectionEnabled}
|
||||
onCheckedChange={setConnectionEnabled}
|
||||
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"
|
||||
className="mt-2 text-xs leading-snug text-muted-foreground"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
cliProtocol: daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</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"
|
||||
@@ -236,9 +267,9 @@ export function App() {
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span title={t("popup.daemonVersionHint")}>CLI {daemonVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
<span title={t("popup.extensionVersionHint")}>Ext {extensionVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
|
||||
@@ -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,14 @@
|
||||
import { attachRecordFrameAgent } from "@/content/recording/frame-agent";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["<all_urls>"],
|
||||
runAt: "document_start",
|
||||
allFrames: true,
|
||||
matchAboutBlank: true,
|
||||
matchOriginAsFallback: true,
|
||||
|
||||
main(ctx) {
|
||||
const dispose = attachRecordFrameAgent();
|
||||
ctx.onInvalidated(dispose);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CAPTURE_SUPPRESS,
|
||||
type CaptureSuppressMessage,
|
||||
withExtensionOverlayHidden,
|
||||
} 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("withExtensionOverlayHidden", () => {
|
||||
it("sends begin → fn → end and returns fn's result", async () => {
|
||||
const events: string[] = [];
|
||||
const sendToTab = recordingSendToTab(events);
|
||||
|
||||
const result = await withExtensionOverlayHidden(
|
||||
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(
|
||||
withExtensionOverlayHidden(
|
||||
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 withExtensionOverlayHidden(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 withExtensionOverlayHidden(7, async () => "shot", sendToTab);
|
||||
|
||||
expect(result).toBe("shot");
|
||||
expect(sendToTab).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
|
||||
import type { ConnectionStateHandler, Transport } from "../../transport/transport";
|
||||
import type { ConnectionState, 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", () => ({
|
||||
@@ -26,20 +26,20 @@ function handshake(
|
||||
}
|
||||
|
||||
describe("computeConnectedState (protocol-based compat)", () => {
|
||||
it("returns connected when protocol strings match", () => {
|
||||
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
||||
it("returns connected when daemon protocol equals extension protocol", () => {
|
||||
expect(computeConnectedState(handshake("1.1", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
|
||||
kind: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns version_skew when daemon protocol minor is newer", () => {
|
||||
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
|
||||
expect(computeConnectedState(handshake("1.2", "1.0"))).toEqual({
|
||||
kind: "version_skew",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
|
||||
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
|
||||
expect(computeConnectedState(handshake("1.1.0", "1.0"))).toEqual({
|
||||
kind: "version_skew",
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
});
|
||||
|
||||
it("rejects when extension is below daemon min_compatible_protocol", () => {
|
||||
const result = computeConnectedState(handshake("1.0", "1.5"));
|
||||
const result = computeConnectedState(handshake("1.1", "1.5"));
|
||||
expect(result.kind).toBe("rejected");
|
||||
if (result.kind === "rejected") {
|
||||
expect(result.reason).toContain("min_compatible_protocol");
|
||||
@@ -65,7 +65,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
const result = computeConnectedState({
|
||||
server: "browser-skill-daemon",
|
||||
version: "0.1.0",
|
||||
protocol_version: "1.0",
|
||||
protocol_version: "1.1",
|
||||
min_compatible_peer: "0.1.0",
|
||||
});
|
||||
expect(result).toEqual({ kind: "connected" });
|
||||
@@ -80,7 +80,7 @@ describe("computeConnectedState (protocol-based compat)", () => {
|
||||
});
|
||||
|
||||
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
|
||||
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
|
||||
const result = computeConnectedState(handshake("1.1", "not-a-protocol"));
|
||||
expect(result.kind).toBe("rejected");
|
||||
if (result.kind === "rejected") {
|
||||
expect(result.reason).toContain("daemon min_compatible_protocol");
|
||||
@@ -97,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;
|
||||
@@ -110,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) };
|
||||
@@ -119,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;
|
||||
}
|
||||
@@ -128,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();
|
||||
@@ -152,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();
|
||||
@@ -171,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.1", "1.0") });
|
||||
await Promise.resolve();
|
||||
expect(controller.snapshot().state).not.toBe("connected");
|
||||
|
||||
transport.emitMessage({ id: second.id, result: handshake("1.1", "1.0") });
|
||||
await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ describe("help-bridge", () => {
|
||||
prompt: "log in",
|
||||
displayMode: "compact",
|
||||
selectors: ["#login"],
|
||||
rects: [{ top: 10, left: 20, width: 100, height: 40 }],
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRandomUuid } from "../random-uuid";
|
||||
|
||||
describe("createRandomUuid", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses crypto.randomUUID when it is available", () => {
|
||||
const randomUUID = vi.fn(() => "00000000-0000-4000-8000-000000000000");
|
||||
vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() });
|
||||
|
||||
expect(createRandomUuid()).toBe("00000000-0000-4000-8000-000000000000");
|
||||
expect(randomUUID).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to getRandomValues and sets UUID v4 bits", () => {
|
||||
const bytes = new Uint8Array(16).fill(0xff);
|
||||
const getRandomValues = vi.fn((target: Uint8Array) => {
|
||||
target.set(bytes);
|
||||
return target;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
|
||||
const uuid = createRandomUuid();
|
||||
|
||||
expect(uuid).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff");
|
||||
expect(getRandomValues).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isAcceptedRecordStepAck,
|
||||
isRecordStepMessage,
|
||||
RECORD_STEP,
|
||||
type RecordStepMessage,
|
||||
} from "../record-bridge";
|
||||
|
||||
const message: RecordStepMessage = {
|
||||
type: RECORD_STEP,
|
||||
requestId: "rec-1",
|
||||
producerId: "document-1",
|
||||
sequence: 1,
|
||||
step: { op: "click", target: { tag: "button", name: "Submit" } },
|
||||
};
|
||||
|
||||
describe("record step delivery protocol", () => {
|
||||
it("requires a producer and a positive integer sequence", () => {
|
||||
expect(isRecordStepMessage(message)).toBe(true);
|
||||
expect(isRecordStepMessage({ ...message, producerId: "" })).toBe(false);
|
||||
expect(isRecordStepMessage({ ...message, sequence: 0 })).toBe(false);
|
||||
expect(isRecordStepMessage({ ...message, sequence: 1.5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only the acknowledgement for the sent sequence", () => {
|
||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 2 }, 2)).toBe(true);
|
||||
expect(isAcceptedRecordStepAck({ ok: true, sequence: 1 }, 2)).toBe(false);
|
||||
expect(isAcceptedRecordStepAck({ ok: false, expectedSequence: 2, error: "gap" }, 2)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RECORD_FRAME_PORT, RECORD_FRAME_QUERY } from "../recording/frame-bridge";
|
||||
import { RecordFrameCoordinator } from "../recording/frame-coordinator";
|
||||
|
||||
class ListenerSet<T extends (...args: never[]) => unknown> {
|
||||
readonly listeners = new Set<T>();
|
||||
addListener = (listener: T) => this.listeners.add(listener);
|
||||
removeListener = (listener: T) => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
function fakePort(sender: chrome.runtime.MessageSender) {
|
||||
const messages: unknown[] = [];
|
||||
const onMessage = new ListenerSet<(message: unknown) => void>();
|
||||
const onDisconnect = new ListenerSet<() => void>();
|
||||
return {
|
||||
port: {
|
||||
name: RECORD_FRAME_PORT,
|
||||
sender,
|
||||
onMessage,
|
||||
onDisconnect,
|
||||
postMessage: vi.fn((message: unknown) => messages.push(message)),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as chrome.runtime.Port,
|
||||
messages,
|
||||
receive(message: unknown) {
|
||||
for (const listener of onMessage.listeners) listener(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RecordFrameCoordinator", () => {
|
||||
const onConnect = new ListenerSet<(port: chrome.runtime.Port) => void>();
|
||||
const onMessage = new ListenerSet<
|
||||
(
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => boolean
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onConnect.listeners.clear();
|
||||
onMessage.listeners.clear();
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { onConnect, onMessage },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers child-document queries for the initial tab before it is armed", () => {
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [],
|
||||
sendToDocument: async () => ({ ok: true }),
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
|
||||
const query = (tabId: number) => {
|
||||
let response: unknown;
|
||||
for (const listener of onMessage.listeners) {
|
||||
listener(
|
||||
{ type: RECORD_FRAME_QUERY },
|
||||
{
|
||||
tab: { id: tabId },
|
||||
frameId: 7,
|
||||
documentId: "child-document",
|
||||
} as chrome.runtime.MessageSender,
|
||||
(value) => {
|
||||
response = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
expect(query(3)).toEqual({ active: true, requestId: "rec-1", startedAtMs: 10 });
|
||||
expect(query(4)).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it("starts a child Document that appears after the initial frame snapshot", async () => {
|
||||
let notifyFrameNavigation:
|
||||
| ((frame: {
|
||||
tabId: number;
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
lifecycle: "committed" | "completed";
|
||||
}) => void)
|
||||
| undefined;
|
||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
||||
sendToDocument,
|
||||
subscribeFrameNavigation(listener) {
|
||||
notifyFrameNavigation = listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
coordinator.attach();
|
||||
const onDocumentReady = vi.fn();
|
||||
coordinator.begin("rec-1", 10, 3, onDocumentReady);
|
||||
await coordinator.armTab("rec-1", 3);
|
||||
sendToDocument.mockClear();
|
||||
|
||||
notifyFrameNavigation?.({
|
||||
tabId: 3,
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
lifecycle: "completed",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(sendToDocument).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ type: "bsk-record-frame-start", requestId: "rec-1", startedAtMs: 10 },
|
||||
{ documentId: "late-child" },
|
||||
);
|
||||
});
|
||||
expect(onDocumentReady).not.toHaveBeenCalled();
|
||||
|
||||
const child = fakePort({
|
||||
tab: { id: 3 },
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
} as chrome.runtime.MessageSender);
|
||||
for (const listener of onConnect.listeners) listener(child.port);
|
||||
child.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
notifyFrameNavigation?.({
|
||||
tabId: 3,
|
||||
frameId: 7,
|
||||
documentId: "late-child",
|
||||
lifecycle: "completed",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onDocumentReady).toHaveBeenCalledWith({
|
||||
tabId: 3,
|
||||
documentId: "late-child",
|
||||
browserFrameId: 7,
|
||||
producerId: "producer-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("binds a producer to its sender Document and keeps final steps valid while stopping", async () => {
|
||||
const sendToDocument = vi.fn(async () => ({ ok: true }));
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [
|
||||
{ frameId: 0, documentId: "top-document" },
|
||||
{ frameId: 7, documentId: "child-document" },
|
||||
],
|
||||
sendToDocument,
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
await expect(coordinator.armTab("rec-1", 3)).resolves.toBe(true);
|
||||
|
||||
const sender = {
|
||||
tab: { id: 3 },
|
||||
frameId: 7,
|
||||
documentId: "child-document",
|
||||
} as chrome.runtime.MessageSender;
|
||||
const frame = fakePort(sender);
|
||||
for (const listener of onConnect.listeners) listener(frame.port);
|
||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toEqual({
|
||||
tabId: 3,
|
||||
documentId: "child-document",
|
||||
browserFrameId: 7,
|
||||
producerId: "producer-1",
|
||||
});
|
||||
|
||||
const stopping = coordinator.stop("rec-1");
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).not.toBeNull();
|
||||
const stop = frame.messages.find(
|
||||
(message): message is { type: "stop"; commandId: string } =>
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: string }).type === "stop",
|
||||
);
|
||||
expect(stop).toBeDefined();
|
||||
frame.receive({
|
||||
type: "stopped",
|
||||
requestId: "rec-1",
|
||||
commandId: stop!.commandId,
|
||||
ok: true,
|
||||
});
|
||||
await expect(stopping).resolves.toBe(true);
|
||||
expect(coordinator.sourceFor("rec-1", "producer-1", sender)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a producer used from another Document", async () => {
|
||||
const coordinator = new RecordFrameCoordinator({
|
||||
getAllFrames: async () => [{ frameId: 0, documentId: "top-document" }],
|
||||
sendToDocument: async () => ({ ok: true }),
|
||||
});
|
||||
coordinator.attach();
|
||||
coordinator.begin("rec-1", 10, 3);
|
||||
await coordinator.armTab("rec-1", 3);
|
||||
const sender = {
|
||||
tab: { id: 3 },
|
||||
frameId: 0,
|
||||
documentId: "top-document",
|
||||
} as chrome.runtime.MessageSender;
|
||||
const frame = fakePort(sender);
|
||||
for (const listener of onConnect.listeners) listener(frame.port);
|
||||
frame.receive({ type: "ready", requestId: "rec-1", producerId: "producer-1" });
|
||||
|
||||
expect(
|
||||
coordinator.sourceFor("rec-1", "producer-1", {
|
||||
...sender,
|
||||
documentId: "other-document",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RECORD_DOCUMENT_ATTRIBUTE,
|
||||
recordingDocumentMarkerValue,
|
||||
} from "@/shared/recording-document-identity";
|
||||
import { markRecordingDocument } from "../recording/document-marker";
|
||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
||||
|
||||
describe("recording document marker", () => {
|
||||
it("rejects a non-random Document identity", () => {
|
||||
expect(() => markRecordingDocument("user@example.com")).toThrow(
|
||||
"recording Document identity must be a random UUID",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the Document attribute exactly", () => {
|
||||
document.documentElement.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, "page-value");
|
||||
const producerId = "123e4567-e89b-42d3-a456-426614174000";
|
||||
const marker = markRecordingDocument(producerId);
|
||||
|
||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(
|
||||
recordingDocumentMarkerValue(producerId),
|
||||
);
|
||||
marker.restore();
|
||||
expect(document.documentElement.getAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe("page-value");
|
||||
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
});
|
||||
|
||||
it("indexes the CDP scope from the safe frame identity", () => {
|
||||
const index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 3, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [],
|
||||
refs: [],
|
||||
});
|
||||
|
||||
expect(index.documentScope("producer-1")).toEqual({
|
||||
frameId: "child",
|
||||
target: { tabId: 3, sessionId: "oopif-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a recording identity maps to multiple Documents", () => {
|
||||
const index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "left",
|
||||
target: { tabId: 3, sessionId: "left-session" },
|
||||
recordingDocumentId: "duplicate",
|
||||
},
|
||||
{
|
||||
frameId: "right",
|
||||
target: { tabId: 3, sessionId: "right-session" },
|
||||
recordingDocumentId: "duplicate",
|
||||
},
|
||||
],
|
||||
matchNodes: [],
|
||||
refs: [],
|
||||
});
|
||||
|
||||
expect(index.documentScope("duplicate")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
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,
|
||||
vomText: '@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 finalizedFillTrace(value: string, redactValues: boolean) {
|
||||
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",
|
||||
redactValues,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizedFillBody(value: string, redactValues: boolean): string {
|
||||
return finalizedFillTrace(value, redactValues).states[0]!.body;
|
||||
}
|
||||
|
||||
describe("record observation annotations", () => {
|
||||
it("omits a fill literal when values are redacted", () => {
|
||||
const secret = "hunter2-private";
|
||||
const dumped = JSON.stringify(finalizedFillTrace(secret, true));
|
||||
expect(dumped).toContain("step 1: fill");
|
||||
expect(dumped).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",
|
||||
vomText: "@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, vomText: "same" }).id).toBe("s1");
|
||||
expect(first.register({ url: URL, vomText: "same" }).id).toBe("s1");
|
||||
expect(second.register({ url: URL, vomText: "other" }).id).toBe("s1");
|
||||
});
|
||||
|
||||
it("enriches metadata when a deduplicated observation becomes more complete", () => {
|
||||
const registry = new RecordingStateRegistry();
|
||||
registry.register({ url: URL, vomText: "same" });
|
||||
const state = registry.register({
|
||||
url: URL,
|
||||
title: "Login",
|
||||
vomText: "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,289 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
|
||||
const captureRecordingObservation = vi.hoisted(() => vi.fn());
|
||||
const waitForDocumentSettled = vi.hoisted(() => vi.fn(async () => "quiet" as const));
|
||||
|
||||
vi.mock("../recording/observation-capture", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../recording/observation-capture")>();
|
||||
return { ...actual, captureRecordingObservation };
|
||||
});
|
||||
|
||||
vi.mock("../recording/document-settle", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../recording/document-settle")>();
|
||||
return { ...actual, waitForDocumentSettled };
|
||||
});
|
||||
|
||||
import { ObservationNodeIndex } from "../recording/observation-capture";
|
||||
import { RecordingObservationRuntime } from "../recording/recording-runtime";
|
||||
import type { RecordingDraftStep } from "../recording/types";
|
||||
|
||||
function observation(url = "https://example.com/") {
|
||||
return {
|
||||
rootFrameId: "root",
|
||||
index: new ObservationNodeIndex({ rootFrameId: "root", matchNodes: [], refs: [] }),
|
||||
url,
|
||||
title: "Example",
|
||||
vomText: '@vom 1\nRootWebArea "Example"',
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(): RecordingObservationRuntime {
|
||||
return new RecordingObservationRuntime({
|
||||
cdp: { send: vi.fn() as unknown as CdpRunner["send"] },
|
||||
tabsApi: { get: vi.fn(), query: vi.fn() } as unknown as ChromeTabsApi,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RecordingObservationRuntime", () => {
|
||||
afterEach(() => {
|
||||
captureRecordingObservation.mockReset();
|
||||
waitForDocumentSettled.mockClear();
|
||||
});
|
||||
|
||||
it("shares one initial capture and includes it in flush", async () => {
|
||||
let release!: (value: ReturnType<typeof observation>) => void;
|
||||
captureRecordingObservation.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const recording = runtime();
|
||||
|
||||
const first = recording.captureInitial(7);
|
||||
const second = recording.captureInitial(7);
|
||||
const flushed = recording.flush();
|
||||
let flushCompleted = false;
|
||||
void flushed.then(() => {
|
||||
flushCompleted = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(1);
|
||||
expect(flushCompleted).toBe(false);
|
||||
|
||||
release(observation());
|
||||
await Promise.all([first, second, flushed]);
|
||||
expect(flushCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels an in-flight initial capture", async () => {
|
||||
captureRecordingObservation.mockImplementationOnce(
|
||||
(input: { signal?: AbortSignal }) =>
|
||||
new Promise((_, reject) => {
|
||||
input.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("observation aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const recording = runtime();
|
||||
|
||||
const pending = recording.captureInitial(7);
|
||||
recording.cancel();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("retries a failed initial capture when the recording settles at stop", async () => {
|
||||
captureRecordingObservation
|
||||
.mockRejectedValueOnce(new Error("document swapped"))
|
||||
.mockResolvedValueOnce(observation());
|
||||
const recording = runtime();
|
||||
|
||||
await expect(recording.captureInitial(7)).rejects.toThrow("document swapped");
|
||||
await recording.settleTrailing(7, []);
|
||||
const trace = recording.buildTrace({
|
||||
drafts: [],
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "0.1.6",
|
||||
});
|
||||
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(2);
|
||||
expect(trace.states).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps runtime matching data out of the serialized trace", async () => {
|
||||
const captured = observation();
|
||||
captured.index = new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
matchNodes: [
|
||||
{
|
||||
frameId: "root",
|
||||
backendNodeId: 42,
|
||||
tag: "button",
|
||||
rect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [{ ref: "e1", backendNodeId: 42, role: "button", name: "Submit", line: 1 }],
|
||||
});
|
||||
captureRecordingObservation.mockResolvedValueOnce(captured);
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
const dumped = JSON.stringify(
|
||||
recording.buildTrace({
|
||||
drafts: [],
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
stoppedBy: "user_finish",
|
||||
bskVersion: "0.1.6",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dumped).toContain("RootWebArea");
|
||||
expect(dumped).not.toContain("matchNodes");
|
||||
expect(dumped).not.toContain("localRect");
|
||||
expect(dumped).not.toContain("backendNodeId");
|
||||
expect(dumped).not.toContain("ObservationNodeIndex");
|
||||
});
|
||||
|
||||
it("captures a tab transition without sharing observation cursors", async () => {
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation("https://example.com/first"))
|
||||
.mockResolvedValueOnce(observation("https://example.com/second"));
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(4);
|
||||
const transition = await recording.captureTabTransition(4, 5);
|
||||
await recording.captureInitial(4);
|
||||
await recording.captureInitial(5);
|
||||
|
||||
expect(transition).toEqual({
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
targetUrl: "https://example.com/second",
|
||||
});
|
||||
expect(captureRecordingObservation.mock.calls.map(([input]) => input.tabId)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it("binds an iframe draft to its marked CDP Document scope", async () => {
|
||||
captureRecordingObservation.mockResolvedValueOnce({
|
||||
...observation(),
|
||||
index: new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 7, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [
|
||||
{
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
tag: "button",
|
||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
||||
}),
|
||||
});
|
||||
const recording = runtime();
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "click" as const,
|
||||
captureTarget: { tag: "button", role: "button", name: "Save" },
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
||||
recording.cancel();
|
||||
|
||||
const draft = drafts[0];
|
||||
expect(draft?.op).toBe("click");
|
||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
||||
expect(draft.targetHint).toMatchObject({
|
||||
frameId: "child",
|
||||
geometrySpace: "local",
|
||||
});
|
||||
expect(draft.matchedTarget?.ref).toBe("e1");
|
||||
});
|
||||
|
||||
it("refreshes a late iframe before matching its first action", async () => {
|
||||
const iframeObservation = {
|
||||
...observation(),
|
||||
index: new ObservationNodeIndex({
|
||||
rootFrameId: "root",
|
||||
frames: [
|
||||
{
|
||||
frameId: "child",
|
||||
target: { tabId: 7, sessionId: "oopif-session" },
|
||||
recordingDocumentId: "producer-1",
|
||||
},
|
||||
],
|
||||
matchNodes: [
|
||||
{
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
tag: "button",
|
||||
rect: { x: 410, y: 20, w: 100, h: 30 },
|
||||
localRect: { x: 10, y: 20, w: 100, h: 30 },
|
||||
},
|
||||
],
|
||||
refs: [
|
||||
{
|
||||
ref: "e1",
|
||||
backendNodeId: 42,
|
||||
frameId: "child",
|
||||
role: "button",
|
||||
name: "表格视图",
|
||||
line: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation())
|
||||
.mockResolvedValueOnce(iframeObservation)
|
||||
.mockResolvedValueOnce(iframeObservation);
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await recording.refreshDocument(7, "producer-1");
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "button", role: "button", name: "表格视图" },
|
||||
targetHint: {
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
},
|
||||
];
|
||||
await recording.processDraft(7, drafts, 0, "producer-1");
|
||||
recording.cancel();
|
||||
|
||||
expect(captureRecordingObservation).toHaveBeenCalledTimes(3);
|
||||
expect(waitForDocumentSettled).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ frameId: "child", target: { tabId: 7, sessionId: "oopif-session" } },
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
const draft = drafts[0];
|
||||
expect(draft?.op).toBe("click");
|
||||
if (!draft || draft.op !== "click") throw new Error("expected click draft");
|
||||
expect(draft.matchedTarget).toMatchObject({ ref: "e1", name: "表格视图" });
|
||||
});
|
||||
|
||||
it("does not poison final flush when a frame readiness refresh fails", async () => {
|
||||
captureRecordingObservation
|
||||
.mockResolvedValueOnce(observation())
|
||||
.mockRejectedValueOnce(new Error("child Document replaced"));
|
||||
const recording = runtime();
|
||||
|
||||
await recording.captureInitial(7);
|
||||
await expect(recording.refreshDocument(7, "producer-1")).rejects.toThrow(
|
||||
"child Document replaced",
|
||||
);
|
||||
await expect(recording.flush()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
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",
|
||||
target: { 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,
|
||||
target: { 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",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
navigated_to: "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,
|
||||
target: { 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",
|
||||
target: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
navigated_to: "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,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,164 @@
|
||||
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: [], navigation: { 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.navigation.pendingNavigation).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the hovered element description as a capture fallback", () => {
|
||||
const buffer = { steps: [], navigation: { pendingNavigation: false } };
|
||||
appendRecordedPayload(
|
||||
buffer,
|
||||
{
|
||||
op: "hover",
|
||||
target: { tag: "button", role: "button", name: "新建" },
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
{
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "hover",
|
||||
captureTarget: { tag: "button", role: "button", name: "新建" },
|
||||
targetHint: {
|
||||
geometry: {
|
||||
rect: { x: 900, y: 8, w: 60, h: 32 },
|
||||
tag: "button",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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: "发布" },
|
||||
},
|
||||
],
|
||||
navigation: {
|
||||
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"],
|
||||
},
|
||||
],
|
||||
navigation: {
|
||||
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: [],
|
||||
navigation: { currentUrl: "https://example.com/a", pendingNavigation: false },
|
||||
};
|
||||
const result = observeRecordedNavigation(buffer, "https://example.com/b", false);
|
||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
||||
expect(buffer.steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
url: "https://example.com/b",
|
||||
});
|
||||
});
|
||||
|
||||
it("asks the recorder to coalesce redirect hops instead of emitting each one", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
navigation: {
|
||||
currentUrl: "https://passport.example/login",
|
||||
pendingNavigation: false,
|
||||
},
|
||||
};
|
||||
const hop1 = observeRecordedNavigation(
|
||||
buffer,
|
||||
"https://passport.example/callback",
|
||||
false,
|
||||
"link",
|
||||
["server_redirect"],
|
||||
);
|
||||
expect(hop1).toEqual({
|
||||
kind: "coalesce_redirect",
|
||||
url: "https://passport.example/callback",
|
||||
});
|
||||
expect(buffer.steps).toEqual([]);
|
||||
|
||||
const hop2 = observeRecordedNavigation(buffer, "https://app.example/dashboard", false, "link", [
|
||||
"client_redirect",
|
||||
]);
|
||||
expect(hop2).toEqual({
|
||||
kind: "coalesce_redirect",
|
||||
url: "https://app.example/dashboard",
|
||||
});
|
||||
expect(buffer.steps).toEqual([]);
|
||||
expect(buffer.navigation.currentUrl).toBe("https://app.example/dashboard");
|
||||
});
|
||||
|
||||
it("does not reuse redirect metadata for a later content-observed URL change", () => {
|
||||
const buffer = {
|
||||
steps: [],
|
||||
navigation: {
|
||||
currentUrl: "https://example.com/redirect",
|
||||
pendingNavigation: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = observeRecordedNavigation(buffer, "https://example.com/spa");
|
||||
|
||||
expect(result).toEqual({ kind: "appended", index: 0 });
|
||||
expect(buffer.steps[0]).toMatchObject({ op: "navigate", url: "https://example.com/spa" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RecordingTabCoordinator } from "../recording/tab-coordinator";
|
||||
|
||||
describe("RecordingTabCoordinator", () => {
|
||||
it("keeps navigation state isolated per tab", () => {
|
||||
const tabs = new RecordingTabCoordinator(4, "https://example.com/first");
|
||||
const first = tabs.navigation(4);
|
||||
first.pendingNavigation = true;
|
||||
const second = tabs.navigation(5, "https://example.com/second");
|
||||
|
||||
expect(second).toEqual({
|
||||
currentUrl: "https://example.com/second",
|
||||
pendingNavigation: false,
|
||||
});
|
||||
expect(tabs.navigation(4)).toBe(first);
|
||||
expect(tabs.navigation(4).pendingNavigation).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates an earlier activation when a newer tab becomes active", () => {
|
||||
const tabs = new RecordingTabCoordinator(4);
|
||||
const second = tabs.noteActivation(5);
|
||||
const third = tabs.noteActivation(6);
|
||||
|
||||
expect(tabs.isLatest(second)).toBe(false);
|
||||
expect(tabs.isLatest(third)).toBe(true);
|
||||
tabs.commit(6);
|
||||
expect(tabs.currentTabId).toBe(6);
|
||||
});
|
||||
|
||||
it("commits the freshly observed URL when returning to an existing tab", () => {
|
||||
const tabs = new RecordingTabCoordinator(4);
|
||||
tabs.navigation(5, "https://example.com/old");
|
||||
|
||||
tabs.commit(5, "https://example.com/new");
|
||||
|
||||
expect(tabs.currentTabId).toBe(5);
|
||||
expect(tabs.navigation(5).currentUrl).toBe("https://example.com/new");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
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("matches iframe capture geometry in the child viewport coordinate space", () => {
|
||||
const child = node(42, "child", 410);
|
||||
child.localRect = { x: 10, y: 20, w: 100, h: 30 };
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[child],
|
||||
[{ ref: "e1", backendNodeId: 42, frameId: "child", line: 1 }],
|
||||
),
|
||||
hint: {
|
||||
frameId: "child",
|
||||
geometrySpace: "local",
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
});
|
||||
expect(target.ref).toBe("e1");
|
||||
});
|
||||
|
||||
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("fails closed when the source Document has no VOM frame mapping", () => {
|
||||
const target = matchObservationTarget({
|
||||
observation: observation(
|
||||
[node(42, "root")],
|
||||
[{ ref: "e1", backendNodeId: 42, role: "button", name: "发布", line: 1 }],
|
||||
),
|
||||
hint: {
|
||||
frameId: null,
|
||||
geometry: { rect: { x: 10, y: 20, w: 100, h: 30 }, tag: "button" },
|
||||
},
|
||||
fallback: { tag: "button", role: "button", name: "发布" },
|
||||
});
|
||||
expect(target).toEqual({ role: "button", 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");
|
||||
});
|
||||
});
|
||||
+82
-21
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftTraceStep } from "@/transport/types";
|
||||
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
|
||||
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", () => {
|
||||
@@ -18,36 +27,47 @@ describe("shouldRecordPress", () => {
|
||||
});
|
||||
|
||||
describe("reduceTraceSteps", () => {
|
||||
it("does not expose internal tab transitions in trace v2", () => {
|
||||
const trace = reduceTraceSteps([
|
||||
{
|
||||
op: "switch_tab",
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
},
|
||||
]);
|
||||
expect(trace.steps).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds steps with pages dictionary and page id references", () => {
|
||||
const drafts: DraftTraceStep[] = [
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/search?q=hello&utm_source=x",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "fill",
|
||||
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
captureTarget: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
value: "browser skill",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
pageUrl: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
navigated_to: "https://example.com/results/42",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
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",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
navigated_to: "https://example.com/p/99",
|
||||
page_url: "https://example.com/results/42",
|
||||
captureTarget: { tag: "button", role: "button", name: "发布" },
|
||||
navigatedTo: "https://example.com/p/99",
|
||||
pageUrl: "https://example.com/results/42",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "a",
|
||||
page_url: "https://example.com/p/99",
|
||||
pageUrl: "https://example.com/p/99",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -104,16 +124,37 @@ describe("reduceTraceSteps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
||||
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",
|
||||
target: { tag: "select", role: "combobox", name: "分类" },
|
||||
captureTarget: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
labels: ["技术"],
|
||||
navigated_to: "https://example.com/list?cat=tech",
|
||||
page_url: "https://example.com/list",
|
||||
navigatedTo: "https://example.com/list?cat=tech",
|
||||
pageUrl: "https://example.com/list",
|
||||
},
|
||||
],
|
||||
"https://example.com/list",
|
||||
@@ -130,12 +171,32 @@ describe("reduceTraceSteps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops hover steps, which only exist in trace v3", () => {
|
||||
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(["click"]);
|
||||
});
|
||||
|
||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||
expect(
|
||||
resolveTraceStartUrl(
|
||||
[{ op: "navigate", url: "https://example.com/other" }],
|
||||
"https://example.com/start",
|
||||
),
|
||||
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,132 @@
|
||||
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("removes form literals when value redaction is requested", () => {
|
||||
const reduced = reduceTraceStepsV3(
|
||||
[
|
||||
{
|
||||
op: "fill",
|
||||
value: "private@example.com",
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
{
|
||||
op: "select",
|
||||
values: ["private-account-id"],
|
||||
labels: ["Private account"],
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
],
|
||||
{ redactValues: true },
|
||||
);
|
||||
|
||||
expect(reduced.steps[0]).toMatchObject({ op: "fill", value: "***", redacted: true });
|
||||
expect(reduced.steps[1]).toMatchObject({ op: "select" });
|
||||
expect(reduced.steps[1]).not.toHaveProperty("selection");
|
||||
expect(JSON.stringify(reduced.steps)).not.toContain("private@example.com");
|
||||
expect(JSON.stringify(reduced.steps)).not.toContain("private-account-id");
|
||||
});
|
||||
|
||||
it("emits tab transitions only for callers that advertised support", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "switch_tab",
|
||||
preStateId: "s1",
|
||||
postStateId: "s2",
|
||||
},
|
||||
];
|
||||
|
||||
expect(reduceTraceStepsV3(drafts).steps).toEqual([]);
|
||||
expect(reduceTraceStepsV3(drafts, { includeTabSwitches: true }).steps).toEqual([
|
||||
{ op: "switch_tab", id: 1, state: "s1", result: { state: "s2" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps hover steps before menu clicks", () => {
|
||||
const drafts: RecordingDraftStep[] = [
|
||||
{
|
||||
op: "hover",
|
||||
captureTarget: { tag: "span", role: "button", name: "Account" },
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
captureTarget: { tag: "a", role: "link", name: "Profile" },
|
||||
preStateId: "s1",
|
||||
postStateId: "s1",
|
||||
},
|
||||
];
|
||||
|
||||
expect(reduceTraceStepsV3(drafts).steps.map((s) => s.op)).toEqual(["hover", "click"]);
|
||||
});
|
||||
|
||||
it("does not export a tab transition without both observation endpoints", () => {
|
||||
const sourceOnly: RecordingDraftStep = { op: "switch_tab", preStateId: "s1" };
|
||||
const targetOnly: RecordingDraftStep = { op: "switch_tab", postStateId: "s2" };
|
||||
|
||||
expect(
|
||||
reduceTraceStepsV3([sourceOnly, targetOnly], { includeTabSwitches: true }).steps,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
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", vomText: "@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 temporarily excluding the
|
||||
* extension's in-page overlay. Screenshots use it to avoid captured chrome;
|
||||
* coordinate-driven native input uses it so Chrome hit-tests the page.
|
||||
*
|
||||
* The background wraps each bounded operation in `withExtensionOverlayHidden`,
|
||||
* 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 withExtensionOverlayHidden<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.
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Build a semantic TargetDescriptor for an interacted element.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface TargetDescriptor {
|
||||
/** Content-script capture descriptor before VOM geometric matching. */
|
||||
export interface CaptureTargetDescriptor {
|
||||
role?: string;
|
||||
name?: string;
|
||||
tag: string;
|
||||
@@ -228,12 +229,26 @@ export function resolveClickableElement(target: Element): Element | null {
|
||||
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: TargetDescriptor): boolean {
|
||||
export function isMeaningfulClickTarget(target: CaptureTargetDescriptor): boolean {
|
||||
const name = target.name?.trim();
|
||||
if (name && isActionableLabel(name)) return true;
|
||||
if (
|
||||
@@ -309,7 +324,7 @@ function nearbyLabelText(el: Element): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function describeTarget(el: Element): TargetDescriptor {
|
||||
export function describeTarget(el: Element): CaptureTargetDescriptor {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = inferRole(el);
|
||||
const name = accessibleName(el);
|
||||
@@ -336,7 +351,7 @@ export function describeTarget(el: Element): TargetDescriptor {
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
|
||||
export function describeEventTarget(target: EventTarget | null): CaptureTargetDescriptor | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const clickable = resolveClickableElement(target);
|
||||
if (!clickable) return null;
|
||||
|
||||
@@ -16,6 +16,13 @@ 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;
|
||||
requestId: string;
|
||||
@@ -26,6 +33,8 @@ export interface HelpRequestMessage {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -74,6 +83,16 @@ export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
|
||||
(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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,8 +3,10 @@
|
||||
* between the content-script control overlay and the background SW.
|
||||
*
|
||||
* Content script → background:
|
||||
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
|
||||
* overlay state for the sender's window.
|
||||
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
|
||||
* (see overlayStateForTab) and pushes the authoritative overlay state for
|
||||
* that tab. It also proactively pushes on tab create and control-mode
|
||||
* change, so a tab usually receives state before it ever sends ready.
|
||||
* - `{ 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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Generate a UUID v4 in browser contexts where randomUUID() is unavailable.
|
||||
*
|
||||
* Crypto.randomUUID() is restricted to secure contexts, while
|
||||
* crypto.getRandomValues() is available to content scripts on ordinary HTTP
|
||||
* origins. BrowserSkill records many intranet pages served over HTTP, so the
|
||||
* fallback is required for recording to work on those pages.
|
||||
*/
|
||||
export function createRandomUuid(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join("-");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* service worker and a tab's content script.
|
||||
*/
|
||||
|
||||
import type { TargetDescriptor } from "./describe-target";
|
||||
import type { CaptureTargetDescriptor } from "./describe-target";
|
||||
|
||||
export const RECORD_START = "bsk-record-start";
|
||||
export const RECORD_STEP = "bsk-record-step";
|
||||
@@ -16,6 +16,10 @@ export interface RecordStartAck {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type RecordStepAck =
|
||||
| { ok: true; sequence: number }
|
||||
| { ok: false; expectedSequence: number; error: string };
|
||||
|
||||
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface RecordQueryMessage {
|
||||
@@ -25,16 +29,24 @@ export interface RecordQueryMessage {
|
||||
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" | "fill" | "press" | "select" | "navigate";
|
||||
target?: TargetDescriptor;
|
||||
op: "click" | "hover" | "fill" | "press" | "select" | "navigate";
|
||||
target?: CaptureTargetDescriptor;
|
||||
value?: string;
|
||||
key?: string;
|
||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
|
||||
@@ -42,17 +54,30 @@ export interface RecordStepPayload {
|
||||
labels?: string[];
|
||||
url?: string;
|
||||
redacted?: boolean;
|
||||
commit?: "enter" | "suggestion" | "blur";
|
||||
/** Page URL when the step was captured. */
|
||||
page_url?: string;
|
||||
/** Event-target bounds in the source frame's viewport coordinate space. */
|
||||
geometry?: {
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
tag: 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;
|
||||
/** Raw webNavigation transition metadata for cause mapping. */
|
||||
transitionType?: string;
|
||||
transitionQualifiers?: string[];
|
||||
}
|
||||
|
||||
export interface RecordStepMessage {
|
||||
type: typeof RECORD_STEP;
|
||||
requestId: string;
|
||||
/** Stable for the lifetime of one content-script document. */
|
||||
producerId: string;
|
||||
/** Monotonic within one content-script producer and recording request. */
|
||||
sequence: number;
|
||||
step: RecordStepPayload;
|
||||
}
|
||||
|
||||
@@ -104,8 +129,27 @@ export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
|
||||
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;
|
||||
if (
|
||||
m.type !== RECORD_STEP ||
|
||||
typeof m.requestId !== "string" ||
|
||||
typeof m.producerId !== "string" ||
|
||||
m.producerId.length === 0 ||
|
||||
typeof m.sequence !== "number" ||
|
||||
!Number.isSafeInteger(m.sequence) ||
|
||||
m.sequence < 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const step = m.step;
|
||||
if (typeof step !== "object" || step === null) return false;
|
||||
return typeof (step as RecordStepPayload).op === "string";
|
||||
}
|
||||
|
||||
export function isAcceptedRecordStepAck(
|
||||
value: unknown,
|
||||
sequence: number,
|
||||
): value is Extract<RecordStepAck, { ok: true }> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const ack = value as Partial<RecordStepAck>;
|
||||
return ack.ok === true && ack.sequence === sequence;
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { DraftTraceStep } from "@/transport/types";
|
||||
import type { RecordStepPayload } from "./record-bridge";
|
||||
|
||||
export interface RecordingStepBuffer {
|
||||
steps: DraftTraceStep[];
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
}
|
||||
|
||||
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
||||
|
||||
function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
|
||||
const pageUrl = payload.page_url;
|
||||
switch (payload.op) {
|
||||
case "click":
|
||||
return payload.target
|
||||
? {
|
||||
op: "click",
|
||||
target: payload.target,
|
||||
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "fill":
|
||||
return payload.target
|
||||
? {
|
||||
op: "fill",
|
||||
target: payload.target,
|
||||
value: payload.value ?? "",
|
||||
...(payload.redacted ? { redacted: true } : {}),
|
||||
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "press":
|
||||
return payload.key
|
||||
? {
|
||||
op: "press",
|
||||
key: payload.key,
|
||||
...(payload.target ? { target: payload.target } : {}),
|
||||
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
||||
...(pageUrl ? { page_url: pageUrl } : {}),
|
||||
}
|
||||
: null;
|
||||
case "select":
|
||||
return payload.target && payload.values
|
||||
? {
|
||||
op: "select",
|
||||
target: payload.target,
|
||||
values: payload.values,
|
||||
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
||||
...(pageUrl ? { page_url: 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, navigated_to: 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,
|
||||
page_url: url,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buffer.pendingNavigation = false;
|
||||
buffer.pendingNavigationDeadline = undefined;
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
page_url: 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,39 @@
|
||||
import {
|
||||
RECORD_DOCUMENT_ATTRIBUTE,
|
||||
recordingDocumentMarkerValue,
|
||||
} from "@/shared/recording-document-identity";
|
||||
|
||||
export interface RecordingDocumentMarker {
|
||||
restore(): void;
|
||||
ensure(): void;
|
||||
}
|
||||
|
||||
export function waitForDocumentElement(): Promise<HTMLElement> {
|
||||
if (document.documentElement) return Promise.resolve(document.documentElement);
|
||||
return new Promise((resolve) => {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!document.documentElement) return;
|
||||
observer.disconnect();
|
||||
resolve(document.documentElement);
|
||||
});
|
||||
observer.observe(document, { childList: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function markRecordingDocument(
|
||||
producerId: string,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): RecordingDocumentMarker {
|
||||
const previous = root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
const markerValue = recordingDocumentMarkerValue(producerId);
|
||||
const ensure = () => root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, markerValue);
|
||||
ensure();
|
||||
return {
|
||||
ensure,
|
||||
restore() {
|
||||
if (root.getAttribute(RECORD_DOCUMENT_ATTRIBUTE) !== markerValue) return;
|
||||
if (previous === null) root.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
|
||||
else root.setAttribute(RECORD_DOCUMENT_ATTRIBUTE, previous);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,65 @@
|
||||
export const RECORD_FRAME_PORT = "bsk-record-frame";
|
||||
export const RECORD_FRAME_QUERY = "bsk-record-frame-query";
|
||||
export const RECORD_FRAME_START = "bsk-record-frame-start";
|
||||
|
||||
export interface RecordFrameQueryMessage {
|
||||
type: typeof RECORD_FRAME_QUERY;
|
||||
}
|
||||
|
||||
export interface RecordFrameQueryResponse {
|
||||
active: boolean;
|
||||
requestId?: string;
|
||||
startedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordFrameStartMessage {
|
||||
type: typeof RECORD_FRAME_START;
|
||||
requestId: string;
|
||||
startedAtMs: number;
|
||||
}
|
||||
|
||||
export type RecordFramePortMessage =
|
||||
| {
|
||||
type: "ready";
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
}
|
||||
| {
|
||||
type: "ready_ack";
|
||||
requestId: string;
|
||||
producerId: string;
|
||||
}
|
||||
| {
|
||||
type: "stop";
|
||||
requestId: string;
|
||||
commandId: string;
|
||||
}
|
||||
| {
|
||||
type: "cancel";
|
||||
requestId: string;
|
||||
}
|
||||
| {
|
||||
type: "stopped";
|
||||
requestId: string;
|
||||
commandId: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function isRecordFrameQueryMessage(value: unknown): value is RecordFrameQueryMessage {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as { type?: unknown }).type === RECORD_FRAME_QUERY
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecordFrameStartMessage(value: unknown): value is RecordFrameStartMessage {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const message = value as Partial<RecordFrameStartMessage>;
|
||||
return (
|
||||
message.type === RECORD_FRAME_START &&
|
||||
typeof message.requestId === "string" &&
|
||||
typeof message.startedAtMs === "number"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { createRandomUuid } from "../random-uuid";
|
||||
import {
|
||||
isRecordFrameQueryMessage,
|
||||
RECORD_FRAME_PORT,
|
||||
RECORD_FRAME_START,
|
||||
type RecordFramePortMessage,
|
||||
type RecordFrameQueryResponse,
|
||||
type RecordFrameStartMessage,
|
||||
} from "./frame-bridge";
|
||||
|
||||
export interface RecordingCaptureScope {
|
||||
tabId: number;
|
||||
documentId: string;
|
||||
browserFrameId: number;
|
||||
producerId: string;
|
||||
}
|
||||
|
||||
interface ArmedRecording {
|
||||
requestId: string;
|
||||
startedAtMs: number;
|
||||
tabIds: Set<number>;
|
||||
agents: Map<string, FrameAgent>;
|
||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>;
|
||||
finishing: boolean;
|
||||
}
|
||||
|
||||
interface FrameAgent extends RecordingCaptureScope {
|
||||
requestId: string;
|
||||
port: chrome.runtime.Port;
|
||||
stopWaiters: Map<string, (ok: boolean) => void>;
|
||||
}
|
||||
|
||||
interface BrowserFrame {
|
||||
frameId: number;
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
interface BrowserFrameNavigation extends BrowserFrame {
|
||||
tabId: number;
|
||||
lifecycle: "committed" | "completed";
|
||||
}
|
||||
|
||||
const RECORD_FRAME_STOP_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface RecordFrameCoordinatorDeps {
|
||||
getAllFrames(tabId: number): Promise<BrowserFrame[]>;
|
||||
sendToDocument(
|
||||
tabId: number,
|
||||
message: RecordFrameStartMessage,
|
||||
target: { documentId?: string; frameId?: number },
|
||||
): Promise<unknown>;
|
||||
subscribeFrameNavigation?(listener: (frame: BrowserFrameNavigation) => void): () => void;
|
||||
}
|
||||
|
||||
function documentKey(tabId: number, documentId: string): string {
|
||||
return `${tabId}:${documentId}`;
|
||||
}
|
||||
|
||||
function senderAddress(
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): { tabId: number; documentId: string; browserFrameId: number } | null {
|
||||
const tabId = sender.tab?.id;
|
||||
const documentId = sender.documentId;
|
||||
const browserFrameId = sender.frameId;
|
||||
if (
|
||||
typeof tabId !== "number" ||
|
||||
typeof documentId !== "string" ||
|
||||
typeof browserFrameId !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { tabId, documentId, browserFrameId };
|
||||
}
|
||||
|
||||
function defaultDeps(): RecordFrameCoordinatorDeps {
|
||||
return {
|
||||
async getAllFrames(tabId) {
|
||||
return (await chrome.webNavigation.getAllFrames({ tabId })) ?? [];
|
||||
},
|
||||
sendToDocument(tabId, message, target) {
|
||||
return chrome.tabs.sendMessage(tabId, message, target);
|
||||
},
|
||||
subscribeFrameNavigation(listener) {
|
||||
const onCommitted = (details: chrome.webNavigation.WebNavigationTransitionCallbackDetails) =>
|
||||
listener({ ...details, lifecycle: "committed" });
|
||||
const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails) =>
|
||||
listener({ ...details, lifecycle: "completed" });
|
||||
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
||||
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
||||
return () => {
|
||||
chrome.webNavigation.onCommitted.removeListener(onCommitted);
|
||||
chrome.webNavigation.onCompleted.removeListener(onCompleted);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class RecordFrameCoordinator {
|
||||
readonly #deps: RecordFrameCoordinatorDeps;
|
||||
readonly #recordings = new Map<string, ArmedRecording>();
|
||||
#attached = false;
|
||||
#detachFrameNavigation: (() => void) | null = null;
|
||||
|
||||
constructor(deps: RecordFrameCoordinatorDeps = defaultDeps()) {
|
||||
this.#deps = deps;
|
||||
}
|
||||
|
||||
attach(): () => void {
|
||||
if (this.#attached) return () => {};
|
||||
this.#attached = true;
|
||||
chrome.runtime.onConnect.addListener(this.#onConnect);
|
||||
chrome.runtime.onMessage.addListener(this.#onMessage);
|
||||
this.#detachFrameNavigation =
|
||||
this.#deps.subscribeFrameNavigation?.(this.#onFrameNavigation) ?? null;
|
||||
return () => {
|
||||
if (!this.#attached) return;
|
||||
this.#attached = false;
|
||||
chrome.runtime.onConnect.removeListener(this.#onConnect);
|
||||
chrome.runtime.onMessage.removeListener(this.#onMessage);
|
||||
this.#detachFrameNavigation?.();
|
||||
this.#detachFrameNavigation = null;
|
||||
for (const recording of this.#recordings.values()) this.#cancelAgents(recording);
|
||||
this.#recordings.clear();
|
||||
};
|
||||
}
|
||||
|
||||
begin(
|
||||
requestId: string,
|
||||
startedAtMs: number,
|
||||
initialTabId: number,
|
||||
onDocumentReady?: (scope: RecordingCaptureScope) => void | Promise<void>,
|
||||
): void {
|
||||
const previous = this.#recordings.get(requestId);
|
||||
if (previous) this.#cancelAgents(previous);
|
||||
this.#recordings.set(requestId, {
|
||||
requestId,
|
||||
startedAtMs,
|
||||
// Register the initial tab before navigation begins. A newly-created
|
||||
// child document can query at document_start before armTab's frame
|
||||
// snapshot sees it, especially when Chromium promotes it to an OOPIF.
|
||||
tabIds: new Set([initialTabId]),
|
||||
agents: new Map(),
|
||||
...(onDocumentReady ? { onDocumentReady } : {}),
|
||||
finishing: false,
|
||||
});
|
||||
}
|
||||
|
||||
async armTab(requestId: string, tabId: number): Promise<boolean> {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording || recording.finishing) return false;
|
||||
recording.tabIds.add(tabId);
|
||||
|
||||
let frames: BrowserFrame[];
|
||||
try {
|
||||
frames = await this.#deps.getAllFrames(tabId);
|
||||
} catch {
|
||||
frames = [{ frameId: 0 }];
|
||||
}
|
||||
if (!frames.some((frame) => frame.frameId === 0)) frames.unshift({ frameId: 0 });
|
||||
|
||||
const results = await Promise.all(
|
||||
frames.map(async (frame) => {
|
||||
const started = await this.#startFrame(recording, tabId, frame);
|
||||
return { frameId: frame.frameId, started };
|
||||
}),
|
||||
);
|
||||
return results.some((result) => result.frameId === 0 && result.started);
|
||||
}
|
||||
|
||||
sourceFor(
|
||||
requestId: string,
|
||||
producerId: string,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): RecordingCaptureScope | null {
|
||||
const address = senderAddress(sender);
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!address || !recording) return null;
|
||||
const agent = recording.agents.get(documentKey(address.tabId, address.documentId));
|
||||
if (!agent || agent.producerId !== producerId) return null;
|
||||
return {
|
||||
tabId: agent.tabId,
|
||||
documentId: agent.documentId,
|
||||
browserFrameId: agent.browserFrameId,
|
||||
producerId: agent.producerId,
|
||||
};
|
||||
}
|
||||
|
||||
async stop(requestId: string): Promise<boolean> {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording) return true;
|
||||
recording.finishing = true;
|
||||
const commandId = createRandomUuid();
|
||||
const results = await Promise.all(
|
||||
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
|
||||
);
|
||||
const succeeded = results.every(Boolean);
|
||||
if (succeeded) this.#recordings.delete(requestId);
|
||||
else recording.finishing = false;
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (!recording) return;
|
||||
this.#cancelAgents(recording);
|
||||
this.#recordings.delete(requestId);
|
||||
}
|
||||
|
||||
readonly #onMessage = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: RecordFrameQueryResponse) => void,
|
||||
): boolean => {
|
||||
if (!isRecordFrameQueryMessage(message)) return false;
|
||||
const tabId = sender.tab?.id;
|
||||
const recording =
|
||||
typeof tabId === "number"
|
||||
? [...this.#recordings.values()].find(
|
||||
(candidate) => !candidate.finishing && candidate.tabIds.has(tabId),
|
||||
)
|
||||
: undefined;
|
||||
sendResponse(
|
||||
recording
|
||||
? {
|
||||
active: true,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
}
|
||||
: { active: false },
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
readonly #onFrameNavigation = (frame: BrowserFrameNavigation): void => {
|
||||
if (frame.frameId === 0) return;
|
||||
for (const recording of this.#recordings.values()) {
|
||||
if (recording.finishing || !recording.tabIds.has(frame.tabId)) continue;
|
||||
// onCommitted can race document_start, while onCompleted runs after the
|
||||
// content script is available. Starting twice is safe and closes both
|
||||
// the early-query and late-SPA-frame gaps.
|
||||
void (async () => {
|
||||
const started = await this.#startFrame(recording, frame.tabId, frame);
|
||||
if (!started || frame.lifecycle !== "completed" || recording.finishing) return;
|
||||
const agent = frame.documentId
|
||||
? recording.agents.get(documentKey(frame.tabId, frame.documentId))
|
||||
: [...recording.agents.values()].find(
|
||||
(candidate) =>
|
||||
candidate.tabId === frame.tabId && candidate.browserFrameId === frame.frameId,
|
||||
);
|
||||
if (!agent) return;
|
||||
await recording.onDocumentReady?.({
|
||||
tabId: agent.tabId,
|
||||
documentId: agent.documentId,
|
||||
browserFrameId: agent.browserFrameId,
|
||||
producerId: agent.producerId,
|
||||
});
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
readonly #onConnect = (port: chrome.runtime.Port): void => {
|
||||
if (port.name !== RECORD_FRAME_PORT) return;
|
||||
const address = senderAddress(port.sender ?? {});
|
||||
if (!address) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
let registered: FrameAgent | null = null;
|
||||
port.onMessage.addListener((raw: unknown) => {
|
||||
const message = raw as Partial<RecordFramePortMessage>;
|
||||
if (message.type === "ready") {
|
||||
if (typeof message.requestId !== "string" || typeof message.producerId !== "string") {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
const requestId = message.requestId;
|
||||
const producerId = message.producerId;
|
||||
if (registered) {
|
||||
if (registered.requestId === requestId && registered.producerId === producerId) {
|
||||
port.postMessage({
|
||||
type: "ready_ack",
|
||||
requestId,
|
||||
producerId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} else {
|
||||
port.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const recording = this.#recordings.get(requestId);
|
||||
if (
|
||||
!recording ||
|
||||
recording.finishing ||
|
||||
!recording.tabIds.has(address.tabId) ||
|
||||
producerId.length === 0
|
||||
) {
|
||||
port.disconnect();
|
||||
return;
|
||||
}
|
||||
const agent: FrameAgent = {
|
||||
...address,
|
||||
requestId,
|
||||
producerId,
|
||||
port,
|
||||
stopWaiters: new Map(),
|
||||
};
|
||||
registered = agent;
|
||||
const key = documentKey(address.tabId, address.documentId);
|
||||
const previous = recording.agents.get(key);
|
||||
if (previous && previous !== agent) previous.port.disconnect();
|
||||
recording.agents.set(key, agent);
|
||||
port.postMessage({
|
||||
type: "ready_ack",
|
||||
requestId,
|
||||
producerId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "stopped" &&
|
||||
registered &&
|
||||
message.requestId === registered.requestId &&
|
||||
typeof message.commandId === "string"
|
||||
) {
|
||||
registered.stopWaiters.get(message.commandId)?.(message.ok === true);
|
||||
registered.stopWaiters.delete(message.commandId);
|
||||
}
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
if (!registered) return;
|
||||
for (const resolve of registered.stopWaiters.values()) resolve(true);
|
||||
const recording = this.#recordings.get(registered.requestId);
|
||||
const key = documentKey(registered.tabId, registered.documentId);
|
||||
if (recording?.agents.get(key) === registered) recording.agents.delete(key);
|
||||
});
|
||||
};
|
||||
|
||||
#stopAgent(agent: FrameAgent, requestId: string, commandId: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
agent.stopWaiters.delete(commandId);
|
||||
resolve(false);
|
||||
}, RECORD_FRAME_STOP_TIMEOUT_MS);
|
||||
agent.stopWaiters.set(commandId, (ok) => {
|
||||
clearTimeout(timer);
|
||||
resolve(ok);
|
||||
});
|
||||
try {
|
||||
agent.port.postMessage({
|
||||
type: "stop",
|
||||
requestId,
|
||||
commandId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
agent.stopWaiters.delete(commandId);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #startFrame(
|
||||
recording: ArmedRecording,
|
||||
tabId: number,
|
||||
frame: BrowserFrame,
|
||||
): Promise<boolean> {
|
||||
const message: RecordFrameStartMessage = {
|
||||
type: RECORD_FRAME_START,
|
||||
requestId: recording.requestId,
|
||||
startedAtMs: recording.startedAtMs,
|
||||
};
|
||||
try {
|
||||
const response = await this.#deps.sendToDocument(tabId, message, {
|
||||
...(frame.documentId ? { documentId: frame.documentId } : { frameId: frame.frameId }),
|
||||
});
|
||||
return isStarted(response);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#cancelAgents(recording: ArmedRecording): void {
|
||||
for (const agent of recording.agents.values()) {
|
||||
try {
|
||||
agent.port.postMessage({
|
||||
type: "cancel",
|
||||
requestId: recording.requestId,
|
||||
} satisfies RecordFramePortMessage);
|
||||
} catch {
|
||||
// A destroyed Document has no remaining capture state to cancel.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStarted(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === true;
|
||||
}
|
||||
|
||||
export const recordFrameCoordinator = new RecordFrameCoordinator();
|
||||
@@ -0,0 +1,5 @@
|
||||
const REDIRECT_QUALIFIERS = new Set(["client_redirect", "server_redirect"]);
|
||||
|
||||
export function hasRedirectQualifier(qualifiers?: readonly string[]): boolean {
|
||||
return (qualifiers ?? []).some((qualifier) => REDIRECT_QUALIFIERS.has(qualifier));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { RenderedRef } from "@browser-skill/vom";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RecordingDocumentScope {
|
||||
frameId: string;
|
||||
target: CdpTarget;
|
||||
}
|
||||
|
||||
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[]>();
|
||||
readonly #scopeByProducer = new Map<string, RecordingDocumentScope | null>();
|
||||
|
||||
constructor(
|
||||
input: Pick<CaptureVomObservationResult, "rootFrameId" | "matchNodes" | "refs"> &
|
||||
Partial<Pick<CaptureVomObservationResult, "frames">>,
|
||||
) {
|
||||
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 frame of input.frames ?? []) {
|
||||
const documentId = frame.recordingDocumentId;
|
||||
if (!documentId) continue;
|
||||
this.#scopeByProducer.set(
|
||||
documentId,
|
||||
this.#scopeByProducer.has(documentId)
|
||||
? null
|
||||
: { frameId: frame.frameId, target: frame.target },
|
||||
);
|
||||
}
|
||||
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) ?? [];
|
||||
}
|
||||
|
||||
documentScope(producerId: string): RecordingDocumentScope | undefined {
|
||||
return this.#scopeByProducer.get(producerId) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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,128 @@
|
||||
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 === "click" ||
|
||||
draft.op === "hover" ||
|
||||
draft.op === "fill" ||
|
||||
draft.op === "press" ||
|
||||
draft.op === "select"
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
vomText: 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,277 @@
|
||||
import type { CdpRunner, ChromeTabsApi } from "@/tools/shared";
|
||||
import type { StopReason, TraceV3 } from "@/transport/types";
|
||||
import { type DocumentSettleScope, waitForDocumentSettled } from "./document-settle";
|
||||
import type { RegisteredObservation } from "./observation-capture";
|
||||
import { RecordingObservationSession } from "./observation-session";
|
||||
import { inferMissingPostStates, SettleController } from "./settle-controller";
|
||||
import { RecordingStateRegistry } from "./state-registry";
|
||||
import { buildTraceV3 } from "./trace-builder-v3";
|
||||
import type { RecordingDraftStep, StepAnnotation } from "./types";
|
||||
|
||||
interface TabRecordingContext {
|
||||
session: RecordingObservationSession;
|
||||
settle: SettleController;
|
||||
pendingCapture: PendingCapture | null;
|
||||
frameRefresh: Promise<void>;
|
||||
frameRefreshAbort: AbortController | null;
|
||||
}
|
||||
|
||||
interface PendingCapture {
|
||||
promise: Promise<RegisteredObservation>;
|
||||
abort: AbortController;
|
||||
}
|
||||
|
||||
export class RecordingObservationRuntime {
|
||||
readonly #cdp: CdpRunner;
|
||||
readonly #tabsApi: ChromeTabsApi;
|
||||
readonly #registry = new RecordingStateRegistry();
|
||||
readonly #annotations: StepAnnotation[] = [];
|
||||
readonly #contexts = new Map<number, TabRecordingContext>();
|
||||
readonly #maxTokens?: number;
|
||||
readonly #redactValues: boolean;
|
||||
|
||||
constructor(input: {
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
maxTokens?: number;
|
||||
redactValues?: boolean;
|
||||
}) {
|
||||
this.#cdp = input.cdp;
|
||||
this.#tabsApi = input.tabsApi;
|
||||
this.#maxTokens = input.maxTokens;
|
||||
this.#redactValues = input.redactValues ?? false;
|
||||
}
|
||||
|
||||
#context(tabId: number): TabRecordingContext {
|
||||
const existing = this.#contexts.get(tabId);
|
||||
if (existing) return existing;
|
||||
const session = new RecordingObservationSession({
|
||||
registry: this.#registry,
|
||||
annotations: this.#annotations,
|
||||
maxTokens: this.#maxTokens,
|
||||
redactValues: this.#redactValues,
|
||||
});
|
||||
const context: TabRecordingContext = {
|
||||
session,
|
||||
settle: new SettleController({
|
||||
session,
|
||||
cdp: this.#cdp,
|
||||
tabsApi: this.#tabsApi,
|
||||
tabId,
|
||||
}),
|
||||
pendingCapture: null,
|
||||
frameRefresh: Promise.resolve(),
|
||||
frameRefreshAbort: null,
|
||||
};
|
||||
this.#contexts.set(tabId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
async #capture(tabId: number): Promise<RegisteredObservation> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.pendingCapture) return context.pendingCapture.promise;
|
||||
|
||||
const abort = new AbortController();
|
||||
let pending!: PendingCapture;
|
||||
const promise = context.session.capture(this.#cdp, this.#tabsApi, tabId, abort.signal);
|
||||
pending = {
|
||||
abort,
|
||||
promise: promise.finally(() => {
|
||||
if (context.pendingCapture === pending) context.pendingCapture = null;
|
||||
}),
|
||||
};
|
||||
context.pendingCapture = pending;
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
async captureInitial(tabId: number): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.session.cursor.lastSettled) return;
|
||||
await this.#capture(tabId);
|
||||
}
|
||||
|
||||
/** Refresh the safe observation after a late child Document becomes usable. */
|
||||
async refreshDocument(tabId: number, producerId: string): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
context.frameRefreshAbort?.abort();
|
||||
const abort = new AbortController();
|
||||
context.frameRefreshAbort = abort;
|
||||
const previous = context.frameRefresh;
|
||||
const refresh = (async () => {
|
||||
await previous;
|
||||
if (abort.signal.aborted) return;
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
if (abort.signal.aborted) return;
|
||||
|
||||
// The first capture discovers the safe producer -> CDP Document scope.
|
||||
await this.#capture(tabId);
|
||||
if (abort.signal.aborted) return;
|
||||
const scope = context.session.cursor.lastSettled?.index.documentScope(producerId);
|
||||
if (scope) {
|
||||
await waitForDocumentSettled(this.#cdp, scope, { signal: abort.signal });
|
||||
}
|
||||
if (abort.signal.aborted) return;
|
||||
|
||||
// Capture again after the child SPA is quiet so the next action can be
|
||||
// matched against refs that actually exist in its pre-action state.
|
||||
await this.#capture(tabId);
|
||||
})();
|
||||
const tracked = refresh.catch(() => {});
|
||||
context.frameRefresh = tracked;
|
||||
try {
|
||||
await refresh;
|
||||
} finally {
|
||||
if (context.frameRefresh === tracked) context.frameRefreshAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
async captureTabTransition(
|
||||
fromTabId: number,
|
||||
toTabId: number,
|
||||
): Promise<{ preStateId?: string; postStateId?: string; targetUrl?: string }> {
|
||||
const from = this.#context(fromTabId);
|
||||
await this.#flushContext(from);
|
||||
if (!from.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(fromTabId);
|
||||
} catch {
|
||||
// A target-tab observation can still preserve the forward transition.
|
||||
}
|
||||
}
|
||||
|
||||
const to = this.#context(toTabId);
|
||||
let target = to.session.cursor.lastSettled;
|
||||
try {
|
||||
target = await this.#capture(toTabId);
|
||||
} catch {
|
||||
// Reuse the last settled target state when a refresh races navigation.
|
||||
}
|
||||
|
||||
const source = from.session.cursor.lastSettled;
|
||||
return {
|
||||
...(source ? { preStateId: source.stateId } : {}),
|
||||
...(target ? { postStateId: target.stateId, targetUrl: target.url } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
bindTabTransition(
|
||||
draft: Extract<RecordingDraftStep, { op: "switch_tab" }>,
|
||||
draftId: number,
|
||||
): void {
|
||||
if (draft.preStateId) this.#registry.markStep(draft.preStateId, draftId);
|
||||
}
|
||||
|
||||
async processDraft(
|
||||
tabId: number,
|
||||
drafts: RecordingDraftStep[],
|
||||
draftIndex: number,
|
||||
producerId?: string,
|
||||
): Promise<void> {
|
||||
const draft = drafts[draftIndex];
|
||||
if (!draft) return;
|
||||
const context = this.#context(tabId);
|
||||
if (!context.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(tabId);
|
||||
} catch {
|
||||
// Post-action settle can still provide a usable state.
|
||||
}
|
||||
}
|
||||
const scope = producerId
|
||||
? context.session.cursor.lastSettled?.index.documentScope(producerId)
|
||||
: undefined;
|
||||
if (
|
||||
producerId &&
|
||||
(draft.op === "click" ||
|
||||
draft.op === "hover" ||
|
||||
draft.op === "fill" ||
|
||||
draft.op === "press" ||
|
||||
draft.op === "select")
|
||||
) {
|
||||
draft.targetHint = {
|
||||
...(draft.targetHint ?? {}),
|
||||
frameId: scope?.frameId ?? null,
|
||||
geometrySpace: "local",
|
||||
};
|
||||
}
|
||||
context.session.bindDraft(draft, draftIndex + 1, context.settle.hasPending);
|
||||
context.settle.schedule(drafts, draftIndex, scope);
|
||||
}
|
||||
|
||||
scheduleSettle(
|
||||
tabId: number,
|
||||
drafts: RecordingDraftStep[],
|
||||
draftIndex: number,
|
||||
scope?: DocumentSettleScope,
|
||||
): void {
|
||||
this.#context(tabId).settle.schedule(drafts, draftIndex, scope);
|
||||
}
|
||||
|
||||
clearRedirect(tabId: number): void {
|
||||
this.#contexts.get(tabId)?.settle.clearRedirect();
|
||||
}
|
||||
|
||||
scheduleRedirect(tabId: number, drafts: RecordingDraftStep[], url: string): void {
|
||||
this.#context(tabId).settle.scheduleRedirect(drafts, url);
|
||||
}
|
||||
|
||||
async #flushContext(context: TabRecordingContext): Promise<void> {
|
||||
await context.frameRefresh;
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
await context.settle.flushRedirects();
|
||||
await context.settle.flush();
|
||||
}
|
||||
|
||||
async flushRedirects(tabId: number): Promise<void> {
|
||||
await this.#contexts.get(tabId)?.settle.flushRedirects();
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
for (const context of this.#contexts.values()) await this.#flushContext(context);
|
||||
}
|
||||
|
||||
async settleTrailing(tabId: number, drafts: RecordingDraftStep[]): Promise<void> {
|
||||
const context = this.#context(tabId);
|
||||
if (context.pendingCapture) await Promise.allSettled([context.pendingCapture.promise]);
|
||||
if (!context.session.cursor.lastSettled) {
|
||||
try {
|
||||
await this.captureInitial(tabId);
|
||||
} catch {
|
||||
// The trace may remain observation-free when CDP is unavailable.
|
||||
}
|
||||
}
|
||||
await context.settle.settleTrailing(drafts);
|
||||
inferMissingPostStates(drafts);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
for (const context of this.#contexts.values()) {
|
||||
context.frameRefreshAbort?.abort();
|
||||
context.pendingCapture?.abort.abort();
|
||||
context.settle.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
buildTrace(input: {
|
||||
drafts: RecordingDraftStep[];
|
||||
startedAt: string;
|
||||
purpose?: string;
|
||||
startUrl?: string;
|
||||
stoppedBy: StopReason;
|
||||
bskVersion: string;
|
||||
includeTabSwitches?: boolean;
|
||||
}): TraceV3 {
|
||||
return buildTraceV3({
|
||||
registry: this.#registry,
|
||||
drafts: input.drafts,
|
||||
annotations: this.#annotations,
|
||||
startedAt: input.startedAt,
|
||||
purpose: input.purpose,
|
||||
startUrl: input.startUrl,
|
||||
stoppedBy: input.stoppedBy,
|
||||
bskVersion: input.bskVersion,
|
||||
redactValues: this.#redactValues,
|
||||
includeTabSwitches: input.includeTabSwitches,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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",
|
||||
transitionQualifiers: ["server_redirect"],
|
||||
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,57 @@
|
||||
export interface RecordedStateEntry {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
/** Rendered VOM text; capture indexes and source DOM/AX data never enter the registry. */
|
||||
vomText: 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;
|
||||
vomText: string;
|
||||
truncated?: boolean;
|
||||
}): RecordedStateEntry {
|
||||
const identity = stateIdentity(input.url, input.vomText);
|
||||
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 } : {}),
|
||||
vomText: input.vomText,
|
||||
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,155 @@
|
||||
import type { RecordStepPayload } from "../record-bridge";
|
||||
import { hasRedirectQualifier } from "./navigation-policy";
|
||||
import type { RecordingDraftStep, TargetMatchHint } from "./types";
|
||||
|
||||
export interface RecordingStepBuffer {
|
||||
steps: RecordingDraftStep[];
|
||||
navigation: RecordingNavigationCursor;
|
||||
}
|
||||
|
||||
export interface RecordingNavigationCursor {
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
}
|
||||
|
||||
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
|
||||
|
||||
function toDraftStep(
|
||||
payload: RecordStepPayload,
|
||||
targetHint?: TargetMatchHint,
|
||||
): RecordingDraftStep | null {
|
||||
const pageUrl = payload.page_url;
|
||||
const common = {
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
...(targetHint ? { targetHint } : {}),
|
||||
};
|
||||
switch (payload.op) {
|
||||
case "click":
|
||||
return payload.target ? { op: "click", captureTarget: payload.target, ...common } : null;
|
||||
case "hover":
|
||||
return payload.target ? { op: "hover", captureTarget: payload.target, ...common } : null;
|
||||
case "fill":
|
||||
return payload.target
|
||||
? {
|
||||
op: "fill",
|
||||
captureTarget: payload.target,
|
||||
value: payload.value ?? "",
|
||||
...(payload.commit ? { commit: payload.commit } : {}),
|
||||
...(payload.redacted ? { redacted: true } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "press":
|
||||
return payload.key
|
||||
? {
|
||||
op: "press",
|
||||
key: payload.key,
|
||||
...(payload.target ? { captureTarget: payload.target } : {}),
|
||||
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "select":
|
||||
return payload.target && payload.values
|
||||
? {
|
||||
op: "select",
|
||||
captureTarget: payload.target,
|
||||
values: payload.values,
|
||||
...(payload.labels?.length ? { labels: payload.labels } : {}),
|
||||
...common,
|
||||
}
|
||||
: null;
|
||||
case "navigate":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): number {
|
||||
for (let index = buffer.steps.length - 1; index >= 0; index -= 1) {
|
||||
const step = buffer.steps[index];
|
||||
if (!step) continue;
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||
step.navigatedTo = url;
|
||||
return index;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export type NavigationObserveResult =
|
||||
| { kind: "noop" }
|
||||
| { kind: "annotated"; index: number }
|
||||
| { kind: "appended"; index: number }
|
||||
| { kind: "coalesce_redirect"; url: string };
|
||||
|
||||
export function observeRecordedNavigation(
|
||||
buffer: RecordingStepBuffer,
|
||||
url: string,
|
||||
causedByAction?: boolean,
|
||||
transitionType?: string,
|
||||
transitionQualifiers?: string[],
|
||||
): NavigationObserveResult {
|
||||
const navigation = buffer.navigation;
|
||||
if (!url || url === navigation.currentUrl) return { kind: "noop" };
|
||||
navigation.currentUrl = url;
|
||||
|
||||
const pendingIsCurrent =
|
||||
navigation.pendingNavigation &&
|
||||
(navigation.pendingNavigationDeadline === undefined ||
|
||||
navigation.pendingNavigationDeadline >= Date.now());
|
||||
|
||||
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
|
||||
navigation.pendingNavigation = false;
|
||||
navigation.pendingNavigationDeadline = undefined;
|
||||
const annotatedIndex = annotateLastStepNavigation(buffer, url);
|
||||
if (annotatedIndex >= 0) return { kind: "annotated", index: annotatedIndex };
|
||||
} else {
|
||||
navigation.pendingNavigation = false;
|
||||
navigation.pendingNavigationDeadline = undefined;
|
||||
}
|
||||
|
||||
if (hasRedirectQualifier(transitionQualifiers)) {
|
||||
return { kind: "coalesce_redirect", url };
|
||||
}
|
||||
buffer.steps.push({
|
||||
op: "navigate",
|
||||
url,
|
||||
pageUrl: url,
|
||||
transitionType,
|
||||
transitionQualifiers,
|
||||
});
|
||||
return { kind: "appended", index: buffer.steps.length - 1 };
|
||||
}
|
||||
|
||||
export function appendRecordedPayload(
|
||||
buffer: RecordingStepBuffer,
|
||||
payload: RecordStepPayload,
|
||||
targetHint?: TargetMatchHint,
|
||||
): number | null {
|
||||
if (payload.op === "navigate") {
|
||||
if (!payload.url) return null;
|
||||
const result = observeRecordedNavigation(
|
||||
buffer,
|
||||
payload.url,
|
||||
payload.navigation_caused_by_action,
|
||||
payload.transitionType,
|
||||
payload.transitionQualifiers,
|
||||
);
|
||||
return result.kind === "appended" ? result.index : null;
|
||||
}
|
||||
const step = toDraftStep(
|
||||
{ ...payload, page_url: payload.page_url ?? buffer.navigation.currentUrl },
|
||||
targetHint,
|
||||
);
|
||||
if (!step) return null;
|
||||
buffer.steps.push(step);
|
||||
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
|
||||
buffer.navigation.pendingNavigation = payload.expects_navigation === true;
|
||||
buffer.navigation.pendingNavigationDeadline = buffer.navigation.pendingNavigation
|
||||
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
|
||||
: undefined;
|
||||
}
|
||||
return buffer.steps.length - 1;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { RecordingNavigationCursor } from "./step-buffer";
|
||||
|
||||
export interface TabActivation {
|
||||
tabId: number;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export class RecordingTabCoordinator {
|
||||
readonly #navigationByTab = new Map<number, RecordingNavigationCursor>();
|
||||
#activeTabId: number;
|
||||
#currentTabId: number;
|
||||
#activationRevision = 0;
|
||||
|
||||
constructor(initialTabId: number, initialUrl?: string) {
|
||||
this.#activeTabId = initialTabId;
|
||||
this.#currentTabId = initialTabId;
|
||||
this.#navigationByTab.set(initialTabId, {
|
||||
currentUrl: initialUrl,
|
||||
pendingNavigation: false,
|
||||
});
|
||||
}
|
||||
|
||||
get activeTabId(): number {
|
||||
return this.#activeTabId;
|
||||
}
|
||||
|
||||
get currentTabId(): number {
|
||||
return this.#currentTabId;
|
||||
}
|
||||
|
||||
noteActivation(tabId: number): TabActivation {
|
||||
this.#activeTabId = tabId;
|
||||
this.#activationRevision += 1;
|
||||
return { tabId, revision: this.#activationRevision };
|
||||
}
|
||||
|
||||
isLatest(activation: TabActivation): boolean {
|
||||
return (
|
||||
activation.tabId === this.#activeTabId && activation.revision === this.#activationRevision
|
||||
);
|
||||
}
|
||||
|
||||
commit(tabId: number, currentUrl?: string): void {
|
||||
if (currentUrl !== undefined) this.navigation(tabId).currentUrl = currentUrl;
|
||||
this.#currentTabId = tabId;
|
||||
}
|
||||
|
||||
navigation(tabId: number, fallbackUrl?: string): RecordingNavigationCursor {
|
||||
const existing = this.#navigationByTab.get(tabId);
|
||||
if (existing) {
|
||||
if (!existing.currentUrl && fallbackUrl) existing.currentUrl = fallbackUrl;
|
||||
return existing;
|
||||
}
|
||||
const created: RecordingNavigationCursor = {
|
||||
currentUrl: fallbackUrl,
|
||||
pendingNavigation: false,
|
||||
};
|
||||
this.#navigationByTab.set(tabId, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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,
|
||||
geometrySpace: "top" | "local",
|
||||
): boolean {
|
||||
const rect = geometrySpace === "local" ? candidate.geometry.localRect : candidate.geometry.rect;
|
||||
return rect != null && rectMatches(geometry.rect, 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 {
|
||||
if (input.hint?.frameId === null) return unmatchedTarget(input.fallback);
|
||||
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, input.hint?.geometrySpace ?? "top"),
|
||||
);
|
||||
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,92 @@
|
||||
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;
|
||||
redactValues?: boolean;
|
||||
includeTabSwitches?: boolean;
|
||||
}): TraceV3 {
|
||||
const reduced = reduceTraceStepsV3(input.drafts, {
|
||||
includeTabSwitches: input.includeTabSwitches,
|
||||
redactValues: input.redactValues,
|
||||
});
|
||||
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.vomText,
|
||||
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,
|
||||
};
|
||||
}
|
||||
+62
-65
@@ -1,31 +1,10 @@
|
||||
import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/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;
|
||||
// Drop bare character typing — FillSession already records the value.
|
||||
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldIncludeDraft(step: DraftTraceStep): boolean {
|
||||
if (step.op === "fill" && !(step.value ?? "").trim() && !step.redacted) return false;
|
||||
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
|
||||
return true;
|
||||
}
|
||||
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: DraftTraceStep[]): DraftTraceStep[] {
|
||||
const out: DraftTraceStep[] = [];
|
||||
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") {
|
||||
@@ -37,7 +16,7 @@ function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
|
||||
function collectUrls(steps: RecordingDraftStep[], startUrl?: string): string[] {
|
||||
const urls: string[] = [];
|
||||
if (startUrl) urls.push(startUrl);
|
||||
for (const step of steps) {
|
||||
@@ -45,8 +24,8 @@ function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
|
||||
urls.push(step.url);
|
||||
continue;
|
||||
}
|
||||
if ("page_url" in step && step.page_url) urls.push(step.page_url);
|
||||
if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
|
||||
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[] = [];
|
||||
@@ -59,9 +38,9 @@ function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
|
||||
}
|
||||
|
||||
function buildPageRegistry(
|
||||
steps: DraftTraceStep[],
|
||||
steps: RecordingDraftStep[],
|
||||
startUrl?: string,
|
||||
): { pages: PageRef[]; urlToId: Map<string, string> } {
|
||||
): { pages: PageRefV2[]; urlToId: Map<string, string> } {
|
||||
const urls = collectUrls(steps, startUrl);
|
||||
const urlToId = new Map<string, string>();
|
||||
const pages = urls.map((url, index) => {
|
||||
@@ -82,28 +61,28 @@ function pageIdFor(
|
||||
return urlToId.values().next().value ?? "p1";
|
||||
}
|
||||
|
||||
function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined {
|
||||
if (step.op === "navigate") return step.page_url ?? step.url;
|
||||
if ("page_url" in step && step.page_url) return step.page_url;
|
||||
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>,
|
||||
): Step["effect"] {
|
||||
): StepV2["effect"] {
|
||||
if (!navigatedTo) return undefined;
|
||||
const pageId = urlToId.get(navigatedTo);
|
||||
if (!pageId) return undefined;
|
||||
return { navigated_to: pageId };
|
||||
}
|
||||
|
||||
function withEffect(step: Step, effect: Step["effect"]): Step {
|
||||
function withEffect(step: StepV2, effect: StepV2["effect"]): StepV2 {
|
||||
if (!effect) return step;
|
||||
return { ...step, effect };
|
||||
}
|
||||
|
||||
function toSelection(values: string[], labels?: string[]): SelectedOption[] {
|
||||
function toSelection(values: string[], labels?: string[]): SelectedOptionV2[] {
|
||||
return values.map((value, index) => ({
|
||||
value,
|
||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
||||
@@ -111,11 +90,11 @@ function toSelection(values: string[], labels?: string[]): SelectedOption[] {
|
||||
}
|
||||
|
||||
function toV2Step(
|
||||
step: DraftTraceStep,
|
||||
step: RecordingDraftStep,
|
||||
id: number,
|
||||
urlToId: Map<string, string>,
|
||||
fallbackUrl?: string,
|
||||
): Step | null {
|
||||
): StepV2 | null {
|
||||
if (!shouldIncludeDraft(step)) return null;
|
||||
|
||||
const pageUrl = pageUrlForDraft(step, fallbackUrl);
|
||||
@@ -130,21 +109,23 @@ function toV2Step(
|
||||
to: step.url,
|
||||
};
|
||||
case "click":
|
||||
if (!step.captureTarget) return null;
|
||||
return withEffect(
|
||||
{
|
||||
op: "click",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
target: step.captureTarget,
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "fill":
|
||||
if (!step.captureTarget) return null;
|
||||
return {
|
||||
op: "fill",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
target: step.captureTarget,
|
||||
value: step.value,
|
||||
...(step.redacted ? { redacted: true } : {}),
|
||||
};
|
||||
@@ -155,44 +136,51 @@ function toV2Step(
|
||||
id,
|
||||
page,
|
||||
key: step.key,
|
||||
...(step.target ? { target: step.target } : {}),
|
||||
...(step.captureTarget ? { target: step.captureTarget } : {}),
|
||||
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
case "select":
|
||||
if (!step.captureTarget) return null;
|
||||
return withEffect(
|
||||
{
|
||||
op: "select",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
target: step.captureTarget,
|
||||
selection: toSelection(step.values, step.labels),
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
effectForNavigation(step.navigatedTo, urlToId),
|
||||
);
|
||||
// `hover` only exists in Trace v3. Peers that negotiate v2 predate the
|
||||
// step variant and fail to decode the whole result if we emit it.
|
||||
case "hover":
|
||||
case "scroll":
|
||||
case "switch_tab":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReducedTrace {
|
||||
pages: PageRef[];
|
||||
steps: Step[];
|
||||
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.
|
||||
*/
|
||||
export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace {
|
||||
function reduceTraceSteps(steps: RecordingDraftStep[], startUrl?: string): ReducedTrace {
|
||||
const collapsed = collapseNavigations(steps);
|
||||
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
|
||||
const out: Step[] = [];
|
||||
const out: StepV2[] = [];
|
||||
let id = 1;
|
||||
let lastUrl = startUrl;
|
||||
for (const draft of collapsed) {
|
||||
if (draft.op === "navigate") lastUrl = draft.url;
|
||||
else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to;
|
||||
else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_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);
|
||||
@@ -201,18 +189,27 @@ export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): Re
|
||||
return { pages, steps: out };
|
||||
}
|
||||
|
||||
export function resolveTraceStartUrl(
|
||||
drafts: DraftTraceStep[],
|
||||
function resolveTraceStartUrl(
|
||||
drafts: RecordingDraftStep[],
|
||||
startUrl?: string,
|
||||
pages?: PageRef[],
|
||||
pages?: PageRefV2[],
|
||||
): string {
|
||||
if (startUrl) return startUrl;
|
||||
const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => {
|
||||
return step.op === "navigate";
|
||||
});
|
||||
if (navigate) return navigate.url;
|
||||
for (const draft of drafts) {
|
||||
if ("page_url" in draft && draft.page_url) return draft.page_url;
|
||||
}
|
||||
return pages?.[0]?.url ?? "about:blank";
|
||||
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,154 @@
|
||||
import type { NavigationCause, StepV3 } from "@/transport/types";
|
||||
import { shouldIncludeDraft } from "./draft-policy";
|
||||
import { hasRedirectQualifier } from "./navigation-policy";
|
||||
import { unmatchedTarget } from "./target-matcher";
|
||||
import type { RecordingDraftStep } from "./types";
|
||||
|
||||
interface CollapsedDraft {
|
||||
draft: RecordingDraftStep;
|
||||
draftIds: number[];
|
||||
}
|
||||
|
||||
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 hasRedirectQualifier(step.transitionQualifiers);
|
||||
}
|
||||
|
||||
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] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
interface ReduceDraftOptions {
|
||||
includeTabSwitches: boolean;
|
||||
redactValues: boolean;
|
||||
}
|
||||
|
||||
function reduceDraft(
|
||||
draft: RecordingDraftStep,
|
||||
id: number,
|
||||
options: ReduceDraftOptions,
|
||||
): StepV3 | null {
|
||||
if (!shouldIncludeDraft(draft)) return null;
|
||||
if (draft.op === "switch_tab") {
|
||||
if (!options.includeTabSwitches || !draft.preStateId || !draft.postStateId) return null;
|
||||
return {
|
||||
op: "switch_tab",
|
||||
id,
|
||||
state: draft.preStateId,
|
||||
result: { state: draft.postStateId },
|
||||
};
|
||||
}
|
||||
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":
|
||||
const fillIsRedacted = options.redactValues || draft.redacted === true;
|
||||
return {
|
||||
op: "fill",
|
||||
...common,
|
||||
target: draft.matchedTarget ?? unmatchedTarget(draft.captureTarget),
|
||||
value: fillIsRedacted ? "***" : draft.value,
|
||||
commit: draft.commit ?? "blur",
|
||||
...(fillIsRedacted ? { 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),
|
||||
...(!options.redactValues ? { 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[],
|
||||
options: { includeTabSwitches?: boolean; redactValues?: boolean } = {},
|
||||
): ReducedTraceV3 {
|
||||
const output: StepV3[] = [];
|
||||
const stepIdByDraftId = new Map<number, number>();
|
||||
for (const { draft, draftIds } of collapseRedirects(steps)) {
|
||||
const step = reduceDraft(draft, output.length + 1, {
|
||||
includeTabSwitches: options.includeTabSwitches === true,
|
||||
redactValues: options.redactValues === true,
|
||||
});
|
||||
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,85 @@
|
||||
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 top frame; null means the source Document could not be resolved. */
|
||||
frameId?: string | null;
|
||||
geometrySpace?: "top" | "local";
|
||||
}
|
||||
|
||||
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: "switch_tab" } & 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 () => 1),
|
||||
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 () => 1),
|
||||
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: [] });
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => 1),
|
||||
},
|
||||
});
|
||||
await manager.start("aa11");
|
||||
@@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 4242),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => 1),
|
||||
},
|
||||
});
|
||||
const ctx = await manager.start("aa11");
|
||||
@@ -87,6 +87,7 @@ describe("attachSessionEventHandler", () => {
|
||||
windowEvents: events.api,
|
||||
});
|
||||
|
||||
manager.forgetClosedTab(7, { isWindowClosing: true });
|
||||
events.emit(4242);
|
||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
||||
|
||||
@@ -113,7 +114,7 @@ describe("attachSessionEventHandler", () => {
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => 1),
|
||||
},
|
||||
});
|
||||
const transport = fakeTransport();
|
||||
@@ -129,7 +130,7 @@ describe("attachSessionEventHandler", () => {
|
||||
agentWindow: {
|
||||
create: vi.fn(async () => 1),
|
||||
remove: vi.fn(),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => 1),
|
||||
},
|
||||
});
|
||||
const transport = fakeTransport();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentWindowApi } from "../agent-window";
|
||||
import { SessionManager } from "../manager";
|
||||
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
||||
import { isAgentControlledTab, SessionManager } from "../manager";
|
||||
|
||||
function fakeAgentWindow(): AgentWindowApi & {
|
||||
createMock: ReturnType<typeof vi.fn>;
|
||||
@@ -8,12 +8,12 @@ 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;
|
||||
});
|
||||
const removeMock = vi.fn(async (_id: number) => {});
|
||||
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
|
||||
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
|
||||
return {
|
||||
create: createMock,
|
||||
remove: removeMock,
|
||||
@@ -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 });
|
||||
@@ -87,6 +151,76 @@ describe("SessionManager", () => {
|
||||
expect(sm.list()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("explicit tab claims", () => {
|
||||
it("claims the exact home tab returned by AgentWindowApi", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
|
||||
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps every other tab free without classification state", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
expect(isAgentControlledTab(ctx, 99)).toBe(false);
|
||||
expect(ctx).not.toHaveProperty("userTabs");
|
||||
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
|
||||
});
|
||||
|
||||
it("treats a committed borrow as an explicit claim", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||
expect(isAgentControlledTab(ctx, 42)).toBe(true);
|
||||
});
|
||||
|
||||
it("forgets an agent-created tab after Chrome removes it", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
ctx.agentCreatedTabs.add(42);
|
||||
sm.forgetClosedTab(42);
|
||||
expect(ctx.agentCreatedTabs.has(42)).toBe(false);
|
||||
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("forgets closed borrows idempotently without affecting other tabs or sessions", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const a = await sm.start("aa11");
|
||||
const b = await sm.start("bb22");
|
||||
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 0 });
|
||||
a.borrowedTabs.set(43, { tabId: 43, originalWindowId: 7, originalIndex: 1 });
|
||||
b.borrowedTabs.set(44, { tabId: 44, originalWindowId: 8, originalIndex: 0 });
|
||||
|
||||
sm.forgetClosedTab(42);
|
||||
sm.forgetClosedTab(42);
|
||||
sm.forgetClosedTab(999);
|
||||
|
||||
expect(isAgentControlledTab(a, 42)).toBe(false);
|
||||
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||
expect([...a.borrowedTabs.keys()]).toEqual([43]);
|
||||
expect([...b.borrowedTabs.keys()]).toEqual([44]);
|
||||
expect(sm.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prevents an in-flight borrow from reclaiming a closed tab", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||
|
||||
sm.forgetClosedTab(42);
|
||||
|
||||
expect(() =>
|
||||
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 0 }),
|
||||
).toThrow(/reservation disappeared/);
|
||||
reservation.release();
|
||||
expect(sm.findBorrowingSession(42, null)).toBeNull();
|
||||
expect(ctx.borrowedTabs.has(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBorrowingSession", () => {
|
||||
it("returns null when no session has borrowed the tab", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user