browser-commander 0.2.0
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +296 -0
- package/.husky/pre-commit +1 -0
- package/.jscpd.json +20 -0
- package/.prettierignore +7 -0
- package/.prettierrc +10 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +24 -0
- package/README.md +320 -0
- package/bunfig.toml +3 -0
- package/deno.json +7 -0
- package/eslint.config.js +125 -0
- package/examples/react-test-app/index.html +25 -0
- package/examples/react-test-app/package.json +19 -0
- package/examples/react-test-app/src/App.jsx +473 -0
- package/examples/react-test-app/src/main.jsx +10 -0
- package/examples/react-test-app/src/styles.css +323 -0
- package/examples/react-test-app/vite.config.js +9 -0
- package/package.json +89 -0
- package/scripts/changeset-version.mjs +38 -0
- package/scripts/create-github-release.mjs +93 -0
- package/scripts/create-manual-changeset.mjs +86 -0
- package/scripts/format-github-release.mjs +83 -0
- package/scripts/format-release-notes.mjs +216 -0
- package/scripts/instant-version-bump.mjs +121 -0
- package/scripts/merge-changesets.mjs +260 -0
- package/scripts/publish-to-npm.mjs +126 -0
- package/scripts/setup-npm.mjs +37 -0
- package/scripts/validate-changeset.mjs +262 -0
- package/scripts/version-and-commit.mjs +237 -0
- package/src/ARCHITECTURE.md +270 -0
- package/src/README.md +517 -0
- package/src/bindings.js +298 -0
- package/src/browser/launcher.js +93 -0
- package/src/browser/navigation.js +513 -0
- package/src/core/constants.js +24 -0
- package/src/core/engine-adapter.js +466 -0
- package/src/core/engine-detection.js +49 -0
- package/src/core/logger.js +21 -0
- package/src/core/navigation-manager.js +503 -0
- package/src/core/navigation-safety.js +160 -0
- package/src/core/network-tracker.js +373 -0
- package/src/core/page-session.js +299 -0
- package/src/core/page-trigger-manager.js +564 -0
- package/src/core/preferences.js +46 -0
- package/src/elements/content.js +197 -0
- package/src/elements/locators.js +243 -0
- package/src/elements/selectors.js +360 -0
- package/src/elements/visibility.js +166 -0
- package/src/exports.js +121 -0
- package/src/factory.js +192 -0
- package/src/high-level/universal-logic.js +206 -0
- package/src/index.js +17 -0
- package/src/interactions/click.js +684 -0
- package/src/interactions/fill.js +383 -0
- package/src/interactions/scroll.js +341 -0
- package/src/utilities/url.js +33 -0
- package/src/utilities/wait.js +135 -0
- package/tests/e2e/playwright.e2e.test.js +442 -0
- package/tests/e2e/puppeteer.e2e.test.js +408 -0
- package/tests/helpers/mocks.js +542 -0
- package/tests/unit/bindings.test.js +218 -0
- package/tests/unit/browser/navigation.test.js +345 -0
- package/tests/unit/core/constants.test.js +72 -0
- package/tests/unit/core/engine-adapter.test.js +170 -0
- package/tests/unit/core/engine-detection.test.js +81 -0
- package/tests/unit/core/logger.test.js +80 -0
- package/tests/unit/core/navigation-safety.test.js +202 -0
- package/tests/unit/core/network-tracker.test.js +198 -0
- package/tests/unit/core/page-trigger-manager.test.js +358 -0
- package/tests/unit/elements/content.test.js +318 -0
- package/tests/unit/elements/locators.test.js +236 -0
- package/tests/unit/elements/selectors.test.js +302 -0
- package/tests/unit/elements/visibility.test.js +234 -0
- package/tests/unit/factory.test.js +174 -0
- package/tests/unit/high-level/universal-logic.test.js +299 -0
- package/tests/unit/interactions/click.test.js +340 -0
- package/tests/unit/interactions/fill.test.js +378 -0
- package/tests/unit/interactions/scroll.test.js +330 -0
- package/tests/unit/utilities/url.test.js +63 -0
- package/tests/unit/utilities/wait.test.js +207 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageTriggerManager - Manages stoppable page triggers with proper lifecycle
|
|
3
|
+
*
|
|
4
|
+
* Key guarantees:
|
|
5
|
+
* 1. Only one action runs at a time
|
|
6
|
+
* 2. Action is fully stopped before page loading starts
|
|
7
|
+
* 3. Action can gracefully cleanup on stop
|
|
8
|
+
* 4. All commander operations throw ActionStoppedError when stopped
|
|
9
|
+
*
|
|
10
|
+
* Terminology:
|
|
11
|
+
* - Trigger: A condition + action pair that fires when condition is met
|
|
12
|
+
* - Condition: A function that returns true/false to determine if trigger should fire
|
|
13
|
+
* - Action: The async function that runs when condition is true
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error thrown when action is stopped (navigation detected)
|
|
18
|
+
* Actions can catch this to do cleanup, but should re-throw or return
|
|
19
|
+
*/
|
|
20
|
+
export class ActionStoppedError extends Error {
|
|
21
|
+
constructor(message = 'Action stopped due to navigation') {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ActionStoppedError';
|
|
24
|
+
this.isActionStopped = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if error is an ActionStoppedError
|
|
30
|
+
* @param {Error} error - Error to check
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isActionStoppedError(error) {
|
|
34
|
+
return (
|
|
35
|
+
error &&
|
|
36
|
+
(error.isActionStopped === true || error.name === 'ActionStoppedError')
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a URL condition matcher (similar to express router patterns)
|
|
42
|
+
*
|
|
43
|
+
* @param {string|RegExp|Function} pattern - URL pattern to match
|
|
44
|
+
* - String: Exact match or pattern with :param placeholders (like express)
|
|
45
|
+
* - RegExp: Regular expression to test against URL
|
|
46
|
+
* - Function: Custom function (url, ctx) => boolean
|
|
47
|
+
* @returns {Function} - Condition function (ctx) => boolean
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Exact match
|
|
51
|
+
* makeUrlCondition('https://example.com/page')
|
|
52
|
+
*
|
|
53
|
+
* // Pattern with parameters (express-style)
|
|
54
|
+
* makeUrlCondition('/vacancy/:id')
|
|
55
|
+
* makeUrlCondition('https://hh.ru/vacancy/:vacancyId')
|
|
56
|
+
*
|
|
57
|
+
* // Contains substring
|
|
58
|
+
* makeUrlCondition('*checkout*') // matches any URL containing 'checkout'
|
|
59
|
+
*
|
|
60
|
+
* // RegExp
|
|
61
|
+
* makeUrlCondition(/\/product\/\d+/)
|
|
62
|
+
*
|
|
63
|
+
* // Custom function
|
|
64
|
+
* makeUrlCondition((url, ctx) => url.includes('/admin') && url.includes('edit'))
|
|
65
|
+
*/
|
|
66
|
+
export function makeUrlCondition(pattern) {
|
|
67
|
+
// If already a function, wrap it to receive context
|
|
68
|
+
if (typeof pattern === 'function') {
|
|
69
|
+
return (ctx) => pattern(ctx.url, ctx);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RegExp pattern
|
|
73
|
+
if (pattern instanceof RegExp) {
|
|
74
|
+
return (ctx) => pattern.test(ctx.url);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// String pattern
|
|
78
|
+
if (typeof pattern === 'string') {
|
|
79
|
+
// Wildcard pattern: *substring* means "contains"
|
|
80
|
+
if (pattern.startsWith('*') && pattern.endsWith('*')) {
|
|
81
|
+
const substring = pattern.slice(1, -1);
|
|
82
|
+
return (ctx) => ctx.url.includes(substring);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Starts with wildcard: *suffix means "ends with"
|
|
86
|
+
if (pattern.startsWith('*')) {
|
|
87
|
+
const suffix = pattern.slice(1);
|
|
88
|
+
return (ctx) => ctx.url.endsWith(suffix);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ends with wildcard: prefix* means "starts with"
|
|
92
|
+
if (pattern.endsWith('*')) {
|
|
93
|
+
const prefix = pattern.slice(0, -1);
|
|
94
|
+
return (ctx) => ctx.url.startsWith(prefix);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Express-style pattern with :params
|
|
98
|
+
if (pattern.includes(':')) {
|
|
99
|
+
// Convert express pattern to regex
|
|
100
|
+
// /vacancy/:id -> /vacancy/([^/]+)
|
|
101
|
+
// /user/:userId/profile -> /user/([^/]+)/profile
|
|
102
|
+
const regexPattern = pattern
|
|
103
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars first
|
|
104
|
+
.replace(/\\:/g, ':') // Unescape colons we just escaped
|
|
105
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '([^/&?#]+)'); // Replace :param with capture group
|
|
106
|
+
|
|
107
|
+
const regex = new RegExp(regexPattern);
|
|
108
|
+
return (ctx) => regex.test(ctx.url);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Simple substring match (no wildcards, no params)
|
|
112
|
+
// If it looks like a full URL, do exact match
|
|
113
|
+
if (pattern.startsWith('http://') || pattern.startsWith('https://')) {
|
|
114
|
+
return (ctx) =>
|
|
115
|
+
ctx.url === pattern ||
|
|
116
|
+
ctx.url.startsWith(`${pattern}?`) ||
|
|
117
|
+
ctx.url.startsWith(`${pattern}#`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Otherwise, treat as path pattern - match if URL contains this path
|
|
121
|
+
return (ctx) => ctx.url.includes(pattern);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Invalid URL pattern type: ${typeof pattern}. Expected string, RegExp, or function.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Combine multiple conditions with AND logic
|
|
131
|
+
* @param {...Function} conditions - Condition functions to combine
|
|
132
|
+
* @returns {Function} - Combined condition function
|
|
133
|
+
*/
|
|
134
|
+
export function allConditions(...conditions) {
|
|
135
|
+
return (ctx) => conditions.every((cond) => cond(ctx));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Combine multiple conditions with OR logic
|
|
140
|
+
* @param {...Function} conditions - Condition functions to combine
|
|
141
|
+
* @returns {Function} - Combined condition function
|
|
142
|
+
*/
|
|
143
|
+
export function anyCondition(...conditions) {
|
|
144
|
+
return (ctx) => conditions.some((cond) => cond(ctx));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Negate a condition
|
|
149
|
+
* @param {Function} condition - Condition function to negate
|
|
150
|
+
* @returns {Function} - Negated condition function
|
|
151
|
+
*/
|
|
152
|
+
export function notCondition(condition) {
|
|
153
|
+
return (ctx) => !condition(ctx);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a PageTriggerManager instance
|
|
158
|
+
* @param {Object} options - Configuration options
|
|
159
|
+
* @param {Object} options.navigationManager - NavigationManager instance
|
|
160
|
+
* @param {Function} options.log - Logger instance
|
|
161
|
+
* @returns {Object} - PageTriggerManager API
|
|
162
|
+
*/
|
|
163
|
+
export function createPageTriggerManager(options = {}) {
|
|
164
|
+
const { navigationManager, log } = options;
|
|
165
|
+
|
|
166
|
+
if (!navigationManager) {
|
|
167
|
+
throw new Error('navigationManager is required');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Registered triggers
|
|
171
|
+
const triggers = [];
|
|
172
|
+
|
|
173
|
+
// Current action state
|
|
174
|
+
let currentTrigger = null;
|
|
175
|
+
let currentAbortController = null;
|
|
176
|
+
let actionPromise = null;
|
|
177
|
+
let actionStopPromise = null;
|
|
178
|
+
let actionStopResolve = null;
|
|
179
|
+
let isActionRunning = false;
|
|
180
|
+
let isStopping = false;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Register a page trigger
|
|
184
|
+
* @param {Object} config - Trigger configuration
|
|
185
|
+
* @param {Function} config.condition - Function (ctx) => boolean, returns true if trigger should fire
|
|
186
|
+
* @param {Function} config.action - Async function (ctx) => void, the action to run
|
|
187
|
+
* @param {string} config.name - Trigger name for debugging
|
|
188
|
+
* @param {number} config.priority - Priority (higher runs first if multiple match), default 0
|
|
189
|
+
* @returns {Function} - Unregister function
|
|
190
|
+
*/
|
|
191
|
+
function pageTrigger(config) {
|
|
192
|
+
const { condition, action, name = 'unnamed', priority = 0 } = config;
|
|
193
|
+
|
|
194
|
+
if (typeof condition !== 'function') {
|
|
195
|
+
throw new Error('condition must be a function');
|
|
196
|
+
}
|
|
197
|
+
if (typeof action !== 'function') {
|
|
198
|
+
throw new Error('action must be a function');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const triggerConfig = {
|
|
202
|
+
condition,
|
|
203
|
+
action,
|
|
204
|
+
name,
|
|
205
|
+
priority,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
triggers.push(triggerConfig);
|
|
209
|
+
|
|
210
|
+
// Sort by priority (descending)
|
|
211
|
+
triggers.sort((a, b) => b.priority - a.priority);
|
|
212
|
+
|
|
213
|
+
log.debug(
|
|
214
|
+
() => `📋 Registered page trigger: "${name}" (priority: ${priority})`
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Return unregister function
|
|
218
|
+
return () => {
|
|
219
|
+
const index = triggers.indexOf(triggerConfig);
|
|
220
|
+
if (index !== -1) {
|
|
221
|
+
triggers.splice(index, 1);
|
|
222
|
+
log.debug(() => `📋 Unregistered page trigger: "${name}"`);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Find matching trigger for context
|
|
229
|
+
* @param {Object} ctx - Context with url and other properties
|
|
230
|
+
* @returns {Object|null} - Matching trigger config or null
|
|
231
|
+
*/
|
|
232
|
+
function findMatchingTrigger(ctx) {
|
|
233
|
+
for (const config of triggers) {
|
|
234
|
+
try {
|
|
235
|
+
if (config.condition(ctx)) {
|
|
236
|
+
return config;
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
log.debug(
|
|
240
|
+
() => `⚠️ Error in condition for "${config.name}": ${e.message}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Stop current action and wait for it to finish
|
|
249
|
+
* @returns {Promise<void>}
|
|
250
|
+
*/
|
|
251
|
+
async function stopCurrentAction() {
|
|
252
|
+
if (!isActionRunning) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isStopping) {
|
|
257
|
+
// Already stopping, wait for it
|
|
258
|
+
if (actionStopPromise) {
|
|
259
|
+
await actionStopPromise;
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
isStopping = true;
|
|
265
|
+
log.debug(() => `🛑 Stopping action "${currentTrigger?.name}"...`);
|
|
266
|
+
|
|
267
|
+
// Create promise that resolves when action actually stops
|
|
268
|
+
actionStopPromise = new Promise((resolve) => {
|
|
269
|
+
actionStopResolve = resolve;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Abort the action
|
|
273
|
+
if (currentAbortController) {
|
|
274
|
+
currentAbortController.abort();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Wait for action to finish (with timeout)
|
|
278
|
+
const timeoutMs = 10000; // 10 second max wait
|
|
279
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
log.debug(
|
|
282
|
+
() =>
|
|
283
|
+
`⚠️ Action "${currentTrigger?.name}" did not stop gracefully within ${timeoutMs}ms`
|
|
284
|
+
);
|
|
285
|
+
resolve();
|
|
286
|
+
}, timeoutMs);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await Promise.race([actionPromise, timeoutPromise]);
|
|
290
|
+
|
|
291
|
+
// Cleanup
|
|
292
|
+
isActionRunning = false;
|
|
293
|
+
isStopping = false;
|
|
294
|
+
currentTrigger = null;
|
|
295
|
+
currentAbortController = null;
|
|
296
|
+
actionPromise = null;
|
|
297
|
+
|
|
298
|
+
// Resolve the stop promise
|
|
299
|
+
if (actionStopResolve) {
|
|
300
|
+
actionStopResolve();
|
|
301
|
+
actionStopResolve = null;
|
|
302
|
+
actionStopPromise = null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
log.debug(() => '✅ Action stopped');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Start action for URL
|
|
310
|
+
* @param {string} url - URL to start action for
|
|
311
|
+
* @param {Object} commander - BrowserCommander instance
|
|
312
|
+
*/
|
|
313
|
+
async function startAction(url, commander) {
|
|
314
|
+
// Create context for condition checking
|
|
315
|
+
const conditionCtx = {
|
|
316
|
+
url,
|
|
317
|
+
commander,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Find matching trigger
|
|
321
|
+
const matchingTrigger = findMatchingTrigger(conditionCtx);
|
|
322
|
+
if (!matchingTrigger) {
|
|
323
|
+
log.debug(() => `📋 No trigger registered for: ${url}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
|
|
328
|
+
|
|
329
|
+
// Setup abort controller
|
|
330
|
+
currentAbortController = new AbortController();
|
|
331
|
+
currentTrigger = matchingTrigger;
|
|
332
|
+
isActionRunning = true;
|
|
333
|
+
|
|
334
|
+
// Create action context
|
|
335
|
+
const context = createActionContext({
|
|
336
|
+
url,
|
|
337
|
+
abortSignal: currentAbortController.signal,
|
|
338
|
+
commander,
|
|
339
|
+
triggerName: matchingTrigger.name,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Run action
|
|
343
|
+
actionPromise = (async () => {
|
|
344
|
+
try {
|
|
345
|
+
await matchingTrigger.action(context);
|
|
346
|
+
log.debug(
|
|
347
|
+
() => `✅ Action "${matchingTrigger.name}" completed normally`
|
|
348
|
+
);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (isActionStoppedError(error)) {
|
|
351
|
+
log.debug(
|
|
352
|
+
() =>
|
|
353
|
+
`🛑 Action "${matchingTrigger.name}" stopped (caught ActionStoppedError)`
|
|
354
|
+
);
|
|
355
|
+
} else if (error.name === 'AbortError') {
|
|
356
|
+
log.debug(() => `🛑 Action "${matchingTrigger.name}" aborted`);
|
|
357
|
+
} else {
|
|
358
|
+
log.debug(
|
|
359
|
+
() => `❌ Action "${matchingTrigger.name}" error: ${error.message}`
|
|
360
|
+
);
|
|
361
|
+
console.error(`Action "${matchingTrigger.name}" error:`, error);
|
|
362
|
+
}
|
|
363
|
+
} finally {
|
|
364
|
+
// Only clear if this is still the current trigger
|
|
365
|
+
if (currentTrigger === matchingTrigger) {
|
|
366
|
+
isActionRunning = false;
|
|
367
|
+
currentTrigger = null;
|
|
368
|
+
currentAbortController = null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Create action context with abort-aware commander wrapper
|
|
376
|
+
* @param {Object} options
|
|
377
|
+
* @returns {Object} - Action context
|
|
378
|
+
*/
|
|
379
|
+
function createActionContext(options) {
|
|
380
|
+
const { url, abortSignal, commander, triggerName } = options;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Check if stopped and throw if so
|
|
384
|
+
*/
|
|
385
|
+
function checkStopped() {
|
|
386
|
+
if (abortSignal.aborted) {
|
|
387
|
+
throw new ActionStoppedError(`Action "${triggerName}" stopped`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Wrap async function to check abort before and after
|
|
393
|
+
*/
|
|
394
|
+
function wrapAsync(fn) {
|
|
395
|
+
return async (...args) => {
|
|
396
|
+
checkStopped();
|
|
397
|
+
const result = await fn(...args);
|
|
398
|
+
checkStopped();
|
|
399
|
+
return result;
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Create abort-aware loop helper
|
|
405
|
+
* Use this instead of for/while loops for stoppability
|
|
406
|
+
*/
|
|
407
|
+
async function forEach(items, callback) {
|
|
408
|
+
for (let i = 0; i < items.length; i++) {
|
|
409
|
+
checkStopped();
|
|
410
|
+
await callback(items[i], i, items);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Wait with abort support
|
|
416
|
+
*/
|
|
417
|
+
async function wait(ms) {
|
|
418
|
+
checkStopped();
|
|
419
|
+
await new Promise((resolve, reject) => {
|
|
420
|
+
const timeout = setTimeout(resolve, ms);
|
|
421
|
+
abortSignal.addEventListener(
|
|
422
|
+
'abort',
|
|
423
|
+
() => {
|
|
424
|
+
clearTimeout(timeout);
|
|
425
|
+
reject(new ActionStoppedError());
|
|
426
|
+
},
|
|
427
|
+
{ once: true }
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Register cleanup callback (called when action stops)
|
|
434
|
+
*/
|
|
435
|
+
const cleanupCallbacks = [];
|
|
436
|
+
function onCleanup(callback) {
|
|
437
|
+
cleanupCallbacks.push(callback);
|
|
438
|
+
abortSignal.addEventListener(
|
|
439
|
+
'abort',
|
|
440
|
+
async () => {
|
|
441
|
+
try {
|
|
442
|
+
await callback();
|
|
443
|
+
} catch (e) {
|
|
444
|
+
log.debug(() => `⚠️ Cleanup error: ${e.message}`);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{ once: true }
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Wrap all commander methods to be abort-aware
|
|
452
|
+
const wrappedCommander = {};
|
|
453
|
+
for (const [key, value] of Object.entries(commander)) {
|
|
454
|
+
if (
|
|
455
|
+
typeof value === 'function' &&
|
|
456
|
+
key !== 'destroy' &&
|
|
457
|
+
key !== 'pageTrigger'
|
|
458
|
+
) {
|
|
459
|
+
wrappedCommander[key] = wrapAsync(value);
|
|
460
|
+
} else {
|
|
461
|
+
wrappedCommander[key] = value;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
// URL this action is running for
|
|
467
|
+
url,
|
|
468
|
+
|
|
469
|
+
// Abort signal - use with fetch() or custom abort logic
|
|
470
|
+
abortSignal,
|
|
471
|
+
|
|
472
|
+
// Check if action should stop
|
|
473
|
+
isStopped: () => abortSignal.aborted,
|
|
474
|
+
|
|
475
|
+
// Throw if stopped - call this in loops
|
|
476
|
+
checkStopped,
|
|
477
|
+
|
|
478
|
+
// Abort-aware iteration helper
|
|
479
|
+
forEach,
|
|
480
|
+
|
|
481
|
+
// Abort-aware wait
|
|
482
|
+
wait,
|
|
483
|
+
|
|
484
|
+
// Register cleanup callback
|
|
485
|
+
onCleanup,
|
|
486
|
+
|
|
487
|
+
// Wrapped commander - all methods throw ActionStoppedError if stopped
|
|
488
|
+
commander: wrappedCommander,
|
|
489
|
+
|
|
490
|
+
// Original commander (use carefully)
|
|
491
|
+
rawCommander: commander,
|
|
492
|
+
|
|
493
|
+
// Trigger name for debugging
|
|
494
|
+
triggerName,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Handle navigation start - stop current action first
|
|
500
|
+
*/
|
|
501
|
+
async function onNavigationStart() {
|
|
502
|
+
await stopCurrentAction();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Handle page ready - start matching action
|
|
507
|
+
*/
|
|
508
|
+
async function onPageReady({ url }, commander) {
|
|
509
|
+
await startAction(url, commander);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Check if an action is currently running
|
|
514
|
+
*/
|
|
515
|
+
function isRunning() {
|
|
516
|
+
return isActionRunning;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Get current trigger name
|
|
521
|
+
*/
|
|
522
|
+
function getCurrentTriggerName() {
|
|
523
|
+
return currentTrigger?.name || null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Initialize - connect to navigation manager
|
|
528
|
+
* @param {Object} commander - BrowserCommander instance
|
|
529
|
+
*/
|
|
530
|
+
function initialize(commander) {
|
|
531
|
+
// Stop action before navigation starts
|
|
532
|
+
navigationManager.on('onBeforeNavigate', onNavigationStart);
|
|
533
|
+
|
|
534
|
+
// Start action when page is ready
|
|
535
|
+
navigationManager.on('onPageReady', (event) =>
|
|
536
|
+
onPageReady(event, commander)
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
log.debug(() => '📋 PageTriggerManager initialized');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Cleanup
|
|
544
|
+
*/
|
|
545
|
+
async function destroy() {
|
|
546
|
+
await stopCurrentAction();
|
|
547
|
+
triggers.length = 0;
|
|
548
|
+
navigationManager.off('onBeforeNavigate', onNavigationStart);
|
|
549
|
+
log.debug(() => '📋 PageTriggerManager destroyed');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
pageTrigger,
|
|
554
|
+
stopCurrentAction,
|
|
555
|
+
getCurrentTriggerName,
|
|
556
|
+
isRunning,
|
|
557
|
+
initialize,
|
|
558
|
+
destroy,
|
|
559
|
+
|
|
560
|
+
// Export error class and checker
|
|
561
|
+
ActionStoppedError,
|
|
562
|
+
isActionStoppedError,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disables Chrome translate feature by modifying the Preferences file
|
|
6
|
+
* @param {Object} options - Configuration options
|
|
7
|
+
* @param {string} options.userDataDir - Path to Chrome user data directory
|
|
8
|
+
*/
|
|
9
|
+
export async function disableTranslateInPreferences(options = {}) {
|
|
10
|
+
const { userDataDir } = options;
|
|
11
|
+
|
|
12
|
+
if (!userDataDir) {
|
|
13
|
+
throw new Error('userDataDir is required in options');
|
|
14
|
+
}
|
|
15
|
+
const preferencesPath = path.join(userDataDir, 'Default', 'Preferences');
|
|
16
|
+
const defaultDir = path.join(userDataDir, 'Default');
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await fs.mkdir(defaultDir, { recursive: true });
|
|
20
|
+
|
|
21
|
+
let preferences = {};
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const content = await fs.readFile(preferencesPath, 'utf8');
|
|
25
|
+
preferences = JSON.parse(content);
|
|
26
|
+
} catch {
|
|
27
|
+
// File doesn't exist yet, will create new one
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!preferences.translate) {
|
|
31
|
+
preferences.translate = {};
|
|
32
|
+
}
|
|
33
|
+
preferences.translate.enabled = false;
|
|
34
|
+
|
|
35
|
+
await fs.writeFile(
|
|
36
|
+
preferencesPath,
|
|
37
|
+
JSON.stringify(preferences, null, 2),
|
|
38
|
+
'utf8'
|
|
39
|
+
);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(
|
|
42
|
+
'⚠️ Warning: Could not modify Preferences file:',
|
|
43
|
+
error.message
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|