btcp-browser-agent 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +338 -306
- package/package.json +69 -69
- package/packages/core/dist/actions.js +98 -67
- package/packages/core/dist/assertions.d.ts +118 -0
- package/packages/core/dist/assertions.js +230 -0
- package/packages/core/dist/errors.d.ts +23 -1
- package/packages/core/dist/errors.js +49 -0
- package/packages/core/dist/index.d.ts +1 -0
- package/packages/core/dist/index.js +1 -0
- package/packages/extension/dist/background.d.ts +8 -1
- package/packages/extension/dist/background.js +73 -14
- package/packages/extension/dist/content.js +114 -0
- package/packages/extension/dist/index.d.ts +43 -0
- package/packages/extension/dist/index.js +26 -0
- package/packages/extension/dist/remote.d.ts +133 -0
- package/packages/extension/dist/remote.js +668 -0
- package/packages/extension/dist/script-messenger.d.ts +132 -0
- package/packages/extension/dist/script-messenger.js +86 -0
- package/packages/extension/dist/types.d.ts +76 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @btcp/core - Assertions Module
|
|
3
|
+
*
|
|
4
|
+
* Provides verification utilities for DOM actions with async waiting support.
|
|
5
|
+
* Returns structured results for consistent error handling.
|
|
6
|
+
*/
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// CORE ASSERTIONS
|
|
9
|
+
// ============================================================================
|
|
10
|
+
/**
|
|
11
|
+
* Assert that an element is still connected to the DOM
|
|
12
|
+
*/
|
|
13
|
+
export function assertConnected(element) {
|
|
14
|
+
const connected = element.isConnected;
|
|
15
|
+
return {
|
|
16
|
+
success: connected,
|
|
17
|
+
error: connected ? null : 'Element is not connected to DOM',
|
|
18
|
+
description: 'Element should be connected to DOM',
|
|
19
|
+
expected: true,
|
|
20
|
+
actual: connected,
|
|
21
|
+
context: {
|
|
22
|
+
tagName: element.tagName,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Assert that an input/textarea value contains the expected text
|
|
28
|
+
*/
|
|
29
|
+
export function assertValueContains(element, text) {
|
|
30
|
+
const value = element.value;
|
|
31
|
+
const contains = value.includes(text);
|
|
32
|
+
return {
|
|
33
|
+
success: contains,
|
|
34
|
+
error: contains ? null : `Value "${value}" does not contain "${text}"`,
|
|
35
|
+
description: 'Value should contain expected text',
|
|
36
|
+
expected: text,
|
|
37
|
+
actual: value,
|
|
38
|
+
context: {
|
|
39
|
+
tagName: element.tagName,
|
|
40
|
+
inputType: element instanceof HTMLInputElement ? element.type : 'textarea',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Assert that an input/textarea value equals the expected value exactly
|
|
46
|
+
*/
|
|
47
|
+
export function assertValueEquals(element, value) {
|
|
48
|
+
const actualValue = element.value;
|
|
49
|
+
const equals = actualValue === value;
|
|
50
|
+
return {
|
|
51
|
+
success: equals,
|
|
52
|
+
error: equals ? null : `Value "${actualValue}" does not equal "${value}"`,
|
|
53
|
+
description: 'Value should equal expected value',
|
|
54
|
+
expected: value,
|
|
55
|
+
actual: actualValue,
|
|
56
|
+
context: {
|
|
57
|
+
tagName: element.tagName,
|
|
58
|
+
inputType: element instanceof HTMLInputElement ? element.type : 'textarea',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Assert that a checkbox/radio is in the expected checked state
|
|
64
|
+
*/
|
|
65
|
+
export function assertChecked(element, expected) {
|
|
66
|
+
const checked = element.checked;
|
|
67
|
+
const matches = checked === expected;
|
|
68
|
+
return {
|
|
69
|
+
success: matches,
|
|
70
|
+
error: matches ? null : `Element is ${checked ? 'checked' : 'unchecked'}, expected ${expected ? 'checked' : 'unchecked'}`,
|
|
71
|
+
description: expected ? 'Element should be checked' : 'Element should be unchecked',
|
|
72
|
+
expected,
|
|
73
|
+
actual: checked,
|
|
74
|
+
context: {
|
|
75
|
+
tagName: element.tagName,
|
|
76
|
+
inputType: element.type,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Assert that a select element has the expected values selected
|
|
82
|
+
*/
|
|
83
|
+
export function assertSelected(element, values) {
|
|
84
|
+
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
85
|
+
const missingValues = values.filter(v => !selectedValues.includes(v));
|
|
86
|
+
const success = missingValues.length === 0;
|
|
87
|
+
return {
|
|
88
|
+
success,
|
|
89
|
+
error: success ? null : `Options not selected: ${missingValues.join(', ')}`,
|
|
90
|
+
description: 'Select should have expected options selected',
|
|
91
|
+
expected: values,
|
|
92
|
+
actual: selectedValues,
|
|
93
|
+
context: {
|
|
94
|
+
tagName: element.tagName,
|
|
95
|
+
multiple: element.multiple,
|
|
96
|
+
missingValues: missingValues.length > 0 ? missingValues : undefined,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Assert that a URL matches the expected origin
|
|
102
|
+
*/
|
|
103
|
+
export function assertUrlOrigin(actualUrl, expectedUrl) {
|
|
104
|
+
try {
|
|
105
|
+
const actual = new URL(actualUrl);
|
|
106
|
+
const expected = new URL(expectedUrl);
|
|
107
|
+
const success = actual.origin === expected.origin;
|
|
108
|
+
return {
|
|
109
|
+
success,
|
|
110
|
+
error: success ? null : `Origin mismatch: expected ${expected.origin}, got ${actual.origin}`,
|
|
111
|
+
description: 'URL origin should match expected',
|
|
112
|
+
expected: expected.origin,
|
|
113
|
+
actual: actual.origin,
|
|
114
|
+
context: {
|
|
115
|
+
actualUrl,
|
|
116
|
+
expectedUrl,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// URL parsing failed
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: `URL parsing failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
125
|
+
description: 'URL origin should match expected',
|
|
126
|
+
expected: expectedUrl,
|
|
127
|
+
actual: actualUrl,
|
|
128
|
+
context: {
|
|
129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Assert that an element is visible (or hidden)
|
|
136
|
+
*/
|
|
137
|
+
export function assertVisible(element, win, expected = true) {
|
|
138
|
+
const style = win.getComputedStyle(element);
|
|
139
|
+
const visible = style.display !== 'none' &&
|
|
140
|
+
style.visibility !== 'hidden' &&
|
|
141
|
+
style.opacity !== '0';
|
|
142
|
+
const success = visible === expected;
|
|
143
|
+
return {
|
|
144
|
+
success,
|
|
145
|
+
error: success ? null : `Element is ${visible ? 'visible' : 'hidden'}, expected ${expected ? 'visible' : 'hidden'}`,
|
|
146
|
+
description: expected ? 'Element should be visible' : 'Element should be hidden',
|
|
147
|
+
expected,
|
|
148
|
+
actual: visible,
|
|
149
|
+
context: {
|
|
150
|
+
tagName: element.tagName,
|
|
151
|
+
display: style.display,
|
|
152
|
+
visibility: style.visibility,
|
|
153
|
+
opacity: style.opacity,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Assert that an element is enabled (or disabled)
|
|
159
|
+
*/
|
|
160
|
+
export function assertEnabled(element, expected = true) {
|
|
161
|
+
const disabled = element.disabled ?? false;
|
|
162
|
+
const enabled = !disabled;
|
|
163
|
+
const success = enabled === expected;
|
|
164
|
+
return {
|
|
165
|
+
success,
|
|
166
|
+
error: success ? null : `Element is ${enabled ? 'enabled' : 'disabled'}, expected ${expected ? 'enabled' : 'disabled'}`,
|
|
167
|
+
description: expected ? 'Element should be enabled' : 'Element should be disabled',
|
|
168
|
+
expected,
|
|
169
|
+
actual: enabled,
|
|
170
|
+
context: {
|
|
171
|
+
tagName: element.tagName,
|
|
172
|
+
disabled,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// ASYNC WAITING
|
|
178
|
+
// ============================================================================
|
|
179
|
+
/**
|
|
180
|
+
* Wait for an assertion to pass within a timeout period
|
|
181
|
+
*
|
|
182
|
+
* Polls the assertion function at regular intervals until it passes
|
|
183
|
+
* or the timeout is reached.
|
|
184
|
+
*
|
|
185
|
+
* @param assertFn - Function that returns an AssertionResult
|
|
186
|
+
* @param options - Timeout and interval options
|
|
187
|
+
* @returns WaitResult with success status and timing information
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```typescript
|
|
191
|
+
* const result = await waitForAssertion(
|
|
192
|
+
* () => assertValueEquals(input, 'hello'),
|
|
193
|
+
* { timeout: 1000, interval: 50 }
|
|
194
|
+
* );
|
|
195
|
+
*
|
|
196
|
+
* if (!result.success) {
|
|
197
|
+
* throw createVerificationError('fill', result, selector);
|
|
198
|
+
* }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export async function waitForAssertion(assertFn, options = {}) {
|
|
202
|
+
const { timeout = 1000, interval = 50 } = options;
|
|
203
|
+
const startTime = Date.now();
|
|
204
|
+
let attempts = 0;
|
|
205
|
+
let lastResult;
|
|
206
|
+
while (Date.now() - startTime < timeout) {
|
|
207
|
+
attempts++;
|
|
208
|
+
lastResult = assertFn();
|
|
209
|
+
if (lastResult.success) {
|
|
210
|
+
return {
|
|
211
|
+
success: true,
|
|
212
|
+
elapsed: Date.now() - startTime,
|
|
213
|
+
attempts,
|
|
214
|
+
result: lastResult,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// Wait before next attempt
|
|
218
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
219
|
+
}
|
|
220
|
+
// Final attempt after timeout
|
|
221
|
+
attempts++;
|
|
222
|
+
lastResult = assertFn();
|
|
223
|
+
return {
|
|
224
|
+
success: lastResult.success,
|
|
225
|
+
elapsed: Date.now() - startTime,
|
|
226
|
+
attempts,
|
|
227
|
+
result: lastResult,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=assertions.js.map
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Provides structured error types with machine-readable codes and
|
|
5
5
|
* actionable suggestions to help AI agents self-correct.
|
|
6
6
|
*/
|
|
7
|
+
import type { WaitResult } from './assertions.js';
|
|
7
8
|
/**
|
|
8
9
|
* Machine-readable error codes for programmatic error handling
|
|
9
10
|
*/
|
|
@@ -27,7 +28,9 @@ export declare enum ErrorCode {
|
|
|
27
28
|
/** Element is not in the expected state */
|
|
28
29
|
INVALID_STATE = "INVALID_STATE",
|
|
29
30
|
/** Network or navigation error */
|
|
30
|
-
NAVIGATION_ERROR = "NAVIGATION_ERROR"
|
|
31
|
+
NAVIGATION_ERROR = "NAVIGATION_ERROR",
|
|
32
|
+
/** Action verification failed after completion */
|
|
33
|
+
VERIFICATION_FAILED = "VERIFICATION_FAILED"
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
33
36
|
* Structured context information for errors
|
|
@@ -135,4 +138,23 @@ export declare function createTimeoutError(selector: string | undefined, expecte
|
|
|
135
138
|
* Helper function to create invalid parameters error
|
|
136
139
|
*/
|
|
137
140
|
export declare function createInvalidParametersError(message: string, conflictingParams: string[], suggestion: string): DetailedError;
|
|
141
|
+
/**
|
|
142
|
+
* Helper function to create verification error with assertion details
|
|
143
|
+
*
|
|
144
|
+
* @param action - The action that failed verification (e.g., 'fill', 'type', 'check')
|
|
145
|
+
* @param waitResult - The result from waitForAssertion
|
|
146
|
+
* @param selector - Optional selector for the element
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```typescript
|
|
150
|
+
* const result = await waitForAssertion(
|
|
151
|
+
* () => assertValueEquals(element, value)
|
|
152
|
+
* );
|
|
153
|
+
*
|
|
154
|
+
* if (!result.success) {
|
|
155
|
+
* throw createVerificationError('fill', result, selector);
|
|
156
|
+
* }
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export declare function createVerificationError(action: string, waitResult: WaitResult, selector?: string): DetailedError;
|
|
138
160
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -29,6 +29,8 @@ export var ErrorCode;
|
|
|
29
29
|
ErrorCode["INVALID_STATE"] = "INVALID_STATE";
|
|
30
30
|
/** Network or navigation error */
|
|
31
31
|
ErrorCode["NAVIGATION_ERROR"] = "NAVIGATION_ERROR";
|
|
32
|
+
/** Action verification failed after completion */
|
|
33
|
+
ErrorCode["VERIFICATION_FAILED"] = "VERIFICATION_FAILED";
|
|
32
34
|
})(ErrorCode || (ErrorCode = {}));
|
|
33
35
|
/**
|
|
34
36
|
* Detailed error with structured data for AI agents
|
|
@@ -154,4 +156,51 @@ export function createInvalidParametersError(message, conflictingParams, suggest
|
|
|
154
156
|
conflictingParams,
|
|
155
157
|
}, [suggestion]);
|
|
156
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Helper function to create verification error with assertion details
|
|
161
|
+
*
|
|
162
|
+
* @param action - The action that failed verification (e.g., 'fill', 'type', 'check')
|
|
163
|
+
* @param waitResult - The result from waitForAssertion
|
|
164
|
+
* @param selector - Optional selector for the element
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* const result = await waitForAssertion(
|
|
169
|
+
* () => assertValueEquals(element, value)
|
|
170
|
+
* );
|
|
171
|
+
*
|
|
172
|
+
* if (!result.success) {
|
|
173
|
+
* throw createVerificationError('fill', result, selector);
|
|
174
|
+
* }
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function createVerificationError(action, waitResult, selector) {
|
|
178
|
+
const { result, elapsed, attempts } = waitResult;
|
|
179
|
+
const suggestions = [];
|
|
180
|
+
// Add context-specific suggestions based on the assertion
|
|
181
|
+
if (result.description.includes('value')) {
|
|
182
|
+
suggestions.push('The element value may have been modified by event handlers.', 'Check for input masking, formatting, or validation that transforms the value.');
|
|
183
|
+
}
|
|
184
|
+
if (result.description.includes('checked')) {
|
|
185
|
+
suggestions.push('The checkbox/radio state may be controlled by JavaScript.', 'Verify the element is not disabled or read-only.');
|
|
186
|
+
}
|
|
187
|
+
if (result.description.includes('selected')) {
|
|
188
|
+
suggestions.push('Some options may not exist in the select element.', 'Use snapshot() to verify available option values.');
|
|
189
|
+
}
|
|
190
|
+
if (result.description.includes('origin')) {
|
|
191
|
+
suggestions.push('The page may have redirected to a different domain.', 'Check for authentication redirects or HTTPS upgrades.');
|
|
192
|
+
}
|
|
193
|
+
suggestions.push(`Verification waited ${elapsed}ms with ${attempts} attempts.`);
|
|
194
|
+
const message = selector
|
|
195
|
+
? `${action} verification failed for ${selector}: ${result.description}`
|
|
196
|
+
: `${action} verification failed: ${result.description}`;
|
|
197
|
+
return new DetailedError(ErrorCode.VERIFICATION_FAILED, message, {
|
|
198
|
+
selector,
|
|
199
|
+
expected: result.expected,
|
|
200
|
+
actual: result.actual,
|
|
201
|
+
elapsed,
|
|
202
|
+
attempts,
|
|
203
|
+
...result.context,
|
|
204
|
+
}, suggestions);
|
|
205
|
+
}
|
|
157
206
|
//# sourceMappingURL=errors.js.map
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type { Command, Response, RefMap } from './types.js';
|
|
21
21
|
export * from './types.js';
|
|
22
22
|
export * from './errors.js';
|
|
23
|
+
export * from './assertions.js';
|
|
23
24
|
export { createSnapshot } from './snapshot.js';
|
|
24
25
|
export { createRefMap, createSimpleRefMap } from './ref-map.js';
|
|
25
26
|
export { DOMActions, generateCommandId } from './actions.js';
|
|
@@ -21,6 +21,7 @@ import { DOMActions } from './actions.js';
|
|
|
21
21
|
import { createRefMap, createSimpleRefMap } from './ref-map.js';
|
|
22
22
|
export * from './types.js';
|
|
23
23
|
export * from './errors.js';
|
|
24
|
+
export * from './assertions.js';
|
|
24
25
|
export { createSnapshot } from './snapshot.js';
|
|
25
26
|
export { createRefMap, createSimpleRefMap } from './ref-map.js';
|
|
26
27
|
export { DOMActions, generateCommandId } from './actions.js';
|
|
@@ -119,8 +119,10 @@ export declare class BackgroundAgent {
|
|
|
119
119
|
switchTab(tabId: number): Promise<void>;
|
|
120
120
|
/**
|
|
121
121
|
* Navigate to a URL (only in session tabs)
|
|
122
|
+
* Always waits for page to be idle before returning.
|
|
123
|
+
* Verifies navigation completed to the expected origin.
|
|
122
124
|
*/
|
|
123
|
-
navigate(url: string,
|
|
125
|
+
navigate(url: string, _options?: {
|
|
124
126
|
waitUntil?: 'load' | 'domcontentloaded';
|
|
125
127
|
}): Promise<void>;
|
|
126
128
|
/**
|
|
@@ -205,6 +207,11 @@ export declare class BackgroundAgent {
|
|
|
205
207
|
private isExtensionCommand;
|
|
206
208
|
private executeExtensionCommand;
|
|
207
209
|
private waitForTabLoad;
|
|
210
|
+
/**
|
|
211
|
+
* Wait for the page to become idle (no pending network requests or JS execution)
|
|
212
|
+
* Uses requestIdleCallback in the content script to detect when the browser is idle.
|
|
213
|
+
*/
|
|
214
|
+
private waitForIdle;
|
|
208
215
|
}
|
|
209
216
|
/**
|
|
210
217
|
* Get or create the BackgroundAgent singleton
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - Routing DOM commands to ContentAgents in target tabs
|
|
13
13
|
*/
|
|
14
14
|
import { SessionManager } from './session-manager.js';
|
|
15
|
+
import { assertUrlOrigin, createVerificationError } from '../../core/dist/index.js';
|
|
15
16
|
// Command ID counter for auto-generated IDs
|
|
16
17
|
let bgCommandIdCounter = 0;
|
|
17
18
|
/**
|
|
@@ -282,8 +283,10 @@ export class BackgroundAgent {
|
|
|
282
283
|
// ============================================================================
|
|
283
284
|
/**
|
|
284
285
|
* Navigate to a URL (only in session tabs)
|
|
286
|
+
* Always waits for page to be idle before returning.
|
|
287
|
+
* Verifies navigation completed to the expected origin.
|
|
285
288
|
*/
|
|
286
|
-
async navigate(url,
|
|
289
|
+
async navigate(url, _options) {
|
|
287
290
|
const tabId = this.activeTabId ?? (await this.getActiveTab())?.id;
|
|
288
291
|
if (!tabId)
|
|
289
292
|
throw new Error('No active tab');
|
|
@@ -295,20 +298,40 @@ export class BackgroundAgent {
|
|
|
295
298
|
await new Promise((resolve) => {
|
|
296
299
|
chrome.tabs.update(tabId, { url }, () => resolve());
|
|
297
300
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
// Always wait for tab to load and become idle
|
|
302
|
+
await this.waitForTabLoad(tabId);
|
|
303
|
+
await this.waitForIdle(tabId);
|
|
304
|
+
// Verify navigation completed to the expected origin
|
|
305
|
+
const tab = await chrome.tabs.get(tabId);
|
|
306
|
+
const finalUrl = tab.url || '';
|
|
307
|
+
// Use assertion module for origin verification
|
|
308
|
+
const result = assertUrlOrigin(finalUrl, url);
|
|
309
|
+
if (!result.success) {
|
|
310
|
+
// Only throw for actual origin mismatches, not URL parsing errors
|
|
311
|
+
if (result.context?.error) {
|
|
312
|
+
// URL parsing failed (e.g., chrome:// pages) - skip verification
|
|
313
|
+
console.log('[BackgroundAgent] Skipping URL verification for special page:', finalUrl);
|
|
306
314
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
315
|
+
else {
|
|
316
|
+
throw createVerificationError('navigate', {
|
|
317
|
+
success: false,
|
|
318
|
+
elapsed: 0,
|
|
319
|
+
attempts: 1,
|
|
320
|
+
result,
|
|
321
|
+
});
|
|
310
322
|
}
|
|
311
323
|
}
|
|
324
|
+
// Clear refs and highlights after navigation completes
|
|
325
|
+
try {
|
|
326
|
+
await this.sendToContentAgent({
|
|
327
|
+
id: `nav_clear_${Date.now()}`,
|
|
328
|
+
action: 'clearHighlight'
|
|
329
|
+
}, tabId);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// Ignore errors - content script might not be ready yet
|
|
333
|
+
console.log('[BackgroundAgent] Failed to clear highlights after navigation:', error);
|
|
334
|
+
}
|
|
312
335
|
}
|
|
313
336
|
/**
|
|
314
337
|
* Go back in history
|
|
@@ -494,9 +517,9 @@ export class BackgroundAgent {
|
|
|
494
517
|
if (!tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
|
|
495
518
|
return false; // Can't inject into chrome:// or extension pages
|
|
496
519
|
}
|
|
497
|
-
// Execute content script
|
|
520
|
+
// Execute content script (main frame only)
|
|
498
521
|
await chrome.scripting.executeScript({
|
|
499
|
-
target: { tabId },
|
|
522
|
+
target: { tabId, frameIds: [0] },
|
|
500
523
|
files: ['content.js'],
|
|
501
524
|
});
|
|
502
525
|
console.log(`[Recovery] Successfully re-injected content script into tab ${tabId}`);
|
|
@@ -694,6 +717,42 @@ export class BackgroundAgent {
|
|
|
694
717
|
checkTab();
|
|
695
718
|
});
|
|
696
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Wait for the page to become idle (no pending network requests or JS execution)
|
|
722
|
+
* Uses requestIdleCallback in the content script to detect when the browser is idle.
|
|
723
|
+
*/
|
|
724
|
+
async waitForIdle(tabId, timeout = 10000) {
|
|
725
|
+
const startTime = Date.now();
|
|
726
|
+
// Poll until the page reports idle or timeout
|
|
727
|
+
while (Date.now() - startTime < timeout) {
|
|
728
|
+
try {
|
|
729
|
+
const results = await chrome.scripting.executeScript({
|
|
730
|
+
target: { tabId, frameIds: [0] }, // Only inject into main frame
|
|
731
|
+
func: () => {
|
|
732
|
+
return new Promise((resolve) => {
|
|
733
|
+
// Use requestIdleCallback to detect when browser is idle
|
|
734
|
+
if ('requestIdleCallback' in window) {
|
|
735
|
+
requestIdleCallback(() => resolve(true), { timeout: 1000 });
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
// Fallback: wait a short period
|
|
739
|
+
setTimeout(() => resolve(true), 500);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
if (results?.[0]?.result) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
// Content script may not be ready yet, wait and retry
|
|
750
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Timeout reached, continue anyway
|
|
754
|
+
console.log('[BackgroundAgent] waitForIdle timeout reached, continuing');
|
|
755
|
+
}
|
|
697
756
|
}
|
|
698
757
|
// ============================================================================
|
|
699
758
|
// MESSAGE LISTENER SETUP
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
import { createContentAgent } from '../../core/dist/index.js';
|
|
8
8
|
let agent = null;
|
|
9
9
|
let isContentScriptReady = false;
|
|
10
|
+
// Track injected scripts by scriptId
|
|
11
|
+
const injectedScripts = new Map();
|
|
12
|
+
// Track pending script commands waiting for ack
|
|
13
|
+
const pendingScriptCommands = new Map();
|
|
14
|
+
// Counter for generating unique command IDs
|
|
15
|
+
let scriptCommandCounter = 0;
|
|
10
16
|
/**
|
|
11
17
|
* Get or create the ContentAgent instance for this page
|
|
12
18
|
*/
|
|
@@ -28,9 +34,113 @@ function isCoreCommand(command) {
|
|
|
28
34
|
'navigate', 'back', 'forward', 'reload',
|
|
29
35
|
'getUrl', 'getTitle', 'screenshot',
|
|
30
36
|
'tabNew', 'tabClose', 'tabSwitch', 'tabList',
|
|
37
|
+
'scriptInject', 'scriptSend',
|
|
31
38
|
];
|
|
32
39
|
return !extensionActions.includes(command.action);
|
|
33
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Inject a script into the page's main world
|
|
43
|
+
*/
|
|
44
|
+
function handleScriptInject(command) {
|
|
45
|
+
const id = command.id || 'unknown';
|
|
46
|
+
const scriptId = command.scriptId || 'default';
|
|
47
|
+
try {
|
|
48
|
+
// Remove existing script with same ID if present
|
|
49
|
+
const existing = injectedScripts.get(scriptId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
existing.element.remove();
|
|
52
|
+
injectedScripts.delete(scriptId);
|
|
53
|
+
}
|
|
54
|
+
// Create script element
|
|
55
|
+
const script = document.createElement('script');
|
|
56
|
+
script.textContent = command.code;
|
|
57
|
+
script.setAttribute('data-btcp-script-id', scriptId);
|
|
58
|
+
// Inject into page's main world by appending to document
|
|
59
|
+
(document.head || document.documentElement).appendChild(script);
|
|
60
|
+
// Track the injected script
|
|
61
|
+
injectedScripts.set(scriptId, {
|
|
62
|
+
element: script,
|
|
63
|
+
injectedAt: Date.now(),
|
|
64
|
+
});
|
|
65
|
+
console.log(`[ContentScript] Injected script: ${scriptId}`);
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
success: true,
|
|
69
|
+
data: { scriptId, injected: true },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
success: false,
|
|
76
|
+
error: error instanceof Error ? error.message : String(error),
|
|
77
|
+
errorCode: 'INJECTION_FAILED',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Send a command to an injected script and wait for acknowledgment
|
|
83
|
+
*/
|
|
84
|
+
function handleScriptSend(command) {
|
|
85
|
+
const id = command.id || 'unknown';
|
|
86
|
+
const scriptId = command.scriptId || 'default';
|
|
87
|
+
const timeout = command.timeout ?? 30000;
|
|
88
|
+
const commandId = `script_cmd_${Date.now()}_${scriptCommandCounter++}`;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
// Set up timeout
|
|
91
|
+
const timeoutId = setTimeout(() => {
|
|
92
|
+
pendingScriptCommands.delete(commandId);
|
|
93
|
+
resolve({
|
|
94
|
+
id,
|
|
95
|
+
success: false,
|
|
96
|
+
error: `Script command timed out after ${timeout}ms`,
|
|
97
|
+
errorCode: 'SCRIPT_TIMEOUT',
|
|
98
|
+
});
|
|
99
|
+
}, timeout);
|
|
100
|
+
// Track pending command
|
|
101
|
+
pendingScriptCommands.set(commandId, { resolve, timeoutId });
|
|
102
|
+
// Send command to page script via postMessage
|
|
103
|
+
const message = {
|
|
104
|
+
type: 'btcp:script-command',
|
|
105
|
+
commandId,
|
|
106
|
+
scriptId,
|
|
107
|
+
payload: command.payload,
|
|
108
|
+
};
|
|
109
|
+
window.postMessage(message, '*');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Listen for script acknowledgments from page scripts
|
|
114
|
+
*/
|
|
115
|
+
window.addEventListener('message', (event) => {
|
|
116
|
+
if (event.source !== window)
|
|
117
|
+
return;
|
|
118
|
+
const msg = event.data;
|
|
119
|
+
if (msg?.type !== 'btcp:script-ack')
|
|
120
|
+
return;
|
|
121
|
+
const pending = pendingScriptCommands.get(msg.commandId);
|
|
122
|
+
if (!pending)
|
|
123
|
+
return;
|
|
124
|
+
// Clear timeout and remove from pending
|
|
125
|
+
clearTimeout(pending.timeoutId);
|
|
126
|
+
pendingScriptCommands.delete(msg.commandId);
|
|
127
|
+
// Resolve with response
|
|
128
|
+
if (msg.error) {
|
|
129
|
+
pending.resolve({
|
|
130
|
+
id: msg.commandId,
|
|
131
|
+
success: false,
|
|
132
|
+
error: msg.error,
|
|
133
|
+
errorCode: 'SCRIPT_ERROR',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
pending.resolve({
|
|
138
|
+
id: msg.commandId,
|
|
139
|
+
success: true,
|
|
140
|
+
data: { result: msg.result },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
34
144
|
/**
|
|
35
145
|
* Handle a command from the background script
|
|
36
146
|
*/
|
|
@@ -54,6 +164,10 @@ async function handleCommand(command) {
|
|
|
54
164
|
success: true,
|
|
55
165
|
data: { title: document.title },
|
|
56
166
|
};
|
|
167
|
+
case 'scriptInject':
|
|
168
|
+
return handleScriptInject(command);
|
|
169
|
+
case 'scriptSend':
|
|
170
|
+
return handleScriptSend(command);
|
|
57
171
|
default:
|
|
58
172
|
// Forward to background script
|
|
59
173
|
return {
|