@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 +2 -0
- package/package.json +1 -1
- package/scripts/bump-version.mjs +5 -9
- package/src/commands/browser.mjs +21 -3
- package/src/container/runtime-core/operations/index.mjs +19 -1
- package/src/container/runtime-core/operations/selector-scripts.mjs +22 -4
- package/src/container/runtime-core/search.mjs +190 -0
- package/src/services/browser-service/index.js +8 -2
- package/src/services/browser-service/internal/BrowserSession.input.test.js +36 -2
- package/src/services/browser-service/internal/browser-session/input-ops.js +10 -4
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +11 -2
- package/src/services/browser-service/internal/browser-session/page-management.js +26 -18
- package/src/services/browser-service/internal/browser-session/utils.js +21 -1
- package/src/utils/help.mjs +2 -0
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
package/scripts/bump-version.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Version bumper for camo CLI
|
|
4
|
-
* Increments patch version
|
|
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
|
-
|
|
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');
|
package/src/commands/browser.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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 =
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
package/src/utils/help.mjs
CHANGED
|
@@ -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
|
`);
|