mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
36
Commits
cli-v0.1.7
...
ext-v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff74db48c8 | ||
|
|
bc8d6fa190 | ||
|
|
19981526c7 | ||
|
|
d1de850b42 | ||
|
|
585308df45 | ||
|
|
ac028a7a18 | ||
|
|
ccf3c32140 | ||
|
|
9799b98937 | ||
|
|
259b4826b7 | ||
|
|
3d6c95c22a | ||
|
|
11ec51dfb8 | ||
|
|
e16b37aaa0 | ||
|
|
d27ef1f5e0 | ||
|
|
e55ce8ad99 | ||
|
|
7bee906ffb | ||
|
|
bf6c384b2e | ||
|
|
ec2cde4467 | ||
|
|
c5e83eac4c | ||
|
|
9f82d89788 | ||
|
|
2964f6219d | ||
|
|
f8907e5f5a | ||
|
|
d422f655d4 | ||
|
|
d4d4c3beeb | ||
|
|
3c7a22e7c7 | ||
|
|
dca2b58cbd | ||
|
|
13279a7f56 | ||
|
|
ed6d14eab3 | ||
|
|
913a3f80a4 | ||
|
|
f60d1e65a7 | ||
|
|
d4a89e89f1 | ||
|
|
3c936982a3 | ||
|
|
37056b3c6e | ||
|
|
fa3133a094 | ||
|
|
d395faa0ab | ||
|
|
a7c48275f9 | ||
|
|
647ae659b7 |
@@ -0,0 +1,84 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust fmt, clippy, tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, tests, build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare extension types
|
||||
run: pnpm --filter @browser-skill/extension exec wxt prepare
|
||||
|
||||
- name: Run lint checks
|
||||
run: pnpm lint
|
||||
|
||||
- name: Compile extension TypeScript
|
||||
run: pnpm --filter @browser-skill/extension compile
|
||||
|
||||
- name: Run extension tests
|
||||
run: pnpm ext:test
|
||||
|
||||
- name: Build extension
|
||||
run: pnpm ext:build
|
||||
|
||||
node-scripts:
|
||||
name: Node script tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run script tests
|
||||
run: node --test scripts/*.test.mjs
|
||||
@@ -139,6 +139,7 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--server-url "${{ github.server_url }}" \
|
||||
--branch "${{ github.event.repository.default_branch }}" \
|
||||
--dist dist \
|
||||
--out dist/version.json
|
||||
|
||||
- name: Publish GitHub Release
|
||||
|
||||
Generated
+1220
-10
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -7,7 +7,7 @@ members = [
|
||||
default-members = ["crates/bsk-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MIT"
|
||||
@@ -31,6 +31,11 @@ console = "0.15"
|
||||
dialoguer = "0.11"
|
||||
dirs = "5"
|
||||
fs2 = "0.4"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
|
||||
flate2 = "1.1.9"
|
||||
tar = "0.4.46"
|
||||
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
||||
sha2 = "0.11.0"
|
||||
tempfile = "3"
|
||||
nix = { version = "0.29", features = ["signal", "process"] }
|
||||
rand = "0.8"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browser-skill/extension",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -139,6 +139,25 @@ describe("ChromiumCdp", () => {
|
||||
expect(enableCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retries and surfaces Network.enable failures for explicit capture", async () => {
|
||||
const { api } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (_target, method: string) => {
|
||||
if (method === "Network.enable") throw new Error("network restricted");
|
||||
return {};
|
||||
},
|
||||
);
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
|
||||
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
|
||||
|
||||
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([, method]) => method === "Network.enable",
|
||||
);
|
||||
expect(enableCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("records javascriptDialogOpening and auto-accepts", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
@@ -321,6 +340,30 @@ describe("ChromiumCdp", () => {
|
||||
truncated: true,
|
||||
entries: [{ sequence: 2, text: "message 2" }],
|
||||
});
|
||||
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
|
||||
next_since: 3,
|
||||
truncated: true,
|
||||
entries: [{ sequence: 3, text: "message 3" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports dropped buffered entries only when they affect the cursor", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(25);
|
||||
for (let i = 1; i <= 201; i += 1) {
|
||||
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
|
||||
type: "log",
|
||||
args: [{ value: `message ${i}` }],
|
||||
});
|
||||
}
|
||||
|
||||
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
|
||||
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
|
||||
next_since: 201,
|
||||
truncated: false,
|
||||
entries: [{ sequence: 201, text: "message 201" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears console state on detach", async () => {
|
||||
@@ -336,6 +379,103 @@ describe("ChromiumCdp", () => {
|
||||
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates network request metadata by tab and consumes it", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(31);
|
||||
await cdp.ensureAttached(32);
|
||||
|
||||
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
|
||||
requestId: "shared-id",
|
||||
request: { url: "https://one.test/api", method: "GET" },
|
||||
type: "Fetch",
|
||||
});
|
||||
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
|
||||
requestId: "shared-id",
|
||||
request: { url: "https://two.test/script.js", method: "POST" },
|
||||
type: "Script",
|
||||
});
|
||||
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
|
||||
requestId: "shared-id",
|
||||
response: { status: 204 },
|
||||
type: "Fetch",
|
||||
});
|
||||
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
|
||||
requestId: "shared-id",
|
||||
errorText: "net::ERR_FAILED",
|
||||
type: "Script",
|
||||
});
|
||||
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
|
||||
requestId: "shared-id",
|
||||
errorText: "net::ERR_ABORTED",
|
||||
});
|
||||
|
||||
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
|
||||
{
|
||||
kind: "response",
|
||||
method: "GET",
|
||||
url: "https://one.test/api",
|
||||
status: 204,
|
||||
},
|
||||
{
|
||||
kind: "failure",
|
||||
url: undefined,
|
||||
error_text: "net::ERR_ABORTED",
|
||||
},
|
||||
]);
|
||||
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
|
||||
{
|
||||
kind: "failure",
|
||||
method: "POST",
|
||||
url: "https://two.test/script.js",
|
||||
error_text: "net::ERR_FAILED",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds request metadata and clears it on detach", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
await cdp.ensureAttached(33);
|
||||
const longUrl = `https://example.test/${"x".repeat(5000)}`;
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "long",
|
||||
request: { url: longUrl, method: "GET" },
|
||||
});
|
||||
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
|
||||
requestId: "long",
|
||||
errorText: "net::ERR_FAILED",
|
||||
});
|
||||
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
|
||||
expect(bounded.url).toHaveLength(4096);
|
||||
expect(bounded.truncated).toBe(true);
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "long-method",
|
||||
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
|
||||
});
|
||||
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
|
||||
requestId: "long-method",
|
||||
response: { url: "https://example.test/ok", status: 200 },
|
||||
});
|
||||
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
|
||||
expect(boundedResponse.method).toHaveLength(4096);
|
||||
expect(boundedResponse.truncated).toBe(true);
|
||||
|
||||
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
|
||||
requestId: "stale",
|
||||
request: { url: "https://example.test/stale", method: "GET" },
|
||||
});
|
||||
await cdp.detach(33);
|
||||
await cdp.ensureAttached(33);
|
||||
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
|
||||
requestId: "stale",
|
||||
errorText: "net::ERR_ABORTED",
|
||||
});
|
||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detachSession only detaches tabs no other session owns", async () => {
|
||||
const { api } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
|
||||
@@ -27,6 +27,9 @@ import type {
|
||||
ConsoleStackFrame,
|
||||
JavaScriptDialogInfo,
|
||||
JavaScriptDialogType,
|
||||
NetworkEntry,
|
||||
NetworkEntryKind,
|
||||
NetworkResult,
|
||||
} from "@/transport/types";
|
||||
|
||||
/**
|
||||
@@ -87,6 +90,9 @@ const MAX_DIALOG_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_BUFFER = 200;
|
||||
const MAX_CONSOLE_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_STACK_FRAMES = 20;
|
||||
const MAX_NETWORK_BUFFER = 200;
|
||||
const MAX_NETWORK_FIELD_LENGTH = 4096;
|
||||
const MAX_NETWORK_REQUEST_META = 1024;
|
||||
|
||||
interface ParsedDialogOpening {
|
||||
type: JavaScriptDialogType;
|
||||
@@ -98,6 +104,16 @@ interface ParsedDialogOpening {
|
||||
|
||||
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
|
||||
|
||||
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
|
||||
|
||||
/** Bounded request metadata remembered from `requestWillBeSent`. */
|
||||
interface NetworkRequestMeta {
|
||||
url: string;
|
||||
method?: string;
|
||||
resourceType?: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around `chrome.debugger` that owns the "attach once per
|
||||
* tabId" cache and exposes typed `send<T>()`.
|
||||
@@ -112,15 +128,21 @@ export class ChromiumCdp {
|
||||
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
|
||||
private readonly consoleSequences = new Map<number, number>();
|
||||
private readonly consoleDomainsAttemptedTabs = 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 detachSubscription: { dispose(): void } | null = null;
|
||||
private dialogSubscription: { dispose(): void } | null = null;
|
||||
private consoleSubscription: { dispose(): void } | null = null;
|
||||
private networkSubscription: { dispose(): void } | null = null;
|
||||
|
||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
||||
this.api = api;
|
||||
this.bindAutoDetach();
|
||||
this.bindDialogHandler();
|
||||
this.bindConsoleHandler();
|
||||
this.bindNetworkHandler();
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
@@ -135,6 +157,7 @@ export class ChromiumCdp {
|
||||
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
|
||||
await this.enablePageDomain(tabId);
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomainBestEffort(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
})()
|
||||
.catch((err) => {
|
||||
@@ -195,19 +218,44 @@ export class ChromiumCdp {
|
||||
includeStack: boolean,
|
||||
): ConsoleResult {
|
||||
const buf = this.consoleBuffers.get(tabId) ?? [];
|
||||
const hasCursor = typeof since === "number";
|
||||
const candidates = hasCursor
|
||||
? buf.filter((entry) => entry.sequence > since)
|
||||
: buf.slice(Math.max(0, buf.length - limit));
|
||||
const limited = hasCursor ? candidates.slice(0, limit) : candidates;
|
||||
const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack));
|
||||
const lastReturned = entries.at(-1)?.sequence;
|
||||
const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0;
|
||||
const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length;
|
||||
const truncated =
|
||||
droppedEntries ||
|
||||
candidates.length > limited.length ||
|
||||
entries.some((entry) => entry.truncated);
|
||||
const { entries, nextSince, truncated } = readBufferedEntries(
|
||||
buf,
|
||||
this.consoleSequences.get(tabId) ?? 0,
|
||||
since,
|
||||
limit,
|
||||
(entry) => projectConsoleEntry(entry, maxTextChars, includeStack),
|
||||
);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
next_since: nextSince,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensure CDP domains for network capture are enabled for this tab. */
|
||||
async ensureNetworkCapture(tabId: number): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId)) {
|
||||
await this.ensureAttached(tabId);
|
||||
}
|
||||
await this.enableNetworkDomain(tabId);
|
||||
}
|
||||
|
||||
/** Network entries observed on `tabId`, bounded for agent context safety. */
|
||||
networkEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
): NetworkResult {
|
||||
const buf = this.networkBuffers.get(tabId) ?? [];
|
||||
const { entries, nextSince, truncated } = readBufferedEntries(
|
||||
buf,
|
||||
this.networkSequences.get(tabId) ?? 0,
|
||||
since,
|
||||
limit,
|
||||
(entry) => projectNetworkEntry(entry, maxTextChars),
|
||||
);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
@@ -223,6 +271,7 @@ export class ChromiumCdp {
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
@@ -265,6 +314,10 @@ export class ChromiumCdp {
|
||||
this.consoleBuffers.clear();
|
||||
this.consoleSequences.clear();
|
||||
this.consoleDomainsAttemptedTabs.clear();
|
||||
this.networkBuffers.clear();
|
||||
this.networkSequences.clear();
|
||||
this.networkDomainsEnabledTabs.clear();
|
||||
this.networkRequestMeta.clear();
|
||||
await Promise.all(
|
||||
tabs.map(async (tabId) => {
|
||||
try {
|
||||
@@ -292,6 +345,20 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
private async enableNetworkDomain(tabId: number): Promise<void> {
|
||||
if (this.networkDomainsEnabledTabs.has(tabId)) return;
|
||||
await this.api.sendCommand({ tabId }, "Network.enable", {});
|
||||
this.networkDomainsEnabledTabs.add(tabId);
|
||||
}
|
||||
|
||||
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
|
||||
try {
|
||||
await this.enableNetworkDomain(tabId);
|
||||
} catch (err) {
|
||||
console.debug("[bsk cdp] network domain enable failed", { tabId, err });
|
||||
}
|
||||
}
|
||||
|
||||
private bindDialogHandler(): void {
|
||||
if (this.dialogSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
@@ -372,12 +439,7 @@ export class ChromiumCdp {
|
||||
if (!entry) return;
|
||||
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
|
||||
this.consoleSequences.set(tabId, sequence);
|
||||
const buf = this.consoleBuffers.get(tabId) ?? [];
|
||||
buf.push({ ...entry, sequence });
|
||||
while (buf.length > MAX_CONSOLE_BUFFER) {
|
||||
buf.shift();
|
||||
}
|
||||
this.consoleBuffers.set(tabId, buf);
|
||||
appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER);
|
||||
}
|
||||
|
||||
private clearConsoleState(tabId: number): void {
|
||||
@@ -386,6 +448,93 @@ export class ChromiumCdp {
|
||||
this.consoleDomainsAttemptedTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindNetworkHandler(): void {
|
||||
if (this.networkSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
switch (method) {
|
||||
case "Network.requestWillBeSent":
|
||||
this.rememberNetworkRequest(tabId, params);
|
||||
break;
|
||||
case "Network.responseReceived":
|
||||
this.appendNetwork(
|
||||
tabId,
|
||||
parseNetworkResponse(this.takeNetworkRequest(tabId, params), params),
|
||||
);
|
||||
break;
|
||||
case "Network.loadingFailed":
|
||||
this.appendNetwork(
|
||||
tabId,
|
||||
parseNetworkFailure(this.takeNetworkRequest(tabId, params), params),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.networkSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private appendNetwork(tabId: number, entry: ParsedNetworkEntry | null): void {
|
||||
if (!entry) return;
|
||||
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
|
||||
this.networkSequences.set(tabId, sequence);
|
||||
appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER);
|
||||
}
|
||||
|
||||
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
|
||||
private rememberNetworkRequest(tabId: number, params: unknown): void {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const request = (raw.request ?? {}) as Record<string, unknown>;
|
||||
if (!id || typeof request.url !== "string") return;
|
||||
const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH);
|
||||
const method = truncateOptionalText(
|
||||
typeof request.method === "string" ? request.method : undefined,
|
||||
MAX_NETWORK_FIELD_LENGTH,
|
||||
);
|
||||
const resourceType = truncateOptionalText(
|
||||
typeof raw.type === "string" ? raw.type : undefined,
|
||||
MAX_NETWORK_FIELD_LENGTH,
|
||||
);
|
||||
const tabMeta = this.networkRequestMeta.get(tabId) ?? new Map<string, NetworkRequestMeta>();
|
||||
tabMeta.delete(id);
|
||||
tabMeta.set(id, {
|
||||
url: url.text,
|
||||
method: method?.text,
|
||||
resourceType: resourceType?.text,
|
||||
truncated:
|
||||
url.truncated || (method?.truncated ?? false) || (resourceType?.truncated ?? false),
|
||||
});
|
||||
while (tabMeta.size > MAX_NETWORK_REQUEST_META) {
|
||||
const oldest = tabMeta.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
tabMeta.delete(oldest);
|
||||
}
|
||||
this.networkRequestMeta.set(tabId, tabMeta);
|
||||
}
|
||||
|
||||
/** Consume request metadata once its response or failure has been observed. */
|
||||
private takeNetworkRequest(tabId: number, params: unknown): NetworkRequestMeta | undefined {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
if (!id) return undefined;
|
||||
const tabMeta = this.networkRequestMeta.get(tabId);
|
||||
const info = tabMeta?.get(id);
|
||||
tabMeta?.delete(id);
|
||||
if (tabMeta?.size === 0) this.networkRequestMeta.delete(tabId);
|
||||
return info;
|
||||
}
|
||||
|
||||
private clearNetworkState(tabId: number): void {
|
||||
this.networkBuffers.delete(tabId);
|
||||
this.networkSequences.delete(tabId);
|
||||
this.networkDomainsEnabledTabs.delete(tabId);
|
||||
this.networkRequestMeta.delete(tabId);
|
||||
}
|
||||
|
||||
private bindAutoDetach(): void {
|
||||
if (this.detachSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
@@ -395,6 +544,7 @@ export class ChromiumCdp {
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
this.clearConsoleState(source.tabId);
|
||||
this.clearNetworkState(source.tabId);
|
||||
}
|
||||
};
|
||||
this.api.onDetach.addListener(listener);
|
||||
@@ -411,6 +561,8 @@ export class ChromiumCdp {
|
||||
this.dialogSubscription = null;
|
||||
this.consoleSubscription?.dispose();
|
||||
this.consoleSubscription = null;
|
||||
this.networkSubscription?.dispose();
|
||||
this.networkSubscription = null;
|
||||
}
|
||||
|
||||
/** Detach tabs only when no other live session has claimed them. */
|
||||
@@ -427,6 +579,45 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
function appendBoundedEntry<T>(
|
||||
buffers: Map<number, T[]>,
|
||||
tabId: number,
|
||||
entry: T,
|
||||
maxEntries: number,
|
||||
): void {
|
||||
const buffer = buffers.get(tabId) ?? [];
|
||||
buffer.push(entry);
|
||||
while (buffer.length > maxEntries) buffer.shift();
|
||||
buffers.set(tabId, buffer);
|
||||
}
|
||||
|
||||
function readBufferedEntries<
|
||||
TEntry extends { sequence: number },
|
||||
TProjected extends { sequence: number; truncated: boolean },
|
||||
>(
|
||||
buffer: TEntry[],
|
||||
currentSequence: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
project: (entry: TEntry) => TProjected,
|
||||
): { entries: TProjected[]; nextSince: number; truncated: boolean } {
|
||||
const hasCursor = typeof since === "number";
|
||||
const candidates = hasCursor ? buffer.filter((entry) => entry.sequence > since) : buffer;
|
||||
const limited = hasCursor ? candidates.slice(0, limit) : candidates.slice(-limit);
|
||||
const entries = limited.map(project);
|
||||
const oldestSequence = buffer[0]?.sequence ?? currentSequence + 1;
|
||||
const droppedEntries =
|
||||
currentSequence > buffer.length && (!hasCursor || (since ?? 0) < oldestSequence - 1);
|
||||
return {
|
||||
entries,
|
||||
nextSince: entries.at(-1)?.sequence ?? currentSequence,
|
||||
truncated:
|
||||
droppedEntries ||
|
||||
candidates.length > limited.length ||
|
||||
entries.some((entry) => entry.truncated),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const type = normalizeDialogType(raw.type);
|
||||
@@ -518,7 +709,7 @@ function makeConsoleEntry(input: {
|
||||
timestamp?: number;
|
||||
stack_trace: ConsoleStackFrame[];
|
||||
}): ParsedConsoleEntry {
|
||||
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
|
||||
const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH);
|
||||
return {
|
||||
kind: input.kind,
|
||||
level: input.level,
|
||||
@@ -542,7 +733,7 @@ function projectConsoleEntry(
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleEntry {
|
||||
const projected = truncateConsoleText(entry.text, maxTextChars);
|
||||
const projected = truncateText(entry.text, maxTextChars);
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
@@ -591,17 +782,120 @@ function firstLine(value: string | undefined): string | undefined {
|
||||
return value.split(/\r?\n/, 1)[0] || undefined;
|
||||
}
|
||||
|
||||
function truncateConsoleText(
|
||||
value: string,
|
||||
maxChars: number,
|
||||
): { text: string; truncated: boolean } {
|
||||
function truncateText(value: string, maxChars: number): { text: string; truncated: boolean } {
|
||||
if (value.length <= maxChars) return { text: value, truncated: false };
|
||||
return { text: value.slice(0, maxChars), truncated: true };
|
||||
}
|
||||
|
||||
function truncateOptionalText(
|
||||
value: string | undefined,
|
||||
maxChars: number,
|
||||
): { text: string; truncated: boolean } | undefined {
|
||||
return value === undefined ? undefined : truncateText(value, maxChars);
|
||||
}
|
||||
|
||||
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
|
||||
return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text;
|
||||
}
|
||||
|
||||
function parseNetworkResponse(
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const response = (raw.response ?? {}) as Record<string, unknown>;
|
||||
const hasResponseUrl = typeof response.url === "string";
|
||||
const url = hasResponseUrl ? (response.url as string) : info?.url;
|
||||
if (!url) return null;
|
||||
return makeNetworkEntry({
|
||||
kind: "response",
|
||||
method: info?.method,
|
||||
url,
|
||||
status: typeof response.status === "number" ? response.status : undefined,
|
||||
status_text: typeof response.statusText === "string" ? response.statusText : undefined,
|
||||
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
truncated: info?.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function parseNetworkFailure(
|
||||
info: NetworkRequestMeta | undefined,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
return makeNetworkEntry({
|
||||
kind: "failure",
|
||||
method: info?.method,
|
||||
url: info?.url,
|
||||
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
truncated: info?.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeNetworkEntry(input: {
|
||||
kind: NetworkEntryKind;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
status_text?: string;
|
||||
mime_type?: string;
|
||||
resource_type?: string;
|
||||
error_text?: string;
|
||||
timestamp?: number;
|
||||
truncated?: boolean;
|
||||
}): ParsedNetworkEntry {
|
||||
const projectedMethod = truncateOptionalText(input.method, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedUrl = truncateOptionalText(input.url, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedStatusText = truncateOptionalText(input.status_text, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedMimeType = truncateOptionalText(input.mime_type, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedResourceType = truncateOptionalText(input.resource_type, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedError =
|
||||
input.error_text !== undefined
|
||||
? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
|
||||
: undefined;
|
||||
return {
|
||||
kind: input.kind,
|
||||
method: projectedMethod?.text,
|
||||
url: projectedUrl?.text,
|
||||
status: input.status,
|
||||
status_text: projectedStatusText?.text,
|
||||
mime_type: projectedMimeType?.text,
|
||||
resource_type: projectedResourceType?.text,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: input.timestamp,
|
||||
truncated:
|
||||
input.truncated === true ||
|
||||
(projectedMethod?.truncated ?? false) ||
|
||||
(projectedUrl?.truncated ?? false) ||
|
||||
(projectedStatusText?.truncated ?? false) ||
|
||||
(projectedMimeType?.truncated ?? false) ||
|
||||
(projectedResourceType?.truncated ?? false) ||
|
||||
(projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
|
||||
const projectedUrl = truncateOptionalText(entry.url, maxTextChars);
|
||||
const projectedError =
|
||||
entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined;
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
method: entry.method,
|
||||
url: projectedUrl?.text,
|
||||
status: entry.status,
|
||||
status_text: entry.status_text,
|
||||
mime_type: entry.mime_type,
|
||||
resource_type: entry.resource_type,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: entry.timestamp,
|
||||
truncated:
|
||||
entry.truncated || (projectedUrl?.truncated ?? false) || (projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown): Error {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiCloseLine } from "@remixicon/react";
|
||||
import { useEffect, useRef, useState, type TransitionEvent } from "react";
|
||||
import { type TransitionEvent, useEffect, useRef, useState } from "react";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
export interface BorrowRequestData {
|
||||
@@ -62,33 +62,25 @@ function BorrowToastStack({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */
|
||||
const PROGRESS_TRANSITION_MS = 1000;
|
||||
|
||||
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
|
||||
const { t } = useTranslation("extension");
|
||||
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
|
||||
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
|
||||
const allowedRef = useRef(false);
|
||||
const [awaitingAutoDeny, setAwaitingAutoDeny] = useState(false);
|
||||
const settledRef = useRef(false);
|
||||
const onAllowRef = useRef(request.onAllow);
|
||||
const onDenyRef = useRef(request.onDeny);
|
||||
onAllowRef.current = request.onAllow;
|
||||
onDenyRef.current = request.onDeny;
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
if (!allowedRef.current) {
|
||||
setAwaitingAutoAllow(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [secondsLeft]);
|
||||
|
||||
function triggerAllow() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
@@ -98,13 +90,34 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
if (allowedRef.current) return;
|
||||
allowedRef.current = true;
|
||||
setAwaitingAutoAllow(false);
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
setAwaitingAutoDeny(false);
|
||||
setExiting(true);
|
||||
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) {
|
||||
if (!settledRef.current) {
|
||||
setAwaitingAutoDeny(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearTimeout(id);
|
||||
}, [secondsLeft]);
|
||||
|
||||
// Prefer transitionend for visual sync, but fall back to a timer so
|
||||
// background tabs / suppressed CSS transitions still auto-deny.
|
||||
useEffect(() => {
|
||||
if (!awaitingAutoDeny || settledRef.current) return;
|
||||
const id = setTimeout(() => {
|
||||
handleDeny();
|
||||
}, PROGRESS_TRANSITION_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [awaitingAutoDeny]);
|
||||
|
||||
const progress = secondsLeft / totalSeconds;
|
||||
const truncatedTitle =
|
||||
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
|
||||
@@ -128,13 +141,13 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
progress={progress}
|
||||
seconds={secondsLeft}
|
||||
onProgressTransitionEnd={(propertyName) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (propertyName !== "stroke-dashoffset") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<span className="text-[13px] text-gray-500">
|
||||
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
|
||||
{t("borrowConfirmation.autoDeny", { count: secondsLeft })}
|
||||
</span>
|
||||
</div>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
|
||||
@@ -156,9 +169,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
|
||||
<BorrowProgressBar
|
||||
progress={progress}
|
||||
onProgressTransitionEnd={(event) => {
|
||||
if (!awaitingAutoAllow || secondsLeft !== 0) return;
|
||||
if (!awaitingAutoDeny || secondsLeft !== 0) return;
|
||||
if (event.propertyName !== "width") return;
|
||||
triggerAllow();
|
||||
handleDeny();
|
||||
}}
|
||||
/>
|
||||
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
|
||||
|
||||
@@ -58,6 +58,7 @@ export function ControlOverlay({
|
||||
/>
|
||||
|
||||
<div
|
||||
data-slot="control-overlay-blocker"
|
||||
onPointerDown={(event) => {
|
||||
if (automationBypass) return;
|
||||
event.preventDefault();
|
||||
@@ -80,13 +81,16 @@ export function ControlOverlay({
|
||||
/>
|
||||
|
||||
<div
|
||||
data-slot="control-overlay-pill"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 2147483647,
|
||||
pointerEvents,
|
||||
// Always receive clicks so Interrupt works even while the page
|
||||
// blocker is in automation-bypass mode (CDP pass-through).
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
@@ -122,7 +126,7 @@ export function ControlOverlay({
|
||||
disabled={interrupting}
|
||||
onClick={onInterrupt}
|
||||
style={{
|
||||
pointerEvents,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiArrowDownSLine } from "@remixicon/react";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
|
||||
@@ -239,25 +239,29 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
};
|
||||
}, [request]);
|
||||
|
||||
const onHeaderPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return;
|
||||
const banner = bannerRef.current;
|
||||
if (!banner) return;
|
||||
const rect = banner.getBoundingClientRect();
|
||||
dragStartRef.current = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
panelW: banner.offsetWidth || PANEL_WIDTH,
|
||||
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
|
||||
};
|
||||
userMovedRef.current = true;
|
||||
setDragPos({ top: rect.top, left: rect.left });
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
}, [collapsed]);
|
||||
const onHeaderPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select"))
|
||||
return;
|
||||
const banner = bannerRef.current;
|
||||
if (!banner) return;
|
||||
const rect = banner.getBoundingClientRect();
|
||||
dragStartRef.current = {
|
||||
pointerX: e.clientX,
|
||||
pointerY: e.clientY,
|
||||
originLeft: rect.left,
|
||||
originTop: rect.top,
|
||||
panelW: banner.offsetWidth || PANEL_WIDTH,
|
||||
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
|
||||
};
|
||||
userMovedRef.current = true;
|
||||
setDragPos({ top: rect.top, left: rect.left });
|
||||
setDragging(true);
|
||||
e.preventDefault();
|
||||
},
|
||||
[collapsed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
@@ -636,7 +640,11 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="bsk-help-header">
|
||||
<img src={logoUrl} alt="browser-skill" style={{ width: 22, height: 22, borderRadius: 4 }} />
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="browser-skill"
|
||||
style={{ width: 22, height: 22, borderRadius: 4 }}
|
||||
/>
|
||||
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
|
||||
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiStopCircleLine } from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface RecordRequestData {
|
||||
id: string;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
request: RecordRequestData | null;
|
||||
};
|
||||
|
||||
export function RecordOverlay({ request }: Props) {
|
||||
const { t } = useTranslation("extension");
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
const raf = requestAnimationFrame(() => setShow(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
setShow(false);
|
||||
}, [request]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes bsk-rec-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.82); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
data-slot="record-overlay-pill"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 32,
|
||||
left: "50%",
|
||||
zIndex: 2147483647,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
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)",
|
||||
opacity: show ? 1 : 0,
|
||||
transform: show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)",
|
||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
maxWidth: "min(420px, calc(100vw - 32px))",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{t("recordOverlay.recording")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="record-overlay-finish"
|
||||
onClick={request.onFinish}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
border: "none",
|
||||
borderRadius: 9999,
|
||||
padding: "8px 20px 8px 16px",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
backgroundColor: "#f97316",
|
||||
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")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
|
||||
it("auto-denies after countdown reaches zero and progress transition ends", async () => {
|
||||
const onAllow = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
|
||||
@@ -37,10 +37,7 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
}
|
||||
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
|
||||
const progressCircle = container.querySelectorAll("svg circle")[1];
|
||||
expect(progressCircle).toBeTruthy();
|
||||
await act(() => {
|
||||
@@ -54,8 +51,44 @@ describe("BorrowConfirmationOverlay", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
expect(onAllow).toHaveBeenCalledTimes(1);
|
||||
expect(onDeny).not.toHaveBeenCalled();
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(onAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-denies via timer fallback when progress transitionend never fires", async () => {
|
||||
const onAllow = vi.fn();
|
||||
const onDeny = vi.fn();
|
||||
|
||||
render(
|
||||
<BorrowConfirmationOverlay
|
||||
requests={[
|
||||
{
|
||||
id: "req-fallback",
|
||||
isActiveTab: true,
|
||||
tabTitle: "Example",
|
||||
timeoutMs: 5000,
|
||||
onAllow,
|
||||
onDeny,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
}
|
||||
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
|
||||
// No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
});
|
||||
expect(onDeny).toHaveBeenCalledTimes(1);
|
||||
expect(onAllow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("ControlOverlay", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("sets pointer-events none on blocker and pill when automationBypass is true", () => {
|
||||
it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => {
|
||||
const { container } = render(
|
||||
<ControlOverlay
|
||||
visible={true}
|
||||
@@ -17,13 +17,17 @@ describe("ControlOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
|
||||
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
|
||||
expect(blocker).toBeTruthy();
|
||||
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
|
||||
|
||||
const pill = container.querySelector("[data-slot='control-overlay-pill']");
|
||||
expect(pill).toBeTruthy();
|
||||
expect((pill as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
|
||||
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
|
||||
expect(stopBtn).toBeTruthy();
|
||||
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none");
|
||||
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
it("uses pointer-events auto on blocker when automationBypass is false", () => {
|
||||
@@ -36,7 +40,7 @@ describe("ControlOverlay", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
|
||||
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
|
||||
expect(blocker).toBeTruthy();
|
||||
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HelpRequestOverlay, type HelpRequestData } from "../HelpRequestOverlay";
|
||||
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
|
||||
|
||||
function renderOverlay(req: HelpRequestData) {
|
||||
return render(
|
||||
@@ -79,7 +79,7 @@ describe("HelpRequestOverlay", () => {
|
||||
renderOverlay(baseRequest({ selectors: ["#login", "#missing"] }));
|
||||
// One highlight box rendered for the matched selector.
|
||||
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(1);
|
||||
expect((el.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect(el.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
el.remove();
|
||||
});
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("HelpRequestOverlay", () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect((elA.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect((elB.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect(elA.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
expect(elB.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
|
||||
elA.remove();
|
||||
elB.remove();
|
||||
});
|
||||
@@ -286,9 +286,7 @@ describe("HelpRequestOverlay", () => {
|
||||
it("does not start drag when pointer down on a button in the header", () => {
|
||||
const { container } = renderOverlay(baseRequest());
|
||||
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
|
||||
const collapseBtn = screen.getByLabelText(
|
||||
i18n.t("helpRequest.collapse", { ns: "extension" }),
|
||||
);
|
||||
const collapseBtn = screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }));
|
||||
|
||||
fireEvent.pointerDown(collapseBtn, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RecordOverlay } from "../RecordOverlay";
|
||||
|
||||
describe("RecordOverlay", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the bottom pill with orange finish CTA and no logo", () => {
|
||||
const onFinish = vi.fn();
|
||||
const { container } = render(<RecordOverlay request={{ 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");
|
||||
|
||||
const finish = container.querySelector("[data-slot='record-overlay-finish']");
|
||||
expect(finish).toBeTruthy();
|
||||
expect((finish as HTMLElement).style.backgroundColor).toBe("#f97316");
|
||||
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
|
||||
|
||||
fireEvent.click(finish!);
|
||||
expect(onFinish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
|
||||
const { container } = render(<RecordOverlay request={{ 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");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OverlayController } from "../overlay-controller";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "../overlay-controller";
|
||||
|
||||
describe("OverlayController", () => {
|
||||
it("resets agent overlays without clearing user-tab borrow requests", () => {
|
||||
@@ -31,6 +31,7 @@ describe("OverlayController", () => {
|
||||
expect(state.controlVisible).toBe(false);
|
||||
expect(state.activeSessionId).toBeNull();
|
||||
expect(state.activeHelp).toBeNull();
|
||||
expect(state.activeRecord).toBeNull();
|
||||
expect(state.automationBypassCount).toBe(0);
|
||||
});
|
||||
|
||||
@@ -69,4 +70,50 @@ describe("OverlayController", () => {
|
||||
expect(previous?.id).toBe("help-1");
|
||||
expect(controller.snapshot().activeHelp?.id).toBe("help-2");
|
||||
});
|
||||
|
||||
it("tracks active record request state", () => {
|
||||
const controller = new OverlayController();
|
||||
|
||||
controller.activateAgentSession("sess-1");
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
|
||||
expect(controller.snapshot().activeRecord?.id).toBe("rec-1");
|
||||
});
|
||||
|
||||
it("hides agent control overlay while recording is active", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
|
||||
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
expect(controller.snapshot().controlVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps control hidden after record ends until session overlays reset", () => {
|
||||
const controller = new OverlayController();
|
||||
controller.activateAgentSession("sess-1");
|
||||
controller.setAgentRecordRequest({
|
||||
id: "rec-1",
|
||||
onFinish: vi.fn(),
|
||||
});
|
||||
controller.setAutomationBypass(true);
|
||||
controller.setAutomationBypass(true);
|
||||
|
||||
controller.clearAgentRecordRequest("rec-1");
|
||||
expect(controller.snapshot().activeRecord).toBeNull();
|
||||
expect(controller.snapshot().suppressControlAfterRecord).toBe(true);
|
||||
expect(controller.snapshot().automationBypassCount).toBe(0);
|
||||
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
|
||||
|
||||
controller.resetAgentOverlays("sess-1");
|
||||
expect(controller.snapshot().suppressControlAfterRecord).toBe(false);
|
||||
expect(controller.snapshot().controlVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
|
||||
describe("handleRecordContentMessage stop/cancel", () => {
|
||||
it("ignores STOP when no recording is active", () => {
|
||||
const dispose = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
const setActiveRequestId = vi.fn();
|
||||
const setCapture = vi.fn();
|
||||
const sendResponse = vi.fn();
|
||||
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
{ type: RECORD_STOP, requestId: "rec-stale" },
|
||||
{
|
||||
activeRequestId: null,
|
||||
capture: { dispose },
|
||||
setActiveRequestId,
|
||||
setCapture,
|
||||
onStart: vi.fn(),
|
||||
onStop,
|
||||
},
|
||||
sendResponse,
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
expect(setActiveRequestId).not.toHaveBeenCalled();
|
||||
expect(setCapture).not.toHaveBeenCalled();
|
||||
expect(sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores STOP for a mismatched requestId", () => {
|
||||
const dispose = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
const needsAsync = handleRecordContentMessage(
|
||||
{ type: RECORD_STOP, requestId: "rec-other" },
|
||||
{
|
||||
activeRequestId: "rec-1",
|
||||
capture: { dispose },
|
||||
setActiveRequestId: vi.fn(),
|
||||
setCapture: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onStop,
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(needsAsync).toBe(false);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(onStop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("record-capture semantic", () => {
|
||||
let steps: RecordStepPayload[];
|
||||
|
||||
beforeEach(() => {
|
||||
steps = [];
|
||||
document.body.innerHTML = `
|
||||
<label for="q">查询</label>
|
||||
<input id="q" name="q" />
|
||||
<button type="button" aria-label="搜索">搜索</button>
|
||||
<div role="listbox" id="sug">
|
||||
<div role="option">建议项</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("commits final fill value and records semantic click", () => {
|
||||
const capture = startRecordCapture("rec-1", (step) => steps.push(step));
|
||||
const input = document.querySelector("input")!;
|
||||
const button = document.querySelector("button")!;
|
||||
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.value = "hello";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
|
||||
capture.dispose();
|
||||
|
||||
expect(steps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
op: "fill",
|
||||
value: "hello",
|
||||
target: expect.objectContaining({ name: "查询", tag: "input" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
op: "click",
|
||||
target: expect.objectContaining({ name: "搜索", role: "button" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(steps)).not.toMatch(/@e\d+/);
|
||||
expect(steps.some((s) => "selector" in s)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores autocomplete suggestion clicks while a fill session is open", () => {
|
||||
const capture = startRecordCapture("rec-2", (step) => steps.push(step));
|
||||
const input = document.querySelector("input")!;
|
||||
const option = document.querySelector('[role="option"]')!;
|
||||
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.value = "hel";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
option.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
input.value = "hello";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps.filter((s) => s.op === "click")).toHaveLength(0);
|
||||
expect(steps.some((s) => s.op === "fill" && s.value === "hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("records Enter press but not bare typing keys", () => {
|
||||
const capture = startRecordCapture("rec-press", (step) => steps.push(step));
|
||||
const input = document.querySelector("input")!;
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
capture.dispose();
|
||||
expect(steps.filter((s) => s.op === "press")).toEqual([
|
||||
expect.objectContaining({ op: "press", key: "Enter" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not record clicks on anonymous layout divs", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="chrome">page chrome</div>
|
||||
<button type="button" aria-label="下一步">下一步</button>
|
||||
`;
|
||||
const capture = startRecordCapture("rec-3", (step) => steps.push(step));
|
||||
document
|
||||
.querySelector("#chrome")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
document
|
||||
.querySelector("button")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
|
||||
capture.dispose();
|
||||
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "click",
|
||||
target: { name: "下一步", role: "button" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
|
||||
import type { HelpRequestData } from "./HelpRequestOverlay";
|
||||
import type { RecordRequestData } from "./RecordOverlay";
|
||||
|
||||
export interface OverlayState {
|
||||
borrowRequests: BorrowRequestData[];
|
||||
activeHelp: HelpRequestData | null;
|
||||
activeRecord: RecordRequestData | null;
|
||||
controlVisible: boolean;
|
||||
interrupting: boolean;
|
||||
activeSessionId: string | null;
|
||||
automationBypassCount: number;
|
||||
/**
|
||||
* After record Finish/Stop clears `activeRecord`, the session is still
|
||||
* alive until CLI `session.stop`. Suppress the control mask for that gap
|
||||
* so 「Agent 正在控制」does not flash between RecordOverlay and teardown.
|
||||
*/
|
||||
suppressControlAfterRecord: boolean;
|
||||
}
|
||||
|
||||
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
|
||||
@@ -20,9 +28,11 @@ export class OverlayController {
|
||||
private state: MutableOverlayState = {
|
||||
borrowRequests: [],
|
||||
activeHelp: null,
|
||||
activeRecord: null,
|
||||
interrupting: false,
|
||||
activeSessionId: null,
|
||||
automationBypassCount: 0,
|
||||
suppressControlAfterRecord: false,
|
||||
};
|
||||
|
||||
snapshot(): OverlayState {
|
||||
@@ -70,6 +80,25 @@ export class OverlayController {
|
||||
this.state.activeHelp = null;
|
||||
}
|
||||
|
||||
setAgentRecordRequest(request: RecordRequestData): RecordRequestData | null {
|
||||
const previous = this.state.activeRecord;
|
||||
this.state.activeRecord = request;
|
||||
this.state.suppressControlAfterRecord = false;
|
||||
return previous;
|
||||
}
|
||||
|
||||
clearAgentRecordRequest(requestId?: string): void {
|
||||
if (!this.state.activeRecord) return;
|
||||
if (requestId && this.state.activeRecord.id !== requestId) return;
|
||||
this.state.activeRecord = null;
|
||||
// Hide control until session teardown — see OverlayState.suppressControlAfterRecord.
|
||||
this.state.suppressControlAfterRecord = true;
|
||||
// Record start/rearm may have stacked automation-bypass refs; drop them so a
|
||||
// stray ControlOverlay cannot sit with pointer-events:none (page usable,
|
||||
// Interrupt unclickable).
|
||||
this.state.automationBypassCount = 0;
|
||||
}
|
||||
|
||||
setAutomationBypass(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.state.automationBypassCount += 1;
|
||||
@@ -86,10 +115,22 @@ export class OverlayController {
|
||||
this.state = {
|
||||
...this.state,
|
||||
activeHelp: null,
|
||||
activeRecord: null,
|
||||
interrupting: false,
|
||||
activeSessionId: null,
|
||||
automationBypassCount: 0,
|
||||
suppressControlAfterRecord: false,
|
||||
};
|
||||
return previousHelp;
|
||||
}
|
||||
}
|
||||
|
||||
/** Control mask ("Agent 正在控制") must hide while help/record overlays own the chrome. */
|
||||
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
|
||||
return (
|
||||
state.controlVisible &&
|
||||
!state.suppressControlAfterRecord &&
|
||||
state.activeHelp === null &&
|
||||
state.activeRecord === null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export interface RecordCaptureController {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface FillSession {
|
||||
element: FillableElement;
|
||||
target: TargetDescriptor;
|
||||
baselineValue: string;
|
||||
lastValue: string;
|
||||
}
|
||||
|
||||
type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
|
||||
|
||||
function eventTarget(event: Event): EventTarget | null {
|
||||
return event.composedPath()[0] ?? event.target;
|
||||
}
|
||||
|
||||
function isOverlayTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Node)) return false;
|
||||
const root = document.documentElement;
|
||||
let node: Node | null = target;
|
||||
while (node && node !== root) {
|
||||
if (node instanceof Element && node.hasAttribute("data-bsk-overlay")) {
|
||||
return true;
|
||||
}
|
||||
const rootNode: Node | Document | ShadowRoot = node.getRootNode();
|
||||
if (rootNode instanceof ShadowRoot) {
|
||||
const host: Element = rootNode.host;
|
||||
if (host.hasAttribute("data-bsk-overlay")) {
|
||||
return true;
|
||||
}
|
||||
node = host;
|
||||
} else {
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTextFillable(el: Element): el is FillableElement {
|
||||
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
|
||||
return true;
|
||||
}
|
||||
if (el instanceof HTMLTextAreaElement) return true;
|
||||
if (!(el instanceof HTMLInputElement)) return false;
|
||||
const type = el.type.toLowerCase();
|
||||
return type !== "checkbox" && type !== "radio" && type !== "file" && type !== "button";
|
||||
}
|
||||
|
||||
function fillableFromTarget(target: EventTarget | null): FillableElement | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
if (isTextFillable(target)) return target;
|
||||
const el = target.closest('input,textarea,[contenteditable]:not([contenteditable="false"])');
|
||||
if (el && isTextFillable(el)) return el;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Clicks on search chrome that only focus the nearby input should not become steps. */
|
||||
function nearbyFillableFromSearchChrome(target: Element): FillableElement | null {
|
||||
const container = target.closest(
|
||||
'[id*="chat-input"], [id*="search"], [class*="search"], form, [role="search"]',
|
||||
);
|
||||
if (!container) return null;
|
||||
const fillable = container.querySelector(
|
||||
'textarea, input[type="search"], input[name="q"], #chat-textarea',
|
||||
);
|
||||
if (
|
||||
fillable instanceof HTMLElement &&
|
||||
isTextFillable(fillable) &&
|
||||
fillable !== target &&
|
||||
!fillable.contains(target)
|
||||
) {
|
||||
return fillable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fillableValue(el: FillableElement): string {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return el.value;
|
||||
}
|
||||
return el.textContent ?? "";
|
||||
}
|
||||
|
||||
/** 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;
|
||||
if (session.element.contains(target)) return false;
|
||||
|
||||
const option = target.closest('[role="option"]');
|
||||
if (option?.closest('[role="listbox"]')) return true;
|
||||
|
||||
const suggestionRoot = target.closest(
|
||||
[
|
||||
'[id*="Sug"]',
|
||||
'[id*="sug"]',
|
||||
'[class*="suggest"]',
|
||||
'[class*="autocomplete"]',
|
||||
'[class*="typeahead"]',
|
||||
"[data-autocomplete]",
|
||||
].join(", "),
|
||||
);
|
||||
if (suggestionRoot && !suggestionRoot.contains(session.element)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleInputCompletionCommit(
|
||||
sessionElement: FillableElement,
|
||||
syncFillSessionValue: (el: FillableElement) => void,
|
||||
commitFillSession: () => void,
|
||||
hasSessionFor: (el: FillableElement) => boolean,
|
||||
): void {
|
||||
const syncAndCommit = () => {
|
||||
if (!hasSessionFor(sessionElement)) return;
|
||||
syncFillSessionValue(sessionElement);
|
||||
commitFillSession();
|
||||
};
|
||||
syncFillSessionValue(sessionElement);
|
||||
queueMicrotask(syncAndCommit);
|
||||
setTimeout(syncAndCommit, 0);
|
||||
}
|
||||
|
||||
export function startRecordCapture(
|
||||
_requestId: string,
|
||||
sendStep: (step: RecordStepPayload) => void,
|
||||
): RecordCaptureController {
|
||||
const emitStep = (step: RecordStepPayload) => {
|
||||
sendStep({ page_url: location.href, ...step });
|
||||
};
|
||||
let fillSession: FillSession | null = null;
|
||||
let composing = false;
|
||||
let lastUrl = location.href;
|
||||
let keyboardActivation: { target: EventTarget | null; recordedAt: number } | undefined;
|
||||
let generatedControlClick: Element | null = null;
|
||||
let navigationActionPending = false;
|
||||
let navigationActionVersion = 0;
|
||||
const committedValues = new WeakMap<FillableElement, string>();
|
||||
|
||||
const markNavigationAction = () => {
|
||||
navigationActionPending = true;
|
||||
const version = ++navigationActionVersion;
|
||||
setTimeout(() => {
|
||||
if (navigationActionVersion === version) {
|
||||
navigationActionPending = false;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const emitFill = (session: FillSession) => {
|
||||
if (session.lastValue === session.baselineValue) return;
|
||||
const isPassword =
|
||||
session.element instanceof HTMLInputElement && session.element.type === "password";
|
||||
const value = isPassword ? "***" : session.lastValue;
|
||||
emitStep({
|
||||
op: "fill",
|
||||
target: session.target,
|
||||
value,
|
||||
...(isPassword ? { redacted: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const commitFillSession = () => {
|
||||
if (!fillSession || composing) return;
|
||||
const session = fillSession;
|
||||
fillSession = null;
|
||||
emitFill(session);
|
||||
committedValues.set(session.element, session.lastValue);
|
||||
};
|
||||
|
||||
const syncFillSessionValue = (el: FillableElement) => {
|
||||
if (!fillSession || fillSession.element !== el) return;
|
||||
fillSession.lastValue = fillableValue(el);
|
||||
};
|
||||
|
||||
const ensureFillSession = (el: FillableElement) => {
|
||||
if (fillSession?.element === el) return;
|
||||
if (fillSession) commitFillSession();
|
||||
const currentValue = fillableValue(el);
|
||||
const baselineValue = committedValues.get(el) ?? currentValue;
|
||||
fillSession = {
|
||||
element: el,
|
||||
target: describeTarget(el),
|
||||
baselineValue,
|
||||
lastValue: currentValue,
|
||||
};
|
||||
};
|
||||
|
||||
const emitNavigateIfChanged = (causedByAction?: boolean) => {
|
||||
if (location.href === lastUrl) return;
|
||||
commitFillSession();
|
||||
lastUrl = location.href;
|
||||
emitStep({
|
||||
op: "navigate",
|
||||
url: location.href,
|
||||
...(causedByAction !== undefined ? { navigation_caused_by_action: causedByAction } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const emitClick = (event: MouseEvent) => {
|
||||
// Only record clicks an LLM can re-identify (named interactive controls).
|
||||
const target = describeEventTarget(eventTarget(event));
|
||||
if (!target) return;
|
||||
markNavigationAction();
|
||||
emitStep({
|
||||
op: "click",
|
||||
target,
|
||||
expects_navigation: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = eventTarget(event);
|
||||
if (isOverlayTarget(target)) return;
|
||||
if (event.detail === 0 && generatedControlClick !== null && target === generatedControlClick) {
|
||||
generatedControlClick = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.detail === 0 &&
|
||||
keyboardActivation?.target === target &&
|
||||
Date.now() - keyboardActivation.recordedAt < 500
|
||||
) {
|
||||
keyboardActivation = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const label = target instanceof Element ? target.closest("label") : null;
|
||||
const nestedInteractive =
|
||||
target instanceof Element ? target.closest("a,button,input,select,textarea") : null;
|
||||
if (label instanceof HTMLLabelElement && !nestedInteractive) {
|
||||
commitFillSession();
|
||||
generatedControlClick = label.control;
|
||||
emitClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const fillable = fillableFromTarget(target);
|
||||
if (fillable) {
|
||||
ensureFillSession(fillable);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target instanceof Element) {
|
||||
const nearbyFillable = nearbyFillableFromSearchChrome(target);
|
||||
if (nearbyFillable) {
|
||||
ensureFillSession(nearbyFillable);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInputCompletionClick(target, fillSession)) {
|
||||
markNavigationAction();
|
||||
const sessionElement = fillSession!.element;
|
||||
scheduleInputCompletionCommit(
|
||||
sessionElement,
|
||||
syncFillSessionValue,
|
||||
commitFillSession,
|
||||
(el) => fillSession?.element === el,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
commitFillSession();
|
||||
if (target instanceof Element && target.closest("select")) return;
|
||||
emitClick(event);
|
||||
};
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
const target = fillableFromTarget(eventTarget(event));
|
||||
if (target) ensureFillSession(target);
|
||||
};
|
||||
|
||||
const onFocusOut = (event: FocusEvent) => {
|
||||
const target = fillableFromTarget(eventTarget(event));
|
||||
if (!target) return;
|
||||
if (!fillSession || fillSession.element !== target) return;
|
||||
syncFillSessionValue(target);
|
||||
commitFillSession();
|
||||
};
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = fillableFromTarget(eventTarget(event));
|
||||
if (!target) return;
|
||||
if (composing) return;
|
||||
ensureFillSession(target);
|
||||
syncFillSessionValue(target);
|
||||
};
|
||||
|
||||
const onCompositionStart = () => {
|
||||
composing = true;
|
||||
};
|
||||
|
||||
const onCompositionEnd = (event: CompositionEvent) => {
|
||||
composing = false;
|
||||
const target = fillableFromTarget(eventTarget(event));
|
||||
if (target) {
|
||||
ensureFillSession(target);
|
||||
syncFillSessionValue(target);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
commitFillSession();
|
||||
const target = eventTarget(event);
|
||||
if (target instanceof HTMLSelectElement) {
|
||||
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(),
|
||||
);
|
||||
const desc = describeTarget(target);
|
||||
markNavigationAction();
|
||||
emitStep({
|
||||
op: "select",
|
||||
target: desc,
|
||||
values,
|
||||
labels,
|
||||
expects_navigation: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isOverlayTarget(eventTarget(event))) return;
|
||||
const target = eventTarget(event);
|
||||
const fillable = fillableFromTarget(target);
|
||||
if (fillable) {
|
||||
ensureFillSession(fillable);
|
||||
syncFillSessionValue(fillable);
|
||||
}
|
||||
const modifiers = [
|
||||
...(event.altKey ? (["alt"] as const) : []),
|
||||
...(event.ctrlKey ? (["ctrl"] as const) : []),
|
||||
...(event.metaKey ? (["meta"] as const) : []),
|
||||
...(event.shiftKey ? (["shift"] as const) : []),
|
||||
];
|
||||
if (!shouldRecordPress(event.key, modifiers)) {
|
||||
return;
|
||||
}
|
||||
markNavigationAction();
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
const submitTarget =
|
||||
event.key === "Enter" && fillable
|
||||
? fillable
|
||||
.closest("form")
|
||||
?.querySelector(
|
||||
'button:not([type]),button[type="submit"],input[type="submit"],input[type="image"]',
|
||||
)
|
||||
: null;
|
||||
keyboardActivation = {
|
||||
target: submitTarget ?? target,
|
||||
recordedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
if (fillable) {
|
||||
commitFillSession();
|
||||
}
|
||||
const desc = describeEventTarget(target);
|
||||
if (!desc && !event.key) return;
|
||||
emitStep({
|
||||
op: "press",
|
||||
key: event.key,
|
||||
...(desc ? { target: desc } : {}),
|
||||
...(modifiers.length ? { modifiers } : {}),
|
||||
expects_navigation: event.key === "Enter",
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("focusin", onFocusIn, true);
|
||||
document.addEventListener("focusout", onFocusOut, true);
|
||||
document.addEventListener("input", onInput, true);
|
||||
document.addEventListener("compositionstart", onCompositionStart, true);
|
||||
document.addEventListener("compositionend", onCompositionEnd, true);
|
||||
document.addEventListener("change", onChange, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
|
||||
const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
|
||||
urlObserver.observe(document, { subtree: true, childList: true });
|
||||
const onUrlEvent = () => emitNavigateIfChanged();
|
||||
window.addEventListener("hashchange", onUrlEvent);
|
||||
window.addEventListener("popstate", onUrlEvent);
|
||||
window.addEventListener("pagehide", commitFillSession);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
commitFillSession();
|
||||
document.removeEventListener("click", onClick, true);
|
||||
document.removeEventListener("focusin", onFocusIn, true);
|
||||
document.removeEventListener("focusout", onFocusOut, true);
|
||||
document.removeEventListener("input", onInput, true);
|
||||
document.removeEventListener("compositionstart", onCompositionStart, true);
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
|
||||
// at module load. WXT injects that helper at build time via auto-imports;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/lib/instance-id";
|
||||
import { startKeepalive } from "@/lib/keepalive";
|
||||
import {
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayInterruptRequest,
|
||||
@@ -27,16 +28,18 @@ import {
|
||||
requestBorrowConfirmation,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { ToolDispatcher } from "@/tools/dispatcher";
|
||||
import {
|
||||
attachRecordFinishListener,
|
||||
attachRecordQueryListener,
|
||||
attachRecordStepListener,
|
||||
} from "@/tools/record";
|
||||
import { detectBrowserMeta } from "@/transport/handshake";
|
||||
import type { Transport } from "@/transport/transport";
|
||||
import { WSTransport } from "@/transport/ws-transport";
|
||||
|
||||
export const DEFAULT_DAEMON_PORT = 52800;
|
||||
|
||||
export default defineBackground(() => {
|
||||
const port = DEFAULT_DAEMON_PORT;
|
||||
const controller = new ConnectionController();
|
||||
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
|
||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||
const sessions = new SessionManager();
|
||||
const cdp = new ChromiumCdp();
|
||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||
@@ -57,8 +60,7 @@ export default defineBackground(() => {
|
||||
deps: {
|
||||
// Skip every Agent Window when choosing where to render the
|
||||
// overlay — Agent Windows boot on about:blank, which has no
|
||||
// content script, so sendMessage would fail-open and silently
|
||||
// allow the borrow without any UI shown.
|
||||
// content script, so they cannot surface an authorization decision.
|
||||
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
|
||||
// Resolve i18n strings per-borrow so language switches take effect
|
||||
// without re-creating the dispatcher.
|
||||
@@ -71,6 +73,27 @@ export default defineBackground(() => {
|
||||
}),
|
||||
});
|
||||
dispatcher.start();
|
||||
const recordDeps = {
|
||||
tabsApi: chrome.tabs,
|
||||
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
|
||||
chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId: number, enabled: boolean) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
};
|
||||
// Message listeners stay up (cheap; fire only for record message types).
|
||||
// Tab / webNavigation observation attaches lazily while a recording is
|
||||
// active — see ensureBrowserObservationListeners in tools/record.ts.
|
||||
attachRecordStepListener();
|
||||
attachRecordFinishListener(recordDeps);
|
||||
attachRecordQueryListener(recordDeps);
|
||||
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
|
||||
attachBorrowNotificationClickHandler({
|
||||
onClicked: chrome.notifications.onClicked,
|
||||
@@ -85,8 +108,8 @@ export default defineBackground(() => {
|
||||
// The Allow / Deny buttons on the OS notification are the *explicit*
|
||||
// authorization fallback when every candidate user window's content
|
||||
// script was missing (extension just reloaded, page in BFCache, etc.).
|
||||
// Without this listener those button clicks would land nowhere and we'd
|
||||
// be back to fail-open-after-sendMessage-failure.
|
||||
// Without this listener those button clicks would land nowhere and the
|
||||
// request would only resolve via the fail-closed background timeout.
|
||||
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
|
||||
attachBorrowNotificationButtonHandler({
|
||||
onButtonClicked: chrome.notifications.onButtonClicked,
|
||||
@@ -167,9 +190,9 @@ export default defineBackground(() => {
|
||||
// silently doing nothing (review M4/M5 C2).
|
||||
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
|
||||
} else if (msg.kind === "set_connection_enabled") {
|
||||
void controller.setConnectionEnabled(msg.value).then(() =>
|
||||
persistConnectionEnabled(msg.value),
|
||||
);
|
||||
void controller
|
||||
.setConnectionEnabled(msg.value)
|
||||
.then(() => persistConnectionEnabled(msg.value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
|
||||
import { ControlOverlay } from "@/content/ControlOverlay";
|
||||
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
|
||||
import overlayCss from "@/content/overlay.css?inline";
|
||||
import { OverlayController } from "@/content/overlay-controller";
|
||||
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
|
||||
import { RecordOverlay } from "@/content/RecordOverlay";
|
||||
import {
|
||||
handleRecordContentMessage,
|
||||
isRecordContentMessage,
|
||||
type RecordCaptureController,
|
||||
} from "@/content/record-capture";
|
||||
import {
|
||||
HELP_RESPONSE,
|
||||
type HelpCancelMessage,
|
||||
@@ -12,24 +22,26 @@ import {
|
||||
isHelpRequestMessage,
|
||||
} from "@/lib/help-bridge";
|
||||
import {
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
OVERLAY_AUTOMATION_BYPASS,
|
||||
OVERLAY_MSG_WHO_AM_I,
|
||||
type OverlayAgentOverlayResetMessage,
|
||||
type OverlayAutomationBypassMessage,
|
||||
type OverlayWhoAmIResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
import {
|
||||
RECORD_FINISH,
|
||||
RECORD_QUERY,
|
||||
type RecordStartAck,
|
||||
type RecordStopAck,
|
||||
} from "@/lib/record-bridge";
|
||||
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
|
||||
import type {
|
||||
BorrowCancelMessage,
|
||||
BorrowRequestMessage,
|
||||
BorrowResponseMessage,
|
||||
} from "@/tools/borrow-confirmation";
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Run at document_end so the overlay does not block first paint. Only attach
|
||||
// in the top-level frame so iframes do not double-render overlays.
|
||||
@@ -45,6 +57,8 @@ export default defineContentScript({
|
||||
const overlays = new OverlayController();
|
||||
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
|
||||
null;
|
||||
let recordCapture: RecordCaptureController | null = null;
|
||||
let activeRecordRequestId: string | null = null;
|
||||
let reactRoot: ReactDOM.Root | null = null;
|
||||
let overlayHost: HTMLElement | null = null;
|
||||
let hostLossReported = false;
|
||||
@@ -87,12 +101,13 @@ export default defineContentScript({
|
||||
requests: overlayState.borrowRequests,
|
||||
}),
|
||||
React.createElement(ControlOverlay, {
|
||||
visible: overlayState.controlVisible && overlayState.activeHelp === null,
|
||||
visible: shouldShowAgentControlOverlay(overlayState),
|
||||
interrupting: overlayState.interrupting,
|
||||
automationBypass: overlayState.automationBypassCount > 0,
|
||||
onInterrupt: handleInterrupt,
|
||||
}),
|
||||
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
|
||||
React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -104,6 +119,9 @@ export default defineContentScript({
|
||||
activeHelpRespond?.("cancelled");
|
||||
activeHelpRespond = null;
|
||||
}
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
@@ -117,10 +135,7 @@ export default defineContentScript({
|
||||
}
|
||||
overlays.setInterrupting(true);
|
||||
renderOverlay();
|
||||
void sendInterrupt(
|
||||
(msg) => chrome.runtime.sendMessage(msg),
|
||||
sessionId,
|
||||
).then((reply) => {
|
||||
void sendInterrupt((msg) => chrome.runtime.sendMessage(msg), sessionId).then((reply) => {
|
||||
// Always retract the mask after the round trip resolves
|
||||
// (success, failure, or timeout). Cancellation is fire-and-
|
||||
// forget on the daemon side; the user must not be stuck
|
||||
@@ -143,6 +158,42 @@ export default defineContentScript({
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
renderOverlay();
|
||||
},
|
||||
onStop: () => {
|
||||
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
|
||||
renderOverlay();
|
||||
},
|
||||
},
|
||||
sendResponse as unknown as
|
||||
| ((response: RecordStartAck | RecordStopAck) => void)
|
||||
| undefined,
|
||||
);
|
||||
return needsAsync;
|
||||
}
|
||||
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
@@ -231,23 +282,59 @@ export default defineContentScript({
|
||||
return false;
|
||||
};
|
||||
|
||||
async function mountOverlayIfAgent(): Promise<void> {
|
||||
async function syncAgentOverlay(): Promise<void> {
|
||||
if (!(await anySessionLive())) return;
|
||||
try {
|
||||
const reply = (await chrome.runtime.sendMessage({
|
||||
kind: OVERLAY_MSG_WHO_AM_I,
|
||||
})) as OverlayWhoAmIResponse | undefined;
|
||||
if (!reply?.sessionId) return;
|
||||
|
||||
// Query / re-arm recording *before* activating the control overlay so
|
||||
// record start (navigate → content load) does not flash
|
||||
// 「Agent 正在控制」before RecordOverlay mounts.
|
||||
let recordQuery: { active?: boolean; requestId?: string } | undefined;
|
||||
try {
|
||||
recordQuery = (await chrome.runtime.sendMessage({
|
||||
type: RECORD_QUERY,
|
||||
})) as { active?: boolean; requestId?: string } | undefined;
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] record query failed", err);
|
||||
}
|
||||
|
||||
if (
|
||||
recordQuery?.active &&
|
||||
typeof recordQuery.requestId === "string" &&
|
||||
overlays.snapshot().activeRecord === null
|
||||
) {
|
||||
const requestId = recordQuery.requestId;
|
||||
overlays.setAgentRecordRequest({
|
||||
id: requestId,
|
||||
onFinish: () => {
|
||||
void chrome.runtime.sendMessage({
|
||||
type: RECORD_FINISH,
|
||||
requestId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
overlays.activateAgentSession(reply.sessionId);
|
||||
renderOverlay();
|
||||
} catch (err) {
|
||||
console.debug("[bsk overlay] who_am_i failed", err);
|
||||
console.debug("[bsk overlay] syncAgentOverlay failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void syncAgentOverlay();
|
||||
};
|
||||
|
||||
ui.mount();
|
||||
chrome.runtime.onMessage.addListener(onMessage);
|
||||
void mountOverlayIfAgent();
|
||||
void syncAgentOverlay();
|
||||
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
|
||||
const hostObserver = new MutationObserver(() => {
|
||||
const connected = overlayHost?.isConnected ?? false;
|
||||
@@ -257,6 +344,7 @@ export default defineContentScript({
|
||||
remountInProgress = true;
|
||||
try {
|
||||
ui.mount();
|
||||
void syncAgentOverlay();
|
||||
} finally {
|
||||
remountInProgress = false;
|
||||
}
|
||||
@@ -271,6 +359,11 @@ export default defineContentScript({
|
||||
ctx.onInvalidated(() => {
|
||||
hostObserver.disconnect();
|
||||
chrome.runtime.onMessage.removeListener(onMessage);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
// Restore history hooks / remove capture listeners before the CS unloads.
|
||||
recordCapture?.dispose();
|
||||
recordCapture = null;
|
||||
activeRecordRequestId = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ const baseSnapshot: SnapshotInfo = {
|
||||
connectionEnabled: true,
|
||||
};
|
||||
|
||||
function openRecordView() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
const setLabel = vi.fn();
|
||||
const setConnectionEnabled = vi.fn();
|
||||
@@ -63,13 +68,35 @@ describe("App", () => {
|
||||
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses flex gap for label field spacing", () => {
|
||||
const { container } = render(<App />);
|
||||
it("does not render record UI on the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
const labelField = container.querySelector("[data-slot='popup-label-field']");
|
||||
expect(labelField?.className).toContain("flex");
|
||||
expect(labelField?.className).toContain("flex-col");
|
||||
expect(labelField?.className).toContain("gap-3");
|
||||
expect(screen.queryByRole("button", { name: "复制录制指令" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "操作录制" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the feature list from the launcher and navigates to record", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
|
||||
expect(screen.getByText("操作录制")).toBeTruthy();
|
||||
expect(screen.getByText("录制你的操作,供 Agent 参考")).toBeTruthy();
|
||||
expect(screen.queryByText("未连接")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "复制录制指令" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns from the feature list to the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "返回" }));
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows single-line compact metadata and copies the instance id", async () => {
|
||||
@@ -132,8 +159,88 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("连接已关闭")).toBeTruthy();
|
||||
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
|
||||
"false",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("copies the record prompt with instance id and --browser when connected", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("例如:发布一篇文章"), {
|
||||
target: { value: "发布 wiki 文档" },
|
||||
});
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("03c3e47f");
|
||||
expect(copied).toContain("--browser 03c3e47f");
|
||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||
// Button label stays static; a transient toast confirms the copy.
|
||||
expect(copyButton.textContent).toContain("复制录制指令");
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
|
||||
});
|
||||
|
||||
it("includes --url in record prompt when a start URL is provided", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://…"), {
|
||||
target: { value: "https://example.com/" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制录制指令" }));
|
||||
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("--url https://example.com/");
|
||||
expect(copied).not.toContain("--purpose");
|
||||
});
|
||||
|
||||
it("disables record copy when disconnected", () => {
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiCheckLine,
|
||||
RiFileCopyLine,
|
||||
} from "@remixicon/react";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
import { PROTOCOL_VERSION } from "@/transport/handshake";
|
||||
import { ConnectionStatusIndicator } from "./connection-status-indicator";
|
||||
import { POPUP_FEATURES, type PopupView } from "./features";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
@@ -29,9 +36,15 @@ function getLogoSrc() {
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
|
||||
const [labelDraft, setLabelDraft] = useState(snapshot.label);
|
||||
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
||||
const [view, setView] = useState<PopupView>("main");
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
const [purposeDraft, setPurposeDraft] = useState("");
|
||||
const [startUrlDraft, setStartUrlDraft] = useState("");
|
||||
// Bumped on every successful copy so the "copied" toast re-shows (and its
|
||||
// auto-hide timer restarts) even when the copied content is unchanged.
|
||||
const [copiedTick, setCopiedTick] = useState(0);
|
||||
const showCopiedToast = copiedTick > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -44,17 +57,22 @@ export function App() {
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDraft(snapshot.label);
|
||||
}, [snapshot.label]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedInstanceId(false);
|
||||
setCopiedTick(0);
|
||||
}, [snapshot.instanceId]);
|
||||
|
||||
const onLabelBlur = () => {
|
||||
if (labelDraft !== snapshot.label) setLabel(labelDraft);
|
||||
};
|
||||
// Hide the copied toast when the command changes (topic / start URL edits).
|
||||
useEffect(() => {
|
||||
setCopiedTick(0);
|
||||
}, [purposeDraft, startUrlDraft]);
|
||||
|
||||
// Auto-hide the copied toast shortly after it appears.
|
||||
useEffect(() => {
|
||||
if (copiedTick === 0) return;
|
||||
const timer = window.setTimeout(() => setCopiedTick(0), 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copiedTick]);
|
||||
|
||||
const isSkewed = statusState === "version_skew";
|
||||
const daemonVersion = snapshot.handshake?.version ?? "—";
|
||||
@@ -68,142 +86,302 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
? [
|
||||
`bsk record start --browser ${snapshot.instanceId}`,
|
||||
recordStartUrl ? `--url ${recordStartUrl}` : "",
|
||||
recordPurpose ? `--purpose "${recordPurpose}"` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: "";
|
||||
const recordPrompt = snapshot.instanceId
|
||||
? t("popup.record.promptTemplate", {
|
||||
command: recordCommand,
|
||||
})
|
||||
: "";
|
||||
|
||||
const copyRecordPrompt = async () => {
|
||||
if (!recordReady || !navigator.clipboard?.writeText) return;
|
||||
await navigator.clipboard.writeText(recordPrompt);
|
||||
setCopiedTick((tick) => tick + 1);
|
||||
};
|
||||
|
||||
const headerTitle =
|
||||
view === "features"
|
||||
? t("popup.launcher.title")
|
||||
: view === "record"
|
||||
? t("popup.record.sectionTitle")
|
||||
: t("popup.brandName");
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
|
||||
data-slot="popup-root"
|
||||
data-view={view}
|
||||
data-version-skew={isSkewed ? "true" : undefined}
|
||||
>
|
||||
<header className="flex items-center gap-2" data-slot="popup-brand-header">
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt=""
|
||||
className="size-7 rounded-lg"
|
||||
data-slot="popup-brand-logo"
|
||||
/>
|
||||
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-connection-card"
|
||||
<header
|
||||
className="flex items-center gap-2"
|
||||
data-slot={view === "main" ? "popup-brand-header" : "popup-subview-header"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ConnectionStatusIndicator state={statusState} />
|
||||
<span
|
||||
className="truncate text-sm font-medium"
|
||||
data-slot="popup-state-label"
|
||||
>
|
||||
{t(STATE_LABEL_KEYS[statusState])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[10px] font-medium uppercase"
|
||||
data-slot="popup-state-badge"
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={snapshot.connectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
>
|
||||
{snapshot.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3" data-slot="popup-label-field">
|
||||
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
|
||||
{t("popup.labelTitle")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-label"
|
||||
type="text"
|
||||
value={labelDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
|
||||
onBlur={onLabelBlur}
|
||||
placeholder={t("popup.labelPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-label-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
<code
|
||||
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
|
||||
data-slot="popup-instance-id"
|
||||
>
|
||||
{instanceId}
|
||||
</code>
|
||||
{view !== "main" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 rounded-md"
|
||||
disabled={!snapshot.instanceId}
|
||||
aria-label={t("popup.copyInstanceId")}
|
||||
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
|
||||
onClick={() => {
|
||||
void copyInstanceId();
|
||||
}}
|
||||
data-slot="popup-copy-instance-id"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
aria-label={t("popup.back")}
|
||||
onClick={() => setView(view === "record" ? "features" : "main")}
|
||||
data-slot="popup-back"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<img
|
||||
src={getLogoSrc()}
|
||||
alt=""
|
||||
className="size-7 rounded-lg"
|
||||
data-slot="popup-brand-logo"
|
||||
/>
|
||||
)}
|
||||
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold tracking-tight">
|
||||
{headerTitle}
|
||||
</h1>
|
||||
{view === "main" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
aria-label={t("popup.launcher.title")}
|
||||
onClick={() => setView("features")}
|
||||
data-slot="popup-launcher"
|
||||
>
|
||||
<RiApps2Line className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{view === "main" && (
|
||||
<>
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
||||
data-slot="popup-connection-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ConnectionStatusIndicator state={statusState} />
|
||||
<span className="truncate text-sm font-medium" data-slot="popup-state-label">
|
||||
{t(STATE_LABEL_KEYS[statusState])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 text-[10px] font-medium uppercase"
|
||||
data-slot="popup-state-badge"
|
||||
>
|
||||
{t(STATE_BADGE_KEYS[statusState])}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={snapshot.connectionEnabled}
|
||||
aria-label={t("popup.connectionToggleTitle")}
|
||||
data-slot="popup-connection-toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
|
||||
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isSkewed && (
|
||||
<p
|
||||
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
|
||||
data-slot="popup-version-skew-warning"
|
||||
>
|
||||
{t("popup.versionSkewWarning", {
|
||||
extensionProtocol: PROTOCOL_VERSION,
|
||||
daemonProtocol,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{snapshot.lastError && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
|
||||
data-slot="popup-error"
|
||||
>
|
||||
{snapshot.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
|
||||
data-slot="popup-meta"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
|
||||
<span aria-hidden>/</span>
|
||||
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<span className="shrink-0">{t("popup.instanceTitle")}</span>
|
||||
<code
|
||||
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
|
||||
data-slot="popup-instance-id"
|
||||
>
|
||||
{instanceId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 rounded-md"
|
||||
disabled={!snapshot.instanceId}
|
||||
aria-label={t("popup.copyInstanceId")}
|
||||
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
|
||||
onClick={() => {
|
||||
void copyInstanceId();
|
||||
}}
|
||||
data-slot="popup-copy-instance-id"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "features" && (
|
||||
<section className="space-y-2" data-slot="popup-feature-list">
|
||||
{POPUP_FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<button
|
||||
key={feature.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border/80 bg-card/60 px-3 py-2.5 text-left transition-colors hover:bg-card/80"
|
||||
onClick={() => setView(feature.id)}
|
||||
data-slot={`popup-feature-${feature.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
{t(feature.titleKey)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
|
||||
{t(feature.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === "record" && (
|
||||
<section className="space-y-2.5" data-slot="popup-record-body">
|
||||
<p
|
||||
className="text-[11px] leading-snug text-muted-foreground"
|
||||
data-slot="popup-record-hint-desc"
|
||||
>
|
||||
{t("popup.record.sectionHint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-purpose-field">
|
||||
<Label htmlFor="bh-record-purpose" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.topicLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-purpose"
|
||||
type="text"
|
||||
value={purposeDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setPurposeDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("popup.record.topicPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-purpose-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-start-url-field">
|
||||
<Label htmlFor="bh-record-start-url" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.startUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-start-url"
|
||||
type="url"
|
||||
value={startUrlDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setStartUrlDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("popup.record.startUrlPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-start-url-input"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={recordPrompt}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-md border border-input bg-muted/40 px-2.5 py-2 font-mono text-[10px] leading-snug text-foreground/90 focus-visible:outline-none"
|
||||
data-slot="popup-record-prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{!recordReady && (
|
||||
<p className="text-[10px] text-muted-foreground" data-slot="popup-record-hint">
|
||||
{t("popup.record.hintDisconnected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative ml-auto shrink-0">
|
||||
{showCopiedToast && (
|
||||
<div
|
||||
role="status"
|
||||
className="absolute bottom-full right-0 mb-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium text-background shadow-md backdrop-blur-sm"
|
||||
data-slot="popup-record-copied-toast"
|
||||
>
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
{t("popup.record.copied")}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={!recordReady}
|
||||
onClick={() => {
|
||||
void copyRecordPrompt();
|
||||
}}
|
||||
data-slot="popup-record-copy"
|
||||
>
|
||||
<RiFileCopyLine className="size-3.5" aria-hidden />
|
||||
{t("popup.record.copyButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RemixiconComponentType } from "@remixicon/react";
|
||||
import { RiRecordCircleLine } from "@remixicon/react";
|
||||
|
||||
export type PopupFeatureId = "record";
|
||||
|
||||
export type PopupView = "main" | "features" | PopupFeatureId;
|
||||
|
||||
export type PopupFeature = {
|
||||
id: PopupFeatureId;
|
||||
icon: RemixiconComponentType;
|
||||
titleKey: "popup.record.sectionTitle";
|
||||
descKey: "popup.record.cardDesc";
|
||||
};
|
||||
|
||||
export const POPUP_FEATURES: PopupFeature[] = [
|
||||
{
|
||||
id: "record",
|
||||
icon: RiRecordCircleLine,
|
||||
titleKey: "popup.record.sectionTitle",
|
||||
descKey: "popup.record.cardDesc",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,13 @@
|
||||
@import "@browser-skill/ui/styles";
|
||||
@source "../../";
|
||||
@source "../../../../../packages/ui/src";
|
||||
|
||||
/* The shared base sets html/body/#root to height:100%, which is meant for
|
||||
full-page surfaces. In a popup that pins the window to the tallest content
|
||||
it ever showed, so collapsing the record section leaves blank space. Size
|
||||
to content instead so Chrome can grow and shrink the popup to fit. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { ConnectionController, __testing__ } from "../connection-controller";
|
||||
import type { Transport } from "../../transport/transport";
|
||||
import type { ConnectionState } from "../../transport/types";
|
||||
import type { ConnectionStateHandler } from "../../transport/transport";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
|
||||
import type { HandshakeResult } from "../../transport/types";
|
||||
import type { ConnectionStateHandler, Transport } from "../../transport/transport";
|
||||
import type { ConnectionState, HandshakeResult } from "../../transport/types";
|
||||
import { __testing__, ConnectionController } from "../connection-controller";
|
||||
|
||||
vi.mock("../instance-id", () => ({
|
||||
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { describeEventTarget, describeTarget, isMeaningfulClickTarget } from "../describe-target";
|
||||
|
||||
describe("describeTarget", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("prefers aria-label and button role", () => {
|
||||
document.body.innerHTML = `<button aria-label="发布">go</button>`;
|
||||
const el = document.querySelector("button")!;
|
||||
expect(describeTarget(el)).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses label text and name_attr for inputs", () => {
|
||||
document.body.innerHTML = `
|
||||
<label for="svc">服务名称</label>
|
||||
<input id="svc" name="serviceName" />
|
||||
`;
|
||||
const el = document.querySelector("input")!;
|
||||
expect(describeTarget(el)).toEqual({
|
||||
tag: "input",
|
||||
role: "textbox",
|
||||
name: "服务名称",
|
||||
name_attr: "serviceName",
|
||||
});
|
||||
});
|
||||
|
||||
it("never invents CSS selectors or @eN", () => {
|
||||
document.body.innerHTML = `<a href="/x">详情</a>`;
|
||||
const desc = describeTarget(document.querySelector("a")!);
|
||||
expect(JSON.stringify(desc)).not.toMatch(/@e\d+|nth-of-type|#/);
|
||||
expect(desc).toEqual({ tag: "a", role: "link", name: "详情" });
|
||||
});
|
||||
|
||||
it("prefers heading text inside SERP-style links", () => {
|
||||
document.body.innerHTML = `
|
||||
<a href="https://www.tencent.com">
|
||||
<h3>Tencent 腾讯</h3>
|
||||
<cite>https://www.tencent.com</cite>
|
||||
</a>
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("a")!).name).toBe("Tencent 腾讯");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LLM textbook click targets", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("rejects tag-only targets the LLM cannot find", () => {
|
||||
expect(isMeaningfulClickTarget({ tag: "div" })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a short visible name as the teaching signal", () => {
|
||||
expect(isMeaningfulClickTarget({ tag: "div", name: "发布" })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores anonymous layout chrome and lifts child clicks to the named button", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="noise">background</div>
|
||||
<button type="button"><span id="icon">发布</span></button>
|
||||
`;
|
||||
expect(describeEventTarget(document.querySelector("#noise"))).toBeNull();
|
||||
expect(describeEventTarget(document.querySelector("#icon"))).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach layout sibling text as nearby_label on buttons", () => {
|
||||
document.body.innerHTML = `
|
||||
<div>background</div>
|
||||
<button type="button" id="pub">发布</button>
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("#pub")!)).toEqual({
|
||||
tag: "button",
|
||||
role: "button",
|
||||
name: "发布",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps nearby_label for form fields from a preceding label-like sibling", () => {
|
||||
document.body.innerHTML = `
|
||||
<div>服务名称</div>
|
||||
<input id="svc" />
|
||||
`;
|
||||
expect(describeTarget(document.querySelector("#svc")!)).toEqual(
|
||||
expect.objectContaining({
|
||||
tag: "input",
|
||||
nearby_label: "服务名称",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat plain text blocks as clicks", () => {
|
||||
document.body.innerHTML = `<p id="copy">说明文字</p>`;
|
||||
expect(describeEventTarget(document.querySelector("#copy"))).toBeNull();
|
||||
});
|
||||
|
||||
it("records custom button-like controls by their visible label", () => {
|
||||
document.body.innerHTML = `<div class="btn" id="pub" style="cursor: pointer">发布</div>`;
|
||||
expect(describeEventTarget(document.querySelector("#pub"))).toEqual(
|
||||
expect.objectContaining({ tag: "div", name: "发布" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -51,12 +51,8 @@ describe("help-bridge", () => {
|
||||
});
|
||||
|
||||
it("type-guards a help-cancel message", () => {
|
||||
expect(
|
||||
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
|
||||
).toBe(true);
|
||||
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" })).toBe(true);
|
||||
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(false);
|
||||
expect(isHelpCancelMessage(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
OVERLAY_AGENT_OVERLAY_RESET,
|
||||
OVERLAY_MSG_INTERRUPT,
|
||||
type OverlayInterruptRequest,
|
||||
type OverlayInterruptResponse,
|
||||
isOverlayAgentOverlayResetMessage,
|
||||
} from "@/lib/overlay-bridge";
|
||||
|
||||
describe("OVERLAY_MSG_INTERRUPT", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
||||
|
||||
describe("sendInterrupt", () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendRecordedPayload, observeRecordedNavigation } from "../recording-step-buffer";
|
||||
|
||||
describe("recording-step-buffer", () => {
|
||||
it("stores semantic click without summary", () => {
|
||||
const buffer = { steps: [], pendingNavigation: false };
|
||||
appendRecordedPayload(buffer, {
|
||||
op: "click",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
expects_navigation: true,
|
||||
});
|
||||
expect(buffer.steps).toEqual([
|
||||
{
|
||||
op: "click",
|
||||
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,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftTraceStep } from "@/transport/types";
|
||||
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
|
||||
|
||||
describe("shouldRecordPress", () => {
|
||||
it("keeps Enter and Escape", () => {
|
||||
expect(shouldRecordPress("Enter")).toBe(true);
|
||||
expect(shouldRecordPress("Escape")).toBe(true);
|
||||
});
|
||||
|
||||
it("drops modifiers, clipboard shortcuts, and bare typing", () => {
|
||||
expect(shouldRecordPress("Meta")).toBe(false);
|
||||
expect(shouldRecordPress("c", ["meta"])).toBe(false);
|
||||
expect(shouldRecordPress("a", ["ctrl"])).toBe(false);
|
||||
expect(shouldRecordPress("x")).toBe(false);
|
||||
expect(shouldRecordPress("中")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceTraceSteps", () => {
|
||||
it("builds steps with pages dictionary and page id references", () => {
|
||||
const drafts: DraftTraceStep[] = [
|
||||
{
|
||||
op: "navigate",
|
||||
url: "https://example.com/search?q=hello&utm_source=x",
|
||||
page_url: "https://example.com/search?q=hello&utm_source=x",
|
||||
},
|
||||
{
|
||||
op: "fill",
|
||||
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
|
||||
value: "browser skill",
|
||||
page_url: "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",
|
||||
},
|
||||
{
|
||||
op: "click",
|
||||
target: { tag: "button", role: "button", name: "发布" },
|
||||
navigated_to: "https://example.com/p/99",
|
||||
page_url: "https://example.com/results/42",
|
||||
},
|
||||
{
|
||||
op: "press",
|
||||
key: "a",
|
||||
page_url: "https://example.com/p/99",
|
||||
},
|
||||
];
|
||||
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
drafts,
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
);
|
||||
expect(JSON.stringify(steps)).not.toContain("parameters");
|
||||
expect(JSON.stringify(steps)).not.toContain("intent");
|
||||
expect(JSON.stringify(steps)).not.toContain("summary");
|
||||
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/search?q=hello&utm_source=x",
|
||||
"https://example.com/results/42",
|
||||
"https://example.com/p/99",
|
||||
]);
|
||||
|
||||
expect(steps[0]).toMatchObject({
|
||||
id: 1,
|
||||
op: "navigate",
|
||||
page: "p1",
|
||||
to: "https://example.com/search?q=hello&utm_source=x",
|
||||
});
|
||||
|
||||
expect(steps[1]).toMatchObject({
|
||||
op: "fill",
|
||||
page: "p1",
|
||||
value: "browser skill",
|
||||
});
|
||||
|
||||
expect(steps[2]).toMatchObject({
|
||||
op: "press",
|
||||
key: "Enter",
|
||||
page: "p1",
|
||||
effect: { navigated_to: "p2" },
|
||||
});
|
||||
|
||||
expect(steps[3]).toMatchObject({
|
||||
op: "click",
|
||||
page: "p2",
|
||||
effect: { navigated_to: "p3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses consecutive navigations", () => {
|
||||
const { steps } = reduceTraceSteps([
|
||||
{ op: "navigate", url: "https://a.example/redirect1" },
|
||||
{ op: "navigate", url: "https://a.example/final" },
|
||||
]);
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "navigate",
|
||||
to: "https://a.example/final",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
|
||||
const { pages, steps } = reduceTraceSteps(
|
||||
[
|
||||
{
|
||||
op: "select",
|
||||
target: { tag: "select", role: "combobox", name: "分类" },
|
||||
values: ["tech"],
|
||||
labels: ["技术"],
|
||||
navigated_to: "https://example.com/list?cat=tech",
|
||||
page_url: "https://example.com/list",
|
||||
},
|
||||
],
|
||||
"https://example.com/list",
|
||||
);
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
"https://example.com/list",
|
||||
"https://example.com/list?cat=tech",
|
||||
]);
|
||||
expect(steps[0]).toMatchObject({
|
||||
op: "select",
|
||||
page: "p1",
|
||||
selection: [{ value: "tech", label: "技术" }],
|
||||
effect: { navigated_to: "p2" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveTraceStartUrl prefers explicit start URL", () => {
|
||||
expect(
|
||||
resolveTraceStartUrl(
|
||||
[{ op: "navigate", url: "https://example.com/other" }],
|
||||
"https://example.com/start",
|
||||
),
|
||||
).toBe("https://example.com/start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Build a semantic TargetDescriptor 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 {
|
||||
role?: string;
|
||||
name?: string;
|
||||
tag: string;
|
||||
name_attr?: string;
|
||||
placeholder?: string;
|
||||
nearby_label?: string;
|
||||
}
|
||||
|
||||
/** Max length for a label that is still a useful “find this on screen” hint. */
|
||||
const ACTIONABLE_LABEL_MAX = 48;
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 80): string {
|
||||
const trimmed = normalizeWhitespace(value);
|
||||
if (trimmed.length <= max) return trimmed;
|
||||
return `${trimmed.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/** Short, human label an LLM can search for in a snapshot. */
|
||||
export function isActionableLabel(name: string): boolean {
|
||||
const trimmed = normalizeWhitespace(name);
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.length > ACTIONABLE_LABEL_MAX) return false;
|
||||
if (/^https?:\/\//i.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function labelledByText(el: Element): string | null {
|
||||
const ids = el.getAttribute("aria-labelledby");
|
||||
if (!ids) return null;
|
||||
const parts = ids
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.textContent ?? "")
|
||||
.map(normalizeWhitespace)
|
||||
.filter(Boolean);
|
||||
return parts.length ? truncate(parts.join(" ")) : null;
|
||||
}
|
||||
|
||||
function associatedLabelText(el: Element): string | null {
|
||||
if (!(el instanceof HTMLElement) || !el.id) return null;
|
||||
const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
||||
if (!label) return null;
|
||||
const text = normalizeWhitespace(label.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
function wrappingLabelText(el: Element): string | null {
|
||||
const label = el.closest("label");
|
||||
if (!label) return null;
|
||||
const clone = label.cloneNode(true) as HTMLElement;
|
||||
for (const control of clone.querySelectorAll("input,textarea,select,button")) {
|
||||
control.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
function placeholderOrTitle(el: Element): string | null {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
const ph = normalizeWhitespace(el.placeholder);
|
||||
if (ph) return truncate(ph);
|
||||
}
|
||||
const title = normalizeWhitespace(el.getAttribute("title") ?? "");
|
||||
return title ? truncate(title) : null;
|
||||
}
|
||||
|
||||
function ownText(el: Element): string | null {
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return null;
|
||||
}
|
||||
const text = normalizeWhitespace(el.textContent ?? "");
|
||||
return text ? truncate(text) : null;
|
||||
}
|
||||
|
||||
/** Prefer a compact title inside links (e.g. SERP result `<h3>`), not the whole blob. */
|
||||
function compactLinkName(el: Element): string | null {
|
||||
const heading = el.querySelector("h1, h2, h3, h4");
|
||||
if (heading) {
|
||||
const text = normalizeWhitespace(heading.textContent ?? "");
|
||||
if (text) return truncate(text);
|
||||
}
|
||||
const img = el.querySelector("img[alt]");
|
||||
if (img instanceof HTMLImageElement) {
|
||||
const alt = normalizeWhitespace(img.alt);
|
||||
if (alt) return truncate(alt);
|
||||
}
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
for (const junk of clone.querySelectorAll("cite, script, style, svg, noscript")) {
|
||||
junk.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
if (!text) return null;
|
||||
const withoutUrl = text.replace(/https?:\/\/\S+/g, "").trim();
|
||||
return truncate(withoutUrl || text);
|
||||
}
|
||||
|
||||
/** Visible accessible name preference order for LLM textbooks. */
|
||||
export function accessibleName(el: Element): string | undefined {
|
||||
const ariaLabel = normalizeWhitespace(el.getAttribute("aria-label") ?? "");
|
||||
if (ariaLabel) return truncate(ariaLabel);
|
||||
|
||||
const labelledBy = labelledByText(el);
|
||||
if (labelledBy) return labelledBy;
|
||||
|
||||
const forLabel = associatedLabelText(el);
|
||||
if (forLabel) return forLabel;
|
||||
|
||||
const wrapLabel = wrappingLabelText(el);
|
||||
if (wrapLabel) return wrapLabel;
|
||||
|
||||
const alt =
|
||||
el instanceof HTMLImageElement
|
||||
? normalizeWhitespace(el.alt)
|
||||
: normalizeWhitespace(el.getAttribute("alt") ?? "");
|
||||
if (alt) return truncate(alt);
|
||||
|
||||
const ph = placeholderOrTitle(el);
|
||||
if (ph) return ph;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "a" || el.getAttribute("role") === "link") {
|
||||
const linkName = compactLinkName(el);
|
||||
if (linkName) return linkName;
|
||||
}
|
||||
|
||||
const text = ownText(el);
|
||||
if (text) return text;
|
||||
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) {
|
||||
const value = normalizeWhitespace(el.value);
|
||||
if (
|
||||
value &&
|
||||
(el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")
|
||||
) {
|
||||
return truncate(value);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button",
|
||||
"summary",
|
||||
"select",
|
||||
'input:not([type="hidden"])',
|
||||
"textarea",
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[role="tab"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="menuitemcheckbox"]',
|
||||
'[role="menuitemradio"]',
|
||||
'[role="option"]',
|
||||
'[role="checkbox"]',
|
||||
'[role="radio"]',
|
||||
'[role="switch"]',
|
||||
'[role="treeitem"]',
|
||||
'[role="combobox"]',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
].join(",");
|
||||
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
"button",
|
||||
"link",
|
||||
"tab",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"switch",
|
||||
"treeitem",
|
||||
"combobox",
|
||||
"textbox",
|
||||
"searchbox",
|
||||
"slider",
|
||||
]);
|
||||
|
||||
function looksClickable(el: Element): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
if (el.matches(INTERACTIVE_SELECTOR)) return true;
|
||||
const role = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
|
||||
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
||||
if (el.tabIndex >= 0) return true;
|
||||
try {
|
||||
if (getComputedStyle(el).cursor === "pointer") return true;
|
||||
} catch {
|
||||
// happy-dom / detached nodes
|
||||
}
|
||||
const cls = typeof el.className === "string" ? el.className : "";
|
||||
if (/\b(btn|button|link|clickable|action)\b/i.test(cls)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element a click should describe for the LLM textbook.
|
||||
* Never falls back to an anonymous layout `div`.
|
||||
*/
|
||||
export function resolveClickableElement(target: Element): Element | null {
|
||||
const interactive = target.closest(INTERACTIVE_SELECTOR);
|
||||
if (interactive instanceof Element) return interactive;
|
||||
|
||||
let node: Element | null = target;
|
||||
let depth = 0;
|
||||
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
|
||||
const name = accessibleName(node);
|
||||
if (name && isActionableLabel(name) && looksClickable(node)) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const name = target.name?.trim();
|
||||
if (name && isActionableLabel(name)) return true;
|
||||
if (
|
||||
(target.role === "checkbox" || target.role === "radio" || target.tag === "input") &&
|
||||
target.name_attr
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function inferRole(el: Element): string | undefined {
|
||||
const explicit = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
|
||||
if (explicit) return explicit;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "button") return "button";
|
||||
if (tag === "a" && el.hasAttribute("href")) return "link";
|
||||
if (tag === "select") return "combobox";
|
||||
if (tag === "textarea") return "textbox";
|
||||
if (tag === "img") return "img";
|
||||
if (tag === "summary") return "button";
|
||||
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
|
||||
return "textbox";
|
||||
}
|
||||
if (el instanceof HTMLInputElement) {
|
||||
const type = el.type.toLowerCase();
|
||||
if (type === "checkbox") return "checkbox";
|
||||
if (type === "radio") return "radio";
|
||||
if (type === "submit" || type === "button" || type === "image" || type === "reset") {
|
||||
return "button";
|
||||
}
|
||||
if (type === "range") return "slider";
|
||||
return "textbox";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearby labels help the LLM name form fields. Only apply to fillable
|
||||
* controls — never to buttons/links, where a previous layout sibling
|
||||
* (e.g. a chrome `<div>`) would pollute the textbook target.
|
||||
*/
|
||||
function nearbyLabelText(el: Element): string | undefined {
|
||||
if (
|
||||
!(
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (el.id) {
|
||||
const byFor = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
||||
const text = byFor ? normalizeWhitespace(byFor.textContent ?? "") : "";
|
||||
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
|
||||
}
|
||||
const wrapped = el.closest("label");
|
||||
if (wrapped) {
|
||||
const clone = wrapped.cloneNode(true) as HTMLLabelElement;
|
||||
for (const control of clone.querySelectorAll("input, textarea, select, button")) {
|
||||
control.remove();
|
||||
}
|
||||
const text = normalizeWhitespace(clone.textContent ?? "");
|
||||
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
|
||||
}
|
||||
const prev = el.previousElementSibling;
|
||||
if (prev && /^(LABEL|SPAN|DIV|P|DT)$/i.test(prev.tagName)) {
|
||||
const text = normalizeWhitespace(prev.textContent ?? "");
|
||||
if (text && text.length <= ACTIONABLE_LABEL_MAX) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function describeTarget(el: Element): TargetDescriptor {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = inferRole(el);
|
||||
const name = accessibleName(el);
|
||||
const nameAttr =
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement ||
|
||||
el instanceof HTMLButtonElement
|
||||
? normalizeWhitespace(el.name) || undefined
|
||||
: normalizeWhitespace(el.getAttribute("name") ?? "") || undefined;
|
||||
const placeholder =
|
||||
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
|
||||
? normalizeWhitespace(el.placeholder) || undefined
|
||||
: normalizeWhitespace(el.getAttribute("placeholder") ?? "") || undefined;
|
||||
const nearby = nearbyLabelText(el);
|
||||
|
||||
return {
|
||||
tag,
|
||||
...(role ? { role } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(nameAttr ? { name_attr: nameAttr } : {}),
|
||||
...(placeholder ? { placeholder } : {}),
|
||||
...(nearby && nearby !== name ? { nearby_label: nearby } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
|
||||
if (!(target instanceof Element)) return null;
|
||||
const clickable = resolveClickableElement(target);
|
||||
if (!clickable) return null;
|
||||
const desc = describeTarget(clickable);
|
||||
return isMeaningfulClickTarget(desc) ? desc : null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Wire protocol for user-action recording, sent between the background
|
||||
* service worker and a tab's content script.
|
||||
*/
|
||||
|
||||
import type { TargetDescriptor } from "./describe-target";
|
||||
|
||||
export const RECORD_START = "bsk-record-start";
|
||||
export const RECORD_STEP = "bsk-record-step";
|
||||
export const RECORD_STOP = "bsk-record-stop";
|
||||
export const RECORD_CANCEL = "bsk-record-cancel";
|
||||
export const RECORD_FINISH = "bsk-record-finish";
|
||||
export const RECORD_QUERY = "bsk-record-query";
|
||||
|
||||
export interface RecordStartAck {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export type RecordStopAck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface RecordQueryMessage {
|
||||
type: typeof RECORD_QUERY;
|
||||
}
|
||||
|
||||
export interface RecordQueryResponse {
|
||||
active: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface RecordStartMessage {
|
||||
type: typeof RECORD_START;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface RecordStepPayload {
|
||||
op: "click" | "fill" | "press" | "select" | "navigate";
|
||||
target?: TargetDescriptor;
|
||||
value?: string;
|
||||
key?: string;
|
||||
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
|
||||
values?: string[];
|
||||
labels?: string[];
|
||||
url?: string;
|
||||
redacted?: boolean;
|
||||
/** Page URL when the step was captured. */
|
||||
page_url?: string;
|
||||
/** Capture-only hint; never persisted unless converted to navigated_to. */
|
||||
expects_navigation?: boolean;
|
||||
/** Whether an observed URL change was synchronously caused by the action. */
|
||||
navigation_caused_by_action?: boolean;
|
||||
}
|
||||
|
||||
export interface RecordStepMessage {
|
||||
type: typeof RECORD_STEP;
|
||||
requestId: string;
|
||||
step: RecordStepPayload;
|
||||
}
|
||||
|
||||
export interface RecordStopMessage {
|
||||
type: typeof RECORD_STOP;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface RecordCancelMessage {
|
||||
type: typeof RECORD_CANCEL;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface RecordFinishMessage {
|
||||
type: typeof RECORD_FINISH;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_START && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordStopMessage(msg: unknown): msg is RecordStopMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_STOP && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordCancelMessage(msg: unknown): msg is RecordCancelMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_CANCEL && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordFinishMessage(msg: unknown): msg is RecordFinishMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_FINISH && typeof m.requestId === "string";
|
||||
}
|
||||
|
||||
export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
return m.type === RECORD_QUERY;
|
||||
}
|
||||
|
||||
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
|
||||
if (typeof msg !== "object" || msg === null) return false;
|
||||
const m = msg as Record<string, unknown>;
|
||||
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
|
||||
const step = m.step;
|
||||
if (typeof step !== "object" || step === null) return false;
|
||||
return typeof (step as RecordStepPayload).op === "string";
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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,218 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Collapse consecutive navigations to the last hop. */
|
||||
function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
|
||||
const out: DraftTraceStep[] = [];
|
||||
for (const step of steps) {
|
||||
const prev = out[out.length - 1];
|
||||
if (step.op === "navigate" && prev?.op === "navigate") {
|
||||
out[out.length - 1] = step;
|
||||
continue;
|
||||
}
|
||||
out.push(step);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
|
||||
const urls: string[] = [];
|
||||
if (startUrl) urls.push(startUrl);
|
||||
for (const step of steps) {
|
||||
if (step.op === "navigate") {
|
||||
urls.push(step.url);
|
||||
continue;
|
||||
}
|
||||
if ("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);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
unique.push(url);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildPageRegistry(
|
||||
steps: DraftTraceStep[],
|
||||
startUrl?: string,
|
||||
): { pages: PageRef[]; urlToId: Map<string, string> } {
|
||||
const urls = collectUrls(steps, startUrl);
|
||||
const urlToId = new Map<string, string>();
|
||||
const pages = urls.map((url, index) => {
|
||||
const id = `p${index + 1}`;
|
||||
urlToId.set(url, id);
|
||||
return { id, url };
|
||||
});
|
||||
return { pages, urlToId };
|
||||
}
|
||||
|
||||
function pageIdFor(
|
||||
url: string | undefined,
|
||||
urlToId: Map<string, string>,
|
||||
fallbackUrl?: string,
|
||||
): string {
|
||||
if (url && urlToId.has(url)) return urlToId.get(url)!;
|
||||
if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!;
|
||||
return urlToId.values().next().value ?? "p1";
|
||||
}
|
||||
|
||||
function pageUrlForDraft(step: 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;
|
||||
return fallbackUrl;
|
||||
}
|
||||
|
||||
function effectForNavigation(
|
||||
navigatedTo: string | undefined,
|
||||
urlToId: Map<string, string>,
|
||||
): Step["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 {
|
||||
if (!effect) return step;
|
||||
return { ...step, effect };
|
||||
}
|
||||
|
||||
function toSelection(values: string[], labels?: string[]): SelectedOption[] {
|
||||
return values.map((value, index) => ({
|
||||
value,
|
||||
...(labels?.[index] ? { label: labels[index] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function toV2Step(
|
||||
step: DraftTraceStep,
|
||||
id: number,
|
||||
urlToId: Map<string, string>,
|
||||
fallbackUrl?: string,
|
||||
): Step | null {
|
||||
if (!shouldIncludeDraft(step)) return null;
|
||||
|
||||
const pageUrl = pageUrlForDraft(step, fallbackUrl);
|
||||
const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
|
||||
|
||||
switch (step.op) {
|
||||
case "navigate":
|
||||
return {
|
||||
op: "navigate",
|
||||
id,
|
||||
page: pageIdFor(step.url, urlToId, fallbackUrl),
|
||||
to: step.url,
|
||||
};
|
||||
case "click":
|
||||
return withEffect(
|
||||
{
|
||||
op: "click",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
);
|
||||
case "fill":
|
||||
return {
|
||||
op: "fill",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
value: step.value,
|
||||
...(step.redacted ? { redacted: true } : {}),
|
||||
};
|
||||
case "press":
|
||||
return withEffect(
|
||||
{
|
||||
op: "press",
|
||||
id,
|
||||
page,
|
||||
key: step.key,
|
||||
...(step.target ? { target: step.target } : {}),
|
||||
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
);
|
||||
case "select":
|
||||
return withEffect(
|
||||
{
|
||||
op: "select",
|
||||
id,
|
||||
page,
|
||||
target: step.target,
|
||||
selection: toSelection(step.values, step.labels),
|
||||
},
|
||||
effectForNavigation(step.navigated_to, urlToId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReducedTrace {
|
||||
pages: PageRef[];
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const collapsed = collapseNavigations(steps);
|
||||
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
|
||||
const out: Step[] = [];
|
||||
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;
|
||||
const step = toV2Step(draft, id, urlToId, lastUrl);
|
||||
if (!step) continue;
|
||||
out.push(step);
|
||||
id += 1;
|
||||
}
|
||||
return { pages, steps: out };
|
||||
}
|
||||
|
||||
export function resolveTraceStartUrl(
|
||||
drafts: DraftTraceStep[],
|
||||
startUrl?: string,
|
||||
pages?: PageRef[],
|
||||
): 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";
|
||||
}
|
||||
@@ -162,6 +162,22 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("denies malformed or missing confirmation responses", async () => {
|
||||
tabs.get.mockResolvedValue({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValue(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
);
|
||||
|
||||
for (const response of [undefined, {}, { allowed: true }, { type: "borrow-response" }]) {
|
||||
tabs.sendMessage.mockResolvedValueOnce(response);
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(await pending).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the Agent Window when it is lastFocusedWindow and falls back to a real user window", async () => {
|
||||
tabs.get.mockResolvedValueOnce({
|
||||
id: 42,
|
||||
@@ -250,7 +266,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(tabs.sendMessage.mock.calls[1]?.[0]).toBe(301);
|
||||
});
|
||||
|
||||
it("does NOT fail-open immediately when every candidate's sendMessage fails — waits for notification button or timeout", async () => {
|
||||
it("waits for a notification decision and denies when every UI path times out", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
@@ -282,13 +298,13 @@ describe("requestBorrowConfirmation", () => {
|
||||
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
// CRITICAL: previously this would already be settled(true). Now we
|
||||
// require an explicit user choice (notification button) or timeout.
|
||||
// require an explicit user choice (notification button) or safe timeout.
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
// Allow the BACKGROUND_TIMEOUT_MS fail-open to fire so the test resolves.
|
||||
// The background timeout must resolve the request without authorizing it.
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(resolvedValue).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(resolvedValue).toBe(false);
|
||||
|
||||
const exhaustedWarn = warnSpy.mock.calls.find(
|
||||
(call) =>
|
||||
@@ -375,7 +391,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(await pending).toBe(false);
|
||||
});
|
||||
|
||||
it("fail-opens when no injectable user window exists at all", async () => {
|
||||
it("denies when no injectable user window exists at all", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Stranded Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
|
||||
@@ -394,7 +410,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).not.toHaveBeenCalled();
|
||||
// No notification either — there's no actionable target window.
|
||||
expect(notifications.create).not.toHaveBeenCalled();
|
||||
@@ -421,6 +437,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
expect(notificationId.startsWith(BORROW_NOTIFICATION_PREFIX)).toBe(true);
|
||||
expect(options.type).toBe("basic");
|
||||
expect(options.title).toMatch(/borrow/i);
|
||||
expect(options.requireInteraction).toBe(true);
|
||||
expect(notifications.clear).toHaveBeenCalledWith(notificationId);
|
||||
});
|
||||
|
||||
@@ -447,10 +464,12 @@ describe("requestBorrowConfirmation", () => {
|
||||
userWindowWithActiveTab({ windowId: 77, tabId: 4242, url: "https://docs.example/" }),
|
||||
);
|
||||
// Keep sendMessage pending so the request stays live while we click.
|
||||
// A second call may arrive if the background timeout dismisses the overlay.
|
||||
let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined;
|
||||
tabs.sendMessage.mockImplementationOnce(
|
||||
() => new Promise((res) => (resolveSend = res as never)),
|
||||
);
|
||||
tabs.sendMessage.mockResolvedValue(undefined);
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
@@ -460,7 +479,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
|
||||
// Simulate user clicking the OS notification.
|
||||
for (const l of listeners) l(notificationId);
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
|
||||
|
||||
// Finish the borrow so the test resolves cleanly.
|
||||
@@ -469,21 +488,29 @@ describe("requestBorrowConfirmation", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("fail-opens after background timeout when content script never responds", async () => {
|
||||
it("denies after background timeout when content script never responds", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
|
||||
);
|
||||
tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
tabs.sendMessage.mockResolvedValueOnce(undefined);
|
||||
|
||||
const pending = requestBorrowConfirmation(42, {
|
||||
deps: { tabs, windows, notifications },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
// Timeout must dismiss any in-flight overlay so Allow cannot linger.
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
|
||||
type: string;
|
||||
requestId: string;
|
||||
};
|
||||
expect(cancelMessage.type).toBe("borrow-cancel");
|
||||
});
|
||||
|
||||
it("dismisses pending overlay and fail-opens when aborted", async () => {
|
||||
it("dismisses the pending overlay and denies when aborted", async () => {
|
||||
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
|
||||
windows.getLastFocused.mockResolvedValueOnce(
|
||||
userWindowWithActiveTab({ windowId: 11, tabId: 7, url: "https://app.example/" }),
|
||||
@@ -500,7 +527,7 @@ describe("requestBorrowConfirmation", () => {
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(await pending).toBe(true);
|
||||
expect(await pending).toBe(false);
|
||||
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
|
||||
const requestMessage = tabs.sendMessage.mock.calls[0][1] as { requestId: string };
|
||||
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("handleConsole", () => {
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
|
||||
});
|
||||
|
||||
it("reads an explicit tab and forwards bounded options", async () => {
|
||||
it("reads an explicit tab and forwards cursor options", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
@@ -85,27 +85,15 @@ describe("handleConsole", () => {
|
||||
session_id: "aa11",
|
||||
tab_id: 9,
|
||||
since: 12,
|
||||
limit: 500,
|
||||
max_text_chars: 9999,
|
||||
limit: 75,
|
||||
max_text_chars: 1500,
|
||||
include_stack: true,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true);
|
||||
});
|
||||
|
||||
it("rejects invalid bounds before touching CDP", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps();
|
||||
|
||||
const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
|
||||
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
|
||||
expect(deps.consoleEntriesSince).not.toHaveBeenCalled();
|
||||
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
|
||||
});
|
||||
|
||||
it("hides other sessions' Agent Window tabs", async () => {
|
||||
|
||||
@@ -106,6 +106,13 @@ describe("ToolDispatcher", () => {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureConsoleCapture: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
consoleEntriesSince: vi.fn(
|
||||
() =>
|
||||
({
|
||||
@@ -149,6 +156,13 @@ describe("ToolDispatcher", () => {
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
detachSession: vi.fn(async () => {}),
|
||||
ensureNetworkCapture: vi.fn(async () => {}),
|
||||
networkEntriesSince: vi.fn(() => ({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
})),
|
||||
};
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
|
||||
dispatcher.start();
|
||||
|
||||
@@ -284,6 +284,9 @@ describe("handleEvaluate", () => {
|
||||
{ session_id: "aa11", expression: "1+1", tab_id: 11 },
|
||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||
);
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,13 +36,21 @@ function baseDeps(over: Partial<RequestHelpDeps> = {}): RequestHelpDeps {
|
||||
|
||||
describe("handleRequestHelp", () => {
|
||||
it("rejects unknown session", async () => {
|
||||
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ session_id: "zzzz" }), baseDeps());
|
||||
const res = await handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ session_id: "zzzz" }),
|
||||
baseDeps(),
|
||||
);
|
||||
expect("code" in res && res.code).toBe("not_found");
|
||||
});
|
||||
|
||||
it("brings the tab to the foreground and returns the user outcome", async () => {
|
||||
const deps = baseDeps();
|
||||
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
|
||||
const res = await handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ tab_id: 5 }),
|
||||
deps,
|
||||
);
|
||||
expect(deps.windows.update).toHaveBeenCalledWith(99, { focused: true });
|
||||
expect(deps.activateTab).toHaveBeenCalledWith(5);
|
||||
expect(res).toMatchObject({ outcome: "continued", note: "ok", tab_id: 5 });
|
||||
@@ -79,7 +87,11 @@ describe("handleRequestHelp", () => {
|
||||
return unwatch;
|
||||
}),
|
||||
});
|
||||
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
|
||||
const res = await handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ tab_id: 5 }),
|
||||
deps,
|
||||
);
|
||||
expect(res).toMatchObject({ outcome: "navigated", tab_id: 5 });
|
||||
expect(unwatch).toHaveBeenCalled();
|
||||
});
|
||||
@@ -107,12 +119,19 @@ describe("handleRequestHelp", () => {
|
||||
const mgr = {
|
||||
get: (id: string) =>
|
||||
id === "abcd"
|
||||
? { sessionId: "abcd", agentWindowId: 99, refStore: { resolve: () => 42 }, borrowedTabs: new Map() }
|
||||
? {
|
||||
sessionId: "abcd",
|
||||
agentWindowId: 99,
|
||||
refStore: { resolve: () => 42 },
|
||||
borrowedTabs: new Map(),
|
||||
}
|
||||
: null,
|
||||
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
|
||||
} as unknown as SessionManager;
|
||||
const deps = baseDeps({
|
||||
cdp: { send: vi.fn(async () => ({ object: { objectId: "obj-1" } })) } as unknown as RequestHelpDeps["cdp"],
|
||||
cdp: {
|
||||
send: vi.fn(async () => ({ object: { objectId: "obj-1" } })),
|
||||
} as unknown as RequestHelpDeps["cdp"],
|
||||
});
|
||||
const res = await handleRequestHelp(
|
||||
mgr,
|
||||
@@ -177,7 +196,11 @@ describe("handleRequestHelp", () => {
|
||||
throw new Error("no receiver");
|
||||
}),
|
||||
});
|
||||
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
|
||||
const res = await handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ tab_id: 5 }),
|
||||
deps,
|
||||
);
|
||||
expect("code" in res && res.code).toBe("protocol_error");
|
||||
});
|
||||
|
||||
@@ -254,7 +277,11 @@ describe("handleRequestHelp", () => {
|
||||
it("returns cancelled when the signal aborts", async () => {
|
||||
const ac = new AbortController();
|
||||
const deps = baseDeps({ signal: ac.signal, sendToTab: vi.fn(() => new Promise(() => {})) });
|
||||
const p = handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5, timeout_ms: 60_000 }), deps);
|
||||
const p = handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ tab_id: 5, timeout_ms: 60_000 }),
|
||||
deps,
|
||||
);
|
||||
ac.abort();
|
||||
const res = await p;
|
||||
expect("code" in res ? res.code : (res as { outcome: string }).outcome).toMatch(/cancelled/);
|
||||
|
||||
@@ -982,4 +982,3 @@ describe("handleSelect", () => {
|
||||
expect(fake.sent.some((c) => c.method === "Runtime.callFunctionOn")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
import {
|
||||
eventLoaderIsRelevant,
|
||||
handleNavigate,
|
||||
handleNavigateBack,
|
||||
handleNavigateForward,
|
||||
handleReload,
|
||||
lifecycleAlreadyReached,
|
||||
eventLoaderIsRelevant,
|
||||
lifecycleMeetsOrExceeds,
|
||||
shouldTrustReadyStateProbe,
|
||||
} from "../navigation";
|
||||
@@ -60,10 +60,7 @@ function makeFakeCdp(opts?: {
|
||||
};
|
||||
const events: EventListener[] = [];
|
||||
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
|
||||
const methodHandlers: Record<
|
||||
string,
|
||||
(tabId: number, params?: object) => object
|
||||
> = {
|
||||
const methodHandlers: Record<string, (tabId: number, params?: object) => object> = {
|
||||
"Page.enable": () => ({}),
|
||||
"Page.setLifecycleEventsEnabled": () => ({}),
|
||||
"Page.navigate": () => {
|
||||
@@ -72,9 +69,7 @@ function makeFakeCdp(opts?: {
|
||||
name: opts.fireLifecycleDuringNavigate,
|
||||
frameId: opts.fireLifecycleDuringNavigateFrameId ?? opts.navigateFrameId ?? "frame-1",
|
||||
loaderId:
|
||||
opts.fireLifecycleDuringNavigateLoaderId ??
|
||||
opts.navigateLoaderId ??
|
||||
"loader-after",
|
||||
opts.fireLifecycleDuringNavigateLoaderId ?? opts.navigateLoaderId ?? "loader-after",
|
||||
};
|
||||
for (const listener of [...events]) {
|
||||
listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
|
||||
@@ -208,9 +203,7 @@ describe("lifecycleMeetsOrExceeds", () => {
|
||||
|
||||
describe("eventLoaderIsRelevant", () => {
|
||||
it("rejects events from the pre-navigation loader", () => {
|
||||
expect(
|
||||
eventLoaderIsRelevant("loader-before", { beforeLoaderId: "loader-before" }),
|
||||
).toBe(false);
|
||||
expect(eventLoaderIsRelevant("loader-before", { beforeLoaderId: "loader-before" })).toBe(false);
|
||||
expect(
|
||||
eventLoaderIsRelevant("loader-after", {
|
||||
beforeLoaderId: "loader-before",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { NetworkResult } from "@/transport/types";
|
||||
import { handleNetwork, type NetworkCdpRunner } from "../network";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
opts: {
|
||||
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
|
||||
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
|
||||
result?: NetworkResult;
|
||||
captureError?: Error;
|
||||
} = {},
|
||||
) {
|
||||
const result =
|
||||
opts.result ??
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
} satisfies NetworkResult);
|
||||
const ensureNetworkCapture = vi.fn(async () => {
|
||||
if (opts.captureError) throw opts.captureError;
|
||||
});
|
||||
const networkEntriesSince = vi.fn(() => result);
|
||||
const trackSessionTab = vi.fn();
|
||||
const cdp = {
|
||||
trackSessionTab,
|
||||
ensureNetworkCapture,
|
||||
networkEntriesSince,
|
||||
} satisfies NetworkCdpRunner;
|
||||
const tabsApi = {
|
||||
get:
|
||||
opts.get ??
|
||||
vi.fn(
|
||||
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query:
|
||||
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||
};
|
||||
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince, trackSessionTab };
|
||||
}
|
||||
|
||||
describe("handleNetwork", () => {
|
||||
it("reads the Agent Window active tab with safe defaults", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [
|
||||
{ sequence: 3, kind: "response", url: "https://x/api", status: 404, truncated: false },
|
||||
],
|
||||
next_since: 3,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.entries[0].status).toBe(404);
|
||||
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(7);
|
||||
expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000);
|
||||
});
|
||||
|
||||
it("reads an explicit tab and forwards cursor options", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
await handleNetwork(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
tab_id: 9,
|
||||
since: 12,
|
||||
limit: 75,
|
||||
max_text_chars: 1500,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9);
|
||||
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500);
|
||||
});
|
||||
|
||||
it("surfaces network capture failures as cdp_failed", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({ captureError: new Error("Network.enable denied") });
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
|
||||
|
||||
expect(res).toEqual({ code: "cdp_failed", message: "Network.enable denied" });
|
||||
expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||
expect(deps.networkEntriesSince).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides other sessions' Agent Window tabs", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
|
||||
await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11", tab_id: 9 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "not_found" });
|
||||
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -42,8 +42,7 @@ function makeScreenshotDeps(
|
||||
opts.get ??
|
||||
vi.fn(async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab);
|
||||
const query =
|
||||
opts.query ??
|
||||
vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]);
|
||||
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]);
|
||||
const captureVisibleTab =
|
||||
opts.captureVisibleTab ?? vi.fn(async () => `data:image/png;base64,${TINY_PNG}`);
|
||||
const tabsApi = { get, query };
|
||||
@@ -118,7 +117,11 @@ describe("handleScreenshot", () => {
|
||||
const res = await handleScreenshot(
|
||||
sm,
|
||||
{ session_id: "aa11" },
|
||||
makeScreenshotDeps({ captureVisibleTab: vi.fn(), get: emptyGet, query: vi.fn(async () => []) }),
|
||||
makeScreenshotDeps({
|
||||
captureVisibleTab: vi.fn(),
|
||||
get: emptyGet,
|
||||
query: vi.fn(async () => []),
|
||||
}),
|
||||
);
|
||||
expect(res).toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureBrowserObservationListeners,
|
||||
isBrowserObservationAttachedForTests,
|
||||
RECORD_DEFAULT_START_URL,
|
||||
releaseBrowserObservationListenersIfIdle,
|
||||
resetBrowserObservationForTests,
|
||||
setBrowserObservationAttachForTests,
|
||||
} from "../record";
|
||||
|
||||
describe("record defaults", () => {
|
||||
it("uses example.com as the default injectable start URL", () => {
|
||||
expect(RECORD_DEFAULT_START_URL).toBe("https://example.com/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser observation lifecycle", () => {
|
||||
afterEach(() => {
|
||||
resetBrowserObservationForTests();
|
||||
});
|
||||
|
||||
it("does not attach listeners until ensure is called", () => {
|
||||
const tab = vi.fn(() => () => undefined);
|
||||
const nav = vi.fn(() => () => undefined);
|
||||
setBrowserObservationAttachForTests(tab, nav);
|
||||
|
||||
expect(isBrowserObservationAttachedForTests()).toBe(false);
|
||||
expect(tab).not.toHaveBeenCalled();
|
||||
expect(nav).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches once on ensure and detaches when idle", () => {
|
||||
const detachTab = vi.fn();
|
||||
const detachNav = vi.fn();
|
||||
const tab = vi.fn(() => detachTab);
|
||||
const nav = vi.fn(() => detachNav);
|
||||
setBrowserObservationAttachForTests(tab, nav);
|
||||
|
||||
ensureBrowserObservationListeners({
|
||||
tabsApi: { get: vi.fn(), query: vi.fn(), create: vi.fn() } as never,
|
||||
sendToTab: vi.fn(),
|
||||
});
|
||||
expect(isBrowserObservationAttachedForTests()).toBe(true);
|
||||
expect(tab).toHaveBeenCalledTimes(1);
|
||||
expect(nav).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Idempotent.
|
||||
ensureBrowserObservationListeners({
|
||||
tabsApi: { get: vi.fn(), query: vi.fn(), create: vi.fn() } as never,
|
||||
sendToTab: vi.fn(),
|
||||
});
|
||||
expect(tab).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
expect(isBrowserObservationAttachedForTests()).toBe(false);
|
||||
expect(detachTab).toHaveBeenCalledTimes(1);
|
||||
expect(detachNav).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -139,9 +139,13 @@ describe("handleSessionStop with auto-return", () => {
|
||||
resetAgentOverlays: vi.fn(async () => {}),
|
||||
} satisfies AgentOverlayResetApi;
|
||||
|
||||
const res = await handleSessionStop(sm, { session_id: "aa11" }, {
|
||||
tabManagement: { tabs, windows, agentOverlayReset },
|
||||
});
|
||||
const res = await handleSessionStop(
|
||||
sm,
|
||||
{ session_id: "aa11" },
|
||||
{
|
||||
tabManagement: { tabs, windows, agentOverlayReset },
|
||||
},
|
||||
);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(agentOverlayReset.resetAgentOverlays).toHaveBeenCalledWith(7, "aa11");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import { lookupSession } from "../shared";
|
||||
import { lookupSession, parseBufferedReadBounds } from "../shared";
|
||||
|
||||
function fakeAgentWindow() {
|
||||
return {
|
||||
@@ -20,9 +20,7 @@ describe("lookupSession", () => {
|
||||
expect(lookupSession(sm, { session_id: "" }, "tool.test")).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
expect(
|
||||
lookupSession(sm, { session_id: 42 as unknown as string }, "tool.test"),
|
||||
).toMatchObject({
|
||||
expect(lookupSession(sm, { session_id: 42 as unknown as string }, "tool.test")).toMatchObject({
|
||||
code: "invalid_params",
|
||||
});
|
||||
});
|
||||
@@ -42,3 +40,30 @@ describe("lookupSession", () => {
|
||||
expect(result).toBe(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBufferedReadBounds", () => {
|
||||
it("applies shared defaults and caps", () => {
|
||||
expect(parseBufferedReadBounds({})).toEqual({
|
||||
since: undefined,
|
||||
limit: 50,
|
||||
maxTextChars: 1000,
|
||||
});
|
||||
expect(parseBufferedReadBounds({ since: 12, limit: 500, max_text_chars: 9999 })).toEqual({
|
||||
since: 12,
|
||||
limit: 200,
|
||||
maxTextChars: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ since: -1 }, "since"],
|
||||
[{ since: 1.5 }, "since"],
|
||||
[{ limit: 0 }, "limit"],
|
||||
[{ max_text_chars: 0 }, "max_text_chars"],
|
||||
])("rejects invalid bounds in %o", (params, field) => {
|
||||
expect(parseBufferedReadBounds(params)).toMatchObject({
|
||||
code: "invalid_params",
|
||||
message: expect.stringContaining(field),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +292,10 @@ describe("handleTabClose", () => {
|
||||
};
|
||||
const { api } = makeTabMutationApi(state);
|
||||
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 9 }, { tabs: api });
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects when another session has borrowed the tab", async () => {
|
||||
@@ -338,7 +341,10 @@ describe("handleTabSelect", () => {
|
||||
};
|
||||
const { api } = makeTabMutationApi(state);
|
||||
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a borrowed tab that is no longer in the Agent Window", async () => {
|
||||
@@ -352,7 +358,10 @@ describe("handleTabSelect", () => {
|
||||
};
|
||||
const { api } = makeTabMutationApi(state);
|
||||
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -444,7 +453,10 @@ describe("handleTabBorrow", () => {
|
||||
};
|
||||
const { api } = makeTabMutationApi(state);
|
||||
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
||||
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
|
||||
expect(res).toMatchObject({
|
||||
code: "permission_denied",
|
||||
data: { reason: "agent_window_scope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancelled when the approver declines", async () => {
|
||||
|
||||
@@ -28,8 +28,9 @@ export const CONFIRMATION_TIMEOUT_MS = 5000;
|
||||
const EXIT_ANIMATION_MS = 150;
|
||||
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
|
||||
export const PROGRESS_TRANSITION_MS = 1000;
|
||||
// UI auto-allows after countdown + progress transition + exit fade. Background
|
||||
// timeout must not fire before that chain completes or deny is ignored.
|
||||
// UI auto-denies after countdown + progress transition + exit fade. Background
|
||||
// timeout must not fire before that chain completes or the UI decision races
|
||||
// with an already-settled deny.
|
||||
export const BACKGROUND_TIMEOUT_MS =
|
||||
CONFIRMATION_TIMEOUT_MS + PROGRESS_TRANSITION_MS + EXIT_ANIMATION_MS + 500;
|
||||
|
||||
@@ -114,9 +115,8 @@ const pendingBorrowNotifications = new Map<string, PendingBorrowNotification>();
|
||||
*
|
||||
* This is the *fallback* authorization path that fires when no candidate
|
||||
* user window could host the in-page overlay (every `chrome.tabs.sendMessage`
|
||||
* was rejected because the content script was not present). Without it the
|
||||
* background-task fail-open at the end of `tryCandidate` would silently
|
||||
* allow the borrow without any user-visible authorization step.
|
||||
* was rejected because the content script was not present). It preserves an
|
||||
* explicit authorization path without weakening the fail-closed timeout.
|
||||
*/
|
||||
const pendingBorrowDecisions = new Map<string, (allowed: boolean) => void>();
|
||||
|
||||
@@ -170,7 +170,7 @@ export function attachBorrowNotificationClickHandler(deps: {
|
||||
* Allow / Deny buttons on the OS notification can resolve the matching
|
||||
* pending `requestBorrowConfirmation`. This is the explicit-authorization
|
||||
* fallback path used when every candidate user window's content script was
|
||||
* missing — without it we'd be back to silently fail-opening the borrow.
|
||||
* missing.
|
||||
*
|
||||
* Button index convention (matches `BorrowNotificationCopy`):
|
||||
* - 0 → Allow → `resolve(true)`
|
||||
@@ -326,8 +326,7 @@ async function listConfirmationCandidates(
|
||||
if (typeof w.id !== "number") continue;
|
||||
// Never bounce the overlay back into a session's Agent Window — those
|
||||
// pages can't host a content script when they're on the about:blank
|
||||
// bootstrap URL, so sendMessage would fail-open and silently allow the
|
||||
// borrow with no UI shown.
|
||||
// bootstrap URL, so they cannot surface an authorization decision.
|
||||
if (isAgentWindowId(w.id)) continue;
|
||||
const activeTab = w.tabs?.find((t) => t.active === true) ?? null;
|
||||
if (!activeTab || typeof activeTab.id !== "number") continue;
|
||||
@@ -360,16 +359,14 @@ async function listConfirmationCandidates(
|
||||
* *explicit-authorization fallback* when every candidate fails sendMessage:
|
||||
* we no longer silently allow the borrow in that case, the promise stays
|
||||
* pending until the user clicks a notification button (resolves true/false)
|
||||
* or `BACKGROUND_TIMEOUT_MS` fires (last-resort fail-open so the agent
|
||||
* doesn't hang forever when even the notification path is broken).
|
||||
* or `BACKGROUND_TIMEOUT_MS` fires and safely denies the request.
|
||||
*
|
||||
* Clicking the notification body (vs. its buttons) focuses the chosen user
|
||||
* window so the overlay becomes visible — the previous behaviour.
|
||||
*
|
||||
* Returns `true` on user-allow (overlay or notification) OR last-resort
|
||||
* fail-open (no candidate window at all; abort signal; timeout). Returns
|
||||
* `false` only on explicit user-deny (overlay button or notification Deny
|
||||
* button).
|
||||
* Returns `true` only on an explicit user allow from the overlay or
|
||||
* notification. Missing UI, aborts, malformed responses, and timeouts all
|
||||
* fail closed and return `false`.
|
||||
*/
|
||||
export async function requestBorrowConfirmation(
|
||||
tabId: number,
|
||||
@@ -385,12 +382,9 @@ export async function requestBorrowConfirmation(
|
||||
|
||||
// The borrow pipeline already fetched this tab moments earlier
|
||||
// (validateBorrowTarget in tabs.ts), so a failure here is a transient
|
||||
// SW/page hiccup, not a missing tab. Do NOT fail-open: silently allowing
|
||||
// a borrow with no confirmation UI contradicts the gate's purpose and is
|
||||
// not one of the documented fail-open cases (no candidate window; abort;
|
||||
// timeout — see this function's doc comment). Fall back to a generic
|
||||
// title and still surface the overlay + notification so the user gets a
|
||||
// real chance to approve or deny.
|
||||
// SW/page hiccup, not a missing tab. Fall back to a generic title and still
|
||||
// surface the overlay + notification so the user gets a real chance to
|
||||
// approve or deny.
|
||||
let tabTitle: string;
|
||||
try {
|
||||
const tab = await tabsApi.get(tabId);
|
||||
@@ -405,10 +399,10 @@ export async function requestBorrowConfirmation(
|
||||
|
||||
const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId);
|
||||
if (candidates.length === 0) {
|
||||
console.warn("[bsk borrow] no injectable user window available — proceeding without overlay", {
|
||||
console.warn("[bsk borrow] no injectable user window available — denying borrow", {
|
||||
tabId,
|
||||
});
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestId = createBorrowRequestId(tabId);
|
||||
@@ -419,7 +413,7 @@ export async function requestBorrowConfirmation(
|
||||
// overlay immediately.
|
||||
const notificationAnchor = candidates[0];
|
||||
if (!notificationAnchor) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -448,22 +442,25 @@ export async function requestBorrowConfirmation(
|
||||
resolve(allowed);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (activeMessageTabId !== null) {
|
||||
const cancelMessage: BorrowCancelMessage = {
|
||||
type: "borrow-cancel",
|
||||
const dismissPendingOverlay = () => {
|
||||
if (activeMessageTabId === null) return;
|
||||
const cancelMessage: BorrowCancelMessage = {
|
||||
type: "borrow-cancel",
|
||||
requestId,
|
||||
};
|
||||
void Promise.resolve(tabsApi.sendMessage(activeMessageTabId, cancelMessage)).catch((err) => {
|
||||
console.debug("[bsk borrow] cancel message failed", {
|
||||
tabId,
|
||||
messageTabId: activeMessageTabId,
|
||||
requestId,
|
||||
};
|
||||
void tabsApi.sendMessage(activeMessageTabId, cancelMessage).catch((err) => {
|
||||
console.debug("[bsk borrow] cancel message failed", {
|
||||
tabId,
|
||||
messageTabId: activeMessageTabId,
|
||||
requestId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
settle(true);
|
||||
});
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
dismissPendingOverlay();
|
||||
settle(false);
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -473,19 +470,18 @@ export async function requestBorrowConfirmation(
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
console.info("[bsk borrow] confirmation timed out — proceeding", {
|
||||
console.info("[bsk borrow] confirmation timed out — denying borrow", {
|
||||
tabId,
|
||||
messageTabId: activeMessageTabId,
|
||||
});
|
||||
settle(true);
|
||||
dismissPendingOverlay();
|
||||
settle(false);
|
||||
}, BACKGROUND_TIMEOUT_MS);
|
||||
|
||||
// Surface the OS notification *before* messaging any candidate so the
|
||||
// user has a parallel signal even if every content script is missing.
|
||||
// The Allow / Deny buttons let the user authorize explicitly when the
|
||||
// in-page overlay never reached them — without those buttons we'd have
|
||||
// to fail-open after `tryCandidate` exhausts its candidates, which is
|
||||
// exactly the silent-allow bug we're fixing here.
|
||||
// in-page overlay never reached them.
|
||||
if (notificationsApi) {
|
||||
pendingBorrowNotifications.set(notificationId, {
|
||||
windowId: notificationAnchor.windowId,
|
||||
@@ -499,7 +495,7 @@ export async function requestBorrowConfirmation(
|
||||
title: copy.title,
|
||||
message: copy.body(tabTitle),
|
||||
priority: 2,
|
||||
requireInteraction: false,
|
||||
requireInteraction: true,
|
||||
silent: false,
|
||||
buttons: [{ title: copy.allowButton }, { title: copy.denyButton }],
|
||||
})
|
||||
@@ -523,15 +519,12 @@ export async function requestBorrowConfirmation(
|
||||
): void => {
|
||||
if (settled) return;
|
||||
if (index >= candidates.length) {
|
||||
// Every candidate rejected sendMessage. We deliberately do NOT
|
||||
// fail-open here anymore: silently allowing a borrow with zero
|
||||
// user-visible authorization UI is the bug. Instead we keep the
|
||||
// promise pending and rely on:
|
||||
// Every candidate rejected sendMessage. Keep the promise pending and
|
||||
// rely on:
|
||||
// • the OS notification's Allow / Deny buttons for an explicit
|
||||
// user choice (preferred), or
|
||||
// • the BACKGROUND_TIMEOUT_MS fail-open as the last-resort
|
||||
// soft-fail so the agent doesn't hang forever when even the
|
||||
// notification API is unavailable.
|
||||
// • the BACKGROUND_TIMEOUT_MS fail-closed result so the agent does
|
||||
// not hang forever when even the notification API is unavailable.
|
||||
// Keep the diagnostics so real-world frequency is still observable.
|
||||
console.warn(
|
||||
"[bsk borrow] every candidate user window failed sendMessage — awaiting notification button or timeout",
|
||||
@@ -560,7 +553,9 @@ export async function requestBorrowConfirmation(
|
||||
.sendMessage(candidate.tabId, message)
|
||||
.then((response) => {
|
||||
if (settled) return;
|
||||
const allowed = (response as BorrowResponseMessage | undefined)?.allowed !== false;
|
||||
const candidateResponse = response as BorrowResponseMessage | undefined;
|
||||
const allowed =
|
||||
candidateResponse?.type === "borrow-response" && candidateResponse.allowed === true;
|
||||
console.info("[bsk borrow] confirmation response", { tabId, allowed });
|
||||
settle(allowed);
|
||||
})
|
||||
|
||||
@@ -7,14 +7,10 @@ import {
|
||||
chromeTabsApi,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
parseBufferedReadBounds,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
|
||||
const DEFAULT_CONSOLE_LIMIT = 50;
|
||||
const MAX_CONSOLE_LIMIT = 200;
|
||||
const DEFAULT_MAX_TEXT_CHARS = 1000;
|
||||
const MAX_TEXT_CHARS = 4096;
|
||||
|
||||
export interface ConsoleDeps {
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
@@ -34,7 +30,7 @@ export async function handleConsole(
|
||||
): Promise<ConsoleResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "console");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const bounds = parseConsoleBounds(params);
|
||||
const bounds = parseBufferedReadBounds(params);
|
||||
if (isRpcError(bounds)) return bounds;
|
||||
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
@@ -50,7 +46,7 @@ export async function handleConsole(
|
||||
bounds.since,
|
||||
bounds.limit,
|
||||
bounds.maxTextChars,
|
||||
bounds.includeStack,
|
||||
params.include_stack === true,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -59,50 +55,3 @@ export async function handleConsole(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseConsoleBounds(params: ConsoleParams):
|
||||
| {
|
||||
since: number | undefined;
|
||||
limit: number;
|
||||
maxTextChars: number;
|
||||
includeStack: boolean;
|
||||
}
|
||||
| RpcError {
|
||||
const since = params.since;
|
||||
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
|
||||
return { code: "invalid_params", message: "since must be a non-negative integer" };
|
||||
}
|
||||
const limit = boundedOptionalInteger(
|
||||
params.limit,
|
||||
DEFAULT_CONSOLE_LIMIT,
|
||||
MAX_CONSOLE_LIMIT,
|
||||
"limit",
|
||||
);
|
||||
if (isRpcError(limit)) return limit;
|
||||
const maxTextChars = boundedOptionalInteger(
|
||||
params.max_text_chars,
|
||||
DEFAULT_MAX_TEXT_CHARS,
|
||||
MAX_TEXT_CHARS,
|
||||
"max_text_chars",
|
||||
);
|
||||
if (isRpcError(maxTextChars)) return maxTextChars;
|
||||
return {
|
||||
since,
|
||||
limit,
|
||||
maxTextChars,
|
||||
includeStack: params.include_stack === true,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedOptionalInteger(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
field: string,
|
||||
): number | RpcError {
|
||||
if (value === undefined) return defaultValue;
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
return { code: "invalid_params", message: `${field} must be a positive integer` };
|
||||
}
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ import type {
|
||||
NavigateBackParams,
|
||||
NavigateForwardParams,
|
||||
NavigateParams,
|
||||
NetworkParams,
|
||||
PressParams,
|
||||
ProtocolFrame,
|
||||
RecordAwaitParams,
|
||||
RecordStartParams,
|
||||
RecordStopParams,
|
||||
ReloadParams,
|
||||
RequestFrame,
|
||||
RequestHelpParams,
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
handleNavigateForward,
|
||||
handleReload,
|
||||
} from "./navigation";
|
||||
import { handleNetwork, type NetworkCdpRunner } from "./network";
|
||||
import {
|
||||
type CdpRunner,
|
||||
chromeTabsCaptureApi,
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
handleScreenshot,
|
||||
handleSnapshot,
|
||||
} from "./observation";
|
||||
import { handleRecordAwait, handleRecordStart, handleRecordStop } from "./record";
|
||||
import {
|
||||
handleSessionStart,
|
||||
handleSessionStop,
|
||||
@@ -64,12 +70,15 @@ import {
|
||||
} from "./tabs";
|
||||
import { handleWaitForNavigation } from "./waits";
|
||||
|
||||
type DispatcherCdpRunner = CdpRunner &
|
||||
NetworkCdpRunner & {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
|
||||
export interface DispatcherDeps {
|
||||
transport: Transport;
|
||||
sessions: SessionManager;
|
||||
cdp?: CdpRunner & {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
cdp?: DispatcherCdpRunner;
|
||||
/**
|
||||
* Invoked whenever a dispatched RPC may have changed the live
|
||||
* session set (currently `tool.session_start` and
|
||||
@@ -103,9 +112,7 @@ export interface DispatcherDeps {
|
||||
export class ToolDispatcher {
|
||||
private readonly transport: Transport;
|
||||
private readonly sessions: SessionManager;
|
||||
private readonly cdp?: CdpRunner & {
|
||||
detachSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
private readonly cdp?: DispatcherCdpRunner;
|
||||
private readonly onSessionsChanged?: () => void;
|
||||
private readonly approveBorrow?: BorrowConfirmationApprover;
|
||||
private readonly helpNotificationCopy?: () => { title: string; body: string };
|
||||
@@ -285,6 +292,12 @@ export class ToolDispatcher {
|
||||
req.params as ConsoleParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.network":
|
||||
return handleNetwork(
|
||||
this.sessions,
|
||||
req.params as NetworkParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.snapshot":
|
||||
return handleSnapshot(
|
||||
this.sessions,
|
||||
@@ -387,6 +400,45 @@ export class ToolDispatcher {
|
||||
notificationCopy: this.helpNotificationCopy?.(),
|
||||
signal,
|
||||
});
|
||||
case "tool.record_start":
|
||||
return handleRecordStart(this.sessions, req.params as RecordStartParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId, enabled) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
...(this.cdp ? { cdp: this.cdp } : {}),
|
||||
signal,
|
||||
});
|
||||
case "tool.record_stop":
|
||||
return handleRecordStop(this.sessions, req.params as RecordStopParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
bypassOverlay: async (tabId, enabled) => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
type: OVERLAY_AUTOMATION_BYPASS,
|
||||
enabled,
|
||||
});
|
||||
} catch {
|
||||
// Content script may be unavailable on restricted pages.
|
||||
}
|
||||
},
|
||||
signal,
|
||||
});
|
||||
case "tool.record_await":
|
||||
return handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
signal,
|
||||
});
|
||||
default:
|
||||
return {
|
||||
code: "unknown_method",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { EvaluateError, EvaluateParams, EvaluateResult, RpcError } from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
|
||||
export interface EvaluateDeps {
|
||||
cdp: CdpRunner;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// until the user clicks Continue / Cancel, the wait times out, or the
|
||||
// daemon cancels the RPC.
|
||||
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import {
|
||||
HELP_CANCEL,
|
||||
HELP_REQUEST,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
type HelpRequestMessage,
|
||||
type HelpResponseMessage,
|
||||
} from "@/lib/help-bridge";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type {
|
||||
HelpTarget,
|
||||
RequestHelpParams,
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
ResolvedTarget,
|
||||
RpcError,
|
||||
} from "@/transport/types";
|
||||
import { lookupSnapshotRef } from "./snapshot-ref";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { lookupSnapshotRef } from "./snapshot-ref";
|
||||
|
||||
const DEFAULT_HELP_TIMEOUT_MS = 300_000;
|
||||
/** Attribute the overlay highlights for resolved `ref` targets. */
|
||||
@@ -46,7 +46,9 @@ export type TabNavigationUnsubscribe = () => void;
|
||||
|
||||
export interface RequestHelpDeps {
|
||||
tabsApi: ChromeTabsApi;
|
||||
windows: { update(windowId: number, info: { focused?: boolean }): Promise<chrome.windows.Window> };
|
||||
windows: {
|
||||
update(windowId: number, info: { focused?: boolean }): Promise<chrome.windows.Window>;
|
||||
};
|
||||
/** Activate a tab inside its window. */
|
||||
activateTab(tabId: number): Promise<void>;
|
||||
/** Send the help-request to the tab's content script and await reply. */
|
||||
@@ -68,10 +70,7 @@ export function defaultWatchTabNavigation(
|
||||
tabId: number,
|
||||
onNavigated: () => void,
|
||||
): TabNavigationUnsubscribe {
|
||||
const listener = (
|
||||
updatedTabId: number,
|
||||
changeInfo: chrome.tabs.TabChangeInfo,
|
||||
) => {
|
||||
const listener = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
|
||||
if (updatedTabId !== tabId) return;
|
||||
if (changeInfo.url !== undefined || changeInfo.status === "loading") {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
@@ -102,7 +101,8 @@ function getDefaultDeps(): RequestHelpDeps {
|
||||
else resolve(rid ?? id);
|
||||
}),
|
||||
),
|
||||
clear: (id) => new Promise((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
|
||||
clear: (id) =>
|
||||
new Promise((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -167,7 +167,9 @@ async function selectorExists(
|
||||
async function clearRefTags(cdp: CdpRunner | undefined, tabId: number): Promise<void> {
|
||||
if (!cdp) return;
|
||||
try {
|
||||
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", { depth: 0 });
|
||||
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
|
||||
depth: 0,
|
||||
});
|
||||
const rootId = doc.root?.nodeId;
|
||||
if (rootId === undefined) return;
|
||||
const { nodeIds } = await cdp.send<{ nodeIds: number[] }>(tabId, "DOM.querySelectorAll", {
|
||||
|
||||
@@ -21,10 +21,11 @@ import type {
|
||||
MouseButton,
|
||||
PressParams,
|
||||
PressResult,
|
||||
RpcError,
|
||||
SelectParams,
|
||||
SelectResult,
|
||||
RpcError,
|
||||
} from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import {
|
||||
backendNodeToObject,
|
||||
boxCentre,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
scrollNodeIntoView,
|
||||
} from "./element-geometry";
|
||||
import { rpcError } from "./errors";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
|
||||
export interface InteractionDeps {
|
||||
cdp: CdpRunner;
|
||||
@@ -858,7 +858,9 @@ export async function handleSelect(
|
||||
);
|
||||
}
|
||||
const attrs = described.node.attributes ?? [];
|
||||
const isMultiple = attrs.some((attr, idx) => idx % 2 === 0 && attr.toLowerCase() === "multiple");
|
||||
const isMultiple = attrs.some(
|
||||
(attr, idx) => idx % 2 === 0 && attr.toLowerCase() === "multiple",
|
||||
);
|
||||
if (!isMultiple && params.values.length !== 1) {
|
||||
return rpcError(
|
||||
"invalid_params",
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
RpcError,
|
||||
WaitUntil,
|
||||
} from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
|
||||
export interface NavigationDeps {
|
||||
cdp: CdpRunner;
|
||||
@@ -120,9 +120,7 @@ export function lifecycleAlreadyReached(readyState: string, targetName: string):
|
||||
case "DOMContentLoaded":
|
||||
return readyState === "interactive" || readyState === "complete";
|
||||
case "commit":
|
||||
return (
|
||||
readyState === "interactive" || readyState === "complete" || readyState === "loading"
|
||||
);
|
||||
return readyState === "interactive" || readyState === "complete" || readyState === "loading";
|
||||
case "networkIdle":
|
||||
// Must come from `Page.lifecycleEvent`; readyState does not imply network idle.
|
||||
return false;
|
||||
@@ -285,7 +283,13 @@ function startLifecycleWait(
|
||||
|
||||
const maybeFinishPending = () => {
|
||||
if (settled || !pendingLifecycle) return;
|
||||
if (!acceptLifecycleEvent(pendingLifecycle.name, pendingLifecycle.frameId, pendingLifecycle.loaderId)) {
|
||||
if (
|
||||
!acceptLifecycleEvent(
|
||||
pendingLifecycle.name,
|
||||
pendingLifecycle.frameId,
|
||||
pendingLifecycle.loaderId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types";
|
||||
import {
|
||||
type ChromeTabsApi,
|
||||
chromeTabsApi,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
parseBufferedReadBounds,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
|
||||
export interface NetworkCdpRunner {
|
||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||
ensureNetworkCapture(tabId: number): Promise<void>;
|
||||
networkEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
): NetworkResult;
|
||||
}
|
||||
|
||||
export interface NetworkDeps {
|
||||
cdp: NetworkCdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
}
|
||||
|
||||
function defaultNetworkDeps(): NetworkDeps {
|
||||
return {
|
||||
cdp: new ChromiumCdp(),
|
||||
tabsApi: chromeTabsApi,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleNetwork(
|
||||
manager: SessionManager,
|
||||
params: NetworkParams,
|
||||
deps: NetworkDeps = defaultNetworkDeps(),
|
||||
): Promise<NetworkResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "network");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const bounds = parseBufferedReadBounds(params);
|
||||
if (isRpcError(bounds)) return bounds;
|
||||
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
|
||||
try {
|
||||
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
||||
await deps.cdp.ensureNetworkCapture(target.tabId);
|
||||
return deps.cdp.networkEntriesSince(
|
||||
target.tabId,
|
||||
bounds.since,
|
||||
bounds.limit,
|
||||
bounds.maxTextChars,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
SnapshotParams,
|
||||
SnapshotResult,
|
||||
} from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import { nodeBoundingRect, scrollNodeIntoView } from "./element-geometry";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
import { rpcError } from "./errors";
|
||||
import {
|
||||
type ChromeTabsApi,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type CdpRunner as SharedCdpRunner,
|
||||
normaliseRef as sharedNormaliseRef,
|
||||
} from "./shared";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import { resolveSnapshotRef } from "./snapshot-ref";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers (legacy aliases — observation.ts kept exporting these
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
// `tool.record_start` / `tool.record_stop` / `tool.record_await` — capture
|
||||
// user actions in the Agent Window via the content script and return a
|
||||
// semantic (LLM textbook) trace.
|
||||
|
||||
import {
|
||||
isRecordFinishMessage,
|
||||
isRecordQueryMessage,
|
||||
isRecordStepMessage,
|
||||
RECORD_CANCEL,
|
||||
RECORD_START,
|
||||
RECORD_STEP,
|
||||
RECORD_STOP,
|
||||
type RecordCancelMessage,
|
||||
type RecordFinishMessage,
|
||||
type RecordQueryResponse,
|
||||
type RecordStartAck,
|
||||
type RecordStartMessage,
|
||||
type RecordStopMessage,
|
||||
} from "@/lib/record-bridge";
|
||||
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer";
|
||||
import { reduceTraceSteps, resolveTraceStartUrl } from "@/lib/trace-reducer";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type {
|
||||
DraftTraceStep,
|
||||
RecordAwaitParams,
|
||||
RecordAwaitResult,
|
||||
RecordStartParams,
|
||||
RecordStartResult,
|
||||
RecordStopParams,
|
||||
RecordStopResult,
|
||||
RpcError,
|
||||
Trace,
|
||||
} from "@/transport/types";
|
||||
import { handleNavigate } from "./navigation";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
chromeTabsApi,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
|
||||
interface ActiveRecording {
|
||||
requestId: string;
|
||||
tabId: number;
|
||||
agentWindowId: number;
|
||||
startUrl?: string;
|
||||
purpose?: string;
|
||||
steps: DraftTraceStep[];
|
||||
startedAt: string;
|
||||
finishPromise: Promise<Trace>;
|
||||
resolveFinish: (trace: Trace) => void;
|
||||
rejectFinish: (err: Error) => void;
|
||||
settled: boolean;
|
||||
finishing: boolean;
|
||||
currentUrl?: string;
|
||||
pendingNavigation: boolean;
|
||||
pendingNavigationDeadline?: number;
|
||||
}
|
||||
|
||||
const recordings = new Map<string, ActiveRecording>();
|
||||
|
||||
const RECORD_START_RETRIES = 3;
|
||||
const RECORD_START_RETRY_DELAY_MS = 500;
|
||||
const RECORD_REARM_DEBOUNCE_MS = 150;
|
||||
const RECORD_REARM_MAX_ATTEMPTS = 12;
|
||||
const RECORD_REARM_RETRY_DELAY_MS = 400;
|
||||
|
||||
const rearmTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function makeRequestId(tabId: number): string {
|
||||
return `rec-${tabId}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
|
||||
export const RECORD_DEFAULT_START_URL = "https://example.com/";
|
||||
|
||||
/** Pages where MV3 content scripts cannot attach (Agent Window boots here). */
|
||||
function isContentScriptRestrictedUrl(url: string | undefined): boolean {
|
||||
if (!url) return true;
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower === "about:blank" ||
|
||||
lower.startsWith("about:") ||
|
||||
lower.startsWith("chrome://") ||
|
||||
lower.startsWith("chrome-extension://") ||
|
||||
lower.startsWith("edge://") ||
|
||||
lower.startsWith("devtools://") ||
|
||||
lower.startsWith("devtools:") ||
|
||||
lower.startsWith("https://chrome.google.com/webstore")
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTabReady(
|
||||
tabId: number,
|
||||
tabsApi: ChromeTabsApi,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const tab = await tabsApi.get(tabId);
|
||||
if (tab.status === "complete") return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
reject(new Error("tab load timeout"));
|
||||
}, timeoutMs);
|
||||
const listener = (updatedTabId: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (updatedTabId !== tabId || info.status !== "complete") return;
|
||||
clearTimeout(timer);
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
});
|
||||
}
|
||||
|
||||
function isRecordStartAck(response: unknown): response is RecordStartAck {
|
||||
return (
|
||||
typeof response === "object" &&
|
||||
response !== null &&
|
||||
"ok" in response &&
|
||||
(response as RecordStartAck).ok === true
|
||||
);
|
||||
}
|
||||
|
||||
async function sendRecordStartWithAck(
|
||||
tabId: number,
|
||||
msg: RecordStartMessage,
|
||||
sendToTab: RecordDeps["sendToTab"],
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
|
||||
try {
|
||||
const response = await sendToTab(tabId, msg);
|
||||
if (isRecordStartAck(response)) return;
|
||||
lastError = new Error("content script did not ack RECORD_START");
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
if (attempt + 1 < RECORD_START_RETRIES) {
|
||||
await sleep(RECORD_START_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("failed to start recording in content script");
|
||||
}
|
||||
|
||||
function buildTrace(recording: ActiveRecording): Trace {
|
||||
const { pages, steps } = reduceTraceSteps(recording.steps, recording.startUrl);
|
||||
const startUrl = resolveTraceStartUrl(recording.steps, recording.startUrl, pages);
|
||||
return {
|
||||
...(recording.purpose ? { purpose: recording.purpose } : {}),
|
||||
recorded_at: new Date().toISOString(),
|
||||
started_at: recording.startedAt,
|
||||
entry: { start_url: startUrl },
|
||||
pages,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordDeps {
|
||||
tabsApi: ChromeTabsApi;
|
||||
sendToTab(
|
||||
tabId: number,
|
||||
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
|
||||
): Promise<unknown>;
|
||||
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
|
||||
cdp?: CdpRunner;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
let defaultDeps: RecordDeps | null = null;
|
||||
function getDefaultDeps(): RecordDeps {
|
||||
if (!defaultDeps) {
|
||||
defaultDeps = {
|
||||
tabsApi: chromeTabsApi,
|
||||
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
|
||||
};
|
||||
}
|
||||
return defaultDeps;
|
||||
}
|
||||
|
||||
/** Disposer for lazily attached tab / webNavigation observers. */
|
||||
let detachBrowserObservation: (() => void) | null = null;
|
||||
|
||||
type AttachObservation = (deps: RecordDeps) => () => void;
|
||||
|
||||
// Deferred wrappers so we do not capture attach* before their declarations.
|
||||
let attachTabObservation: AttachObservation = (deps) => attachRecordTabListener(deps);
|
||||
let attachNavObservation: AttachObservation = (deps) => attachRecordNavigationListener(deps);
|
||||
|
||||
/** Test seam: swap real chrome listeners for fakes. */
|
||||
export function setBrowserObservationAttachForTests(
|
||||
tab: AttachObservation | null,
|
||||
nav: AttachObservation | null,
|
||||
): void {
|
||||
attachTabObservation = tab ?? ((deps) => attachRecordTabListener(deps));
|
||||
attachNavObservation = nav ?? ((deps) => attachRecordNavigationListener(deps));
|
||||
}
|
||||
|
||||
export function isBrowserObservationAttachedForTests(): boolean {
|
||||
return detachBrowserObservation !== null;
|
||||
}
|
||||
|
||||
export function resetBrowserObservationForTests(): void {
|
||||
detachBrowserObservation?.();
|
||||
detachBrowserObservation = null;
|
||||
recordings.clear();
|
||||
attachTabObservation = (deps) => attachRecordTabListener(deps);
|
||||
attachNavObservation = (deps) => attachRecordNavigationListener(deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach tab/webNavigation listeners while any recording is active.
|
||||
* Must run before navigate-on-start so rearm observes the destination load.
|
||||
*/
|
||||
export function ensureBrowserObservationListeners(deps: RecordDeps = getDefaultDeps()): void {
|
||||
if (detachBrowserObservation) return;
|
||||
const detachTab = attachTabObservation(deps);
|
||||
const detachNav = attachNavObservation(deps);
|
||||
detachBrowserObservation = () => {
|
||||
detachTab();
|
||||
detachNav();
|
||||
};
|
||||
}
|
||||
|
||||
/** Detach when the recordings map is empty. */
|
||||
export function releaseBrowserObservationListenersIfIdle(): void {
|
||||
if (recordings.size > 0) return;
|
||||
if (!detachBrowserObservation) return;
|
||||
detachBrowserObservation();
|
||||
detachBrowserObservation = null;
|
||||
}
|
||||
|
||||
export function attachRecordStepListener(): () => void {
|
||||
const listener = (
|
||||
message: unknown,
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
_sendResponse: () => void,
|
||||
) => {
|
||||
if (!isRecordStepMessage(message)) return false;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.requestId !== message.requestId) continue;
|
||||
appendRecordedPayload(recording, message.step);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
return () => chrome.runtime.onMessage.removeListener(listener);
|
||||
}
|
||||
|
||||
export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const listener = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
_sendResponse: () => void,
|
||||
) => {
|
||||
if (!isRecordFinishMessage(message)) return false;
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId === undefined) return false;
|
||||
void finishRecordingByRequest(message.requestId, tabId, deps);
|
||||
return false;
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
return () => chrome.runtime.onMessage.removeListener(listener);
|
||||
}
|
||||
|
||||
function findRecordingByTabId(tabId: number): ActiveRecording | null {
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.tabId === tabId && !recording.settled) return recording;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findRecordingForTab(
|
||||
tabId: number,
|
||||
deps: RecordDeps,
|
||||
): Promise<ActiveRecording | null> {
|
||||
const direct = findRecordingByTabId(tabId);
|
||||
if (direct) return direct;
|
||||
|
||||
try {
|
||||
const tab = await deps.tabsApi.get(tabId);
|
||||
const windowId = tab.windowId;
|
||||
if (typeof windowId !== "number") return null;
|
||||
for (const recording of recordings.values()) {
|
||||
if (!recording.settled && recording.agentWindowId === windowId) return recording;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearRearmTimer(tabId: number): void {
|
||||
const timer = rearmTimers.get(tabId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
rearmTimers.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRearmTimersForRecording(
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
clearRearmTimer(recording.tabId);
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
for (const tab of tabs) {
|
||||
if (typeof tab.id === "number") clearRearmTimer(tab.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecordingOnAllAgentTabs(
|
||||
recording: ActiveRecording,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
|
||||
let tabIds = [recording.tabId];
|
||||
try {
|
||||
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
|
||||
tabIds = [
|
||||
...new Set([
|
||||
recording.tabId,
|
||||
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
|
||||
]),
|
||||
];
|
||||
} catch {
|
||||
// Fall back to the current recording tab.
|
||||
}
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
try {
|
||||
const response = await deps.sendToTab(tabId, stopMsg);
|
||||
if (tabId === recording.tabId && !isRecordStartAck(response)) {
|
||||
throw new Error("content script did not confirm recorded steps");
|
||||
}
|
||||
} catch {
|
||||
if (tabId === recording.tabId) {
|
||||
throw new Error("failed to flush recorded steps");
|
||||
}
|
||||
}
|
||||
if (deps.bypassOverlay) {
|
||||
try {
|
||||
await deps.bypassOverlay(tabId, false);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rearmRecording(
|
||||
recording: ActiveRecording,
|
||||
targetTabId: number,
|
||||
deps: RecordDeps,
|
||||
): Promise<boolean> {
|
||||
// Do NOT toggle automation-bypass here: each retry used to increment the
|
||||
// content-script counter, and a single stop decrement left the ControlOverlay
|
||||
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
||||
// already hides the control chrome while activeRecord is set.
|
||||
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId: recording.requestId };
|
||||
try {
|
||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||
recording.tabId = targetTabId;
|
||||
return true;
|
||||
} catch {
|
||||
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
|
||||
await sleep(RECORD_REARM_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
|
||||
const existing = rearmTimers.get(tabId);
|
||||
if (existing) clearTimeout(existing);
|
||||
rearmTimers.set(
|
||||
tabId,
|
||||
setTimeout(() => {
|
||||
rearmTimers.delete(tabId);
|
||||
void (async () => {
|
||||
const current = await findRecordingForTab(tabId, deps);
|
||||
if (current) await rearmRecording(current, tabId, deps);
|
||||
})();
|
||||
}, RECORD_REARM_DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const onCreated = (tab: chrome.tabs.Tab) => {
|
||||
const tabId = tab.id;
|
||||
const windowId = tab.windowId;
|
||||
if (tabId === undefined || windowId === undefined) return;
|
||||
for (const recording of recordings.values()) {
|
||||
if (recording.settled || recording.agentWindowId !== windowId) continue;
|
||||
scheduleRearmForTab(tabId, deps);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||
scheduleRearmForTab(activeInfo.tabId, deps);
|
||||
};
|
||||
|
||||
chrome.tabs.onCreated.addListener(onCreated);
|
||||
chrome.tabs.onActivated.addListener(onActivated);
|
||||
return () => {
|
||||
chrome.tabs.onCreated.removeListener(onCreated);
|
||||
chrome.tabs.onActivated.removeListener(onActivated);
|
||||
};
|
||||
}
|
||||
|
||||
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const observeMainFrame = (tabId: number, url?: string, causedByAction?: boolean) => {
|
||||
void (async () => {
|
||||
const recording = await findRecordingForTab(tabId, deps);
|
||||
if (recording && url) {
|
||||
observeRecordedNavigation(recording, url, causedByAction);
|
||||
}
|
||||
})();
|
||||
};
|
||||
const onMainFrameComplete = (tabId: number, url?: string) => {
|
||||
void (async () => {
|
||||
observeMainFrame(tabId, url);
|
||||
scheduleRearmForTab(tabId, deps);
|
||||
})();
|
||||
};
|
||||
|
||||
if (chrome.webNavigation?.onCompleted) {
|
||||
const completedListener = (
|
||||
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
|
||||
) => {
|
||||
if (details.frameId !== 0) return;
|
||||
onMainFrameComplete(details.tabId, details.url);
|
||||
};
|
||||
const committedListener = (
|
||||
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
|
||||
) => {
|
||||
if (details.frameId !== 0) return;
|
||||
const causedByAction =
|
||||
details.transitionType === "link" || details.transitionType === "form_submit"
|
||||
? true
|
||||
: details.transitionType === "typed" ||
|
||||
details.transitionType === "auto_bookmark" ||
|
||||
details.transitionType === "generated" ||
|
||||
details.transitionType === "keyword" ||
|
||||
details.transitionType === "keyword_generated" ||
|
||||
details.transitionType === "reload"
|
||||
? false
|
||||
: undefined;
|
||||
observeMainFrame(details.tabId, details.url, causedByAction);
|
||||
};
|
||||
chrome.webNavigation.onCompleted.addListener(completedListener);
|
||||
chrome.webNavigation.onCommitted?.addListener(committedListener);
|
||||
return () => {
|
||||
chrome.webNavigation.onCompleted.removeListener(completedListener);
|
||||
chrome.webNavigation.onCommitted?.removeListener(committedListener);
|
||||
};
|
||||
}
|
||||
|
||||
const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => {
|
||||
if (info.status !== "complete") return;
|
||||
onMainFrameComplete(tabId, info.url);
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
return () => chrome.tabs.onUpdated.removeListener(listener);
|
||||
}
|
||||
|
||||
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
|
||||
const listener = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: RecordQueryResponse) => void,
|
||||
) => {
|
||||
if (!isRecordQueryMessage(message)) return false;
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId === undefined) {
|
||||
sendResponse({ active: false });
|
||||
return false;
|
||||
}
|
||||
void (async () => {
|
||||
const recording = await findRecordingForTab(tabId, deps);
|
||||
if (!recording) {
|
||||
sendResponse({ active: false });
|
||||
return;
|
||||
}
|
||||
await rearmRecording(recording, tabId, deps);
|
||||
sendResponse({ active: true, requestId: recording.requestId });
|
||||
})();
|
||||
return true;
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
return () => chrome.runtime.onMessage.removeListener(listener);
|
||||
}
|
||||
|
||||
async function finishRecordingByRequest(
|
||||
requestId: string,
|
||||
tabId: number,
|
||||
deps: RecordDeps,
|
||||
): Promise<void> {
|
||||
for (const [sessionId, recording] of recordings) {
|
||||
if (recording.requestId !== requestId || recording.settled) continue;
|
||||
const match = await findRecordingForTab(tabId, deps);
|
||||
if (match !== recording) continue;
|
||||
await finishRecording(sessionId, deps);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRecording(sessionId: string, deps: RecordDeps): Promise<Trace | null> {
|
||||
const recording = recordings.get(sessionId);
|
||||
if (!recording || recording.settled || recording.finishing) return null;
|
||||
recording.finishing = true;
|
||||
|
||||
await clearRearmTimersForRecording(recording, deps);
|
||||
try {
|
||||
await stopRecordingOnAllAgentTabs(recording, deps);
|
||||
} catch {
|
||||
recording.finishing = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
recording.settled = true;
|
||||
recordings.delete(sessionId);
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
const trace = buildTrace(recording);
|
||||
recording.resolveFinish(trace);
|
||||
return trace;
|
||||
}
|
||||
|
||||
export async function handleRecordStart(
|
||||
manager: SessionManager,
|
||||
params: RecordStartParams,
|
||||
deps: RecordDeps = getDefaultDeps(),
|
||||
): Promise<RecordStartResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "record_start");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
if (recordings.has(params.session_id)) {
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message: `session ${params.session_id} is already recording`,
|
||||
};
|
||||
}
|
||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
|
||||
// Register the recording *before* navigate so content-script syncAgentOverlay
|
||||
// on the destination page can RECORD_QUERY → rearm → show RecordOverlay
|
||||
// instead of flashing ControlOverlay ("Agent 正在控制").
|
||||
const requestId = makeRequestId(target.tabId);
|
||||
let resolveFinish!: (trace: Trace) => void;
|
||||
let rejectFinish!: (err: Error) => void;
|
||||
const finishPromise = new Promise<Trace>((resolve, reject) => {
|
||||
resolveFinish = resolve;
|
||||
rejectFinish = reject;
|
||||
});
|
||||
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
||||
recordings.set(params.session_id, {
|
||||
requestId,
|
||||
tabId: target.tabId,
|
||||
agentWindowId: ctx.agentWindowId,
|
||||
startUrl: navigateUrl,
|
||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||
steps: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
finishPromise,
|
||||
resolveFinish,
|
||||
rejectFinish,
|
||||
settled: false,
|
||||
finishing: false,
|
||||
currentUrl: navigateUrl,
|
||||
pendingNavigation: false,
|
||||
pendingNavigationDeadline: undefined,
|
||||
});
|
||||
// Observe navigations for the whole recording lifetime; attach before
|
||||
// optional navigate so the destination load can rearm capture.
|
||||
ensureBrowserObservationListeners(deps);
|
||||
|
||||
const abortPending = async (notifyContent: boolean) => {
|
||||
recordings.delete(params.session_id);
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
if (notifyContent) {
|
||||
try {
|
||||
await deps.sendToTab(target.tabId, {
|
||||
type: RECORD_CANCEL,
|
||||
requestId,
|
||||
});
|
||||
} catch {
|
||||
// Content may never have received RECORD_START.
|
||||
}
|
||||
}
|
||||
if (deps.bypassOverlay) {
|
||||
try {
|
||||
await deps.bypassOverlay(target.tabId, false);
|
||||
} catch {
|
||||
// Ignore cleanup errors.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelledError = (): RpcError => ({
|
||||
code: "cancelled",
|
||||
message: "record_start aborted",
|
||||
});
|
||||
|
||||
/** Dispatcher may race-cancel while we still hold a provisional recording. */
|
||||
const abortIfCancelled = async (notifyContent: boolean): Promise<RpcError | null> => {
|
||||
if (!deps.signal?.aborted) return null;
|
||||
await abortPending(notifyContent);
|
||||
return cancelledError();
|
||||
};
|
||||
|
||||
{
|
||||
const cancelled = await abortIfCancelled(false);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
|
||||
if (deps.cdp) {
|
||||
const nav = await handleNavigate(
|
||||
manager,
|
||||
{
|
||||
session_id: params.session_id,
|
||||
url: navigateUrl,
|
||||
tab_id: target.tabId,
|
||||
},
|
||||
{ cdp: deps.cdp, tabsApi: deps.tabsApi, signal: deps.signal },
|
||||
);
|
||||
if (isRpcError(nav)) {
|
||||
await abortPending(false);
|
||||
return nav;
|
||||
}
|
||||
{
|
||||
const cancelled = await abortIfCancelled(false);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
try {
|
||||
await waitForTabReady(target.tabId, deps.tabsApi);
|
||||
} catch {
|
||||
// Proceed with retries even if the tab never reports complete.
|
||||
}
|
||||
{
|
||||
const cancelled = await abortIfCancelled(false);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
let startUrl: string | undefined;
|
||||
try {
|
||||
const tab = await deps.tabsApi.get(target.tabId);
|
||||
startUrl = tab.url;
|
||||
} catch {
|
||||
startUrl = navigateUrl;
|
||||
}
|
||||
|
||||
const active = recordings.get(params.session_id);
|
||||
if (!active) {
|
||||
// Cleared by a concurrent abort / session teardown.
|
||||
return cancelledError();
|
||||
}
|
||||
active.startUrl = startUrl;
|
||||
active.currentUrl = startUrl;
|
||||
|
||||
if (isContentScriptRestrictedUrl(startUrl)) {
|
||||
await abortPending(false);
|
||||
return {
|
||||
code: "invalid_params",
|
||||
message: params.url
|
||||
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
|
||||
: `cannot record on restricted URL (${startUrl}); default start page must be injectable http(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const cancelled = await abortIfCancelled(false);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
|
||||
if (deps.bypassOverlay) {
|
||||
try {
|
||||
// Single ref for the initial race before RecordOverlay mounts; rearm must
|
||||
// not stack additional refs (see rearmRecording). Cleared on stop.
|
||||
await deps.bypassOverlay(target.tabId, true);
|
||||
} catch {
|
||||
// Best-effort; activeRecord also hides the control overlay.
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const cancelled = await abortIfCancelled(true);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
|
||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId };
|
||||
|
||||
try {
|
||||
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
||||
} catch {
|
||||
await abortPending(true);
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message:
|
||||
"failed to start recording in content script — reload the BrowserSkill extension, then retry",
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const cancelled = await abortIfCancelled(true);
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
|
||||
return { tab_id: target.tabId, recording: true };
|
||||
}
|
||||
|
||||
export async function handleRecordStop(
|
||||
manager: SessionManager,
|
||||
params: RecordStopParams,
|
||||
deps: RecordDeps = getDefaultDeps(),
|
||||
): Promise<RecordStopResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "record_stop");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
|
||||
const recording = recordings.get(params.session_id);
|
||||
if (!recording) {
|
||||
return {
|
||||
code: "not_found",
|
||||
message: `no active recording for session ${params.session_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const trace = await finishRecording(params.session_id, deps);
|
||||
if (!trace) {
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message: `failed to flush recorded steps for session ${params.session_id}; the recording is still active — retry \`bsk record stop\``,
|
||||
};
|
||||
}
|
||||
return { trace };
|
||||
}
|
||||
|
||||
export async function handleRecordAwait(
|
||||
manager: SessionManager,
|
||||
params: RecordAwaitParams,
|
||||
deps: RecordDeps = getDefaultDeps(),
|
||||
): Promise<RecordAwaitResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "record_await");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
|
||||
const recording = recordings.get(params.session_id);
|
||||
if (!recording) {
|
||||
return {
|
||||
code: "not_found",
|
||||
message: `no active recording for session ${params.session_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (deps.signal?.aborted) {
|
||||
return { code: "cancelled", message: "record_await aborted" };
|
||||
}
|
||||
|
||||
const outcome = await new Promise<{ trace: Trace } | { error: RpcError }>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: { trace: Trace } | { error: RpcError }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
deps.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(result);
|
||||
};
|
||||
const onAbort = () => finish({ error: { code: "cancelled", message: "record_await aborted" } });
|
||||
const timer =
|
||||
params.timeout_ms === undefined
|
||||
? undefined
|
||||
: setTimeout(
|
||||
() =>
|
||||
finish({
|
||||
error: {
|
||||
code: "timeout",
|
||||
message: `record_await timed out after ${params.timeout_ms}ms`,
|
||||
},
|
||||
}),
|
||||
params.timeout_ms,
|
||||
);
|
||||
deps.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
void recording.finishPromise.then(
|
||||
(trace) => finish({ trace }),
|
||||
() =>
|
||||
finish({
|
||||
error: { code: "cancelled", message: "recording was cleared" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
return "trace" in outcome ? { trace: outcome.trace } : outcome.error;
|
||||
}
|
||||
|
||||
export function clearRecordingForSession(sessionId: string): void {
|
||||
const recording = recordings.get(sessionId);
|
||||
if (!recording) {
|
||||
recordings.delete(sessionId);
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
return;
|
||||
}
|
||||
void clearRearmTimersForRecording(recording, getDefaultDeps());
|
||||
if (!recording.settled) {
|
||||
recording.settled = true;
|
||||
recording.rejectFinish(new Error("recording cleared"));
|
||||
}
|
||||
recordings.delete(sessionId);
|
||||
releaseBrowserObservationListenersIfIdle();
|
||||
}
|
||||
|
||||
export type { RecordFinishMessage };
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { RpcError } from "@/transport/types";
|
||||
import { clearRecordingForSession } from "./record";
|
||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||
|
||||
export interface SessionStartParams {
|
||||
@@ -156,6 +157,8 @@ export async function handleSessionStop(
|
||||
// Step 2: clear the per-session RefStore (review M6/M7 parity).
|
||||
ctx.refStore.clear();
|
||||
|
||||
clearRecordingForSession(params.session_id);
|
||||
|
||||
// Step 3: detach CDP sessions this session opened (no-op if none).
|
||||
await deps.cdp?.detachSession(params.session_id);
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import { normaliseRef } from "@/session-manager/ref-store";
|
||||
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
|
||||
const DEFAULT_BUFFERED_READ_LIMIT = 50;
|
||||
const MAX_BUFFERED_READ_LIMIT = 200;
|
||||
const DEFAULT_MAX_TEXT_CHARS = 1000;
|
||||
const MAX_TEXT_CHARS = 4096;
|
||||
|
||||
/**
|
||||
* Subset of `chrome.tabs` we depend on across tool handlers. Kept on
|
||||
* a thin interface so vitest can inject a fake without monkey-patching
|
||||
@@ -55,6 +60,52 @@ export interface CdpRunner {
|
||||
): ConsoleResult;
|
||||
}
|
||||
|
||||
export interface BufferedReadBounds {
|
||||
since: number | undefined;
|
||||
limit: number;
|
||||
maxTextChars: number;
|
||||
}
|
||||
|
||||
/** Parse the common cursor and output bounds used by buffered read tools. */
|
||||
export function parseBufferedReadBounds(params: {
|
||||
since?: number;
|
||||
limit?: number;
|
||||
max_text_chars?: number;
|
||||
}): BufferedReadBounds | RpcError {
|
||||
const since = params.since;
|
||||
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
|
||||
return { code: "invalid_params", message: "since must be a non-negative integer" };
|
||||
}
|
||||
const limit = boundedOptionalInteger(
|
||||
params.limit,
|
||||
DEFAULT_BUFFERED_READ_LIMIT,
|
||||
MAX_BUFFERED_READ_LIMIT,
|
||||
"limit",
|
||||
);
|
||||
if (isRpcError(limit)) return limit;
|
||||
const maxTextChars = boundedOptionalInteger(
|
||||
params.max_text_chars,
|
||||
DEFAULT_MAX_TEXT_CHARS,
|
||||
MAX_TEXT_CHARS,
|
||||
"max_text_chars",
|
||||
);
|
||||
if (isRpcError(maxTextChars)) return maxTextChars;
|
||||
return { since, limit, maxTextChars };
|
||||
}
|
||||
|
||||
function boundedOptionalInteger(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
field: string,
|
||||
): number | RpcError {
|
||||
if (value === undefined) return defaultValue;
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
return { code: "invalid_params", message: `${field} must be a positive integer` };
|
||||
}
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an active session by its `session_id` param, returning the
|
||||
* matching `SessionContext` or a structured `RpcError`. Tool handlers
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
WaitForNavigationResult,
|
||||
WaitUntil,
|
||||
} from "@/transport/types";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
import { cdpLifecycleName, ensureCdpReady, waitForLifecyclePassive } from "./navigation";
|
||||
import {
|
||||
type CdpRunner,
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
import { attachDialogs, markDialogCursor } from "./dialogs";
|
||||
|
||||
export interface WaitForNavigationDeps {
|
||||
cdp: CdpRunner;
|
||||
|
||||
@@ -5,3 +5,10 @@
|
||||
// at runtime (review M3 fix to round-1).
|
||||
|
||||
declare const __BSK_EXT_VERSION__: string;
|
||||
|
||||
/**
|
||||
* WebSocket URL the extension uses to connect to the local bsk daemon.
|
||||
* Defaults to {@code ws://127.0.0.1:52800}. Override at build time by
|
||||
* setting the {@code BSK_DAEMON_WS_URL} environment variable.
|
||||
*/
|
||||
declare const __BSK_DAEMON_WS_URL__: string;
|
||||
|
||||
@@ -172,6 +172,37 @@ export interface ConsoleResult {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type NetworkEntryKind = "response" | "failure";
|
||||
|
||||
export interface NetworkEntry {
|
||||
sequence: number;
|
||||
kind: NetworkEntryKind;
|
||||
method?: string;
|
||||
url?: string;
|
||||
status?: number;
|
||||
status_text?: string;
|
||||
mime_type?: string;
|
||||
resource_type?: string;
|
||||
error_text?: string;
|
||||
timestamp?: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
since?: number;
|
||||
limit?: number;
|
||||
max_text_chars?: number;
|
||||
}
|
||||
|
||||
export interface NetworkResult {
|
||||
tab_id: number;
|
||||
entries: NetworkEntry[];
|
||||
next_since: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type TabScopeFilter = "user" | "agent" | "all";
|
||||
|
||||
export interface TabInfo {
|
||||
@@ -506,3 +537,135 @@ export interface RequestHelpResult {
|
||||
tab_id: number;
|
||||
resolved_targets?: ResolvedTarget[];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Semantic record payloads — mirror bsk-protocol record.rs (Trace v2)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface TargetDescriptor {
|
||||
role?: string;
|
||||
name?: string;
|
||||
tag: string;
|
||||
name_attr?: string;
|
||||
placeholder?: string;
|
||||
nearby_label?: string;
|
||||
}
|
||||
|
||||
export interface TraceEntry {
|
||||
start_url: string;
|
||||
}
|
||||
|
||||
export interface PageRef {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface SelectedOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface StepEffect {
|
||||
navigated_to: string;
|
||||
}
|
||||
|
||||
export interface StepCommon {
|
||||
id: number;
|
||||
page: string;
|
||||
effect?: StepEffect;
|
||||
}
|
||||
|
||||
/** Capture/buffer draft before v2 reduction. */
|
||||
export type DraftTraceStep =
|
||||
| {
|
||||
op: "click";
|
||||
target: TargetDescriptor;
|
||||
navigated_to?: string;
|
||||
page_url?: string;
|
||||
}
|
||||
| {
|
||||
op: "fill";
|
||||
target: TargetDescriptor;
|
||||
value: string;
|
||||
redacted?: boolean;
|
||||
page_url?: string;
|
||||
}
|
||||
| {
|
||||
op: "press";
|
||||
key: string;
|
||||
target?: TargetDescriptor;
|
||||
modifiers?: KeyModifier[];
|
||||
navigated_to?: string;
|
||||
page_url?: string;
|
||||
}
|
||||
| {
|
||||
op: "select";
|
||||
target: TargetDescriptor;
|
||||
values: string[];
|
||||
labels?: string[];
|
||||
navigated_to?: string;
|
||||
page_url?: string;
|
||||
}
|
||||
| {
|
||||
op: "navigate";
|
||||
url: string;
|
||||
page_url?: string;
|
||||
};
|
||||
|
||||
/** Exported record-only step (trace v2). */
|
||||
export type Step =
|
||||
| ({ op: "navigate" } & StepCommon & { to: string })
|
||||
| ({ op: "click" } & StepCommon & { target: TargetDescriptor })
|
||||
| ({ op: "fill" } & StepCommon & {
|
||||
target: TargetDescriptor;
|
||||
value: string;
|
||||
redacted?: boolean;
|
||||
})
|
||||
| ({ op: "select" } & StepCommon & {
|
||||
target: TargetDescriptor;
|
||||
selection: SelectedOption[];
|
||||
})
|
||||
| ({ op: "press" } & StepCommon & {
|
||||
key: string;
|
||||
modifiers?: KeyModifier[];
|
||||
target?: TargetDescriptor;
|
||||
});
|
||||
|
||||
export interface Trace {
|
||||
recorded_at: string;
|
||||
started_at?: string;
|
||||
purpose?: string;
|
||||
entry: TraceEntry;
|
||||
pages: PageRef[];
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
export interface RecordStartParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
url?: string;
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
export interface RecordStartResult {
|
||||
tab_id: number;
|
||||
recording: boolean;
|
||||
}
|
||||
|
||||
export interface RecordStopParams {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface RecordStopResult {
|
||||
trace: Trace;
|
||||
}
|
||||
|
||||
export interface RecordAwaitParams {
|
||||
session_id: string;
|
||||
timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface RecordAwaitResult {
|
||||
trace: Trace;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
__BSK_EXT_VERSION__: JSON.stringify(pkg.version),
|
||||
__BSK_DAEMON_WS_URL__: JSON.stringify(process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -25,7 +25,15 @@ export default defineConfig({
|
||||
name: "BrowserSkill",
|
||||
description:
|
||||
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
|
||||
permissions: ["alarms", "debugger", "notifications", "tabs", "storage", "windows"],
|
||||
permissions: [
|
||||
"alarms",
|
||||
"debugger",
|
||||
"notifications",
|
||||
"tabs",
|
||||
"storage",
|
||||
"webNavigation",
|
||||
"windows",
|
||||
],
|
||||
host_permissions: ["<all_urls>"],
|
||||
icons: {
|
||||
16: "icon/logo.png",
|
||||
@@ -59,6 +67,9 @@ export default defineConfig({
|
||||
],
|
||||
define: {
|
||||
__BSK_EXT_VERSION__: JSON.stringify(EXTENSION_VERSION),
|
||||
__BSK_DAEMON_WS_URL__: JSON.stringify(
|
||||
process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800",
|
||||
),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -48,6 +48,11 @@ fs2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
bsk-protocol = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { workspace = true }
|
||||
|
||||
@@ -46,6 +46,21 @@ Optional: `bsk session start --browser <instance-id-or-label>` when multiple bro
|
||||
|
||||
Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.
|
||||
|
||||
## Stop when the goal is met
|
||||
|
||||
Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both.
|
||||
|
||||
1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started").
|
||||
2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps.
|
||||
3. **Stop as soon as success is reached** — run `bsk session stop <id>` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing").
|
||||
4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task.
|
||||
5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below.
|
||||
6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking.
|
||||
|
||||
**With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal.
|
||||
|
||||
**Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply.
|
||||
|
||||
## Core interaction loop
|
||||
|
||||
Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).
|
||||
@@ -191,6 +206,23 @@ acts. The result `outcome` is one of:
|
||||
`note` carries any text the user typed back. `resolved_targets` reports
|
||||
which refs/selectors matched a live element.
|
||||
|
||||
### Recording — `bsk record`
|
||||
|
||||
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
|
||||
|
||||
```bash
|
||||
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
|
||||
|
||||
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
|
||||
```
|
||||
|
||||
- The trace is a **record-only action log** (a `pages[]` dictionary + `navigate`/`click`/`fill`/`select`/`press` steps with `target` descriptors). It records *what the user did*; deciding which inputs are variable is left to the executing agent.
|
||||
- `--purpose` is optional context metadata; it does **not** change what gets captured.
|
||||
- There is **no** `bsk replay` — to redo a flow, read the trace and reuse the existing `session` / `snapshot` / `@eN` / `click` / `fill` tools. Follow **Stop when the goal is met**.
|
||||
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
|
||||
|
||||
## Error handling
|
||||
|
||||
### Exit codes (`echo $?` after `bsk …`)
|
||||
@@ -221,8 +253,9 @@ Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window
|
||||
1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration.
|
||||
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
|
||||
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
|
||||
4. **No observe escalation before snapshot** — use `bsk snapshot` first; only use `bsk get-html` or `bsk screenshot` when the snapshot is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot ref — never skip snapshot just to grab a visual.
|
||||
5. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
|
||||
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
|
||||
5. **No observe escalation before snapshot** — use `bsk snapshot` first; only use `bsk get-html` or `bsk screenshot` when the snapshot is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot ref — never skip snapshot just to grab a visual.
|
||||
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ pub enum DaemonCmd {
|
||||
Restart(StartArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
#[derive(Debug, Clone, Default, clap::Args)]
|
||||
pub struct StartArgs {
|
||||
/// Override the WebSocket port (default 52800).
|
||||
#[arg(long, value_name = "PORT")]
|
||||
|
||||
@@ -104,6 +104,12 @@ pub fn run(output: Output) -> Result<Vec<CheckResult>> {
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
/// Whether the rendered doctor report contains an active failure.
|
||||
/// `NotApplicable` remains informational and must not change the exit code.
|
||||
pub fn has_failures(checks: &[CheckResult]) -> bool {
|
||||
checks.iter().any(|check| check.status == CheckStatus::Fail)
|
||||
}
|
||||
|
||||
/// Ensure the daemon is reachable and give the browser extension time to
|
||||
/// connect before checks run. Returns a single [`DaemonState`] snapshot
|
||||
/// for check evaluation.
|
||||
@@ -500,6 +506,21 @@ mod m2_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_active_failures_make_doctor_unsuccessful() {
|
||||
let healthy = vec![
|
||||
CheckResult::ok("ok", "ready"),
|
||||
CheckResult::na("optional", "not connected"),
|
||||
];
|
||||
assert!(!has_failures(&healthy));
|
||||
|
||||
let unhealthy = vec![
|
||||
CheckResult::ok("ok", "ready"),
|
||||
CheckResult::fail("broken", "not ready", "repair it"),
|
||||
];
|
||||
assert!(has_failures(&unhealthy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsers_check_reports_na_when_no_browser_connected() {
|
||||
// Review M2: 0 browsers means there is nothing to compare
|
||||
|
||||
@@ -43,6 +43,12 @@ pub enum CliError {
|
||||
#[error("{message}")]
|
||||
Rendered { code: ErrorCode, message: String },
|
||||
|
||||
/// Command already rendered its result and only needs to communicate
|
||||
/// a command-specific non-zero status. Unlike `Rendered`, this is not
|
||||
/// pretending that a daemon protocol error occurred.
|
||||
#[error("command reported an unsuccessful status")]
|
||||
RenderedExit { exit_code: u8 },
|
||||
|
||||
/// Local transport / setup failure (e.g. couldn't reach the
|
||||
/// daemon, JSON encode failed). Maps to exit code 2.
|
||||
#[error(transparent)]
|
||||
@@ -63,6 +69,7 @@ impl CliError {
|
||||
match self {
|
||||
CliError::Rpc { code, .. } => Some(*code),
|
||||
CliError::Rendered { code, .. } => Some(*code),
|
||||
CliError::RenderedExit { .. } => None,
|
||||
CliError::Local(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +83,9 @@ impl CliError {
|
||||
|
||||
/// Map this error to the CLI exit code (§3.1).
|
||||
pub fn exit_code(&self) -> u8 {
|
||||
if let CliError::RenderedExit { exit_code } = self {
|
||||
return *exit_code;
|
||||
}
|
||||
match self.code() {
|
||||
Some(code) => exit_code_for(code),
|
||||
None => 2, // local / protocol-level transport failure
|
||||
@@ -162,7 +172,10 @@ fn render_info_for(err: &CliError) -> Option<render_error::RenderInfo> {
|
||||
|
||||
/// Hint line for human / JSON rendering, including the local-failure
|
||||
/// fallback when there is no daemon [`ErrorCode`].
|
||||
fn hint_for(err: &CliError, render_info: Option<&render_error::RenderInfo>) -> Option<&'static str> {
|
||||
fn hint_for(
|
||||
err: &CliError,
|
||||
render_info: Option<&render_error::RenderInfo>,
|
||||
) -> Option<&'static str> {
|
||||
render_info
|
||||
.and_then(|info| info.hint)
|
||||
.or(if matches!(err, CliError::Local(_)) {
|
||||
@@ -204,7 +217,10 @@ pub fn render_with_extras(
|
||||
extras: Option<&dyn RenderExtras>,
|
||||
) -> ExitCode {
|
||||
let exit = err.exit_code();
|
||||
if matches!(err, CliError::Rendered { .. }) {
|
||||
if matches!(
|
||||
err,
|
||||
CliError::Rendered { .. } | CliError::RenderedExit { .. }
|
||||
) {
|
||||
return ExitCode::from(exit);
|
||||
}
|
||||
let render_info = render_info_for(err);
|
||||
@@ -256,7 +272,10 @@ pub fn render_with_extras(
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_human_to_string(err: &CliError, extras: Option<&dyn RenderExtras>) -> String {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
if matches!(err, CliError::Rendered { .. }) {
|
||||
if matches!(
|
||||
err,
|
||||
CliError::Rendered { .. } | CliError::RenderedExit { .. }
|
||||
) {
|
||||
return String::new();
|
||||
}
|
||||
let render_info = render_info_for(err);
|
||||
@@ -337,6 +356,13 @@ mod tests {
|
||||
assert_eq!(err.exit_code(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_exit_uses_command_specific_status_without_protocol_code() {
|
||||
let err = CliError::RenderedExit { exit_code: 1 };
|
||||
assert_eq!(err.code(), None);
|
||||
assert_eq!(err.exit_code(), 1);
|
||||
}
|
||||
|
||||
/// Review I2: the `--json` payload must be flat at the top level.
|
||||
/// The previous shape nested everything under `"error"` which was
|
||||
/// awkward for shell scripts (`jq -r '.error.code'`) and contradicted
|
||||
|
||||
@@ -18,12 +18,16 @@ pub mod install_skill;
|
||||
pub mod interaction;
|
||||
pub mod logs;
|
||||
pub mod navigate;
|
||||
pub mod network;
|
||||
pub mod record;
|
||||
pub mod record_state;
|
||||
pub mod render_error;
|
||||
pub mod screenshot;
|
||||
pub mod session;
|
||||
pub mod snapshot;
|
||||
pub mod status;
|
||||
pub mod tab;
|
||||
pub mod update;
|
||||
pub mod waits;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
@@ -36,10 +40,13 @@ use crate::cli::human_loop::RequestHelpArgs;
|
||||
use crate::cli::install_skill::InstallSkillArgs;
|
||||
use crate::cli::interaction::{ClickArgs, FillArgs, PressArgs, SelectArgs};
|
||||
use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs};
|
||||
use crate::cli::network::NetworkArgs;
|
||||
use crate::cli::record::RecordCmd;
|
||||
use crate::cli::screenshot::ScreenshotArgs;
|
||||
use crate::cli::session::SessionCmd;
|
||||
use crate::cli::snapshot::SnapshotArgs;
|
||||
use crate::cli::tab::TabCmd;
|
||||
use crate::cli::update::UpdateArgs;
|
||||
use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs};
|
||||
|
||||
/// Tool calls wait slightly longer than the daemon's 30s tool timeout so
|
||||
@@ -94,6 +101,9 @@ pub enum Command {
|
||||
#[command(name = "install-skill")]
|
||||
InstallSkill(InstallSkillArgs),
|
||||
|
||||
/// Check for and install bsk CLI updates.
|
||||
Update(UpdateArgs),
|
||||
|
||||
/// Print (and optionally follow) the daemon log file.
|
||||
Logs(LogsCmd),
|
||||
|
||||
@@ -115,6 +125,9 @@ pub enum Command {
|
||||
/// Read buffered console/log/exception messages.
|
||||
Console(ConsoleArgs),
|
||||
|
||||
/// Read buffered network responses / failures.
|
||||
Network(NetworkArgs),
|
||||
|
||||
/// Dump raw HTML for a tab or a snapshot ref.
|
||||
#[command(name = "get-html")]
|
||||
GetHtml(GetHtmlArgs),
|
||||
@@ -159,6 +172,9 @@ pub enum Command {
|
||||
/// Ask the human to complete an in-page step (captcha / login / confirm).
|
||||
#[command(name = "request-help")]
|
||||
RequestHelp(RequestHelpArgs),
|
||||
|
||||
/// Record user actions into a semantic `trace.json` textbook for LLMs.
|
||||
Record(RecordCmd),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, Default)]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! `bsk network` — read buffered page network responses / failures.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{NetworkEntry, NetworkParams, NetworkResult};
|
||||
use clap::Args;
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct NetworkArgs {
|
||||
/// Session id (must be active).
|
||||
#[arg(long)]
|
||||
pub session: String,
|
||||
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Return entries with sequence greater than this cursor.
|
||||
#[arg(long)]
|
||||
pub since: Option<u64>,
|
||||
|
||||
/// Maximum number of entries to return. Defaults to 50; extension caps at 200.
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Maximum characters per URL / error text. Defaults to 1000; extension caps at 4096.
|
||||
#[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub max_text_chars: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: NetworkArgs, format: Format) -> Result<(), CliError> {
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
run(info.sock_path, args, format)
|
||||
}
|
||||
|
||||
fn run(sock: PathBuf, args: NetworkArgs, format: Format) -> Result<(), CliError> {
|
||||
let params = NetworkParams {
|
||||
session_id: args.session,
|
||||
tab_id: args.tab_id,
|
||||
since: args.since,
|
||||
limit: args.limit,
|
||||
max_text_chars: args.max_text_chars,
|
||||
};
|
||||
let reply: NetworkResult = call(sock, params)?;
|
||||
render(&reply, format)
|
||||
}
|
||||
|
||||
fn call(sock: PathBuf, params: NetworkParams) -> Result<NetworkResult, CliError> {
|
||||
crate::cli::business_rpc::call::<NetworkParams, NetworkResult>(
|
||||
sock,
|
||||
"network",
|
||||
Method::ToolNetwork,
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
let json = serde_json::to_string_pretty(reply)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
||||
println!("{json}");
|
||||
}
|
||||
Format::Human => {
|
||||
if reply.entries.is_empty() {
|
||||
println!("(no network activity captured)");
|
||||
} else {
|
||||
for entry in &reply.entries {
|
||||
println!("{}", render_entry(entry));
|
||||
}
|
||||
}
|
||||
if reply.truncated {
|
||||
eprintln!(
|
||||
"warning: network output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.",
|
||||
reply.next_since
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_entry(entry: &NetworkEntry) -> String {
|
||||
let method = entry.method.as_deref().unwrap_or("?");
|
||||
let url = entry.url.as_deref().unwrap_or("(unknown)");
|
||||
match entry.kind {
|
||||
bsk_protocol::tools::NetworkEntryKind::Failure => {
|
||||
let err = entry.error_text.as_deref().unwrap_or("failed");
|
||||
format!("#{} FAILED {method} {url} - {err}", entry.sequence)
|
||||
}
|
||||
bsk_protocol::tools::NetworkEntryKind::Response => {
|
||||
let status = entry
|
||||
.status
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!("#{} {status} {method} {url}", entry.sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bsk_protocol::tools::NetworkEntryKind;
|
||||
|
||||
#[test]
|
||||
fn human_failure_output_uses_ascii_and_displays_unknown_url() {
|
||||
let entry = NetworkEntry {
|
||||
sequence: 7,
|
||||
kind: NetworkEntryKind::Failure,
|
||||
method: Some("GET".into()),
|
||||
url: None,
|
||||
status: None,
|
||||
status_text: None,
|
||||
mime_type: None,
|
||||
resource_type: Some("Fetch".into()),
|
||||
error_text: Some("net::ERR_FAILED".into()),
|
||||
timestamp: None,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
render_entry(&entry),
|
||||
"#7 FAILED GET (unknown) - net::ERR_FAILED"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! `bsk record start|stop` — capture user actions in the Agent Window.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{
|
||||
RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams,
|
||||
RecordStopResult, Trace,
|
||||
};
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
use crate::cli::business_rpc;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
use crate::cli::record_state;
|
||||
use crate::cli::session::{start_session, stop_session};
|
||||
|
||||
/// Max wait for the user to click 结束 in the browser (24 hours).
|
||||
const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000;
|
||||
|
||||
/// `bsk record …` subcommand tree.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RecordCmd {
|
||||
#[command(subcommand)]
|
||||
pub sub: RecordSub,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum RecordSub {
|
||||
/// Open the Agent Window, record user actions, and block until finished in the browser.
|
||||
Start(RecordStartArgs),
|
||||
/// Stop recording from the terminal (fallback), write trace JSON, and close the window.
|
||||
/// Works even while `record start` is blocked in `record_await` (daemon forwards
|
||||
/// `tool.record_stop` without the per-session busy gate).
|
||||
Stop(RecordStopArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RecordStartArgs {
|
||||
/// Target browser instance id or label. Required when multiple browsers
|
||||
/// are connected; omit when only one is online.
|
||||
#[arg(long)]
|
||||
pub browser: Option<String>,
|
||||
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Navigate to this http(s) URL before recording. When omitted, defaults
|
||||
/// to `https://example.com/`.
|
||||
#[arg(long)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Optional goal text stored on the exported trace for LLM context.
|
||||
#[arg(long)]
|
||||
pub purpose: Option<String>,
|
||||
|
||||
/// Output path for the trace JSON (default `./trace.json`).
|
||||
#[arg(long, default_value = "trace.json")]
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RecordStopArgs {
|
||||
/// Output path for the trace JSON (default `./trace.json`).
|
||||
#[arg(long, default_value = "trace.json")]
|
||||
pub output: PathBuf,
|
||||
}
|
||||
|
||||
pub fn dispatch(cmd: RecordCmd, format: Format) -> Result<(), CliError> {
|
||||
match cmd.sub {
|
||||
RecordSub::Start(args) => dispatch_start(args, format),
|
||||
RecordSub::Stop(args) => dispatch_stop(args, format),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> {
|
||||
if record_state::read().is_ok() {
|
||||
return Err(CliError::Local(anyhow::anyhow!(
|
||||
"a recording is already in progress; run `bsk record stop` first"
|
||||
)));
|
||||
}
|
||||
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session = start_session(info.sock_path.clone(), args.browser)?;
|
||||
|
||||
let start_params = RecordStartParams {
|
||||
session_id: session.session_id.clone(),
|
||||
tab_id: args.tab_id,
|
||||
url: args.url,
|
||||
purpose: args.purpose.clone(),
|
||||
};
|
||||
let start_result = business_rpc::call::<RecordStartParams, RecordStartResult>(
|
||||
info.sock_path.clone(),
|
||||
"record-start",
|
||||
Method::ToolRecordStart,
|
||||
Some(start_params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
);
|
||||
|
||||
let start_result = match start_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let _ = stop_session(info.sock_path, &session.session_id);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = record_state::write(&session.session_id) {
|
||||
let _ = stop_session(info.sock_path.clone(), &session.session_id);
|
||||
return Err(CliError::Local(err));
|
||||
}
|
||||
|
||||
if format == Format::Human {
|
||||
println!(
|
||||
"recording on tab={} — click 结束 in the browser when done",
|
||||
start_result.tab_id
|
||||
);
|
||||
}
|
||||
|
||||
let await_params = RecordAwaitParams {
|
||||
session_id: session.session_id.clone(),
|
||||
timeout_ms: Some(RECORD_AWAIT_TIMEOUT_MS),
|
||||
};
|
||||
let await_result = business_rpc::call::<RecordAwaitParams, RecordAwaitResult>(
|
||||
info.sock_path.clone(),
|
||||
"record-await",
|
||||
Method::ToolRecordAwait,
|
||||
Some(await_params),
|
||||
record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
// Keep write/render inside a Result so `?` cannot skip session teardown.
|
||||
let run_result: Result<(), CliError> = match await_result {
|
||||
Ok(result) => (|| {
|
||||
write_trace_file(&args.output, &result.trace)?;
|
||||
render_finish(&result.trace, &args.output, format)
|
||||
})(),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
let session_stop_result = stop_session(info.sock_path, &session.session_id);
|
||||
record_state::clear();
|
||||
|
||||
run_result?;
|
||||
session_stop_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> {
|
||||
let state = record_state::read().map_err(CliError::Local)?;
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session_id = state.session_id.clone();
|
||||
|
||||
let run_result: Result<(), CliError> = (|| {
|
||||
let params = RecordStopParams {
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
let result = business_rpc::call::<RecordStopParams, RecordStopResult>(
|
||||
info.sock_path.clone(),
|
||||
"record-stop",
|
||||
Method::ToolRecordStop,
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)?;
|
||||
write_trace_file(&args.output, &result.trace)?;
|
||||
render_stop(&result, &args.output, format)
|
||||
})();
|
||||
|
||||
let session_stop_result = stop_session(info.sock_path, &session_id);
|
||||
record_state::clear();
|
||||
|
||||
run_result?;
|
||||
session_stop_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_await_ipc_timeout(timeout_ms: u32) -> Duration {
|
||||
Duration::from_millis(u64::from(timeout_ms))
|
||||
.checked_add(Duration::from_secs(15))
|
||||
.unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15))
|
||||
}
|
||||
|
||||
fn write_trace_file(output: &PathBuf, trace: &Trace) -> Result<(), CliError> {
|
||||
let json = serde_json::to_string_pretty(trace)
|
||||
.context("serialize trace JSON")
|
||||
.map_err(CliError::Local)?;
|
||||
if let Some(parent) = output.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create output directory {}", parent.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
}
|
||||
}
|
||||
fs::write(output, format!("{json}\n"))
|
||||
.with_context(|| format!("write trace to {}", output.display()))
|
||||
.map_err(CliError::Local)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_finish(trace: &Trace, output: &PathBuf, format: Format) -> Result<(), CliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"output": output,
|
||||
"trace": trace,
|
||||
"window_closed": true,
|
||||
}))
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?
|
||||
);
|
||||
}
|
||||
Format::Human => {
|
||||
println!("saved {} steps to {}", trace.steps.len(), output.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_stop(
|
||||
result: &RecordStopResult,
|
||||
output: &PathBuf,
|
||||
format: Format,
|
||||
) -> Result<(), CliError> {
|
||||
render_finish(&result.trace, output, format)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_output_is_trace_json() {
|
||||
let args = RecordStopArgs {
|
||||
output: PathBuf::from("trace.json"),
|
||||
};
|
||||
assert_eq!(args.output, PathBuf::from("trace.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_args_default_output_is_trace_json() {
|
||||
let args = RecordStartArgs {
|
||||
browser: None,
|
||||
tab_id: None,
|
||||
url: None,
|
||||
purpose: None,
|
||||
output: PathBuf::from("trace.json"),
|
||||
};
|
||||
assert_eq!(args.output, PathBuf::from("trace.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_await_ipc_timeout_covers_long_wait() {
|
||||
let got = record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS);
|
||||
assert!(got >= Duration::from_secs(86_400));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Persist the session id between `bsk record start` and `bsk record stop`.
|
||||
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::daemon::paths;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecordSessionState {
|
||||
pub session_id: String,
|
||||
pub started_at: String,
|
||||
}
|
||||
|
||||
pub fn write(session_id: &str) -> Result<()> {
|
||||
let path = paths::record_session_path()?;
|
||||
paths::ensure_bsk_home()?;
|
||||
if path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"a recording is already in progress; run `bsk record stop` first"
|
||||
));
|
||||
}
|
||||
let state = RecordSessionState {
|
||||
session_id: session_id.to_string(),
|
||||
started_at: started_at_unix_ms(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&state).context("serialize record session state")?;
|
||||
fs::write(&path, format!("{json}\n")).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read() -> Result<RecordSessionState> {
|
||||
let path = paths::record_session_path()?;
|
||||
if !path.exists() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"no recording in progress; run `bsk record start` first"
|
||||
));
|
||||
}
|
||||
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Ok(path) = paths::record_session_path() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn started_at_unix_ms() -> String {
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
format!("{ms}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_guard() -> &'static Mutex<()> {
|
||||
static GUARD: Mutex<()> = Mutex::new(());
|
||||
&GUARD
|
||||
}
|
||||
|
||||
fn with_temp_home<F: FnOnce()>(f: F) {
|
||||
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
unsafe {
|
||||
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
|
||||
}
|
||||
f();
|
||||
clear();
|
||||
unsafe {
|
||||
std::env::remove_var(crate::daemon::paths::BSK_HOME_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_round_trips() {
|
||||
with_temp_home(|| {
|
||||
write("abcd").unwrap();
|
||||
let state = read().unwrap();
|
||||
assert_eq!(state.session_id, "abcd");
|
||||
assert!(!state.started_at.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_duplicate() {
|
||||
with_temp_home(|| {
|
||||
write("abcd").unwrap();
|
||||
let err = write("efgh").unwrap_err();
|
||||
assert!(err.to_string().contains("already in progress"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_errors_when_missing() {
|
||||
with_temp_home(|| {
|
||||
let err = read().unwrap_err();
|
||||
assert!(err.to_string().contains("no recording in progress"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_state_file() {
|
||||
with_temp_home(|| {
|
||||
write("abcd").unwrap();
|
||||
clear();
|
||||
assert!(read().is_err());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ pub fn info_for(code: ErrorCode) -> RenderInfo {
|
||||
ErrorCode::NotFound => RenderInfo {
|
||||
summary: "requested resource does not exist",
|
||||
hint: Some(
|
||||
"the session, tab, or browser may have stopped; run `bsk sessions` / `bsk browsers` to see current state",
|
||||
"the session, tab, or browser may have stopped; run `bsk session list` / `bsk browsers` to see current state",
|
||||
),
|
||||
exit_code: 1,
|
||||
},
|
||||
|
||||
@@ -63,11 +63,11 @@ struct StartParams {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StartReply {
|
||||
session_id: String,
|
||||
browser_instance_id: String,
|
||||
pub struct StartReply {
|
||||
pub session_id: String,
|
||||
pub browser_instance_id: String,
|
||||
#[serde(default)]
|
||||
agent_window_id: Option<i64>,
|
||||
pub agent_window_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -78,21 +78,21 @@ struct StopParams {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StopReply {
|
||||
stopped: Vec<String>,
|
||||
pub struct StopReply {
|
||||
pub stopped: Vec<String>,
|
||||
#[serde(default)]
|
||||
failed: Vec<StopFailure>,
|
||||
pub failed: Vec<StopFailure>,
|
||||
#[serde(default)]
|
||||
returned_tab_ids: Vec<i64>,
|
||||
pub returned_tab_ids: Vec<i64>,
|
||||
#[serde(default)]
|
||||
return_failures: Vec<ReturnFailure>,
|
||||
pub return_failures: Vec<ReturnFailure>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct StopFailure {
|
||||
session_id: String,
|
||||
code: ErrorCode,
|
||||
message: String,
|
||||
pub struct StopFailure {
|
||||
pub session_id: String,
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -113,14 +113,7 @@ pub fn dispatch(cmd: SessionCmd, format: Format) -> Result<(), CliError> {
|
||||
}
|
||||
|
||||
fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<(), CliError> {
|
||||
let result: Result<StartReply, CliError> = call(
|
||||
sock,
|
||||
Method::SessionStart,
|
||||
Some(StartParams {
|
||||
browser_instance_id: args.browser,
|
||||
}),
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
let result = start_session(sock, args.browser);
|
||||
match result {
|
||||
Ok(reply) => match format {
|
||||
Format::Json => {
|
||||
@@ -143,6 +136,31 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a session and open the Agent Window. Used by `session start` and `record start`.
|
||||
pub fn start_session(sock: PathBuf, browser: Option<String>) -> Result<StartReply, CliError> {
|
||||
call(
|
||||
sock,
|
||||
Method::SessionStart,
|
||||
Some(StartParams {
|
||||
browser_instance_id: browser,
|
||||
}),
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop a single session by id.
|
||||
pub fn stop_session(sock: PathBuf, session_id: &str) -> Result<StopReply, CliError> {
|
||||
call(
|
||||
sock,
|
||||
Method::SessionStop,
|
||||
Some(StopParams {
|
||||
session_id: Some(session_id.to_string()),
|
||||
all: false,
|
||||
}),
|
||||
SESSION_STOP_IPC_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a `session.start` failure (review I3).
|
||||
///
|
||||
/// The previous implementation hand-rolled `eprintln!("error: ...")`
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
//! `bsk update` — check for and install CLI updates.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Args;
|
||||
use flate2::read::GzDecoder;
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::cli::daemon::StartArgs;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
|
||||
pub const DEFAULT_MANIFEST_URL: &str =
|
||||
"https://github.com/Tencent/BrowserSkill/releases/latest/download/version.json";
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const ARCHIVE_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateManifest {
|
||||
pub version: Version,
|
||||
pub tag: String,
|
||||
pub release_url: Option<String>,
|
||||
pub assets: BTreeMap<String, ManifestAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManifestAsset {
|
||||
pub url: String,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateCandidate {
|
||||
pub current: Version,
|
||||
pub latest: Version,
|
||||
pub tag: String,
|
||||
pub release_url: Option<String>,
|
||||
pub asset: ManifestAsset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InstallAction {
|
||||
Replaced,
|
||||
Staged,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StagedReplacementPaths {
|
||||
pub binary_path: PathBuf,
|
||||
pub script_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct UpdateReport {
|
||||
status: &'static str,
|
||||
current_version: String,
|
||||
latest_version: Option<String>,
|
||||
release_url: Option<String>,
|
||||
asset_url: Option<String>,
|
||||
install_action: Option<&'static str>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpdateCheckCache {
|
||||
pub checked_at_epoch_secs: u64,
|
||||
pub latest_version: String,
|
||||
}
|
||||
|
||||
impl UpdateCheckCache {
|
||||
pub fn is_fresh(&self, now_epoch_secs: u64, interval: Duration) -> bool {
|
||||
now_epoch_secs.saturating_sub(self.checked_at_epoch_secs) <= interval.as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArchiveKind {
|
||||
TarGz,
|
||||
Zip,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawManifest {
|
||||
version: String,
|
||||
tag: Option<String>,
|
||||
release_url: Option<String>,
|
||||
assets: BTreeMap<String, RawManifestAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawManifestAsset {
|
||||
Url(String),
|
||||
Rich {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
sha256: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl UpdateManifest {
|
||||
pub fn from_slice(bytes: &[u8]) -> Result<Self> {
|
||||
let raw: RawManifest = serde_json::from_slice(bytes).context("parse update manifest")?;
|
||||
let version = Version::parse(raw.version.trim_start_matches('v'))
|
||||
.context("parse manifest version")?;
|
||||
let tag = raw.tag.unwrap_or_else(|| format!("cli-v{version}"));
|
||||
let assets = raw
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(|(platform, asset)| {
|
||||
let asset = match asset {
|
||||
RawManifestAsset::Url(url) => ManifestAsset { url, sha256: None },
|
||||
RawManifestAsset::Rich { url, sha256 } => ManifestAsset { url, sha256 },
|
||||
};
|
||||
(platform, asset)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
tag,
|
||||
release_url: raw.release_url,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_candidate(
|
||||
&self,
|
||||
current_version: &str,
|
||||
platform_key: &str,
|
||||
) -> Result<Option<UpdateCandidate>> {
|
||||
let current = Version::parse(current_version.trim_start_matches('v'))
|
||||
.context("parse current bsk version")?;
|
||||
if self.version <= current {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let asset = self
|
||||
.assets
|
||||
.get(platform_key)
|
||||
.with_context(|| format!("no bsk release asset for platform `{platform_key}`"))?
|
||||
.clone();
|
||||
|
||||
Ok(Some(UpdateCandidate {
|
||||
current,
|
||||
latest: self.version.clone(),
|
||||
tag: self.tag.clone(),
|
||||
release_url: self.release_url.clone(),
|
||||
asset,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchiveKind {
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
if url.ends_with(".tar.gz") || url.ends_with(".tgz") {
|
||||
Ok(Self::TarGz)
|
||||
} else if url.ends_with(".zip") {
|
||||
Ok(Self::Zip)
|
||||
} else {
|
||||
bail!("unsupported bsk archive type: {url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args, Default)]
|
||||
pub struct UpdateArgs {
|
||||
/// Only check whether a newer version is available; do not install it.
|
||||
#[arg(long)]
|
||||
pub check: bool,
|
||||
|
||||
/// Skip interactive confirmation prompts.
|
||||
#[arg(short = 'y', long)]
|
||||
pub yes: bool,
|
||||
|
||||
/// Do not restart the daemon after replacing the CLI binary.
|
||||
#[arg(long = "no-restart-daemon", default_value_t = true, action = clap::ArgAction::SetFalse)]
|
||||
pub restart_daemon: bool,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: UpdateArgs, format: Format) -> Result<(), CliError> {
|
||||
run(args, format).map_err(CliError::Local)
|
||||
}
|
||||
|
||||
fn run(args: UpdateArgs, format: Format) -> Result<()> {
|
||||
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
||||
let manifest = fetch_manifest_with_client(&client, &manifest_url())?;
|
||||
let platform = current_platform_key()?;
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let Some(candidate) = manifest.update_candidate(current_version, platform)? else {
|
||||
return render_report(
|
||||
format,
|
||||
&UpdateReport {
|
||||
status: "up_to_date",
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some(manifest.version.to_string()),
|
||||
release_url: manifest.release_url,
|
||||
asset_url: None,
|
||||
install_action: None,
|
||||
message: format!("bsk {current_version} is already up to date"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if args.check {
|
||||
return render_report(
|
||||
format,
|
||||
&UpdateReport {
|
||||
status: "update_available",
|
||||
current_version: candidate.current.to_string(),
|
||||
latest_version: Some(candidate.latest.to_string()),
|
||||
release_url: candidate.release_url.clone(),
|
||||
asset_url: Some(candidate.asset.url.clone()),
|
||||
install_action: None,
|
||||
message: format!(
|
||||
"bsk {} is available (current {})",
|
||||
candidate.latest, candidate.current
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if !args.yes && !confirm_update(&candidate)? {
|
||||
return render_report(
|
||||
format,
|
||||
&UpdateReport {
|
||||
status: "cancelled",
|
||||
current_version: candidate.current.to_string(),
|
||||
latest_version: Some(candidate.latest.to_string()),
|
||||
release_url: candidate.release_url,
|
||||
asset_url: Some(candidate.asset.url),
|
||||
install_action: None,
|
||||
message: "update cancelled".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let action = install_candidate_with_client(&candidate, args.restart_daemon, &client)?;
|
||||
let action_label = match action {
|
||||
InstallAction::Replaced => "replaced",
|
||||
InstallAction::Staged => "staged",
|
||||
};
|
||||
|
||||
render_report(
|
||||
format,
|
||||
&UpdateReport {
|
||||
status: "updated",
|
||||
current_version: candidate.current.to_string(),
|
||||
latest_version: Some(candidate.latest.to_string()),
|
||||
release_url: candidate.release_url,
|
||||
asset_url: Some(candidate.asset.url),
|
||||
install_action: Some(action_label),
|
||||
message: format!(
|
||||
"updated bsk from {} to {}",
|
||||
candidate.current, candidate.latest
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn current_platform_key() -> Result<&'static str> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => Ok("darwin-arm64"),
|
||||
("macos", "x86_64") => Ok("darwin-x64"),
|
||||
("linux", "aarch64") => Ok("linux-arm64"),
|
||||
("linux", "x86_64") => Ok("linux-x64"),
|
||||
("windows", "x86_64") => Ok("windows-x64"),
|
||||
(os, arch) => bail!("unsupported platform for bsk auto-update: {os}-{arch}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_bytes(url: &str, timeout: Duration) -> Result<Vec<u8>> {
|
||||
let client = update_http_client(timeout)?;
|
||||
fetch_bytes_with_client(&client, url)
|
||||
}
|
||||
|
||||
fn update_http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.context("build update HTTP client")
|
||||
}
|
||||
|
||||
fn fetch_bytes_with_client(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.with_context(|| format!("download {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("download {url}"))?;
|
||||
Ok(response
|
||||
.bytes()
|
||||
.context("read update response body")?
|
||||
.to_vec())
|
||||
}
|
||||
|
||||
pub fn fetch_manifest(url: &str) -> Result<UpdateManifest> {
|
||||
let client = update_http_client(FETCH_TIMEOUT)?;
|
||||
fetch_manifest_with_client(&client, url)
|
||||
}
|
||||
|
||||
fn fetch_manifest_with_client(
|
||||
client: &reqwest::blocking::Client,
|
||||
url: &str,
|
||||
) -> Result<UpdateManifest> {
|
||||
let bytes = fetch_bytes_with_client(client, url)?;
|
||||
UpdateManifest::from_slice(&bytes)
|
||||
}
|
||||
|
||||
fn install_candidate_with_client(
|
||||
candidate: &UpdateCandidate,
|
||||
restart_daemon: bool,
|
||||
client: &reqwest::blocking::Client,
|
||||
) -> Result<InstallAction> {
|
||||
let expected_sha = candidate.asset.sha256.as_deref().with_context(|| {
|
||||
format!(
|
||||
"release {} does not include a sha256 checksum; cannot safely auto-update",
|
||||
candidate.tag
|
||||
)
|
||||
})?;
|
||||
let archive = fetch_bytes_with_client(client, &candidate.asset.url)?;
|
||||
verify_sha256(&archive, expected_sha)?;
|
||||
let kind = ArchiveKind::from_url(&candidate.asset.url)?;
|
||||
let binary = extract_bsk_binary(&archive, kind)?;
|
||||
let target = std::env::current_exe().context("locate current bsk executable")?;
|
||||
|
||||
let daemon_was_running = restart_daemon && crate::daemon::info::read_valid()?.is_some();
|
||||
if daemon_was_running {
|
||||
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
|
||||
}
|
||||
|
||||
let action = replace_binary_at_path(&target, &binary)?;
|
||||
|
||||
if daemon_was_running && matches!(action, InstallAction::Replaced) {
|
||||
crate::daemon::start::run_start(StartArgs::default())
|
||||
.context("restart bsk daemon after update")?;
|
||||
}
|
||||
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||
let actual = hex_sha256(bytes);
|
||||
let expected = expected_hex.trim().to_ascii_lowercase();
|
||||
if actual != expected {
|
||||
bail!("downloaded bsk archive checksum mismatch: expected {expected}, got {actual}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_url() -> String {
|
||||
std::env::var("BSK_UPDATE_MANIFEST_URL").unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string())
|
||||
}
|
||||
|
||||
pub fn update_hint_for_manifest(
|
||||
manifest: &UpdateManifest,
|
||||
current_version: &str,
|
||||
platform_key: &str,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(manifest
|
||||
.update_candidate(current_version, platform_key)?
|
||||
.map(|candidate| {
|
||||
format!(
|
||||
"A new bsk version is available: {} -> {}. Run `bsk update`.",
|
||||
candidate.current, candidate.latest
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn update_hint_for_cache(cache: &UpdateCheckCache, current_version: &str) -> Option<String> {
|
||||
let latest = cache.latest_version.as_str();
|
||||
let current = Version::parse(current_version.trim_start_matches('v')).ok()?;
|
||||
let latest_version = Version::parse(latest.trim_start_matches('v')).ok()?;
|
||||
(latest_version > current).then(|| {
|
||||
format!("A new bsk version is available: {current} -> {latest_version}. Run `bsk update`.")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_update_cache(path: &Path) -> Result<Option<UpdateCheckCache>> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let cache = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(Some(cache))
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(anyhow::Error::from(err).context(format!("read {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_update_cache(path: &Path, cache: &UpdateCheckCache) -> Result<()> {
|
||||
let dir = path
|
||||
.parent()
|
||||
.with_context(|| format!("cache path has no parent: {}", path.display()))?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let tmp = path.with_file_name(format!(
|
||||
"{}.tmp.{}",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("update-check.json"),
|
||||
std::process::id()
|
||||
));
|
||||
let payload = serde_json::to_vec_pretty(cache).context("encode update cache")?;
|
||||
{
|
||||
let mut file =
|
||||
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
|
||||
file.write_all(&payload)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
file.flush()
|
||||
.with_context(|| format!("flush {}", tmp.display()))?;
|
||||
file.sync_all()
|
||||
.with_context(|| format!("sync {}", tmp.display()))?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?;
|
||||
sync_dir(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn refresh_update_cache(cache_path: &Path) -> Result<Option<String>> {
|
||||
let manifest = fetch_manifest(&manifest_url())?;
|
||||
let platform = current_platform_key()?;
|
||||
let hint = update_hint_for_manifest(&manifest, env!("CARGO_PKG_VERSION"), platform)?;
|
||||
write_update_cache(
|
||||
cache_path,
|
||||
&UpdateCheckCache {
|
||||
checked_at_epoch_secs: now_epoch_secs(),
|
||||
latest_version: manifest.version.to_string(),
|
||||
},
|
||||
)?;
|
||||
Ok(hint)
|
||||
}
|
||||
|
||||
pub fn maybe_spawn_background_check(flags: &super::GlobalFlags, command: &super::Command) {
|
||||
if flags.quiet
|
||||
|| flags.json
|
||||
|| matches!(
|
||||
command,
|
||||
super::Command::Daemon(_) | super::Command::Update(_)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(cache_path) = crate::daemon::paths::update_check_path() else {
|
||||
return;
|
||||
};
|
||||
let now = now_epoch_secs();
|
||||
match read_update_cache(&cache_path) {
|
||||
Ok(Some(cache)) => {
|
||||
if let Some(hint) = update_hint_for_cache(&cache, env!("CARGO_PKG_VERSION")) {
|
||||
eprintln!("{hint}");
|
||||
}
|
||||
if cache.is_fresh(now, UPDATE_CHECK_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "update cache read failed");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("bsk-update-check".to_string())
|
||||
.spawn(move || match refresh_update_cache(&cache_path) {
|
||||
Ok(Some(hint)) => eprintln!("{hint}"),
|
||||
Ok(None) => {}
|
||||
Err(err) => tracing::debug!(error = %err, "background update check failed"),
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm_update(candidate: &UpdateCandidate) -> Result<bool> {
|
||||
dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Update bsk from {} to {}?",
|
||||
candidate.current, candidate.latest
|
||||
))
|
||||
.default(true)
|
||||
.interact()
|
||||
.context("read update confirmation")
|
||||
}
|
||||
|
||||
fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
|
||||
match format {
|
||||
Format::Human => {
|
||||
println!("{}", report.message);
|
||||
if let Some(release_url) = &report.release_url {
|
||||
println!("release: {release_url}");
|
||||
}
|
||||
if matches!(report.install_action, Some("staged")) {
|
||||
println!(
|
||||
"restart your terminal and run `bsk --version` to verify the staged update"
|
||||
);
|
||||
}
|
||||
}
|
||||
Format::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(report).context("encode update report as JSON")?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec<u8>> {
|
||||
match kind {
|
||||
ArchiveKind::TarGz => extract_bsk_from_tar_gz(archive_bytes),
|
||||
ArchiveKind::Zip => extract_bsk_from_zip(archive_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
stage_windows_replacement(target, binary)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
replace_binary_atomically(target, binary)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
||||
let dir = target
|
||||
.parent()
|
||||
.with_context(|| format!("target path has no parent: {}", target.display()))?;
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("target path must have a UTF-8 file name")?;
|
||||
let tmp = target.with_file_name(format!(".{file_name}.update-{}.tmp", std::process::id()));
|
||||
|
||||
{
|
||||
let mut file =
|
||||
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
|
||||
file.write_all(binary)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
file.flush()
|
||||
.with_context(|| format!("flush {}", tmp.display()))?;
|
||||
file.sync_all()
|
||||
.with_context(|| format!("sync {}", tmp.display()))?;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
|
||||
.with_context(|| format!("chmod 0755 {}", tmp.display()))?;
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp, target)
|
||||
.with_context(|| format!("rename {} to {}", tmp.display(), target.display()))?;
|
||||
sync_dir(dir);
|
||||
Ok(InstallAction::Replaced)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn stage_windows_replacement(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
||||
let paths = staged_replacement_paths(target, std::process::id())?;
|
||||
std::fs::write(&paths.binary_path, binary)
|
||||
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
||||
std::fs::write(
|
||||
&paths.script_path,
|
||||
windows_replacement_script(target, &paths.binary_path),
|
||||
)
|
||||
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
||||
Ok(InstallAction::Staged)
|
||||
}
|
||||
|
||||
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("target path must have a UTF-8 file name")?;
|
||||
let binary_path = target.with_file_name(format!("{file_name}.update-{pid}"));
|
||||
let script_path = target.with_file_name(format!("{file_name}.update-{pid}.cmd"));
|
||||
Ok(StagedReplacementPaths {
|
||||
binary_path,
|
||||
script_path,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_replacement_script(target: &Path, staged_binary: &Path) -> String {
|
||||
format!(
|
||||
"@echo off\r\n\
|
||||
setlocal\r\n\
|
||||
:retry\r\n\
|
||||
move /Y \"{}\" \"{}\" >nul 2>nul\r\n\
|
||||
if errorlevel 1 (\r\n\
|
||||
timeout /t 1 /nobreak >nul\r\n\
|
||||
goto retry\r\n\
|
||||
)\r\n",
|
||||
staged_binary.display(),
|
||||
target.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn sync_dir(dir: &Path) {
|
||||
if let Ok(dir_file) = std::fs::File::open(dir) {
|
||||
let _ = dir_file.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_bsk_from_tar_gz(archive_bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let decoder = GzDecoder::new(Cursor::new(archive_bytes));
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
for entry in archive.entries().context("read bsk tar.gz entries")? {
|
||||
let mut entry = entry.context("read bsk tar.gz entry")?;
|
||||
let path = entry.path().context("read bsk tar.gz entry path")?;
|
||||
if path.file_name().is_some_and(|name| name == "bsk") {
|
||||
let mut bytes = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut bytes)
|
||||
.context("read bsk binary from tar.gz")?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
bail!("bsk binary not found in tar.gz archive");
|
||||
}
|
||||
|
||||
fn extract_bsk_from_zip(archive_bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let cursor = Cursor::new(archive_bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).context("read bsk zip archive")?;
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(index)
|
||||
.with_context(|| format!("read zip entry {index}"))?;
|
||||
let name = std::path::Path::new(file.name())
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
if name == "bsk.exe" || name == "bsk" {
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.context("read bsk binary from zip")?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
bail!("bsk binary not found in zip archive");
|
||||
}
|
||||
|
||||
fn hex_sha256(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn parses_new_and_legacy_manifest_asset_shapes() {
|
||||
let manifest = UpdateManifest::from_slice(
|
||||
br#"{
|
||||
"name": "bsk",
|
||||
"version": "0.2.0",
|
||||
"tag": "cli-v0.2.0",
|
||||
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://example.test/bsk.tar.gz",
|
||||
"sha256": "abc123"
|
||||
},
|
||||
"linux-x64": "https://example.test/legacy.tar.gz"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manifest.version.to_string(), "0.2.0");
|
||||
assert_eq!(
|
||||
manifest.assets["darwin-arm64"].url,
|
||||
"https://example.test/bsk.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.assets["darwin-arm64"].sha256.as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.assets["linux-x64"].url,
|
||||
"https://example.test/legacy.tar.gz"
|
||||
);
|
||||
assert!(manifest.assets["linux-x64"].sha256.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_newer_manifest_as_update_candidate() {
|
||||
let manifest = UpdateManifest::from_slice(
|
||||
br#"{
|
||||
"name": "bsk",
|
||||
"version": "0.2.0",
|
||||
"tag": "cli-v0.2.0",
|
||||
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
|
||||
"assets": {
|
||||
"linux-x64": {
|
||||
"url": "https://example.test/bsk.tar.gz",
|
||||
"sha256": "abc123"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let candidate = manifest
|
||||
.update_candidate("0.1.7", "linux-x64")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(candidate.latest.to_string(), "0.2.0");
|
||||
assert_eq!(candidate.asset.sha256.as_deref(), Some("abc123"));
|
||||
assert!(
|
||||
manifest
|
||||
.update_candidate("0.2.0", "linux-x64")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifies_sha256_before_installing_archive() {
|
||||
let bytes = b"archive bytes";
|
||||
let expected = hex_sha256(bytes);
|
||||
verify_sha256(bytes, &expected).unwrap();
|
||||
assert!(verify_sha256(bytes, "0000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_bsk_binary_from_tar_gz_archive() {
|
||||
let archive = tar_gz_with_bsk(b"new binary");
|
||||
let extracted = extract_bsk_binary(&archive, ArchiveKind::TarGz).unwrap();
|
||||
assert_eq!(extracted, b"new binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_bsk_binary_from_zip_archive() {
|
||||
let archive = zip_with_bsk_exe(b"windows binary");
|
||||
let extracted = extract_bsk_binary(&archive, ArchiveKind::Zip).unwrap();
|
||||
assert_eq!(extracted, b"windows binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_binary_at_path() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let target = tmp.path().join("bsk");
|
||||
std::fs::write(&target, b"old binary").unwrap();
|
||||
|
||||
let action = replace_binary_at_path(&target, b"new binary").unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
|
||||
assert!(matches!(action, InstallAction::Replaced));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_replacement_paths_are_next_to_target() {
|
||||
let target = std::path::Path::new("/tmp/bsk.exe");
|
||||
let paths = staged_replacement_paths(target, 42).unwrap();
|
||||
assert_eq!(
|
||||
paths.binary_path,
|
||||
std::path::Path::new("/tmp/bsk.exe.update-42")
|
||||
);
|
||||
assert_eq!(
|
||||
paths.script_path,
|
||||
std::path::Path::new("/tmp/bsk.exe.update-42.cmd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_freshness_uses_epoch_seconds() {
|
||||
let cache = UpdateCheckCache {
|
||||
checked_at_epoch_secs: 100,
|
||||
latest_version: "0.2.0".to_string(),
|
||||
};
|
||||
assert!(cache.is_fresh(120, Duration::from_secs(30)));
|
||||
assert!(!cache.is_fresh(131, Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_hint_is_generated_only_for_newer_versions() {
|
||||
let manifest = UpdateManifest::from_slice(
|
||||
br#"{
|
||||
"name": "bsk",
|
||||
"version": "0.2.0",
|
||||
"tag": "cli-v0.2.0",
|
||||
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
|
||||
"assets": {
|
||||
"linux-x64": {
|
||||
"url": "https://example.test/bsk.tar.gz",
|
||||
"sha256": "abc123"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hint = update_hint_for_manifest(&manifest, "0.1.7", "linux-x64").unwrap();
|
||||
assert_eq!(
|
||||
hint.as_deref(),
|
||||
Some("A new bsk version is available: 0.1.7 -> 0.2.0. Run `bsk update`.")
|
||||
);
|
||||
assert!(
|
||||
update_hint_for_manifest(&manifest, "0.2.0", "linux-x64")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_cache_round_trips_to_disk() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("update-check.json");
|
||||
let cache = UpdateCheckCache {
|
||||
checked_at_epoch_secs: 123,
|
||||
latest_version: "0.2.0".to_string(),
|
||||
};
|
||||
|
||||
write_update_cache(&path, &cache).unwrap();
|
||||
assert_eq!(read_update_cache(&path).unwrap(), Some(cache));
|
||||
}
|
||||
|
||||
fn tar_gz_with_bsk(binary: &[u8]) -> Vec<u8> {
|
||||
let mut tar_bytes = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_bytes);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(binary.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, "bsk", binary).unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(&tar_bytes).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn zip_with_bsk_exe(binary: &[u8]) -> Vec<u8> {
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(&mut cursor);
|
||||
writer
|
||||
.start_file("bsk.exe", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(binary).unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
cursor.into_inner()
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolTabReturn
|
||||
| Method::ToolScreenshot
|
||||
| Method::ToolConsole
|
||||
| Method::ToolNetwork
|
||||
| Method::ToolSnapshot
|
||||
| Method::ToolGetHtml
|
||||
| Method::ToolNavigate
|
||||
@@ -241,7 +242,10 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolSelect
|
||||
| Method::ToolEvaluate
|
||||
| Method::ToolWaitForNavigation
|
||||
| Method::ToolRequestHelp => {
|
||||
| Method::ToolRequestHelp
|
||||
| Method::ToolRecordStart
|
||||
| Method::ToolRecordStop
|
||||
| Method::ToolRecordAwait => {
|
||||
handle_tool_dispatch(&state, rpc_id, method, params).await
|
||||
}
|
||||
Method::ToolWaitMs => handle_wait_ms(&state.abort_registry, rpc_id, params).await,
|
||||
@@ -318,10 +322,19 @@ async fn handle_tool_dispatch(
|
||||
}
|
||||
};
|
||||
let entry = inflight_guard.entry();
|
||||
let outcome = state
|
||||
.tool_queues
|
||||
.dispatch(&session_id, method, params, timeout, Some(entry))
|
||||
.await;
|
||||
// `record_stop` must reach the extension while `record_await` holds the
|
||||
// serial busy lock — finishing the recording unblocks await.
|
||||
let outcome = if method == Method::ToolRecordStop {
|
||||
state
|
||||
.tool_queues
|
||||
.dispatch_unlocked(&session_id, method, params, timeout, Some(entry))
|
||||
.await
|
||||
} else {
|
||||
state
|
||||
.tool_queues
|
||||
.dispatch(&session_id, method, params, timeout, Some(entry))
|
||||
.await
|
||||
};
|
||||
drop(inflight_guard);
|
||||
match outcome {
|
||||
Ok(v) => ResponseBody::Ok(v),
|
||||
|
||||
@@ -44,10 +44,16 @@ pub async fn run(
|
||||
Some(path) => Some(ipc::IpcServer::new(Arc::clone(&state)).bind(path).await?),
|
||||
None => None,
|
||||
};
|
||||
let session_idle_task = start::spawn_session_idle_reaper(Arc::clone(&state));
|
||||
// Ensure WS/IPC accept loops have polled `shutdown.notified()` before any
|
||||
// caller can invoke `DaemonHandle::shutdown()` — `Notify::notify_waiters()`
|
||||
// drops wakeups when nothing is registered yet, which otherwise hangs
|
||||
// shutdown forever (hit by tests that connect/shutdown immediately).
|
||||
tokio::task::yield_now().await;
|
||||
Ok(DaemonHandle::new(state, ws_handle, ipc_handle))
|
||||
Ok(DaemonHandle::new(
|
||||
state,
|
||||
ws_handle,
|
||||
ipc_handle,
|
||||
session_idle_task,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ pub fn log_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("daemon.log"))
|
||||
}
|
||||
|
||||
pub fn update_check_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("update-check.json"))
|
||||
}
|
||||
|
||||
pub fn log_dir() -> Result<PathBuf> {
|
||||
bsk_home()
|
||||
}
|
||||
@@ -91,6 +95,11 @@ pub fn sock_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("run").join("daemon.sock"))
|
||||
}
|
||||
|
||||
/// Path to the in-progress recording session state (`record-session.json`).
|
||||
pub fn record_session_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("record-session.json"))
|
||||
}
|
||||
|
||||
/// Windows named-pipe name. Include the resolved `BSK_HOME` path in the
|
||||
/// token so test homes and custom installs do not share a predictable
|
||||
/// per-username pipe.
|
||||
@@ -157,7 +166,12 @@ mod tests {
|
||||
assert_eq!(lock_path().unwrap(), home.join("daemon.lock"));
|
||||
assert_eq!(info_path().unwrap(), home.join("daemon.json"));
|
||||
assert_eq!(log_path().unwrap(), home.join("daemon.log"));
|
||||
assert_eq!(update_check_path().unwrap(), home.join("update-check.json"));
|
||||
assert_eq!(sock_path().unwrap(), home.join("run").join("daemon.sock"));
|
||||
assert_eq!(
|
||||
record_session_path().unwrap(),
|
||||
home.join("record-session.json")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,9 +218,14 @@ impl ToolQueueRegistry {
|
||||
/// Whether `sid`'s queue is still accepting new tool dispatches.
|
||||
pub fn is_accepting(&self, sid: &SessionId) -> bool {
|
||||
let guard = self.queues.lock().expect("tool queue registry poisoned");
|
||||
guard
|
||||
.get(sid)
|
||||
.is_some_and(|entry| entry.accepting)
|
||||
guard.get(sid).is_some_and(|entry| entry.accepting)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn force_busy_for_tests(&self, sid: &SessionId, busy: bool) {
|
||||
let guard = self.queues.lock().expect("tool queue registry poisoned");
|
||||
let entry = guard.get(sid).expect("queue must exist");
|
||||
entry.state.lock().expect("queue state poisoned").busy = busy;
|
||||
}
|
||||
|
||||
/// Spawn the worker task for a session id. Idempotent: spawning the
|
||||
@@ -302,7 +307,8 @@ impl ToolQueueRegistry {
|
||||
queue_state.busy = true;
|
||||
(entry.sender.clone(), Arc::clone(&entry.state))
|
||||
};
|
||||
dispatch_with_sender(
|
||||
self.sessions.touch(sid);
|
||||
let outcome = dispatch_with_sender(
|
||||
sender,
|
||||
state,
|
||||
sid.clone(),
|
||||
@@ -312,7 +318,49 @@ impl ToolQueueRegistry {
|
||||
false,
|
||||
inflight,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
// A long-running request may outlive the idle threshold. Touching
|
||||
// after completion prevents the reaper from immediately closing it;
|
||||
// while it is running, session.stop observes SessionBusy and retries
|
||||
// on a later sweep rather than interrupting the tool.
|
||||
self.sessions.touch(sid);
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Forward a tool RPC to the extension without taking the
|
||||
/// per-session busy lock. Used by `tool.record_stop` so a second
|
||||
/// CLI can finish an in-flight `tool.record_await` (extension
|
||||
/// dispatch already runs concurrent `void dispatch(msg)` handlers).
|
||||
pub async fn dispatch_unlocked(
|
||||
&self,
|
||||
sid: &SessionId,
|
||||
method: Method,
|
||||
params: Value,
|
||||
timeout: Duration,
|
||||
inflight: Option<Arc<ToolInflightEntry>>,
|
||||
) -> Result<Value, DispatchError> {
|
||||
{
|
||||
let guard = self.queues.lock().expect("tool queue registry poisoned");
|
||||
let Some(entry) = guard.get(sid) else {
|
||||
return Err(DispatchError::SessionNotFound);
|
||||
};
|
||||
if !entry.accepting {
|
||||
return Err(DispatchError::SessionStopping);
|
||||
}
|
||||
}
|
||||
let (respond_tx, _respond_rx) = oneshot::channel();
|
||||
let job = ToolJob {
|
||||
method,
|
||||
params,
|
||||
timeout,
|
||||
respond: respond_tx,
|
||||
inflight,
|
||||
session_id: sid.clone(),
|
||||
};
|
||||
match forward_one(sid, &self.browsers, &self.sessions, &job).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(err) => Err(DispatchError::Rpc(err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop accepting new jobs for `sid`, enqueue one final control RPC,
|
||||
@@ -342,10 +390,21 @@ impl ToolQueueRegistry {
|
||||
entry.accepting = false;
|
||||
(entry.sender.clone(), Arc::clone(&entry.state))
|
||||
};
|
||||
dispatch_with_sender(sender, state, sid.clone(), method, params, timeout, true, None).await
|
||||
dispatch_with_sender(
|
||||
sender,
|
||||
state,
|
||||
sid.clone(),
|
||||
method,
|
||||
params,
|
||||
timeout,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_with_sender(
|
||||
sender: mpsc::Sender<ToolJob>,
|
||||
state: Arc<Mutex<QueueState>>,
|
||||
@@ -773,3 +832,50 @@ mod tool_job_session_id_tests {
|
||||
assert_eq!(job.session_id.0, "sess-A");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dispatch_unlocked_tests {
|
||||
use super::*;
|
||||
use crate::daemon::browsers::BrowserRegistry;
|
||||
use crate::daemon::sessions::{SessionId, SessionRegistry};
|
||||
use bsk_protocol::Method;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlocked_dispatch_skips_session_busy_gate() {
|
||||
let browsers = Arc::new(BrowserRegistry::new());
|
||||
let sessions = Arc::new(SessionRegistry::new());
|
||||
let queues = ToolQueueRegistry::new(browsers, sessions);
|
||||
let sid = SessionId("s-record".into());
|
||||
queues.spawn(sid.clone());
|
||||
queues.force_busy_for_tests(&sid, true);
|
||||
|
||||
let busy = queues
|
||||
.dispatch(
|
||||
&sid,
|
||||
Method::ToolTabList,
|
||||
json!({ "session_id": "s-record" }),
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(busy, Err(DispatchError::SessionBusy)));
|
||||
|
||||
let unlocked = queues
|
||||
.dispatch_unlocked(
|
||||
&sid,
|
||||
Method::ToolRecordStop,
|
||||
json!({ "session_id": "s-record" }),
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(unlocked, Err(DispatchError::SessionBusy)),
|
||||
"record_stop must not be rejected as SessionBusy; got {unlocked:?}"
|
||||
);
|
||||
// No session row → forward_one returns NotFound as Rpc.
|
||||
assert!(matches!(unlocked, Err(DispatchError::Rpc(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bsk_protocol::system::{BrowserStatusEntry, SessionStatusEntry};
|
||||
use bsk_protocol::tools::{
|
||||
@@ -66,6 +66,9 @@ impl Session {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionRegistry {
|
||||
inner: Mutex<HashMap<SessionId, Session>>,
|
||||
/// Operational metadata kept outside the public `Session` wire/domain
|
||||
/// shape so idle enforcement does not break external struct users.
|
||||
last_activity: Mutex<HashMap<SessionId, Instant>>,
|
||||
}
|
||||
|
||||
impl SessionRegistry {
|
||||
@@ -100,10 +103,13 @@ impl SessionRegistry {
|
||||
}
|
||||
|
||||
pub fn insert(&self, session: Session) {
|
||||
self.inner
|
||||
let session_id = session.id.clone();
|
||||
let mut sessions = self.inner.lock().expect("session registry poisoned");
|
||||
sessions.insert(session_id.clone(), session);
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("session registry poisoned")
|
||||
.insert(session.id.clone(), session);
|
||||
.expect("session activity registry poisoned")
|
||||
.insert(session_id, Instant::now());
|
||||
}
|
||||
|
||||
/// Reserve a fresh, collision-free [`SessionId`] under the registry
|
||||
@@ -141,6 +147,10 @@ impl SessionRegistry {
|
||||
created_at_ms: now_ms_fn(),
|
||||
},
|
||||
);
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned")
|
||||
.insert(candidate.clone(), Instant::now());
|
||||
return Some(candidate);
|
||||
}
|
||||
None
|
||||
@@ -168,13 +178,23 @@ impl SessionRegistry {
|
||||
.lock()
|
||||
.expect("session registry poisoned")
|
||||
.remove(session_id);
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned")
|
||||
.remove(session_id);
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &SessionId) -> Option<Session> {
|
||||
self.inner
|
||||
let removed = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("session registry poisoned")
|
||||
.remove(id)
|
||||
.remove(id);
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned")
|
||||
.remove(id);
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &SessionId) -> Option<Session> {
|
||||
@@ -185,6 +205,42 @@ impl SessionRegistry {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Record accepted or completed tool activity for a live session.
|
||||
pub fn touch(&self, id: &SessionId) -> bool {
|
||||
if !self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("session registry poisoned")
|
||||
.contains_key(id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned")
|
||||
.insert(id.clone(), Instant::now());
|
||||
true
|
||||
}
|
||||
|
||||
/// Return sessions whose last tool activity is at least `idle_for`
|
||||
/// old. The caller supplies `now` to keep boundary tests deterministic.
|
||||
pub fn idle_ids_at(&self, idle_for: Duration, now: Instant) -> Vec<SessionId> {
|
||||
let sessions = self.inner.lock().expect("session registry poisoned");
|
||||
let activity = self
|
||||
.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned");
|
||||
sessions
|
||||
.values()
|
||||
.filter(|session| {
|
||||
activity
|
||||
.get(&session.id)
|
||||
.is_some_and(|last| now.saturating_duration_since(*last) >= idle_for)
|
||||
})
|
||||
.map(|session| session.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop all sessions owned by `browser_id` (e.g. on disconnect).
|
||||
pub fn purge_browser(&self, browser_id: &BrowserId) -> Vec<Session> {
|
||||
let mut guard = self.inner.lock().expect("session registry poisoned");
|
||||
@@ -196,6 +252,13 @@ impl SessionRegistry {
|
||||
for s in &drained {
|
||||
guard.remove(&s.id);
|
||||
}
|
||||
let mut activity = self
|
||||
.last_activity
|
||||
.lock()
|
||||
.expect("session activity registry poisoned");
|
||||
for session in &drained {
|
||||
activity.remove(&session.id);
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::cli::daemon::StartArgs;
|
||||
use crate::daemon::{
|
||||
browsers::EXTENSION_CONNECT_WAIT, info as daemon_info, ipc, lockfile, paths,
|
||||
state::DaemonState, ws,
|
||||
browsers::EXTENSION_CONNECT_WAIT,
|
||||
info as daemon_info, ipc, lockfile, paths,
|
||||
sessions::{StopSessionError, forget_session, stop_session},
|
||||
state::DaemonState,
|
||||
ws,
|
||||
};
|
||||
|
||||
/// Internal env-var contract: the parent sets this on the spawned child
|
||||
@@ -196,6 +199,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
.with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?;
|
||||
|
||||
let state = Arc::new(DaemonState::new(cfg.clone()));
|
||||
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
||||
let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port);
|
||||
let ws_handle = ws::WsServer::new(Arc::clone(&state))
|
||||
.bind(ws_addr)
|
||||
@@ -355,6 +359,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
|
||||
let _ = ipc_shutdown_tx.send(());
|
||||
let _ = ipc_task.await;
|
||||
session_idle_task.abort();
|
||||
let _ = session_idle_task.await;
|
||||
ws_handle.shutdown.notify_waiters();
|
||||
let _ = ws_handle.task.await;
|
||||
|
||||
@@ -367,6 +373,55 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn the cooperative session-idle reaper shared by the production
|
||||
/// foreground daemon and the test/embed daemon entry point.
|
||||
pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let session_idle = state.config.session_idle;
|
||||
let tick = (session_idle / 4)
|
||||
.max(Duration::from_millis(100))
|
||||
.min(Duration::from_secs(30));
|
||||
let mut ticker = tokio::time::interval(tick);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// `interval`'s first tick is immediate. Consume it so a zero/very
|
||||
// short test setting still gets one real inactivity window.
|
||||
ticker.tick().await;
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let idle_ids = state.sessions.idle_ids_at(session_idle, Instant::now());
|
||||
for session_id in idle_ids {
|
||||
match stop_session(
|
||||
&state.browsers,
|
||||
&state.sessions,
|
||||
&state.tool_queues,
|
||||
&state.session_interrupts,
|
||||
&session_id,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!(session = %session_id, "idle session stopped"),
|
||||
Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => {
|
||||
debug!(session = %session_id, "idle session still active; retrying later");
|
||||
}
|
||||
Err(StopSessionError::NotFound | StopSessionError::BrowserGone) => {
|
||||
forget_session(
|
||||
&state.sessions,
|
||||
&state.tool_queues,
|
||||
&state.session_interrupts,
|
||||
&session_id,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(session = %session_id, error = %err, "failed to stop idle session");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn record_activity(activity: &Arc<Mutex<Instant>>) {
|
||||
if let Ok(mut a) = activity.lock() {
|
||||
*a = Instant::now();
|
||||
|
||||
@@ -81,11 +81,22 @@ pub struct DaemonHandle {
|
||||
state: Arc<DaemonState>,
|
||||
ws: WsHandle,
|
||||
ipc: Option<IpcHandle>,
|
||||
session_idle_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl DaemonHandle {
|
||||
pub(crate) fn new(state: Arc<DaemonState>, ws: WsHandle, ipc: Option<IpcHandle>) -> Self {
|
||||
Self { state, ws, ipc }
|
||||
pub(crate) fn new(
|
||||
state: Arc<DaemonState>,
|
||||
ws: WsHandle,
|
||||
ipc: Option<IpcHandle>,
|
||||
session_idle_task: JoinHandle<()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
ws,
|
||||
ipc,
|
||||
session_idle_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Arc<DaemonState> {
|
||||
@@ -103,6 +114,8 @@ impl DaemonHandle {
|
||||
/// Stop the WS server (and IPC if running). Returns once both join
|
||||
/// handles complete.
|
||||
pub async fn shutdown(self) {
|
||||
self.session_idle_task.abort();
|
||||
let _ = await_join(self.session_idle_task).await;
|
||||
self.ws.shutdown.notify_waiters();
|
||||
let _ = await_join(self.ws.task).await;
|
||||
if let Some(ipc) = self.ipc {
|
||||
|
||||
@@ -124,7 +124,8 @@ async fn handle_connection(
|
||||
let captured_origin: std::sync::Arc<std::sync::Mutex<Option<String>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let captured_origin_for_cb = std::sync::Arc::clone(&captured_origin);
|
||||
let callback = move |req: &Request, response: Response| -> Result<Response, ErrorResponse> {
|
||||
#[allow(clippy::result_large_err)]
|
||||
let callback = move |req: &Request, response: Response| {
|
||||
let origin = req
|
||||
.headers()
|
||||
.get("Origin")
|
||||
|
||||
@@ -25,6 +25,7 @@ fn main() -> ExitCode {
|
||||
if !matches!(cli.command, Command::Daemon(_)) {
|
||||
init_cli_tracing(&cli.flags);
|
||||
}
|
||||
cli::update::maybe_spawn_background_check(&cli.flags, &cli.command);
|
||||
|
||||
let format = if cli.flags.json {
|
||||
Format::Json
|
||||
@@ -55,9 +56,12 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
} else {
|
||||
Output::Human
|
||||
};
|
||||
cli::doctor::run(output)
|
||||
.map(|_| ())
|
||||
.map_err(CliError::Local)
|
||||
let checks = cli::doctor::run(output).map_err(CliError::Local)?;
|
||||
if cli::doctor::has_failures(&checks) {
|
||||
Err(CliError::RenderedExit { exit_code: 1 })
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Command::InstallSkill(args) => {
|
||||
let output = if cli.flags.json {
|
||||
@@ -67,6 +71,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
};
|
||||
cli::install_skill::dispatch(args, output)
|
||||
}
|
||||
Command::Update(args) => cli::update::dispatch(args, format),
|
||||
Command::Logs(cmd) => cli::logs::run(cli::logs::LogsArgs {
|
||||
follow: cmd.follow,
|
||||
lines: cmd.lines,
|
||||
@@ -78,6 +83,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
|
||||
Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
|
||||
Command::Console(args) => cli::console::dispatch(args, format),
|
||||
Command::Network(args) => cli::network::dispatch(args, format),
|
||||
Command::GetHtml(args) => cli::get_html::dispatch(args, format),
|
||||
Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
|
||||
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
|
||||
@@ -91,6 +97,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format),
|
||||
Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format),
|
||||
Command::RequestHelp(args) => cli::human_loop::dispatch(args, format),
|
||||
Command::Record(cmd) => cli::record::dispatch(cmd, format),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ use bsk_protocol::system::BrowserStatusEntry;
|
||||
use bsk_protocol::{ErrorCode, Method};
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
@@ -30,9 +29,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port);
|
||||
let sock = tempfile_path("bsk-test-browser-list");
|
||||
|
||||
@@ -15,7 +15,6 @@ use bsk_protocol::system::{BrowserListParams, HandshakeParams, HandshakeResult,
|
||||
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
@@ -77,9 +76,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port);
|
||||
let sock = tempfile_path("bsk-test-browser-wait");
|
||||
|
||||
@@ -20,14 +20,11 @@ use bsk_protocol::{
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
|
||||
use support::{
|
||||
wait_for_abort_registered, wait_for_inflight_forwarded, wait_until,
|
||||
};
|
||||
use support::{wait_for_abort_registered, wait_for_inflight_forwarded, wait_until};
|
||||
|
||||
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
@@ -42,9 +39,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port);
|
||||
let sock = tempfile_path("bsk-test-cancel");
|
||||
@@ -551,9 +546,7 @@ async fn cancel_arriving_during_promote_critical_section_keeps_request_cancel_in
|
||||
wait_until(
|
||||
"extension snapshot/cancel counters to match",
|
||||
Duration::from_secs(2),
|
||||
|| {
|
||||
snapshots_clone.load(AOrdering::SeqCst) == cancels_clone.load(AOrdering::SeqCst)
|
||||
},
|
||||
|| snapshots_clone.load(AOrdering::SeqCst) == cancels_clone.load(AOrdering::SeqCst),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use bsk::cli::daemon::{DaemonCmd, parse_duration};
|
||||
use bsk::cli::navigate::NavigateCmd;
|
||||
use bsk::cli::record::{RecordCmd, RecordSub};
|
||||
use bsk::{Cli, Command};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -104,12 +105,58 @@ fn rejects_zero_console_bounds() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_network_command_with_context_safety_flags() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"network",
|
||||
"--session",
|
||||
"s1",
|
||||
"--tab-id",
|
||||
"9",
|
||||
"--since",
|
||||
"12",
|
||||
"--limit",
|
||||
"75",
|
||||
"--max-text-chars",
|
||||
"2048",
|
||||
]);
|
||||
let Command::Network(args) = cli.command else {
|
||||
panic!("expected network command");
|
||||
};
|
||||
assert_eq!(args.session, "s1");
|
||||
assert_eq!(args.tab_id, Some(9));
|
||||
assert_eq!(args.since, Some(12));
|
||||
assert_eq!(args.limit, Some(75));
|
||||
assert_eq!(args.max_text_chars, Some(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_network_bounds() {
|
||||
assert!(Cli::try_parse_from(["bsk", "network", "--session", "s1", "--limit", "0"]).is_err());
|
||||
assert!(
|
||||
Cli::try_parse_from(["bsk", "network", "--session", "s1", "--max-text-chars", "0"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_install_skill_subcommand() {
|
||||
let cli = parse(&["bsk", "install-skill", "--list"]);
|
||||
assert!(matches!(cli.command, Command::InstallSkill(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_subcommand_with_flags() {
|
||||
let cli = parse(&["bsk", "update", "--check", "--yes", "--no-restart-daemon"]);
|
||||
let Command::Update(args) = cli.command else {
|
||||
panic!("expected update subcommand");
|
||||
};
|
||||
assert!(args.check);
|
||||
assert!(args.yes);
|
||||
assert!(!args.restart_daemon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_parser_accepts_units() {
|
||||
assert_eq!(parse_duration("750ms").unwrap(), Duration::from_millis(750));
|
||||
@@ -146,3 +193,37 @@ fn rejects_zero_click_count() {
|
||||
Cli::try_parse_from(["bsk", "click", "@e1", "--session", "s1", "--count", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_record_start_with_browser_and_url() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"record",
|
||||
"start",
|
||||
"--browser",
|
||||
"022ca8ac",
|
||||
"--url",
|
||||
"https://x",
|
||||
]);
|
||||
let Command::Record(RecordCmd {
|
||||
sub: RecordSub::Start(args),
|
||||
}) = cli.command
|
||||
else {
|
||||
panic!("expected record start subcommand");
|
||||
};
|
||||
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
|
||||
assert_eq!(args.url.as_deref(), Some("https://x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_record_start_without_url() {
|
||||
let cli = parse(&["bsk", "record", "start", "--browser", "022ca8ac"]);
|
||||
let Command::Record(RecordCmd {
|
||||
sub: RecordSub::Start(args),
|
||||
}) = cli.command
|
||||
else {
|
||||
panic!("expected record start subcommand");
|
||||
};
|
||||
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
|
||||
assert!(args.url.is_none());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
|
||||
use bsk_protocol::{BrowserPeerInfo, ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
@@ -28,9 +27,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port);
|
||||
let sock = tempfile_path("bsk-test-handshake");
|
||||
|
||||
@@ -27,7 +27,6 @@ use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
@@ -46,9 +45,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
let sock = tempfile_path("bsk-test-queue");
|
||||
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
|
||||
.await
|
||||
@@ -272,9 +269,7 @@ async fn same_session_dispatches_run_after_previous_completes() {
|
||||
.expect("request did not reach extension")
|
||||
.expect("request channel closed");
|
||||
assert_eq!(tag, format!("job-{i}"));
|
||||
reply_tx
|
||||
.send((rpc_id, json!({"acked": true})))
|
||||
.unwrap();
|
||||
reply_tx.send((rpc_id, json!({"acked": true}))).unwrap();
|
||||
let r = dispatch_task.await.unwrap();
|
||||
assert!(r.is_ok(), "dispatch should succeed, got {r:?}");
|
||||
}
|
||||
@@ -333,7 +328,10 @@ async fn session_stop_fast_fails_while_tool_is_in_flight() {
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(stop_result, Err(bsk::daemon::sessions::StopSessionError::SessionBusy)),
|
||||
matches!(
|
||||
stop_result,
|
||||
Err(bsk::daemon::sessions::StopSessionError::SessionBusy)
|
||||
),
|
||||
"session.stop should fast-fail while a tool is active, got {stop_result:?}"
|
||||
);
|
||||
assert!(
|
||||
@@ -437,7 +435,10 @@ async fn concurrent_same_session_dispatch_returns_session_busy_immediately() {
|
||||
let rpc = DispatchError::SessionBusy.into_rpc();
|
||||
assert_eq!(rpc.code, ErrorCode::Timeout);
|
||||
assert_eq!(
|
||||
rpc.data.as_ref().and_then(|d| d.get("reason")).and_then(|v| v.as_str()),
|
||||
rpc.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("reason"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some(bsk::rpc_reason::SESSION_BUSY)
|
||||
);
|
||||
|
||||
@@ -471,7 +472,10 @@ async fn concurrent_same_session_dispatch_returns_session_busy_immediately() {
|
||||
.send((after_rpc_id, json!({"acked": true})))
|
||||
.unwrap();
|
||||
let after = after_task.await.unwrap();
|
||||
assert!(after.is_ok(), "dispatch should succeed after first completes, got {after:?}");
|
||||
assert!(
|
||||
after.is_ok(),
|
||||
"dispatch should succeed after first completes, got {after:?}"
|
||||
);
|
||||
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ use bsk_protocol::{
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
@@ -46,9 +45,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port);
|
||||
let sock = tempfile_path("bsk-test-user-interrupt");
|
||||
|
||||
@@ -15,7 +15,6 @@ use bsk_protocol::tools::{SessionStartParams, SessionStartResult, SessionStopPar
|
||||
use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
use tokio_tungstenite::tungstenite::protocol::Message;
|
||||
@@ -39,9 +38,7 @@ async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
}
|
||||
|
||||
async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
|
||||
let config = DaemonConfig::new(port).with_extension_connect_wait(connect_wait);
|
||||
let sock = tempfile_path("bsk-test-ipc");
|
||||
@@ -49,6 +46,16 @@ async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::Daem
|
||||
(handle, sock)
|
||||
}
|
||||
|
||||
async fn spawn_daemon_with_session_idle(session_idle: Duration) -> (daemon::DaemonHandle, PathBuf) {
|
||||
let port = 0;
|
||||
|
||||
let mut config = DaemonConfig::new(port);
|
||||
config.session_idle = session_idle;
|
||||
let sock = tempfile_path("bsk-test-ipc");
|
||||
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
|
||||
(handle, sock)
|
||||
}
|
||||
|
||||
async fn connect_ext(
|
||||
addr: std::net::SocketAddr,
|
||||
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
|
||||
@@ -240,6 +247,47 @@ async fn session_start_stop_round_trip_via_ipc() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_idle_timeout_stops_and_unregisters_session() {
|
||||
let (handle, _sock) = spawn_daemon_with_session_idle(Duration::from_millis(50)).await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = handshake_as_ext(&mut ws).await;
|
||||
let state = handle.state();
|
||||
let session_id = bsk::daemon::sessions::SessionId("idle".into());
|
||||
state.sessions.insert(bsk::daemon::sessions::Session {
|
||||
id: session_id.clone(),
|
||||
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
|
||||
agent_window_id: Some(7),
|
||||
created_at_ms: 0,
|
||||
});
|
||||
state.tool_queues.spawn(session_id);
|
||||
|
||||
let request = tokio::time::timeout(Duration::from_secs(1), ws.next())
|
||||
.await
|
||||
.expect("idle reaper did not contact extension")
|
||||
.expect("extension socket closed")
|
||||
.expect("extension socket failed");
|
||||
let Message::Text(text) = request else {
|
||||
panic!("expected text request");
|
||||
};
|
||||
let request: RequestFrame = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(request.method, Method::ToolSessionStop);
|
||||
ws.send(Message::Text(
|
||||
serde_json::to_string(&ResponseFrame {
|
||||
id: request.id,
|
||||
body: ResponseBody::Ok(
|
||||
serde_json::to_value(bsk_protocol::tools::SessionStopResult::default()).unwrap(),
|
||||
),
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_no_sessions(&state).await;
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_start_errors_without_browser() {
|
||||
let (handle, sock) = spawn_daemon_with_connect_wait(Duration::ZERO).await;
|
||||
|
||||
@@ -92,7 +92,7 @@ fn bsk_doctor_runs_without_running_daemon() {
|
||||
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
|
||||
.output()
|
||||
.expect("run bsk doctor");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.contains("bsk home writable"),
|
||||
@@ -137,7 +137,7 @@ fn bsk_doctor_json_returns_structured_checks() {
|
||||
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
|
||||
.output()
|
||||
.expect("run bsk doctor --json");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("doctor --json should be valid JSON");
|
||||
@@ -203,7 +203,7 @@ fn bsk_doctor_does_not_treat_live_non_daemon_pid_as_running() {
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run bsk doctor --json");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
|
||||
let daemon = checks
|
||||
@@ -262,7 +262,7 @@ fn bsk_doctor_flags_pid_mismatch_against_running_daemon() {
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("bsk doctor --json");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bsk::daemon::queue::ToolQueueRegistry;
|
||||
use bsk::daemon::sessions::SessionId;
|
||||
use bsk::daemon::state::DaemonState;
|
||||
use bsk::daemon::queue::ToolQueueRegistry;
|
||||
use bsk_protocol::RpcId;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
@@ -81,11 +81,9 @@ pub async fn wait_for_inflight_forwarded(state: &Arc<DaemonState>, rpc_id: &RpcI
|
||||
|
||||
pub async fn wait_for_abort_registered(state: &Arc<DaemonState>, rpc_id: &RpcId) {
|
||||
let id = rpc_id.clone();
|
||||
wait_until(
|
||||
&format!("abort token for {id}"),
|
||||
DEFAULT_TIMEOUT,
|
||||
|| state.abort_registry.contains(&id),
|
||||
)
|
||||
wait_until(&format!("abort token for {id}"), DEFAULT_TIMEOUT, || {
|
||||
state.abort_registry.contains(&id)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
|
||||
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
|
||||
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
|
||||
NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ScreenshotParams,
|
||||
ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, SnapshotResult,
|
||||
TabInfo, TabListParams, TabListResult, TabScope,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
@@ -21,7 +22,6 @@ use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
@@ -40,9 +40,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
let sock = tempfile_path("bsk-test-tools");
|
||||
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
|
||||
.await
|
||||
@@ -368,6 +366,62 @@ async fn console_returns_buffered_entries() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn network_returns_buffered_entries() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = do_handshake(&mut ws).await;
|
||||
|
||||
run_extension(ws, |req| {
|
||||
assert_eq!(req.method, Method::ToolNetwork);
|
||||
let params: NetworkParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
|
||||
assert_eq!(params.tab_id, Some(7));
|
||||
assert_eq!(params.since, Some(3));
|
||||
assert_eq!(params.limit, Some(50));
|
||||
assert_eq!(params.max_text_chars, Some(1000));
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(NetworkResult {
|
||||
tab_id: 7,
|
||||
entries: vec![NetworkEntry {
|
||||
sequence: 4,
|
||||
kind: NetworkEntryKind::Response,
|
||||
method: Some("GET".into()),
|
||||
url: Some("https://example.test/api".into()),
|
||||
status: Some(404),
|
||||
status_text: Some("Not Found".into()),
|
||||
mime_type: Some("application/json".into()),
|
||||
resource_type: Some("Fetch".into()),
|
||||
error_text: None,
|
||||
timestamp: None,
|
||||
truncated: false,
|
||||
}],
|
||||
next_since: 4,
|
||||
truncated: false,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let session_id = ipc_session_start(&sock).await;
|
||||
let result: NetworkResult = ipc_tool_call(
|
||||
&sock,
|
||||
Method::ToolNetwork,
|
||||
NetworkParams {
|
||||
session_id,
|
||||
tab_id: Some(7),
|
||||
since: Some(3),
|
||||
limit: Some(50),
|
||||
max_text_chars: Some(1000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("network ok");
|
||||
assert_eq!(result.tab_id, 7);
|
||||
assert_eq!(result.next_since, 4);
|
||||
assert_eq!(result.entries[0].status, Some(404));
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_returns_text_and_ref_count() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
|
||||
@@ -25,7 +25,6 @@ use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::http::Request;
|
||||
@@ -44,9 +43,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
|
||||
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
let port = 0;
|
||||
let sock = tempfile_path("bsk-test-tools-m7");
|
||||
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
|
||||
.await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user