@web-auto/camo 0.1.23 → 0.1.25
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/cli.mjs +9 -0
- package/src/commands/browser.mjs +9 -7
- package/src/container/change-notifier.mjs +90 -39
- package/src/container/runtime-core/operations/index.mjs +108 -48
- package/src/container/runtime-core/operations/tab-pool.mjs +301 -99
- package/src/container/runtime-core/operations/tab-pool.mjs.bak +762 -0
- package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +762 -0
- package/src/container/runtime-core/operations/viewport.mjs +46 -0
- package/src/container/runtime-core/subscription.mjs +72 -7
- package/src/container/runtime-core/validation.mjs +61 -4
- package/src/container/subscription-registry.mjs +1 -1
- package/src/core/utils.mjs +4 -0
- package/src/services/browser-service/index.js +27 -10
- package/src/services/browser-service/index.js.bak +671 -0
- package/src/services/browser-service/internal/BrowserSession.input.test.js +33 -0
- package/src/services/browser-service/internal/BrowserSession.js +34 -2
- package/src/services/browser-service/internal/browser-session/input-ops.js +27 -1
- package/src/services/browser-service/internal/browser-session/page-management.js +152 -36
- package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -0
- package/src/services/controller/controller.js +1 -1
- package/src/services/controller/transport.js +8 -1
- package/src/utils/args.mjs +1 -0
- package/src/utils/browser-service.mjs +13 -1
- package/src/utils/command-log.mjs +64 -0
- package/src/utils/config.mjs +1 -1
- package/src/utils/help.mjs +3 -3
|
@@ -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');
|
|
@@ -37,6 +37,7 @@ export class BrowserSession {
|
|
|
37
37
|
exitNotified = false;
|
|
38
38
|
constructor(options) {
|
|
39
39
|
this.options = options;
|
|
40
|
+
this.maxTabs = Math.max(5, Math.floor(Number(options.maxTabs ?? 5) || 5));
|
|
40
41
|
const profileId = options.profileId || 'default';
|
|
41
42
|
const root = resolveProfilesRoot();
|
|
42
43
|
this.profileDir = path.join(root, profileId);
|
|
@@ -69,6 +70,7 @@ export class BrowserSession {
|
|
|
69
70
|
recordLastKnownUrl: (url) => { if (url)
|
|
70
71
|
this.lastKnownUrl = url; },
|
|
71
72
|
isHeadless: () => this.options.headless === true,
|
|
73
|
+
getMaxTabs: () => this.maxTabs,
|
|
72
74
|
});
|
|
73
75
|
this.navigation = new BrowserSessionNavigation({
|
|
74
76
|
ensurePrimaryPage: () => this.pageManager.ensurePrimaryPage(),
|
|
@@ -147,7 +149,10 @@ export class BrowserSession {
|
|
|
147
149
|
const existing = this.context.pages();
|
|
148
150
|
this.page = existing.length ? existing[0] : await this.context.newPage();
|
|
149
151
|
this.setupPageHooks(this.page);
|
|
150
|
-
this.context.on('page', (p) =>
|
|
152
|
+
this.context.on('page', (p) => {
|
|
153
|
+
this.setupPageHooks(p);
|
|
154
|
+
this.enforceMaxTabs();
|
|
155
|
+
});
|
|
151
156
|
if (this.viewportManager.isFollowingWindow()) {
|
|
152
157
|
await this.viewportManager.refreshFromWindow(this.page).catch(() => { });
|
|
153
158
|
}
|
|
@@ -158,6 +163,33 @@ export class BrowserSession {
|
|
|
158
163
|
setupPageHooks(page) {
|
|
159
164
|
this.pageHooks.setupPageHooks(page);
|
|
160
165
|
}
|
|
166
|
+
|
|
167
|
+
async enforceMaxTabs() {
|
|
168
|
+
try {
|
|
169
|
+
const ctx = this.context;
|
|
170
|
+
if (!ctx) return;
|
|
171
|
+
const pages = ctx.pages().filter((p) => !p.isClosed());
|
|
172
|
+
if (pages.length <= this.maxTabs) return;
|
|
173
|
+
const activePage = this.getActivePage();
|
|
174
|
+
const excess = pages.length - this.maxTabs;
|
|
175
|
+
const closable = pages
|
|
176
|
+
.filter((p) => p !== activePage)
|
|
177
|
+
.sort((a, b) => {
|
|
178
|
+
const aUrl = a.url() || '';
|
|
179
|
+
const bUrl = b.url() || '';
|
|
180
|
+
const aScore = (aUrl.includes('captcha') || aUrl === 'about:blank') ? 1 : 0;
|
|
181
|
+
const bScore = (bUrl.includes('captcha') || bUrl === 'about:blank') ? 1 : 0;
|
|
182
|
+
if (aScore !== bScore) return bScore - aScore;
|
|
183
|
+
return 0;
|
|
184
|
+
});
|
|
185
|
+
const toClose = closable.slice(0, Math.min(excess, closable.length));
|
|
186
|
+
for (const page of toClose) {
|
|
187
|
+
try {
|
|
188
|
+
await page.close({ runBeforeUnload: false });
|
|
189
|
+
} catch { /* ignore */ }
|
|
190
|
+
}
|
|
191
|
+
} catch { /* ignore enforcement errors */ }
|
|
192
|
+
}
|
|
161
193
|
addRuntimeEventObserver(observer) {
|
|
162
194
|
return this.runtimeEvents.addObserver(observer);
|
|
163
195
|
}
|
|
@@ -301,4 +333,4 @@ export class BrowserSession {
|
|
|
301
333
|
this.onExit?.(this.options.profileId);
|
|
302
334
|
}
|
|
303
335
|
}
|
|
304
|
-
//# sourceMappingURL=BrowserSession.js.map
|
|
336
|
+
//# sourceMappingURL=BrowserSession.js.map
|
|
@@ -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)));
|
|
@@ -3,9 +3,122 @@ import { ensurePageRuntime } from '../pageRuntime.js';
|
|
|
3
3
|
import { resolveNavigationWaitUntil, normalizeUrl, shouldSkipBringToFront } from './utils.js';
|
|
4
4
|
export class BrowserSessionPageManagement {
|
|
5
5
|
deps;
|
|
6
|
+
trackedPages = [];
|
|
7
|
+
trackedPageListeners = new WeakSet();
|
|
8
|
+
trackedPageState = new WeakMap();
|
|
9
|
+
static NEW_PAGE_FORCE_ALIVE_MS = 15_000;
|
|
10
|
+
static ACTIVE_PAGE_FORCE_ALIVE_MS = 5_000;
|
|
6
11
|
constructor(deps) {
|
|
7
12
|
this.deps = deps;
|
|
8
13
|
}
|
|
14
|
+
safeIsClosed(page) {
|
|
15
|
+
if (!page)
|
|
16
|
+
return true;
|
|
17
|
+
try {
|
|
18
|
+
return page.isClosed();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
markTrackedPage(page, forceAliveMs = 0) {
|
|
25
|
+
if (!page)
|
|
26
|
+
return null;
|
|
27
|
+
const prev = this.trackedPageState.get(page);
|
|
28
|
+
const next = {
|
|
29
|
+
closed: false,
|
|
30
|
+
forceAliveUntil: Math.max(Number(prev?.forceAliveUntil || 0), forceAliveMs > 0 ? Date.now() + forceAliveMs : 0),
|
|
31
|
+
};
|
|
32
|
+
this.trackedPageState.set(page, next);
|
|
33
|
+
return page;
|
|
34
|
+
}
|
|
35
|
+
isTrackedPageAlive(page) {
|
|
36
|
+
if (!page)
|
|
37
|
+
return false;
|
|
38
|
+
const state = this.trackedPageState.get(page);
|
|
39
|
+
if (state?.closed === true)
|
|
40
|
+
return false;
|
|
41
|
+
if (!this.safeIsClosed(page))
|
|
42
|
+
return true;
|
|
43
|
+
return Number(state?.forceAliveUntil || 0) > Date.now();
|
|
44
|
+
}
|
|
45
|
+
rememberPage(page, options = {}) {
|
|
46
|
+
if (!page)
|
|
47
|
+
return null;
|
|
48
|
+
this.markTrackedPage(page, Math.max(0, Number(options.forceAliveMs || 0) || 0));
|
|
49
|
+
if (this.safeIsClosed(page) && !this.isTrackedPageAlive(page))
|
|
50
|
+
return null;
|
|
51
|
+
if (!this.trackedPages.includes(page)) {
|
|
52
|
+
this.trackedPages.push(page);
|
|
53
|
+
}
|
|
54
|
+
if (typeof page.on === 'function' && !this.trackedPageListeners.has(page)) {
|
|
55
|
+
page.on('close', () => {
|
|
56
|
+
this.trackedPageState.set(page, {
|
|
57
|
+
closed: true,
|
|
58
|
+
forceAliveUntil: 0,
|
|
59
|
+
});
|
|
60
|
+
this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
|
|
61
|
+
});
|
|
62
|
+
this.trackedPageListeners.add(page);
|
|
63
|
+
}
|
|
64
|
+
return page;
|
|
65
|
+
}
|
|
66
|
+
collectPages(ctx) {
|
|
67
|
+
const active = this.deps.getActivePage();
|
|
68
|
+
if (active) {
|
|
69
|
+
this.rememberPage(active, {
|
|
70
|
+
forceAliveMs: BrowserSessionPageManagement.ACTIVE_PAGE_FORCE_ALIVE_MS,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const merged = [...ctx.pages(), ...this.trackedPages, ...(active ? [active] : [])];
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const pages = [];
|
|
76
|
+
for (const page of merged) {
|
|
77
|
+
const tracked = this.trackedPages.includes(page) || page === active;
|
|
78
|
+
const alive = tracked ? this.isTrackedPageAlive(page) : !this.safeIsClosed(page);
|
|
79
|
+
if (!page || !alive || seen.has(page))
|
|
80
|
+
continue;
|
|
81
|
+
seen.add(page);
|
|
82
|
+
pages.push(page);
|
|
83
|
+
this.rememberPage(page);
|
|
84
|
+
}
|
|
85
|
+
this.trackedPages = this.trackedPages.filter((page) => page && this.isTrackedPageAlive(page));
|
|
86
|
+
return pages;
|
|
87
|
+
}
|
|
88
|
+
async openPageViaContext(ctx, beforeCount) {
|
|
89
|
+
try {
|
|
90
|
+
const page = this.rememberPage(await ctx.newPage(), {
|
|
91
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
92
|
+
});
|
|
93
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 1500 }).catch(() => null);
|
|
94
|
+
const after = this.collectPages(ctx).length;
|
|
95
|
+
if (after > beforeCount) {
|
|
96
|
+
return page;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Fall through to shortcut-based creation below.
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
async openPageViaShortcut(ctx, opener, shortcut, beforeCount) {
|
|
105
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
106
|
+
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
107
|
+
await opener.keyboard.press(shortcut).catch(() => null);
|
|
108
|
+
const page = this.rememberPage(await waitPage, {
|
|
109
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
110
|
+
});
|
|
111
|
+
const pagesNow = this.collectPages(ctx);
|
|
112
|
+
const after = pagesNow.length;
|
|
113
|
+
if (page && after > beforeCount)
|
|
114
|
+
return page;
|
|
115
|
+
if (!page && after > beforeCount) {
|
|
116
|
+
return pagesNow[pagesNow.length - 1] || null;
|
|
117
|
+
}
|
|
118
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
9
122
|
tryOsNewTabShortcut() {
|
|
10
123
|
if (this.deps.isHeadless())
|
|
11
124
|
return false;
|
|
@@ -24,6 +137,7 @@ export class BrowserSessionPageManagement {
|
|
|
24
137
|
const ctx = this.deps.ensureContext();
|
|
25
138
|
const existing = this.deps.getActivePage();
|
|
26
139
|
if (existing) {
|
|
140
|
+
this.rememberPage(existing);
|
|
27
141
|
try {
|
|
28
142
|
await this.deps.ensurePageViewport(existing);
|
|
29
143
|
}
|
|
@@ -32,7 +146,9 @@ export class BrowserSessionPageManagement {
|
|
|
32
146
|
}
|
|
33
147
|
return existing;
|
|
34
148
|
}
|
|
35
|
-
const page = await ctx.newPage()
|
|
149
|
+
const page = this.rememberPage(await ctx.newPage(), {
|
|
150
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
151
|
+
});
|
|
36
152
|
this.deps.setActivePage(page);
|
|
37
153
|
this.deps.setupPageHooks(page);
|
|
38
154
|
try {
|
|
@@ -58,7 +174,7 @@ export class BrowserSessionPageManagement {
|
|
|
58
174
|
}
|
|
59
175
|
listPages() {
|
|
60
176
|
const ctx = this.deps.ensureContext();
|
|
61
|
-
const pages =
|
|
177
|
+
const pages = this.collectPages(ctx);
|
|
62
178
|
const active = this.deps.getActivePage();
|
|
63
179
|
return pages.map((p, index) => ({
|
|
64
180
|
index,
|
|
@@ -66,9 +182,16 @@ export class BrowserSessionPageManagement {
|
|
|
66
182
|
active: active === p,
|
|
67
183
|
}));
|
|
68
184
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
185
|
+
async newPage(url, options = {}) {
|
|
186
|
+
const ctx = this.deps.ensureContext();
|
|
187
|
+
const maxTabs = typeof this.deps.getMaxTabs === 'function' ? this.deps.getMaxTabs() : null;
|
|
188
|
+
if (Number.isFinite(maxTabs) && maxTabs >= 1) {
|
|
189
|
+
const pages = this.collectPages(ctx);
|
|
190
|
+
if (pages.length >= maxTabs) {
|
|
191
|
+
throw new Error(`max_tabs_exceeded:${maxTabs}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const isMac = process.platform === 'darwin';
|
|
72
195
|
const shortcut = isMac ? 'Meta+t' : 'Control+t';
|
|
73
196
|
let page = null;
|
|
74
197
|
const opener = this.deps.getActivePage() || ctx.pages()[0];
|
|
@@ -77,29 +200,23 @@ export class BrowserSessionPageManagement {
|
|
|
77
200
|
if (!shouldSkipBringToFront()) {
|
|
78
201
|
await opener.bringToFront().catch(() => null);
|
|
79
202
|
}
|
|
80
|
-
const before =
|
|
81
|
-
|
|
82
|
-
|
|
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));
|
|
203
|
+
const before = this.collectPages(ctx).length;
|
|
204
|
+
if (!options?.strictShortcut) {
|
|
205
|
+
page = await this.openPageViaContext(ctx, before);
|
|
94
206
|
}
|
|
95
|
-
|
|
207
|
+
if (!page) {
|
|
208
|
+
page = await this.openPageViaShortcut(ctx, opener, shortcut, before);
|
|
209
|
+
}
|
|
210
|
+
let after = this.collectPages(ctx).length;
|
|
96
211
|
if (!page || after <= before) {
|
|
97
|
-
const waitPage = ctx.waitForEvent('page', { timeout:
|
|
212
|
+
const waitPage = ctx.waitForEvent('page', { timeout: 1200 }).catch(() => null);
|
|
98
213
|
const osShortcutOk = this.tryOsNewTabShortcut();
|
|
99
214
|
if (osShortcutOk) {
|
|
100
|
-
page = await waitPage
|
|
215
|
+
page = this.rememberPage(await waitPage, {
|
|
216
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
217
|
+
});
|
|
101
218
|
}
|
|
102
|
-
const pagesNow =
|
|
219
|
+
const pagesNow = this.collectPages(ctx);
|
|
103
220
|
after = pagesNow.length;
|
|
104
221
|
if (!page && after > before) {
|
|
105
222
|
page = pagesNow[pagesNow.length - 1] || null;
|
|
@@ -107,16 +224,10 @@ export class BrowserSessionPageManagement {
|
|
|
107
224
|
}
|
|
108
225
|
if (!page || after <= before) {
|
|
109
226
|
if (!options?.strictShortcut) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
await page.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => null);
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
// ignore fallback errors
|
|
116
|
-
}
|
|
117
|
-
after = ctx.pages().filter((p) => !p.isClosed()).length;
|
|
227
|
+
page = await this.openPageViaContext(ctx, before);
|
|
228
|
+
after = this.collectPages(ctx).length;
|
|
118
229
|
if (!page && after > before) {
|
|
119
|
-
const pagesNow =
|
|
230
|
+
const pagesNow = this.collectPages(ctx);
|
|
120
231
|
page = pagesNow[pagesNow.length - 1] || null;
|
|
121
232
|
}
|
|
122
233
|
}
|
|
@@ -125,6 +236,9 @@ export class BrowserSessionPageManagement {
|
|
|
125
236
|
throw new Error('new_tab_failed');
|
|
126
237
|
}
|
|
127
238
|
this.deps.setupPageHooks(page);
|
|
239
|
+
this.rememberPage(page, {
|
|
240
|
+
forceAliveMs: BrowserSessionPageManagement.NEW_PAGE_FORCE_ALIVE_MS,
|
|
241
|
+
});
|
|
128
242
|
this.deps.setActivePage(page);
|
|
129
243
|
try {
|
|
130
244
|
await this.deps.ensurePageViewport(page);
|
|
@@ -151,12 +265,12 @@ export class BrowserSessionPageManagement {
|
|
|
151
265
|
await ensurePageRuntime(page);
|
|
152
266
|
this.deps.recordLastKnownUrl(url);
|
|
153
267
|
}
|
|
154
|
-
const pages =
|
|
268
|
+
const pages = this.collectPages(ctx);
|
|
155
269
|
return { index: Math.max(0, pages.indexOf(page)), url: page.url() };
|
|
156
270
|
}
|
|
157
271
|
async switchPage(index) {
|
|
158
272
|
const ctx = this.deps.ensureContext();
|
|
159
|
-
const pages =
|
|
273
|
+
const pages = this.collectPages(ctx);
|
|
160
274
|
const idx = Number(index);
|
|
161
275
|
if (!Number.isFinite(idx) || idx < 0 || idx >= pages.length) {
|
|
162
276
|
throw new Error(`invalid_page_index: ${index}`);
|
|
@@ -183,7 +297,7 @@ export class BrowserSessionPageManagement {
|
|
|
183
297
|
}
|
|
184
298
|
async closePage(index) {
|
|
185
299
|
const ctx = this.deps.ensureContext();
|
|
186
|
-
const pages =
|
|
300
|
+
const pages = this.collectPages(ctx);
|
|
187
301
|
if (pages.length === 0) {
|
|
188
302
|
return { closedIndex: -1, activeIndex: -1, total: 0 };
|
|
189
303
|
}
|
|
@@ -194,8 +308,10 @@ export class BrowserSessionPageManagement {
|
|
|
194
308
|
throw new Error(`invalid_page_index: ${index}`);
|
|
195
309
|
}
|
|
196
310
|
const page = pages[closedIndex];
|
|
311
|
+
this.trackedPageState.set(page, { closed: true, forceAliveUntil: 0 });
|
|
197
312
|
await page.close().catch(() => { });
|
|
198
|
-
|
|
313
|
+
this.trackedPages = this.trackedPages.filter((item) => item !== page && !item.isClosed());
|
|
314
|
+
const remaining = this.collectPages(ctx);
|
|
199
315
|
const nextIndex = remaining.length === 0 ? -1 : Math.min(Math.max(0, closedIndex - 1), remaining.length - 1);
|
|
200
316
|
if (nextIndex >= 0) {
|
|
201
317
|
const nextPage = remaining[nextIndex];
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
});
|
|
106
|
+
|
|
107
|
+
test('listPages keeps track of newly created pages even when context.pages stays stale', async () => {
|
|
108
|
+
const opener = createPage('opener');
|
|
109
|
+
const created = createPage('created');
|
|
110
|
+
const pages = [opener];
|
|
111
|
+
const { management, getActivePage } = createManagement({
|
|
112
|
+
pages,
|
|
113
|
+
activePage: opener,
|
|
114
|
+
ctxNewPage: async () => created,
|
|
115
|
+
waitForEvent: async () => null,
|
|
116
|
+
});
|
|
117
|
+
const result = await management.newPage();
|
|
118
|
+
assert.equal(result.index, 1);
|
|
119
|
+
assert.equal(result.url, 'https://example.com/created');
|
|
120
|
+
assert.equal(getActivePage(), created);
|
|
121
|
+
const listed = management.listPages();
|
|
122
|
+
assert.deepEqual(listed, [
|
|
123
|
+
{ index: 0, url: 'https://example.com/opener', active: false },
|
|
124
|
+
{ index: 1, url: 'https://example.com/created', active: true },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('listPages keeps a just-created page visible briefly even if page reports closed immediately', async () => {
|
|
129
|
+
const opener = createPage('opener');
|
|
130
|
+
const created = createPage('created');
|
|
131
|
+
const pages = [opener];
|
|
132
|
+
const { management } = createManagement({
|
|
133
|
+
pages,
|
|
134
|
+
activePage: opener,
|
|
135
|
+
ctxNewPage: async () => {
|
|
136
|
+
created.closed = true;
|
|
137
|
+
return created;
|
|
138
|
+
},
|
|
139
|
+
waitForEvent: async () => null,
|
|
140
|
+
});
|
|
141
|
+
const result = await management.newPage();
|
|
142
|
+
assert.equal(result.index, 1);
|
|
143
|
+
const listed = management.listPages();
|
|
144
|
+
assert.deepEqual(listed, [
|
|
145
|
+
{ index: 0, url: 'https://example.com/opener', active: false },
|
|
146
|
+
{ index: 1, url: 'https://example.com/created', active: true },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
@@ -224,7 +224,7 @@ export class UiController {
|
|
|
224
224
|
}
|
|
225
225
|
const args = ['create', '--profile', payload.profile];
|
|
226
226
|
if (payload.url) args.push('--url', payload.url);
|
|
227
|
-
if (payload.headless
|
|
227
|
+
if (payload.headless === false) args.push('--no-headless');
|
|
228
228
|
if (payload.keepOpen !== undefined) args.push('--keep-open', String(payload.keepOpen));
|
|
229
229
|
return this.runCliCommand('session-manager', args);
|
|
230
230
|
}
|
|
@@ -21,11 +21,18 @@ export function createTransport({ env = process.env, defaults = {}, debugLog = n
|
|
|
21
21
|
? options.timeoutMs
|
|
22
22
|
: 20000;
|
|
23
23
|
const profileId = (args?.profileId || args?.profile || args?.sessionId || '').toString();
|
|
24
|
+
const senderMeta = {
|
|
25
|
+
source: String(options?.source || env.CAMO_COMMAND_SOURCE || 'controller').trim() || 'controller',
|
|
26
|
+
cwd: String(options?.cwd || env.CAMO_COMMAND_CWD || process.cwd()).trim() || process.cwd(),
|
|
27
|
+
pid: Number(options?.pid || env.CAMO_COMMAND_PID || process.pid) || process.pid,
|
|
28
|
+
ppid: Number(options?.ppid || env.CAMO_COMMAND_PPID || process.ppid) || process.ppid,
|
|
29
|
+
argv: Array.isArray(options?.argv) ? options.argv.map((item) => String(item)) : process.argv.slice(),
|
|
30
|
+
};
|
|
24
31
|
debugLog?.('browserServiceCommand:start', { action, profileId, timeoutMs });
|
|
25
32
|
const res = await fetch(`${getBrowserHttpBase()}/command`, {
|
|
26
33
|
method: 'POST',
|
|
27
34
|
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
body: JSON.stringify({ action, args }),
|
|
35
|
+
body: JSON.stringify({ action, args, meta: { sender: senderMeta } }),
|
|
29
36
|
signal: AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
30
37
|
});
|
|
31
38
|
|
package/src/utils/args.mjs
CHANGED
|
@@ -10,6 +10,7 @@ export function ensureUrlScheme(rawUrl) {
|
|
|
10
10
|
if (typeof rawUrl !== 'string') return rawUrl;
|
|
11
11
|
const trimmed = rawUrl.trim();
|
|
12
12
|
if (!trimmed) return trimmed;
|
|
13
|
+
if (/^(about|chrome|file|data|blob|javascript):/i.test(trimmed)) return trimmed;
|
|
13
14
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return trimmed;
|
|
14
15
|
return `https://${trimmed}`;
|
|
15
16
|
}
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
9
9
|
import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
|
|
10
10
|
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
11
|
+
import { buildCommandSenderMeta } from './command-log.mjs';
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
@@ -117,12 +118,23 @@ function shouldTrackSessionActivity(action, payload) {
|
|
|
117
118
|
|
|
118
119
|
export async function callAPI(action, payload = {}, options = {}) {
|
|
119
120
|
const timeoutMs = resolveApiTimeoutMs(options);
|
|
121
|
+
const senderMeta = buildCommandSenderMeta({
|
|
122
|
+
source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
|
|
123
|
+
cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
|
|
124
|
+
pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
|
|
125
|
+
ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
|
|
126
|
+
argv: Array.isArray(options?.argv)
|
|
127
|
+
? options.argv
|
|
128
|
+
: Array.isArray(payload?.__commandArgv)
|
|
129
|
+
? payload.__commandArgv
|
|
130
|
+
: process.argv.slice(),
|
|
131
|
+
});
|
|
120
132
|
let r;
|
|
121
133
|
try {
|
|
122
134
|
r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
123
135
|
method: 'POST',
|
|
124
136
|
headers: { 'Content-Type': 'application/json' },
|
|
125
|
-
body: JSON.stringify({ action, args: payload }),
|
|
137
|
+
body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
|
|
126
138
|
signal: AbortSignal.timeout(timeoutMs),
|
|
127
139
|
});
|
|
128
140
|
} catch (error) {
|