@web-auto/camo 0.1.23 → 0.1.24
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/package.json +1 -1
- package/src/container/subscription-registry.mjs +1 -1
- package/src/services/browser-service/internal/BrowserSession.input.test.js +33 -0
- package/src/services/browser-service/internal/browser-session/input-ops.js +27 -1
- package/src/services/browser-service/internal/browser-session/page-management.js +78 -24
- package/src/services/browser-service/internal/browser-session/page-management.test.js +105 -0
- package/src/utils/config.mjs +1 -1
package/package.json
CHANGED
|
@@ -277,6 +277,39 @@ test('mouseWheel retries with refreshed active page after timeout', async () =>
|
|
|
277
277
|
restoreTimeout();
|
|
278
278
|
}
|
|
279
279
|
});
|
|
280
|
+
test('mouseWheel prefers interactive viewport metrics for anchor clamping', async () => {
|
|
281
|
+
const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
|
|
282
|
+
const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
|
|
283
|
+
const restoreDelay = setEnv('CAMO_INPUT_RECOVERY_DELAY_MS', '0');
|
|
284
|
+
const restoreBringToFrontTimeout = setEnv('CAMO_INPUT_RECOVERY_BRING_TO_FRONT_TIMEOUT_MS', '50');
|
|
285
|
+
const restoreReadySettle = setEnv('CAMO_INPUT_READY_SETTLE_MS', '0');
|
|
286
|
+
try {
|
|
287
|
+
const moves = [];
|
|
288
|
+
const page = {
|
|
289
|
+
isClosed: () => false,
|
|
290
|
+
viewportSize: () => ({ width: 1280, height: 720 }),
|
|
291
|
+
evaluate: async () => ({ innerWidth: 2560, innerHeight: 1440, visualWidth: 2560, visualHeight: 1440 }),
|
|
292
|
+
bringToFront: async () => { },
|
|
293
|
+
waitForTimeout: async () => { },
|
|
294
|
+
mouse: {
|
|
295
|
+
move: async (x, y) => {
|
|
296
|
+
moves.push([x, y]);
|
|
297
|
+
},
|
|
298
|
+
wheel: async () => { },
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const session = createSessionWithPage(page);
|
|
302
|
+
await session.mouseWheel({ deltaY: 360, anchorX: 2564, anchorY: 228 });
|
|
303
|
+
assert.deepEqual(moves, [[2559, 228]]);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
restoreReadySettle();
|
|
307
|
+
restoreBringToFrontTimeout();
|
|
308
|
+
restoreDelay();
|
|
309
|
+
restoreAttempts();
|
|
310
|
+
restoreTimeout();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
280
313
|
test('mouseWheel falls back to keyboard paging when wheel keeps timing out', async () => {
|
|
281
314
|
const restoreTimeout = setEnv('CAMO_INPUT_ACTION_TIMEOUT_MS', '80');
|
|
282
315
|
const restoreAttempts = setEnv('CAMO_INPUT_ACTION_MAX_ATTEMPTS', '1');
|
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import { isTimeoutLikeError } from './utils.js';
|
|
2
|
+
|
|
3
|
+
async function readInteractiveViewport(page) {
|
|
4
|
+
const fallback = page.viewportSize?.() || null;
|
|
5
|
+
try {
|
|
6
|
+
const metrics = await page.evaluate(() => ({
|
|
7
|
+
innerWidth: Number(window.innerWidth || 0),
|
|
8
|
+
innerHeight: Number(window.innerHeight || 0),
|
|
9
|
+
visualWidth: Number(window.visualViewport?.width || 0),
|
|
10
|
+
visualHeight: Number(window.visualViewport?.height || 0),
|
|
11
|
+
}));
|
|
12
|
+
const width = Math.max(Number(metrics?.innerWidth || 0), Number(metrics?.visualWidth || 0), Number(fallback?.width || 0));
|
|
13
|
+
const height = Math.max(Number(metrics?.innerHeight || 0), Number(metrics?.visualHeight || 0), Number(fallback?.height || 0));
|
|
14
|
+
if (Number.isFinite(width) && width > 1 && Number.isFinite(height) && height > 1) {
|
|
15
|
+
return {
|
|
16
|
+
width: Math.round(width),
|
|
17
|
+
height: Math.round(height),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
return {
|
|
23
|
+
width: Math.max(1, Number(fallback?.width || 1280)),
|
|
24
|
+
height: Math.max(1, Number(fallback?.height || 720)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
2
28
|
export class BrowserSessionInputOps {
|
|
3
29
|
ensurePrimaryPage;
|
|
4
30
|
ensureInputReady;
|
|
@@ -89,7 +115,7 @@ export class BrowserSessionInputOps {
|
|
|
89
115
|
}
|
|
90
116
|
try {
|
|
91
117
|
await this.runInputAction(page, 'mouse:wheel', async (activePage) => {
|
|
92
|
-
const viewport = activePage
|
|
118
|
+
const viewport = await readInteractiveViewport(activePage);
|
|
93
119
|
const moveX = Number.isFinite(normalizedAnchorX)
|
|
94
120
|
? Math.max(1, Math.min(Math.max(1, Number(viewport?.width || 1280) - 1), Math.round(normalizedAnchorX)))
|
|
95
121
|
: Math.max(1, Math.floor(((viewport?.width || 1280) * 0.5)));
|
|
@@ -6,6 +6,36 @@ export class BrowserSessionPageManagement {
|
|
|
6
6
|
constructor(deps) {
|
|
7
7
|
this.deps = deps;
|
|
8
8
|
}
|
|
9
|
+
async openPageViaContext(ctx, beforeCount) {
|
|
10
|
+
try {
|
|
11
|
+
const page = await ctx.newPage();
|
|
12
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
|
|
13
|
+
const after = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
14
|
+
if (after > beforeCount) {
|
|
15
|
+
return page;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Fall through to shortcut-based creation below.
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
async openPageViaShortcut(ctx, opener, shortcut, beforeCount) {
|
|
24
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
25
|
+
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
26
|
+
await opener.keyboard.press(shortcut).catch(() => null);
|
|
27
|
+
const page = await waitPage;
|
|
28
|
+
const pagesNow = ctx.pages().filter((p) => !p.isClosed());
|
|
29
|
+
const after = pagesNow.length;
|
|
30
|
+
if (page && after > beforeCount)
|
|
31
|
+
return page;
|
|
32
|
+
if (!page && after > beforeCount) {
|
|
33
|
+
return pagesNow[pagesNow.length - 1] || null;
|
|
34
|
+
}
|
|
35
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
9
39
|
tryOsNewTabShortcut() {
|
|
10
40
|
if (this.deps.isHeadless())
|
|
11
41
|
return false;
|
|
@@ -58,7 +88,14 @@ export class BrowserSessionPageManagement {
|
|
|
58
88
|
}
|
|
59
89
|
listPages() {
|
|
60
90
|
const ctx = this.deps.ensureContext();
|
|
61
|
-
|
|
91
|
+
// Filter out closed pages AND pages that are effectively blank (about:newtab/about:blank)
|
|
92
|
+
const pages = ctx.pages().filter((p) => {
|
|
93
|
+
if (p.isClosed()) return false;
|
|
94
|
+
const url = p.url();
|
|
95
|
+
// Filter out blank placeholder pages
|
|
96
|
+
if (url === 'about:newtab' || url === 'about:blank') return false;
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
62
99
|
const active = this.deps.getActivePage();
|
|
63
100
|
return pages.map((p, index) => ({
|
|
64
101
|
index,
|
|
@@ -78,23 +115,15 @@ export class BrowserSessionPageManagement {
|
|
|
78
115
|
await opener.bringToFront().catch(() => null);
|
|
79
116
|
}
|
|
80
117
|
const before = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const after = pagesNow.length;
|
|
87
|
-
if (page && after > before)
|
|
88
|
-
break;
|
|
89
|
-
if (!page && after > before) {
|
|
90
|
-
page = pagesNow[pagesNow.length - 1] || null;
|
|
91
|
-
break;
|
|
92
|
-
}
|
|
93
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
118
|
+
if (!options?.strictShortcut) {
|
|
119
|
+
page = await this.openPageViaContext(ctx, before);
|
|
120
|
+
}
|
|
121
|
+
if (!page) {
|
|
122
|
+
page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
|
|
94
123
|
}
|
|
95
124
|
let after = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
96
125
|
if (!page || after <= before) {
|
|
97
|
-
const waitPage = ctx.waitForEvent('page', { timeout:
|
|
126
|
+
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
98
127
|
const osShortcutOk = this.tryOsNewTabShortcut();
|
|
99
128
|
if (osShortcutOk) {
|
|
100
129
|
page = await waitPage;
|
|
@@ -107,13 +136,7 @@ export class BrowserSessionPageManagement {
|
|
|
107
136
|
}
|
|
108
137
|
if (!page || after <= before) {
|
|
109
138
|
if (!options?.strictShortcut) {
|
|
110
|
-
|
|
111
|
-
page = await ctx.newPage();
|
|
112
|
-
await page.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => null);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
// ignore fallback errors
|
|
116
|
-
}
|
|
139
|
+
page = await this.openPageViaContext(ctx, before);
|
|
117
140
|
after = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
118
141
|
if (!page && after > before) {
|
|
119
142
|
const pagesNow = ctx.pages().filter((p) => !p.isClosed());
|
|
@@ -194,8 +217,39 @@ export class BrowserSessionPageManagement {
|
|
|
194
217
|
throw new Error(`invalid_page_index: ${index}`);
|
|
195
218
|
}
|
|
196
219
|
const page = pages[closedIndex];
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
const beforeUrl = page.url();
|
|
221
|
+
|
|
222
|
+
// Try to close the page
|
|
223
|
+
try {
|
|
224
|
+
await page.close({ runBeforeUnload: false });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Ignore close errors
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Wait for close to take effect
|
|
230
|
+
await new Promise(r => setTimeout(r, 100));
|
|
231
|
+
|
|
232
|
+
// Check if actually closed
|
|
233
|
+
let remaining = ctx.pages().filter((p) => !p.isClosed());
|
|
234
|
+
|
|
235
|
+
// If still same count, the page might not have closed properly
|
|
236
|
+
// Try navigating to about:blank first then close
|
|
237
|
+
if (remaining.length === pages.length) {
|
|
238
|
+
try {
|
|
239
|
+
await page.goto('about:blank', { timeout: 500 }).catch(() => {});
|
|
240
|
+
await page.close({ runBeforeUnload: false }).catch(() => {});
|
|
241
|
+
await new Promise(r => setTimeout(r, 100));
|
|
242
|
+
remaining = ctx.pages().filter((p) => !p.isClosed());
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// Ignore
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Final check - filter out pages that look like closed tabs (about:newtab)
|
|
249
|
+
remaining = remaining.filter(p => {
|
|
250
|
+
const url = p.url();
|
|
251
|
+
return url !== 'about:newtab' && url !== 'about:blank';
|
|
252
|
+
});
|
|
199
253
|
const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
|
|
200
254
|
if (nextIndex >= 0) {
|
|
201
255
|
const nextPage = remaining[nextIndex];
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { BrowserSessionPageManagement } from './page-management.js';
|
|
4
|
+
|
|
5
|
+
function createPage(label) {
|
|
6
|
+
const page = {
|
|
7
|
+
label,
|
|
8
|
+
closed: false,
|
|
9
|
+
bringToFrontCalls: 0,
|
|
10
|
+
gotoCalls: [],
|
|
11
|
+
waitCalls: [],
|
|
12
|
+
keyboard: {
|
|
13
|
+
presses: [],
|
|
14
|
+
press: async (shortcut) => {
|
|
15
|
+
page.keyboard.presses.push(shortcut);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
url: () => `https://example.com/${label}`,
|
|
19
|
+
isClosed() {
|
|
20
|
+
return this.closed;
|
|
21
|
+
},
|
|
22
|
+
async bringToFront() {
|
|
23
|
+
this.bringToFrontCalls += 1;
|
|
24
|
+
},
|
|
25
|
+
async waitForLoadState(_state, opts) {
|
|
26
|
+
this.waitCalls.push(Number(opts?.timeout || 0));
|
|
27
|
+
},
|
|
28
|
+
async goto(url) {
|
|
29
|
+
this.gotoCalls.push(url);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
return page;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createManagement({ pages, activePage, ctxNewPage, waitForEvent }) {
|
|
36
|
+
let currentActive = activePage;
|
|
37
|
+
const ctx = {
|
|
38
|
+
pages: () => pages,
|
|
39
|
+
newPage: ctxNewPage,
|
|
40
|
+
waitForEvent: waitForEvent || (async () => null),
|
|
41
|
+
};
|
|
42
|
+
const management = new BrowserSessionPageManagement({
|
|
43
|
+
ensureContext: () => ctx,
|
|
44
|
+
getActivePage: () => currentActive,
|
|
45
|
+
getCurrentUrl: () => currentActive?.url?.() || null,
|
|
46
|
+
setActivePage: (page) => {
|
|
47
|
+
currentActive = page ?? null;
|
|
48
|
+
},
|
|
49
|
+
setupPageHooks: () => { },
|
|
50
|
+
ensurePageViewport: async () => { },
|
|
51
|
+
maybeCenterPage: async () => { },
|
|
52
|
+
recordLastKnownUrl: () => { },
|
|
53
|
+
isHeadless: () => false,
|
|
54
|
+
});
|
|
55
|
+
return { management, getActivePage: () => currentActive };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('newPage prefers direct context creation before shortcut retries', async () => {
|
|
59
|
+
const opener = createPage('opener');
|
|
60
|
+
const created = createPage('created');
|
|
61
|
+
const pages = [opener];
|
|
62
|
+
let ctxNewPageCalls = 0;
|
|
63
|
+
const { management, getActivePage } = createManagement({
|
|
64
|
+
pages,
|
|
65
|
+
activePage: opener,
|
|
66
|
+
ctxNewPage: async () => {
|
|
67
|
+
ctxNewPageCalls += 1;
|
|
68
|
+
pages.push(created);
|
|
69
|
+
return created;
|
|
70
|
+
},
|
|
71
|
+
waitForEvent: async () => {
|
|
72
|
+
throw new Error('shortcut path should not run');
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const result = await management.newPage();
|
|
76
|
+
assert.equal(ctxNewPageCalls, 1);
|
|
77
|
+
assert.equal(opener.keyboard.presses.length, 0);
|
|
78
|
+
assert.equal(result.index, 1);
|
|
79
|
+
assert.equal(result.url, 'https://example.com/created');
|
|
80
|
+
assert.equal(getActivePage(), created);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('newPage falls back to shortcut path in strictShortcut mode', async () => {
|
|
84
|
+
const opener = createPage('opener');
|
|
85
|
+
const created = createPage('created');
|
|
86
|
+
const pages = [opener];
|
|
87
|
+
let ctxNewPageCalls = 0;
|
|
88
|
+
const { management, getActivePage } = createManagement({
|
|
89
|
+
pages,
|
|
90
|
+
activePage: opener,
|
|
91
|
+
ctxNewPage: async () => {
|
|
92
|
+
ctxNewPageCalls += 1;
|
|
93
|
+
return created;
|
|
94
|
+
},
|
|
95
|
+
waitForEvent: async () => {
|
|
96
|
+
pages.push(created);
|
|
97
|
+
return created;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const result = await management.newPage(undefined, { strictShortcut: true });
|
|
101
|
+
assert.equal(ctxNewPageCalls, 0);
|
|
102
|
+
assert.ok(opener.keyboard.presses.length >= 1);
|
|
103
|
+
assert.equal(result.index, 1);
|
|
104
|
+
assert.equal(getActivePage(), created);
|
|
105
|
+
});
|
package/src/utils/config.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, 'camo-cli.json');
|
|
|
69
69
|
export const PROFILE_META_FILE = 'camo-profile.json';
|
|
70
70
|
export const BROWSER_SERVICE_URL = process.env.CAMO_BROWSER_URL
|
|
71
71
|
|| process.env.CAMO_BROWSER_HTTP_URL
|
|
72
|
-
|| process.env.CAMO_BROWSER_HOST
|
|
72
|
+
|| (process.env.CAMO_BROWSER_HOST ? `http://${process.env.CAMO_BROWSER_HOST}` : '')
|
|
73
73
|
|| 'http://127.0.0.1:7704';
|
|
74
74
|
|
|
75
75
|
export function ensureDir(p) {
|