@web-auto/camo 0.1.22 → 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/README.md +38 -6
- package/package.json +1 -1
- package/src/commands/browser.mjs +29 -53
- package/src/commands/lifecycle.mjs +47 -83
- package/src/container/subscription-registry.mjs +1 -1
- package/src/lifecycle/session-registry.mjs +21 -5
- package/src/lifecycle/session-view.mjs +76 -0
- 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/browser-service.mjs +18 -6
- package/src/utils/config.mjs +1 -1
- package/src/utils/help.mjs +27 -6
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ camo stop --id <instanceId>
|
|
|
176
176
|
camo stop --alias <alias>
|
|
177
177
|
camo stop idle
|
|
178
178
|
camo stop all
|
|
179
|
-
camo status [profileId]
|
|
179
|
+
camo status [profileId] # Show resolved per-profile session view
|
|
180
180
|
camo shutdown # Shutdown browser-service (all sessions)
|
|
181
181
|
```
|
|
182
182
|
|
|
@@ -192,15 +192,47 @@ Set `CAMO_BRING_TO_FRONT_MODE=never` to keep protocol-level input and page lifec
|
|
|
192
192
|
### Lifecycle & Cleanup
|
|
193
193
|
|
|
194
194
|
```bash
|
|
195
|
-
camo instances # List
|
|
196
|
-
camo sessions # List
|
|
197
|
-
camo cleanup [profileId] # Cleanup
|
|
195
|
+
camo instances # List resolved session view (live + registered + idle state)
|
|
196
|
+
camo sessions # List resolved session view for all profiles
|
|
197
|
+
camo cleanup [profileId] # Cleanup only one profile (remote stop + local registry/lock/watchdog)
|
|
198
198
|
camo cleanup all # Cleanup all active sessions
|
|
199
199
|
camo cleanup locks # Cleanup stale lock files
|
|
200
|
-
camo force-stop [profileId] # Force stop
|
|
200
|
+
camo force-stop [profileId] # Force stop only one profile (no alias/id targeting)
|
|
201
201
|
camo lock list # List active session locks
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
+
Session isolation rules:
|
|
205
|
+
- `profileId` is the lifecycle primary key across browser-service session, local registry, watchdog, and lock.
|
|
206
|
+
- `camo start/stop/cleanup/force-stop <profileId>` only target that exact profile and must not affect other profiles.
|
|
207
|
+
- `camo stop --id` and `camo stop --alias` are stop-only convenience selectors; `cleanup` and `force-stop` intentionally reject indirect targeting.
|
|
208
|
+
- `camo status`, `camo sessions`, and `camo instances` share the same resolved session view fields:
|
|
209
|
+
- `live`: browser-service currently has this profile session
|
|
210
|
+
- `registered`: local registry has metadata for this profile
|
|
211
|
+
- `orphaned`: registry exists but the service session is gone
|
|
212
|
+
- `needsRecovery`: registry still says active but browser-service no longer has that profile
|
|
213
|
+
|
|
214
|
+
Isolation examples:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Observe both profiles independently
|
|
218
|
+
camo sessions
|
|
219
|
+
camo status finger
|
|
220
|
+
camo status xhs-qa-1
|
|
221
|
+
|
|
222
|
+
# Stop only finger; xhs-qa-1 must remain live
|
|
223
|
+
camo stop finger
|
|
224
|
+
camo status finger
|
|
225
|
+
camo status xhs-qa-1
|
|
226
|
+
|
|
227
|
+
# cleanup / force-stop require direct profile targeting
|
|
228
|
+
camo cleanup finger
|
|
229
|
+
camo force-stop finger
|
|
230
|
+
|
|
231
|
+
# Invalid on purpose: indirect targeting is rejected
|
|
232
|
+
camo cleanup --alias shard1
|
|
233
|
+
camo force-stop --id inst_xxxxxxxx
|
|
234
|
+
```
|
|
235
|
+
|
|
204
236
|
### Navigation
|
|
205
237
|
|
|
206
238
|
```bash
|
|
@@ -254,7 +286,7 @@ Recorder JSONL events include:
|
|
|
254
286
|
camo new-page [profileId] [--url <url>]
|
|
255
287
|
camo close-page [profileId] [index]
|
|
256
288
|
camo switch-page [profileId] <index>
|
|
257
|
-
camo list-pages [profileId]
|
|
289
|
+
camo list-pages [profileId] # Requires live=true for that profile
|
|
258
290
|
```
|
|
259
291
|
|
|
260
292
|
### Cookies
|
package/package.json
CHANGED
package/src/commands/browser.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getProfileWindowSize,
|
|
8
8
|
setProfileWindowSize,
|
|
9
9
|
} from '../utils/config.mjs';
|
|
10
|
-
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
|
|
10
|
+
import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
11
11
|
import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
|
|
12
12
|
import { ensureJsExecutionEnabled } from '../utils/js-policy.mjs';
|
|
13
13
|
import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
|
|
@@ -298,6 +298,15 @@ async function stopAndCleanupProfile(profileId, options = {}) {
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
async function loadResolvedSessions(serviceUp) {
|
|
302
|
+
if (!serviceUp) return [];
|
|
303
|
+
try {
|
|
304
|
+
return await getResolvedSessions();
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
301
310
|
async function probeViewportSize(profileId) {
|
|
302
311
|
try {
|
|
303
312
|
const payload = await callAPI('evaluate', {
|
|
@@ -726,23 +735,17 @@ export async function handleStopCommand(args) {
|
|
|
726
735
|
const stopIdle = target === 'idle' || args.includes('--idle');
|
|
727
736
|
const stopAll = target === 'all';
|
|
728
737
|
const serviceUp = await checkBrowserService();
|
|
738
|
+
const resolvedSessions = await loadResolvedSessions(serviceUp);
|
|
729
739
|
|
|
730
740
|
if (stopAll) {
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
// Ignore and fallback to local registry.
|
|
741
|
+
const profileSet = new Set(resolvedSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
742
|
+
if (profileSet.size === 0) {
|
|
743
|
+
for (const session of listRegisteredSessions()) {
|
|
744
|
+
if (String(session?.status || '').trim() === 'closed') continue;
|
|
745
|
+
const profileId = String(session?.profileId || '').trim();
|
|
746
|
+
if (profileId) profileSet.add(profileId);
|
|
738
747
|
}
|
|
739
748
|
}
|
|
740
|
-
const profileSet = new Set(liveSessions.map((item) => String(item?.profileId || '').trim()).filter(Boolean));
|
|
741
|
-
for (const session of listRegisteredSessions()) {
|
|
742
|
-
if (String(session?.status || '').trim() === 'closed') continue;
|
|
743
|
-
const profileId = String(session?.profileId || '').trim();
|
|
744
|
-
if (profileId) profileSet.add(profileId);
|
|
745
|
-
}
|
|
746
749
|
|
|
747
750
|
const results = [];
|
|
748
751
|
for (const profileId of profileSet) {
|
|
@@ -763,15 +766,6 @@ export async function handleStopCommand(args) {
|
|
|
763
766
|
if (stopIdle) {
|
|
764
767
|
const now = Date.now();
|
|
765
768
|
const registeredSessions = listRegisteredSessions();
|
|
766
|
-
let liveSessions = [];
|
|
767
|
-
if (serviceUp) {
|
|
768
|
-
try {
|
|
769
|
-
const status = await callAPI('getStatus', {});
|
|
770
|
-
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
771
|
-
} catch {
|
|
772
|
-
// Ignore and fallback to local registry.
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
769
|
const regMap = new Map(
|
|
776
770
|
registeredSessions
|
|
777
771
|
.filter((item) => item && String(item?.status || '').trim() === 'active')
|
|
@@ -785,7 +779,7 @@ export async function handleStopCommand(args) {
|
|
|
785
779
|
.map((item) => item.session.profileId),
|
|
786
780
|
);
|
|
787
781
|
let orphanLiveHeadlessCount = 0;
|
|
788
|
-
for (const live of
|
|
782
|
+
for (const live of resolvedSessions.filter((item) => item.live)) {
|
|
789
783
|
const liveProfileId = String(live?.profileId || '').trim();
|
|
790
784
|
if (!liveProfileId) continue;
|
|
791
785
|
if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
|
|
@@ -863,14 +857,15 @@ export async function handleStopCommand(args) {
|
|
|
863
857
|
|
|
864
858
|
export async function handleStatusCommand(args) {
|
|
865
859
|
await ensureBrowserService();
|
|
866
|
-
const result = await callAPI('getStatus', {});
|
|
867
860
|
const profileId = args[1];
|
|
868
861
|
if (profileId && args[0] === 'status') {
|
|
869
|
-
const
|
|
862
|
+
const sessions = await getResolvedSessions();
|
|
863
|
+
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
870
864
|
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
871
865
|
return;
|
|
872
866
|
}
|
|
873
|
-
|
|
867
|
+
const sessions = await getResolvedSessions();
|
|
868
|
+
console.log(JSON.stringify({ ok: true, sessions, count: sessions.length }, null, 2));
|
|
874
869
|
}
|
|
875
870
|
|
|
876
871
|
export async function handleGotoCommand(args) {
|
|
@@ -1201,6 +1196,11 @@ export async function handleListPagesCommand(args) {
|
|
|
1201
1196
|
await ensureBrowserService();
|
|
1202
1197
|
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
1203
1198
|
if (!profileId) throw new Error('Usage: camo list-pages [profileId] (or set default profile first)');
|
|
1199
|
+
const sessions = await getResolvedSessions();
|
|
1200
|
+
const session = sessions.find((item) => item.profileId === profileId) || null;
|
|
1201
|
+
if (!session?.live) {
|
|
1202
|
+
throw new Error(`Profile session not live: ${profileId}. Start via "camo start ${profileId}" first.`);
|
|
1203
|
+
}
|
|
1204
1204
|
const result = await callAPI('page:list', { profileId });
|
|
1205
1205
|
console.log(JSON.stringify(result, null, 2));
|
|
1206
1206
|
}
|
|
@@ -1241,33 +1241,9 @@ export async function handleShutdownCommand() {
|
|
|
1241
1241
|
|
|
1242
1242
|
export async function handleSessionsCommand(args) {
|
|
1243
1243
|
const serviceUp = await checkBrowserService();
|
|
1244
|
+
const merged = await loadResolvedSessions(serviceUp);
|
|
1244
1245
|
const registeredSessions = listRegisteredSessions();
|
|
1245
|
-
|
|
1246
|
-
let liveSessions = [];
|
|
1247
|
-
if (serviceUp) {
|
|
1248
|
-
try {
|
|
1249
|
-
const status = await callAPI('getStatus', {});
|
|
1250
|
-
liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
1251
|
-
} catch {
|
|
1252
|
-
// Service may have just become unavailable
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
// Merge live and registered sessions
|
|
1257
|
-
const liveProfileIds = new Set(liveSessions.map(s => s.profileId));
|
|
1258
|
-
const merged = [...liveSessions];
|
|
1259
|
-
|
|
1260
|
-
// Add registered sessions that are not in live sessions (need recovery)
|
|
1261
|
-
for (const reg of registeredSessions) {
|
|
1262
|
-
if (!liveProfileIds.has(reg.profileId) && reg.status === 'active') {
|
|
1263
|
-
merged.push({
|
|
1264
|
-
...reg,
|
|
1265
|
-
live: false,
|
|
1266
|
-
needsRecovery: true,
|
|
1267
|
-
});
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1246
|
+
|
|
1271
1247
|
console.log(JSON.stringify({
|
|
1272
1248
|
ok: true,
|
|
1273
1249
|
serviceUp,
|
|
@@ -3,84 +3,26 @@
|
|
|
3
3
|
* Lifecycle commands: cleanup, force-stop, lock management, session recovery
|
|
4
4
|
*/
|
|
5
5
|
import { getDefaultProfile } from '../utils/config.mjs';
|
|
6
|
-
import { callAPI, ensureBrowserService, checkBrowserService } from '../utils/browser-service.mjs';
|
|
6
|
+
import { callAPI, ensureBrowserService, checkBrowserService, getResolvedSessions } from '../utils/browser-service.mjs';
|
|
7
7
|
import { resolveProfileId } from '../utils/args.mjs';
|
|
8
8
|
import { acquireLock, getLockInfo, releaseLock, cleanupStaleLocks, listActiveLocks } from '../lifecycle/lock.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
10
|
getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
|
|
11
11
|
listRegisteredSessions, updateSession
|
|
12
12
|
} from '../lifecycle/session-registry.mjs';
|
|
13
13
|
import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const headless = session?.headless === true;
|
|
19
|
-
const timeoutMs = headless
|
|
20
|
-
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
21
|
-
: 0;
|
|
22
|
-
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
23
|
-
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
24
|
-
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
25
|
-
return { headless, timeoutMs, idleMs, idle };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function buildMergedSessionRows(liveSessions, registeredSessions) {
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const regMap = new Map(registeredSessions.map((item) => [item.profileId, item]));
|
|
31
|
-
const rows = [];
|
|
32
|
-
const liveMap = new Map(liveSessions.map((item) => [item.profileId, item]));
|
|
33
|
-
|
|
34
|
-
for (const live of liveSessions) {
|
|
35
|
-
const reg = regMap.get(live.profileId);
|
|
36
|
-
const idle = computeIdleState(reg || {}, now);
|
|
37
|
-
rows.push({
|
|
38
|
-
profileId: live.profileId,
|
|
39
|
-
sessionId: live.session_id || live.profileId,
|
|
40
|
-
instanceId: reg?.instanceId || live.session_id || live.profileId,
|
|
41
|
-
alias: reg?.alias || null,
|
|
42
|
-
url: live.current_url,
|
|
43
|
-
mode: live.mode,
|
|
44
|
-
headless: idle.headless,
|
|
45
|
-
idleTimeoutMs: idle.timeoutMs,
|
|
46
|
-
idleMs: idle.idleMs,
|
|
47
|
-
idle: idle.idle,
|
|
48
|
-
live: true,
|
|
49
|
-
registered: !!reg,
|
|
50
|
-
registryStatus: reg?.status,
|
|
51
|
-
lastSeen: reg?.lastSeen || null,
|
|
52
|
-
lastActivityAt: reg?.lastActivityAt || null,
|
|
53
|
-
});
|
|
15
|
+
function rejectIndirectSessionTargeting(commandName, args) {
|
|
16
|
+
if (args.includes('--id')) {
|
|
17
|
+
throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --id is only supported by "camo stop")`);
|
|
54
18
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (liveMap.has(reg.profileId)) continue;
|
|
58
|
-
if (reg.status === 'closed') continue;
|
|
59
|
-
const idle = computeIdleState(reg, now);
|
|
60
|
-
rows.push({
|
|
61
|
-
profileId: reg.profileId,
|
|
62
|
-
sessionId: reg.sessionId || reg.profileId,
|
|
63
|
-
instanceId: reg.instanceId || reg.sessionId || reg.profileId,
|
|
64
|
-
alias: reg.alias || null,
|
|
65
|
-
url: reg.url || null,
|
|
66
|
-
mode: reg.mode || null,
|
|
67
|
-
headless: idle.headless,
|
|
68
|
-
idleTimeoutMs: idle.timeoutMs,
|
|
69
|
-
idleMs: idle.idleMs,
|
|
70
|
-
idle: idle.idle,
|
|
71
|
-
live: false,
|
|
72
|
-
orphaned: true,
|
|
73
|
-
needsRecovery: reg.status === 'active',
|
|
74
|
-
registered: true,
|
|
75
|
-
registryStatus: reg.status,
|
|
76
|
-
lastSeen: reg.lastSeen || null,
|
|
77
|
-
lastActivityAt: reg.lastActivityAt || null,
|
|
78
|
-
});
|
|
19
|
+
if (args.includes('--alias')) {
|
|
20
|
+
throw new Error(`Usage: camo ${commandName} [profileId] (direct profile only; --alias is only supported by "camo stop")`);
|
|
79
21
|
}
|
|
80
|
-
return rows;
|
|
81
22
|
}
|
|
82
23
|
|
|
83
24
|
export async function handleCleanupCommand(args) {
|
|
25
|
+
rejectIndirectSessionTargeting('cleanup', args);
|
|
84
26
|
const sub = args[1];
|
|
85
27
|
|
|
86
28
|
if (sub === 'locks') {
|
|
@@ -158,6 +100,7 @@ export async function handleCleanupCommand(args) {
|
|
|
158
100
|
}
|
|
159
101
|
|
|
160
102
|
export async function handleForceStopCommand(args) {
|
|
103
|
+
rejectIndirectSessionTargeting('force-stop', args);
|
|
161
104
|
await ensureBrowserService();
|
|
162
105
|
const profileId = resolveProfileId(args, 1, getDefaultProfile);
|
|
163
106
|
if (!profileId) throw new Error('Usage: camo force-stop [profileId]');
|
|
@@ -217,24 +160,45 @@ export async function handleUnlockCommand(args) {
|
|
|
217
160
|
export async function handleSessionsCommand(args) {
|
|
218
161
|
const serviceUp = await checkBrowserService();
|
|
219
162
|
const registeredSessions = listRegisteredSessions();
|
|
220
|
-
|
|
221
|
-
let liveSessions = [];
|
|
163
|
+
let merged = [];
|
|
222
164
|
if (serviceUp) {
|
|
223
165
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
166
|
+
merged = await getResolvedSessions();
|
|
167
|
+
} catch {
|
|
168
|
+
merged = [];
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
merged = registeredSessions
|
|
172
|
+
.filter((item) => String(item?.status || '').trim() !== 'closed')
|
|
173
|
+
.map((item) => ({
|
|
174
|
+
profileId: item.profileId,
|
|
175
|
+
sessionId: item.sessionId || item.profileId,
|
|
176
|
+
instanceId: item.instanceId || item.sessionId || item.profileId,
|
|
177
|
+
alias: item.alias || null,
|
|
178
|
+
url: item.url || null,
|
|
179
|
+
mode: item.mode || null,
|
|
180
|
+
ownerPid: Number(item?.ownerPid || item?.pid || 0) || null,
|
|
181
|
+
headless: item.headless === true,
|
|
182
|
+
idleTimeoutMs: Number(item.idleTimeoutMs || 0) || 0,
|
|
183
|
+
idleMs: 0,
|
|
184
|
+
idle: false,
|
|
185
|
+
live: false,
|
|
186
|
+
registered: true,
|
|
187
|
+
orphaned: true,
|
|
188
|
+
needsRecovery: String(item.status || '').trim() === 'active',
|
|
189
|
+
registryStatus: item.status || null,
|
|
190
|
+
lastSeen: item.lastSeen || null,
|
|
191
|
+
lastActivityAt: item.lastActivityAt || null,
|
|
192
|
+
}));
|
|
227
193
|
}
|
|
228
|
-
|
|
229
|
-
const merged = buildMergedSessionRows(liveSessions, registeredSessions);
|
|
230
|
-
|
|
194
|
+
|
|
231
195
|
console.log(JSON.stringify({
|
|
232
196
|
ok: true,
|
|
233
197
|
serviceUp,
|
|
234
198
|
sessions: merged,
|
|
235
199
|
count: merged.length,
|
|
236
200
|
registered: registeredSessions.length,
|
|
237
|
-
live:
|
|
201
|
+
live: merged.filter((item) => item.live).length,
|
|
238
202
|
orphaned: merged.filter(s => s.orphaned).length,
|
|
239
203
|
}, null, 2));
|
|
240
204
|
}
|
|
@@ -272,24 +236,24 @@ export async function handleRecoverCommand(args) {
|
|
|
272
236
|
|
|
273
237
|
// Service is up - check if session is still there
|
|
274
238
|
try {
|
|
275
|
-
const
|
|
276
|
-
const existing =
|
|
277
|
-
|
|
239
|
+
const sessions = await getResolvedSessions();
|
|
240
|
+
const existing = sessions.find((item) => item.profileId === profileId && item.live);
|
|
241
|
+
|
|
278
242
|
if (existing) {
|
|
279
243
|
// Session is alive - update registry
|
|
280
244
|
updateSession(profileId, {
|
|
281
|
-
sessionId: existing.
|
|
282
|
-
url: existing.
|
|
245
|
+
sessionId: existing.sessionId || existing.profileId,
|
|
246
|
+
url: existing.url,
|
|
283
247
|
status: 'active',
|
|
284
248
|
recoveredAt: Date.now(),
|
|
285
249
|
});
|
|
286
|
-
acquireLock(profileId, { sessionId: existing.
|
|
250
|
+
acquireLock(profileId, { sessionId: existing.sessionId || existing.profileId });
|
|
287
251
|
console.log(JSON.stringify({
|
|
288
252
|
ok: true,
|
|
289
253
|
recovered: true,
|
|
290
254
|
profileId,
|
|
291
|
-
sessionId: existing.
|
|
292
|
-
url: existing.
|
|
255
|
+
sessionId: existing.sessionId || existing.profileId,
|
|
256
|
+
url: existing.url,
|
|
293
257
|
message: 'Session reconnected successfully',
|
|
294
258
|
}, null, 2));
|
|
295
259
|
} else {
|
|
@@ -25,6 +25,11 @@ function normalizeAlias(value) {
|
|
|
25
25
|
return text.slice(0, 64);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function isAliasProtectedStatus(status) {
|
|
29
|
+
const normalized = String(status || '').trim().toLowerCase();
|
|
30
|
+
return normalized === 'active' || normalized === 'reconnecting';
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
function normalizeTimeoutMs(value) {
|
|
29
34
|
const ms = Number(value);
|
|
30
35
|
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
@@ -159,10 +164,21 @@ export function resolveSessionTarget(target) {
|
|
|
159
164
|
const sessions = listRegisteredSessions();
|
|
160
165
|
const byProfile = sessions.find((item) => String(item?.profileId || '').trim() === value);
|
|
161
166
|
if (byProfile) return { profileId: byProfile.profileId, reason: 'profile', session: byProfile };
|
|
162
|
-
const byInstanceId = sessions.
|
|
163
|
-
if (byInstanceId
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
const byInstanceId = sessions.filter((item) => String(item?.instanceId || '').trim() === value);
|
|
168
|
+
if (byInstanceId.length > 1) {
|
|
169
|
+
throw new Error(`Ambiguous instance id target: ${value}`);
|
|
170
|
+
}
|
|
171
|
+
if (byInstanceId.length === 1) {
|
|
172
|
+
return { profileId: byInstanceId[0].profileId, reason: 'instanceId', session: byInstanceId[0] };
|
|
173
|
+
}
|
|
174
|
+
const normalizedAlias = normalizeAlias(value);
|
|
175
|
+
const byAlias = sessions.filter((item) => normalizeAlias(item?.alias) === normalizedAlias && isAliasProtectedStatus(item?.status));
|
|
176
|
+
if (byAlias.length > 1) {
|
|
177
|
+
throw new Error(`Ambiguous alias target: ${value}`);
|
|
178
|
+
}
|
|
179
|
+
if (byAlias.length === 1) {
|
|
180
|
+
return { profileId: byAlias[0].profileId, reason: 'alias', session: byAlias[0] };
|
|
181
|
+
}
|
|
166
182
|
return null;
|
|
167
183
|
}
|
|
168
184
|
|
|
@@ -173,7 +189,7 @@ export function isSessionAliasTaken(alias, exceptProfileId = '') {
|
|
|
173
189
|
return listRegisteredSessions().some((item) => {
|
|
174
190
|
if (!item) return false;
|
|
175
191
|
if (except && String(item.profileId || '').trim() === except) return false;
|
|
176
|
-
if (
|
|
192
|
+
if (!isAliasProtectedStatus(item.status)) return false;
|
|
177
193
|
return normalizeAlias(item.alias) === target;
|
|
178
194
|
});
|
|
179
195
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { listRegisteredSessions } from './session-registry.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function computeIdleState(session, now = Date.now()) {
|
|
7
|
+
const headless = session?.headless === true;
|
|
8
|
+
const timeoutMs = headless
|
|
9
|
+
? (Number.isFinite(Number(session?.idleTimeoutMs)) ? Math.max(0, Number(session.idleTimeoutMs)) : DEFAULT_HEADLESS_IDLE_TIMEOUT_MS)
|
|
10
|
+
: 0;
|
|
11
|
+
const lastAt = Number(session?.lastActivityAt || session?.lastSeen || session?.startTime || now);
|
|
12
|
+
const idleMs = Math.max(0, now - (Number.isFinite(lastAt) ? lastAt : now));
|
|
13
|
+
const idle = headless && timeoutMs > 0 && idleMs >= timeoutMs;
|
|
14
|
+
return { headless, timeoutMs, idleMs, idle };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeLiveSession(session) {
|
|
18
|
+
if (!session) return null;
|
|
19
|
+
const profileId = String(session.profileId || session.session_id || '').trim();
|
|
20
|
+
if (!profileId) return null;
|
|
21
|
+
return {
|
|
22
|
+
profileId,
|
|
23
|
+
sessionId: session.session_id || session.sessionId || profileId,
|
|
24
|
+
url: session.current_url || session.url || null,
|
|
25
|
+
mode: session.mode || null,
|
|
26
|
+
ownerPid: Number(session.owner_pid || session.ownerPid || 0) || null,
|
|
27
|
+
live: true,
|
|
28
|
+
raw: session,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildResolvedSessionView(liveSessions = [], registeredSessions = listRegisteredSessions()) {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const regMap = new Map(registeredSessions.map((item) => [String(item?.profileId || '').trim(), item]).filter(([key]) => key));
|
|
35
|
+
const liveMap = new Map(liveSessions.map(normalizeLiveSession).filter(Boolean).map((item) => [item.profileId, item]));
|
|
36
|
+
const profileIds = new Set([...liveMap.keys(), ...regMap.keys()]);
|
|
37
|
+
const rows = [];
|
|
38
|
+
|
|
39
|
+
for (const profileId of profileIds) {
|
|
40
|
+
const live = liveMap.get(profileId) || null;
|
|
41
|
+
const reg = regMap.get(profileId) || null;
|
|
42
|
+
if (!live && reg && String(reg.status || '').trim() === 'closed') continue;
|
|
43
|
+
const idle = computeIdleState(reg || live || {}, now);
|
|
44
|
+
const registryStatus = reg?.status || null;
|
|
45
|
+
const row = {
|
|
46
|
+
profileId,
|
|
47
|
+
sessionId: live?.sessionId || reg?.sessionId || reg?.instanceId || profileId,
|
|
48
|
+
instanceId: reg?.instanceId || live?.sessionId || profileId,
|
|
49
|
+
alias: reg?.alias || null,
|
|
50
|
+
url: live?.url || reg?.url || null,
|
|
51
|
+
mode: live?.mode || reg?.mode || null,
|
|
52
|
+
ownerPid: live?.ownerPid || Number(reg?.ownerPid || reg?.pid || 0) || null,
|
|
53
|
+
headless: idle.headless,
|
|
54
|
+
idleTimeoutMs: idle.timeoutMs,
|
|
55
|
+
idleMs: idle.idleMs,
|
|
56
|
+
idle: idle.idle,
|
|
57
|
+
live: Boolean(live),
|
|
58
|
+
registered: Boolean(reg),
|
|
59
|
+
orphaned: Boolean(reg) && !live,
|
|
60
|
+
needsRecovery: Boolean(reg) && !live && registryStatus === 'active',
|
|
61
|
+
registryStatus,
|
|
62
|
+
lastSeen: reg?.lastSeen || null,
|
|
63
|
+
lastActivityAt: reg?.lastActivityAt || null,
|
|
64
|
+
};
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return rows.sort((a, b) => String(a.profileId).localeCompare(String(b.profileId)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveSessionViewByProfile(profileId, liveSessions = [], registeredSessions = listRegisteredSessions()) {
|
|
72
|
+
const id = String(profileId || '').trim();
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
return buildResolvedSessionView(liveSessions, registeredSessions).find((item) => item.profileId === id) || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -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
|
+
});
|
|
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
|
|
|
7
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
|
+
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
@@ -151,16 +152,21 @@ export async function callAPI(action, payload = {}, options = {}) {
|
|
|
151
152
|
|
|
152
153
|
export async function getSessionByProfile(profileId) {
|
|
153
154
|
const status = await callAPI('getStatus', {});
|
|
154
|
-
const activeSession = status?.sessions?.find((s) => s.profileId === profileId) || null;
|
|
155
|
-
if (activeSession) {
|
|
156
|
-
return activeSession;
|
|
157
|
-
}
|
|
158
155
|
if (!profileId) {
|
|
159
156
|
return null;
|
|
160
157
|
}
|
|
158
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
159
|
+
const resolved = resolveSessionViewByProfile(profileId, liveSessions);
|
|
160
|
+
if (resolved?.live) {
|
|
161
|
+
const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
|
|
162
|
+
if (activeSession) return activeSession;
|
|
163
|
+
}
|
|
164
|
+
if (!resolved?.live) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
161
167
|
|
|
162
|
-
// Some browser-service builds do not populate
|
|
163
|
-
// Fallback to page:list
|
|
168
|
+
// Some browser-service builds do not populate current_url reliably.
|
|
169
|
+
// Fallback to page:list only to enrich an already-live profile.
|
|
164
170
|
try {
|
|
165
171
|
const pagePayload = await callAPI('page:list', { profileId });
|
|
166
172
|
const pages = Array.isArray(pagePayload?.pages)
|
|
@@ -185,6 +191,12 @@ export async function getSessionByProfile(profileId) {
|
|
|
185
191
|
}
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
export async function getResolvedSessions() {
|
|
195
|
+
const status = await callAPI('getStatus', {});
|
|
196
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
197
|
+
return buildResolvedSessionView(liveSessions);
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
function buildDomSnapshotScript(maxDepth, maxChildren) {
|
|
189
201
|
return `(() => {
|
|
190
202
|
const MAX_DEPTH = ${maxDepth};
|
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) {
|
package/src/utils/help.mjs
CHANGED
|
@@ -32,16 +32,16 @@ BROWSER CONTROL:
|
|
|
32
32
|
stop --alias <alias> Stop by alias
|
|
33
33
|
stop idle Stop all idle sessions
|
|
34
34
|
stop all Stop all sessions
|
|
35
|
-
status [profileId]
|
|
35
|
+
status [profileId] Show resolved per-profile session view
|
|
36
36
|
list Alias of status
|
|
37
37
|
|
|
38
38
|
LIFECYCLE & CLEANUP:
|
|
39
|
-
instances List
|
|
40
|
-
sessions List
|
|
41
|
-
cleanup [profileId] Cleanup
|
|
39
|
+
instances List resolved session view (live + registered + idle state)
|
|
40
|
+
sessions List resolved session view for all profiles
|
|
41
|
+
cleanup [profileId] Cleanup only one profile (remote stop + local registry/lock/watchdog)
|
|
42
42
|
cleanup all Cleanup all active sessions
|
|
43
43
|
cleanup locks Cleanup stale lock files
|
|
44
|
-
force-stop [profileId] Force stop session (
|
|
44
|
+
force-stop [profileId] Force stop only one profile session (no alias/id targeting)
|
|
45
45
|
lock list List active session locks
|
|
46
46
|
lock [profileId] Show lock info for profile
|
|
47
47
|
unlock [profileId] Release lock for profile
|
|
@@ -63,7 +63,7 @@ PAGES:
|
|
|
63
63
|
new-page [profileId] [--url <url>]
|
|
64
64
|
close-page [profileId] [index]
|
|
65
65
|
switch-page [profileId] <index>
|
|
66
|
-
list-pages [profileId]
|
|
66
|
+
list-pages [profileId] List pages only when that profile is live
|
|
67
67
|
|
|
68
68
|
DEVTOOLS:
|
|
69
69
|
devtools logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
|
|
@@ -184,6 +184,27 @@ ENV:
|
|
|
184
184
|
CAMO_SKIP_BRING_TO_FRONT Legacy alias for CAMO_BRING_TO_FRONT_MODE=never
|
|
185
185
|
CAMO_PROGRESS_EVENTS_FILE Optional path override for progress jsonl
|
|
186
186
|
CAMO_PROGRESS_WS_HOST / CAMO_PROGRESS_WS_PORT Progress daemon host/port (defaults: 127.0.0.1:7788)
|
|
187
|
+
|
|
188
|
+
SESSION ISOLATION:
|
|
189
|
+
- profile is the lifecycle primary key. browser-service, lock, watchdog, registry all bind to the same profileId.
|
|
190
|
+
- camo start/stop/cleanup/force-stop <profileId> only targets that exact profile and must not affect other profiles.
|
|
191
|
+
- stop --id / stop --alias are convenience selectors for stop only; cleanup/force-stop never accept alias or instance id.
|
|
192
|
+
- status/sessions/instances all read the same resolved session view with these fields:
|
|
193
|
+
live: browser-service currently has this profile session
|
|
194
|
+
registered: local registry has metadata for this profile
|
|
195
|
+
orphaned: registry exists but service session is gone
|
|
196
|
+
needsRecovery: registry says active but service no longer has that profile
|
|
197
|
+
- list-pages requires live=true for that profile; otherwise camo will fail fast instead of probing other profiles.
|
|
198
|
+
|
|
199
|
+
ISOLATION EXAMPLES:
|
|
200
|
+
camo sessions
|
|
201
|
+
camo status finger
|
|
202
|
+
camo stop finger
|
|
203
|
+
camo status xhs-qa-1
|
|
204
|
+
camo cleanup finger
|
|
205
|
+
camo force-stop finger
|
|
206
|
+
invalid: camo cleanup --alias shard1
|
|
207
|
+
invalid: camo force-stop --id inst_xxxxxxxx
|
|
187
208
|
`);
|
|
188
209
|
}
|
|
189
210
|
|