@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { CONFIG_DIR } from '../utils/config.mjs';
3
+ import { CONFIG_DIR, loadConfig } from '../utils/config.mjs';
4
4
 
5
5
  const CONTAINER_ROOT_ENV = process.env.CAMO_CONTAINER_ROOT;
6
6
 
@@ -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.viewportSize();
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
- const pages = ctx.pages().filter((p) => !p.isClosed());
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
- for (let attempt = 1; attempt <= 3; attempt += 1) {
82
- const waitPage = ctx.waitForEvent('page', { timeout: 8000 }).catch(() => null);
83
- await opener.keyboard.press(shortcut).catch(() => null);
84
- page = await waitPage;
85
- const pagesNow = ctx.pages().filter((p) => !p.isClosed());
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: 8000 }).catch(() => null);
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
- try {
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
- await page.close().catch(() => { });
198
- const remaining = ctx.pages().filter((p) => !p.isClosed());
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
+ });
@@ -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) {