@web-auto/camo 0.1.21 → 0.1.22

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/README.md CHANGED
@@ -186,6 +186,8 @@ Use `--width/--height` to override and update the saved profile size.
186
186
  For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
187
187
  Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
188
188
  Use `--record` to auto-enable JSONL recording at startup; `--record-name`, `--record-output`, and `--record-overlay` customize file naming/output and floating toggle UI.
189
+ Set `CAMO_BRING_TO_FRONT_MODE=never` to keep protocol-level input and page lifecycle operations from forcing the browser window to front during headed runs.
190
+ `CAMO_SKIP_BRING_TO_FRONT=1` remains supported as a legacy alias.
189
191
 
190
192
  ### Lifecycle & Cleanup
191
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Version bumper for camo CLI
4
- * Increments patch version maintaining 4-digit format (0.1.0001 -> 0.1.0002)
3
+ * Version bumper for camo CLI.
4
+ * Increments standard semver patch version (0.1.21 -> 0.1.22).
5
5
  */
6
6
 
7
7
  import { readFileSync, writeFileSync } from 'fs';
@@ -15,18 +15,14 @@ const packageJsonPath = join(__dirname, '..', 'package.json');
15
15
  function bumpVersion() {
16
16
  const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
17
17
  const currentVersion = pkg.version;
18
-
19
- // Parse version: 0.1.0001 -> [0, 1, 1]
20
18
  const parts = currentVersion.split('.');
21
19
  const major = parseInt(parts[0], 10);
22
20
  const minor = parseInt(parts[1], 10);
23
21
  let patch = parseInt(parts[2], 10);
24
-
25
- // Increment patch
22
+
26
23
  patch += 1;
27
-
28
- // Format with 4 digits
29
- const newVersion = `${major}.${minor}.${patch.toString().padStart(4, '0')}`;
24
+
25
+ const newVersion = `${major}.${minor}.${patch}`;
30
26
 
31
27
  pkg.version = newVersion;
32
28
  writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
@@ -974,14 +974,32 @@ export async function handleScrollCommand(args) {
974
974
 
975
975
  const target = await callAPI('evaluate', {
976
976
  profileId,
977
- script: buildScrollTargetScript({ selector, highlight }),
977
+ script: buildScrollTargetScript({ selector, highlight, requireVisibleContainer: Boolean(selector) }),
978
978
  }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
979
+ const scrollTarget = target?.result || null;
980
+ if (!scrollTarget?.ok || !scrollTarget?.center) {
981
+ throw new Error(scrollTarget?.error || 'visible scroll container not found');
982
+ }
979
983
  const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
980
984
  const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
981
- const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
985
+ await callAPI('mouse:click', {
986
+ profileId,
987
+ x: scrollTarget.center.x,
988
+ y: scrollTarget.center.y,
989
+ button: 'left',
990
+ clicks: 1,
991
+ delay: 30,
992
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
993
+ const result = await callAPI('mouse:wheel', {
994
+ profileId,
995
+ deltaX,
996
+ deltaY,
997
+ anchorX: scrollTarget.center.x,
998
+ anchorY: scrollTarget.center.y,
999
+ }, { timeoutMs: INPUT_ACTION_TIMEOUT_MS });
982
1000
  console.log(JSON.stringify({
983
1001
  ...result,
984
- scrollTarget: target?.result || null,
1002
+ scrollTarget,
985
1003
  highlight,
986
1004
  }, null, 2));
987
1005
  }
@@ -626,7 +626,24 @@ export async function executeOperation({ profileId, operation, context = {} }) {
626
626
  selector: anchorSelector,
627
627
  filterMode,
628
628
  });
629
- const result = await callAPI('mouse:wheel', { profileId: resolvedProfile, deltaX, deltaY });
629
+ if (!anchor?.ok || !anchor?.center) {
630
+ return asErrorPayload('OPERATION_FAILED', 'visible scroll container not found');
631
+ }
632
+ await callAPI('mouse:click', {
633
+ profileId: resolvedProfile,
634
+ x: anchor.center.x,
635
+ y: anchor.center.y,
636
+ button: 'left',
637
+ clicks: 1,
638
+ delay: 30,
639
+ });
640
+ const result = await callAPI('mouse:wheel', {
641
+ profileId: resolvedProfile,
642
+ deltaX,
643
+ deltaY,
644
+ anchorX: anchor.center.x,
645
+ anchorY: anchor.center.y,
646
+ });
630
647
  return {
631
648
  ok: true,
632
649
  code: 'OPERATION_DONE',
@@ -638,6 +655,7 @@ export async function executeOperation({ profileId, operation, context = {} }) {
638
655
  deltaY,
639
656
  filterMode,
640
657
  anchorSource: String(anchor?.source || 'document'),
658
+ anchorCenter: anchor?.center || null,
641
659
  modalLocked: anchor?.modalLocked === true,
642
660
  result,
643
661
  },
@@ -159,11 +159,13 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
159
159
  })()`;
160
160
  }
161
161
 
162
- export function buildScrollTargetScript({ selector, highlight }) {
162
+ export function buildScrollTargetScript({ selector, highlight, requireVisibleContainer = false }) {
163
163
  const selectorLiteral = JSON.stringify(String(selector || '').trim() || null);
164
164
  const highlightLiteral = asBoolLiteral(highlight);
165
+ const requireVisibleContainerLiteral = asBoolLiteral(requireVisibleContainer);
165
166
  return `(() => {
166
167
  const selector = ${selectorLiteral};
168
+ const requireVisibleContainer = ${requireVisibleContainerLiteral};
167
169
  const isVisible = (node) => {
168
170
  if (!(node instanceof Element)) return false;
169
171
  const rect = node.getBoundingClientRect?.();
@@ -183,11 +185,13 @@ export function buildScrollTargetScript({ selector, highlight }) {
183
185
  const style = window.getComputedStyle(node);
184
186
  const overflowY = String(style.overflowY || '');
185
187
  const overflowX = String(style.overflowX || '');
188
+ const scrollableSelectors = ['.comments-container', '.comment-list', '.comments-el', '.note-scroller'];
189
+ const selectorScrollable = scrollableSelectors.some((sel) => typeof node.matches === 'function' && node.matches(sel));
186
190
  const yScrollable = (overflowY.includes('auto') || overflowY.includes('scroll') || overflowY.includes('overlay'))
187
191
  && (node.scrollHeight - node.clientHeight > 2);
188
192
  const xScrollable = (overflowX.includes('auto') || overflowX.includes('scroll') || overflowX.includes('overlay'))
189
193
  && (node.scrollWidth - node.clientWidth > 2);
190
- return yScrollable || xScrollable;
194
+ return yScrollable || xScrollable || selectorScrollable;
191
195
  };
192
196
  const findScrollableAncestor = (node) => {
193
197
  let cursor = node instanceof Element ? node : null;
@@ -203,9 +207,12 @@ export function buildScrollTargetScript({ selector, highlight }) {
203
207
  if (selector) {
204
208
  const list = Array.from(document.querySelectorAll(selector));
205
209
  target = list.find((node) => isVisible(node) && isScrollable(node))
206
- || list.find((node) => isVisible(node))
210
+ || list.map((node) => findScrollableAncestor(node)).find((node) => isVisible(node))
207
211
  || null;
208
212
  if (target) source = 'selector';
213
+ if (!target && requireVisibleContainer) {
214
+ return { ok: false, error: 'visible_scroll_container_not_found', selector };
215
+ }
209
216
  }
210
217
  if (!target) {
211
218
  const active = document.activeElement instanceof Element ? document.activeElement : null;
@@ -250,9 +257,20 @@ export function buildScrollTargetScript({ selector, highlight }) {
250
257
  source,
251
258
  highlight: ${highlightLiteral},
252
259
  center: { x: centerX, y: centerY },
260
+ rect: {
261
+ left: Number(rect.left || 0),
262
+ top: Number(rect.top || 0),
263
+ width: Number(rect.width || 0),
264
+ height: Number(rect.height || 0)
265
+ },
253
266
  target: {
254
267
  tag: String(target.tagName || '').toLowerCase(),
255
- id: target.id || null
268
+ id: target.id || null,
269
+ className: typeof target.className === 'string' ? target.className : null,
270
+ scrollHeight: Number(target.scrollHeight || 0),
271
+ clientHeight: Number(target.clientHeight || 0),
272
+ scrollWidth: Number(target.scrollWidth || 0),
273
+ clientWidth: Number(target.clientWidth || 0)
256
274
  }
257
275
  };
258
276
  })()`;
@@ -0,0 +1,190 @@
1
+ import { buildSelectorCheck } from './utils.mjs';
2
+
3
+ function normalizeQuery(raw) {
4
+ const text = String(raw || '').trim();
5
+ if (!text) return { query: '', queryLower: '' };
6
+ return { query: text, queryLower: text.toLowerCase() };
7
+ }
8
+
9
+ function normalizeDirection(raw) {
10
+ const text = String(raw || 'down').trim().toLowerCase();
11
+ if (text === 'up' || text === 'down' || text === 'both') return text;
12
+ return 'down';
13
+ }
14
+
15
+ function normalizeLimit(raw) {
16
+ const num = Number(raw);
17
+ if (!Number.isFinite(num) || num <= 0) return 1;
18
+ return Math.max(1, Math.floor(num));
19
+ }
20
+
21
+ function normalizeRect(node) {
22
+ const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
23
+ if (!rect) return null;
24
+ const left = Number(rect.left ?? rect.x ?? 0);
25
+ const top = Number(rect.top ?? rect.y ?? 0);
26
+ const width = Number(rect.width ?? 0);
27
+ const height = Number(rect.height ?? 0);
28
+ if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) return null;
29
+ if (width <= 0 || height <= 0) return null;
30
+ return { left, top, width, height, right: left + width, bottom: top + height };
31
+ }
32
+
33
+ function computeCenter(rect) {
34
+ if (!rect) return null;
35
+ return {
36
+ x: Math.round(rect.left + rect.width / 2),
37
+ y: Math.round(rect.top + rect.height / 2),
38
+ };
39
+ }
40
+
41
+ function buildSearchText(node) {
42
+ if (!node || typeof node !== 'object') return '';
43
+ const parts = [];
44
+ const snippet = typeof node.textSnippet === 'string' ? node.textSnippet : '';
45
+ if (snippet) parts.push(snippet);
46
+ const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : null;
47
+ if (attrs) {
48
+ const candidates = [
49
+ attrs['aria-label'],
50
+ attrs['aria-label'.toLowerCase()],
51
+ attrs.title,
52
+ attrs.alt,
53
+ attrs.placeholder,
54
+ ];
55
+ for (const item of candidates) {
56
+ const text = typeof item === 'string' ? item.trim() : '';
57
+ if (text) parts.push(text);
58
+ }
59
+ }
60
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
61
+ }
62
+
63
+ function isPathWithin(path, parentPath) {
64
+ const child = String(path || '').trim();
65
+ const parent = String(parentPath || '').trim();
66
+ if (!child || !parent) return false;
67
+ return child === parent || child.startsWith(`${parent}/`);
68
+ }
69
+
70
+ function collectMatches(node, options, path = 'root', out = []) {
71
+ if (!node) return out;
72
+ const { queryLower, visibleOnly } = options;
73
+ const visible = node.visible === true;
74
+ if (visibleOnly && !visible) {
75
+ return out;
76
+ }
77
+ {
78
+ const searchText = buildSearchText(node);
79
+ if (searchText && searchText.toLowerCase().includes(queryLower)) {
80
+ out.push({ node, path, searchText });
81
+ }
82
+ }
83
+ if (Array.isArray(node.children)) {
84
+ for (let i = 0; i < node.children.length; i += 1) {
85
+ collectMatches(node.children[i], options, `${path}/${i}`, out);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function sortMatches(matches, direction) {
92
+ const sorted = [...matches].sort((a, b) => {
93
+ const ra = normalizeRect(a.targetNode);
94
+ const rb = normalizeRect(b.targetNode);
95
+ const ta = ra ? ra.top : Number.POSITIVE_INFINITY;
96
+ const tb = rb ? rb.top : Number.POSITIVE_INFINITY;
97
+ if (ta !== tb) return ta - tb;
98
+ const la = ra ? ra.left : Number.POSITIVE_INFINITY;
99
+ const lb = rb ? rb.left : Number.POSITIVE_INFINITY;
100
+ return la - lb;
101
+ });
102
+ if (direction === 'up') return sorted.reverse();
103
+ return sorted;
104
+ }
105
+
106
+ function applyStartAfter(matches, startAfterPath) {
107
+ if (!startAfterPath) return matches;
108
+ const idx = matches.findIndex((item) => item.targetPath === startAfterPath || item.matchPath === startAfterPath);
109
+ if (idx < 0) return matches;
110
+ return matches.slice(idx + 1);
111
+ }
112
+
113
+ export function searchSnapshot(snapshot, rawOptions = {}) {
114
+ const { query, queryLower } = normalizeQuery(rawOptions.query || rawOptions.keyword || rawOptions.text);
115
+ if (!query) {
116
+ return { ok: false, code: 'QUERY_REQUIRED', message: 'search requires query keyword', data: { query } };
117
+ }
118
+ const direction = normalizeDirection(rawOptions.direction || 'down');
119
+ const limit = normalizeLimit(rawOptions.limit ?? rawOptions.maxResults ?? 1);
120
+ const visibleOnly = rawOptions.visibleOnly !== false;
121
+ const containerSelector = String(rawOptions.containerSelector || rawOptions.selector || '').trim() || null;
122
+ const startAfterPath = String(rawOptions.startAfterPath || rawOptions.afterPath || '').trim() || null;
123
+
124
+ const containerNodes = containerSelector
125
+ ? buildSelectorCheck(snapshot, { css: containerSelector, visible: visibleOnly })
126
+ : [];
127
+ const containerPaths = containerNodes.map((node) => node.path).filter(Boolean);
128
+
129
+ const matches = collectMatches(snapshot, { queryLower, visibleOnly }, 'root', []);
130
+ const enriched = matches.map((match) => {
131
+ let containerNode = null;
132
+ let containerPath = null;
133
+ if (containerPaths.length > 0) {
134
+ for (const path of containerPaths) {
135
+ if (isPathWithin(match.path, path)) {
136
+ containerPath = path;
137
+ break;
138
+ }
139
+ }
140
+ if (containerPath) {
141
+ containerNode = containerNodes.find((node) => node.path === containerPath) || null;
142
+ }
143
+ }
144
+ const targetNode = containerNode || match.node;
145
+ const rect = normalizeRect(targetNode);
146
+ const center = computeCenter(rect);
147
+ return {
148
+ matchPath: match.path,
149
+ targetPath: containerPath || match.path,
150
+ targetNode,
151
+ matchNode: match.node,
152
+ containerPath,
153
+ rect,
154
+ center,
155
+ searchText: match.searchText,
156
+ };
157
+ });
158
+
159
+ const filtered = containerSelector
160
+ ? enriched.filter((item) => item.containerPath)
161
+ : enriched;
162
+ const ordered = sortMatches(filtered, direction);
163
+ const sliced = applyStartAfter(ordered, startAfterPath).slice(0, limit);
164
+ const results = sliced.map((item) => ({
165
+ matchPath: item.matchPath,
166
+ targetPath: item.targetPath,
167
+ containerPath: item.containerPath,
168
+ rect: item.rect,
169
+ center: item.center,
170
+ text: item.searchText,
171
+ }));
172
+ const nextCursor = results.length > 0 ? results[results.length - 1].targetPath : startAfterPath;
173
+
174
+ return {
175
+ ok: true,
176
+ code: 'SEARCH_OK',
177
+ message: 'search done',
178
+ data: {
179
+ query,
180
+ direction,
181
+ limit,
182
+ visibleOnly,
183
+ containerSelector,
184
+ totalMatches: filtered.length,
185
+ returned: results.length,
186
+ nextCursor,
187
+ results,
188
+ },
189
+ };
190
+ }
@@ -582,8 +582,14 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
582
582
  const session = manager.getSession(profileId);
583
583
  if (!session)
584
584
  throw new Error(`session for profile ${profileId} not started`);
585
- const { deltaY, deltaX } = args;
586
- await session.mouseWheel({ deltaY: Number(deltaY) || 0, deltaX: Number(deltaX) || 0 });
585
+ const { deltaY, deltaX, anchorX, anchorY } = args;
586
+ await session.mouseWheel({
587
+ deltaY: Number(deltaY) || 0,
588
+ deltaX: Number(deltaX) || 0,
589
+ ...(Number.isFinite(Number(anchorX)) && Number.isFinite(Number(anchorY))
590
+ ? { anchorX: Number(anchorX), anchorY: Number(anchorY) }
591
+ : {}),
592
+ });
587
593
  return { ok: true, body: { ok: true } };
588
594
  }
589
595
  case 'keyboard:type': {
@@ -196,10 +196,38 @@ test('ensureInputReady brings page to front even when document reports focus', a
196
196
  isClosed: () => false,
197
197
  };
198
198
  const session = new BrowserSession({ profileId: `test-input-ready-${Date.now()}` });
199
- await session.ensureInputReady(page);
199
+ await session.inputPipeline.ensureInputReady(page);
200
200
  assert.equal(page.bringToFrontCount, 1);
201
201
  assert.equal(page.waitCount, 1);
202
202
  });
203
+ test('ensureInputReady skips bringToFront when CAMO_BRING_TO_FRONT_MODE=never', async () => {
204
+ const restoreSkip = setEnv('CAMO_BRING_TO_FRONT_MODE', 'never');
205
+ try {
206
+ const page = {
207
+ bringToFrontCount: 0,
208
+ waitCount: 0,
209
+ bringToFront: async function bringToFront() {
210
+ this.bringToFrontCount += 1;
211
+ },
212
+ waitForTimeout: async function waitForTimeout() {
213
+ this.waitCount += 1;
214
+ },
215
+ evaluate: async () => ({
216
+ hasFocus: false,
217
+ hidden: false,
218
+ visibilityState: 'visible',
219
+ }),
220
+ isClosed: () => false,
221
+ };
222
+ const session = new BrowserSession({ profileId: `test-input-ready-skip-${Date.now()}` });
223
+ await session.inputPipeline.ensureInputReady(page);
224
+ assert.equal(page.bringToFrontCount, 0);
225
+ assert.equal(page.waitCount, 1);
226
+ }
227
+ finally {
228
+ restoreSkip();
229
+ }
230
+ });
203
231
  test('mouseWheel retries with refreshed active page after timeout', async () => {
204
232
  const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
205
233
  const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '2');
@@ -210,17 +238,21 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
210
238
  const calls = [];
211
239
  const page1 = {
212
240
  isClosed: () => false,
241
+ viewportSize: () => ({ width: 1280, height: 720 }),
213
242
  bringToFront: async () => { },
214
243
  waitForTimeout: async () => { },
215
244
  mouse: {
245
+ move: async () => { },
216
246
  wheel: async () => new Promise(() => { }),
217
247
  },
218
248
  };
219
249
  const page2 = {
220
250
  isClosed: () => false,
251
+ viewportSize: () => ({ width: 1280, height: 720 }),
221
252
  bringToFront: async () => { },
222
253
  waitForTimeout: async () => { },
223
254
  mouse: {
255
+ move: async () => { },
224
256
  wheel: async () => {
225
257
  calls.push('wheel_ok');
226
258
  },
@@ -255,9 +287,11 @@ test('mouseWheel falls back to keyboard paging when wheel keeps timing out', asy
255
287
  const calls = [];
256
288
  const page = {
257
289
  isClosed: () => false,
290
+ viewportSize: () => ({ width: 1280, height: 720 }),
258
291
  bringToFront: async () => { },
259
292
  waitForTimeout: async () => { },
260
293
  mouse: {
294
+ move: async () => { },
261
295
  wheel: async () => new Promise(() => { }),
262
296
  },
263
297
  keyboard: {
@@ -319,4 +353,4 @@ test('mouseWheel uses keyboard mode directly when CAMO_SCROLL_INPUT_MODE=keyboar
319
353
  restoreMode();
320
354
  }
321
355
  });
322
- //# sourceMappingURL=BrowserSession.input.test.js.map
356
+ //# sourceMappingURL=BrowserSession.input.test.js.map
@@ -66,9 +66,11 @@ export class BrowserSessionInputOps {
66
66
  const page = await this.ensurePrimaryPage();
67
67
  await this.withInputActionLock(async () => {
68
68
  await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
69
- const { deltaX = 0, deltaY } = opts;
69
+ const { deltaX = 0, deltaY, anchorX, anchorY } = opts;
70
70
  const normalizedDeltaX = Number(deltaX) || 0;
71
71
  const normalizedDeltaY = Number(deltaY) || 0;
72
+ const normalizedAnchorX = Number(anchorX);
73
+ const normalizedAnchorY = Number(anchorY);
72
74
  if (normalizedDeltaY === 0 && normalizedDeltaX === 0)
73
75
  return;
74
76
  const keyboardKey = normalizedDeltaY > 0 ? 'PageDown' : 'PageUp';
@@ -88,8 +90,12 @@ export class BrowserSessionInputOps {
88
90
  try {
89
91
  await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
90
92
  const viewport = activePage.viewportSize();
91
- const moveX = Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
92
- const moveY = Math.max(1, Math.floor(((viewport?.height || 720) * 0.5)));
93
+ const moveX = Number.isFinite(normalizedAnchorX)
94
+ ? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
95
+ : Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
96
+ const moveY = Number.isFinite(normalizedAnchorY)
97
+ ? Math.max(1, Math.min(Math.max(1, Number(viewport?.height || 720) - 1), Math.round(normalizedAnchorY)))
98
+ : Math.max(1, Math.floor(((viewport?.height || 720) * 0.5)));
93
99
  await activePage.mouse.move(moveX, moveY, { steps: 1 }).catch(() => { });
94
100
  await activePage.mouse.wheel(normalizedDeltaX, normalizedDeltaY);
95
101
  });
@@ -124,4 +130,4 @@ export class BrowserSessionInputOps {
124
130
  });
125
131
  }
126
132
  }
127
- //# sourceMappingURL=input-ops.js.map
133
+ //# sourceMappingURL=input-ops.js.map
@@ -1,4 +1,4 @@
1
- import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs } from './utils.js';
1
+ import { resolveInputActionMaxAttempts, resolveInputActionTimeoutMs, resolveInputRecoveryBringToFrontTimeoutMs, resolveInputRecoveryDelayMs, resolveInputReadySettleMs, shouldSkipBringToFront } from './utils.js';
2
2
  import { ensurePageRuntime } from '../pageRuntime.js';
3
3
  export class BrowserInputPipeline {
4
4
  ensurePrimaryPage;
@@ -11,6 +11,13 @@ export class BrowserInputPipeline {
11
11
  async ensureInputReady(page) {
12
12
  if (this.isHeadless())
13
13
  return;
14
+ if (shouldSkipBringToFront()) {
15
+ const settleMs = resolveInputReadySettleMs();
16
+ if (settleMs > 0) {
17
+ await page.waitForTimeout(settleMs).catch(() => { });
18
+ }
19
+ return;
20
+ }
14
21
  const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
15
22
  let bringToFrontTimer = null;
16
23
  try {
@@ -67,6 +74,7 @@ export class BrowserInputPipeline {
67
74
  }
68
75
  async recoverInputPipeline(page) {
69
76
  const activePage = await this.resolveInputPage(page).catch(() => page);
77
+ if (!shouldSkipBringToFront()) {
70
78
  const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
71
79
  let bringToFrontTimer = null;
72
80
  try {
@@ -86,6 +94,7 @@ export class BrowserInputPipeline {
86
94
  if (bringToFrontTimer)
87
95
  clearTimeout(bringToFrontTimer);
88
96
  }
97
+ }
89
98
  const delayMs = resolveInputRecoveryDelayMs();
90
99
  if (delayMs > 0) {
91
100
  try {
@@ -130,4 +139,4 @@ export class BrowserInputPipeline {
130
139
  }
131
140
  }
132
141
  }
133
- //# sourceMappingURL=input-pipeline.js.map
142
+ //# sourceMappingURL=input-pipeline.js.map
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { ensurePageRuntime } from '../pageRuntime.js';
3
- import { resolveNavigationWaitUntil, normalizeUrl } from './utils.js';
3
+ import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
4
4
  export class BrowserSessionPageManagement {
5
5
  deps;
6
6
  constructor(deps) {
@@ -74,7 +74,9 @@ export class BrowserSessionPageManagement {
74
74
  const opener = this.deps.getActivePage() || ctx.pages()[0];
75
75
  if (!opener)
76
76
  throw new Error('no_opener_page');
77
- await opener.bringToFront().catch(() => null);
77
+ if (!shouldSkipBringToFront()) {
78
+ await opener.bringToFront().catch(() => null);
79
+ }
78
80
  const before = ctx.pages().filter((p) => !p.isClosed()).length;
79
81
  for (let attempt = 1; attempt <= 3; attempt += 1) {
80
82
  const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
@@ -136,11 +138,13 @@ export class BrowserSessionPageManagement {
136
138
  catch {
137
139
  /* ignore */
138
140
  }
139
- try {
140
- await page.bringToFront();
141
- }
142
- catch {
143
- /* ignore */
141
+ if (!shouldSkipBringToFront()) {
142
+ try {
143
+ await page.bringToFront();
144
+ }
145
+ catch {
146
+ /* ignore */
147
+ }
144
148
  }
145
149
  if (url) {
146
150
  await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
@@ -165,11 +169,13 @@ export class BrowserSessionPageManagement {
165
169
  catch {
166
170
  /* ignore */
167
171
  }
168
- try {
169
- await page.bringToFront();
170
- }
171
- catch {
172
- /* ignore */
172
+ if (!shouldSkipBringToFront()) {
173
+ try {
174
+ await page.bringToFront();
175
+ }
176
+ catch {
177
+ /* ignore */
178
+ }
173
179
  }
174
180
  await ensurePageRuntime(page, true).catch(() => { });
175
181
  this.deps.recordLastKnownUrl(page.url());
@@ -194,11 +200,13 @@ export class BrowserSessionPageManagement {
194
200
  if (nextIndex >= 0) {
195
201
  const nextPage = remaining[nextIndex];
196
202
  this.deps.setActivePage(nextPage);
197
- try {
198
- await nextPage.bringToFront();
199
- }
200
- catch {
201
- /* ignore */
203
+ if (!shouldSkipBringToFront()) {
204
+ try {
205
+ await nextPage.bringToFront();
206
+ }
207
+ catch {
208
+ /* ignore */
209
+ }
202
210
  }
203
211
  await ensurePageRuntime(nextPage, true).catch(() => { });
204
212
  this.deps.recordLastKnownUrl(nextPage.url());
@@ -209,4 +217,4 @@ export class BrowserSessionPageManagement {
209
217
  return { closedIndex, activeIndex: nextIndex, total: remaining.length };
210
218
  }
211
219
  }
212
- //# sourceMappingURL=page-management.js.map
220
+ //# sourceMappingURL=page-management.js.map
@@ -28,6 +28,20 @@ export function resolveInputReadySettleMs() {
28
28
  const raw = Number(process.env.CAMO_INPUT_READY_SETTLE_MS ?? 80);
29
29
  return Math.max(0, Number.isFinite(raw) ? Math.floor(raw) : 80);
30
30
  }
31
+ export function resolveBringToFrontMode() {
32
+ const mode = String(process.env.CAMO_BRING_TO_FRONT_MODE ?? '').trim().toLowerCase();
33
+ if (mode === 'never' || mode === 'off' || mode === 'disabled')
34
+ return 'never';
35
+ if (mode === 'always' || mode === 'on' || mode === 'auto')
36
+ return 'auto';
37
+ const legacy = String(process.env.CAMO_SKIP_BRING_TO_FRONT ?? '').trim().toLowerCase();
38
+ if (legacy === '1' || legacy === 'true' || legacy === 'yes' || legacy === 'on')
39
+ return 'never';
40
+ return 'auto';
41
+ }
42
+ export function shouldSkipBringToFront() {
43
+ return resolveBringToFrontMode() === 'never';
44
+ }
31
45
  export function isTimeoutLikeError(error) {
32
46
  const message = String(error?.message || error || '').toLowerCase();
33
47
  return message.includes('timed out') || message.includes('timeout');
@@ -44,6 +58,12 @@ export function normalizeUrl(raw) {
44
58
  export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeoutMs, settleMs) {
45
59
  if (headless)
46
60
  return;
61
+ if (shouldSkipBringToFront()) {
62
+ if (settleMs > 0) {
63
+ await page.waitForTimeout(settleMs).catch(() => { });
64
+ }
65
+ return;
66
+ }
47
67
  let bringToFrontTimer = null;
48
68
  try {
49
69
  await Promise.race([
@@ -66,4 +86,4 @@ export async function ensureInputReadyOnPage(page, headless, bringToFrontTimeout
66
86
  await page.waitForTimeout(settleMs).catch(() => { });
67
87
  }
68
88
  }
69
- //# sourceMappingURL=utils.js.map
89
+ //# sourceMappingURL=utils.js.map
@@ -180,6 +180,8 @@ ENV:
180
180
  CAMO_ROOT Legacy data root (auto-appends .camo if needed)
181
181
  CAMO_WS_URL Optional ws://host:port override
182
182
  CAMO_WS_HOST / CAMO_WS_PORT WS host/port for browser-service
183
+ CAMO_BRING_TO_FRONT_MODE Bring-to-front policy: auto (default) | never
184
+ CAMO_SKIP_BRING_TO_FRONT Legacy alias for CAMO_BRING_TO_FRONT_MODE=never
183
185
  CAMO_PROGRESS_EVENTS_FILE Optional path override for progress jsonl
184
186
  CAMO_PROGRESS_WS_HOST / CAMO_PROGRESS_WS_PORT Progress daemon host/port (defaults: 127.0.0.1:7788)
185
187
  `);