@agentrhq/webcmd 0.3.1 → 0.3.3
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 +13 -6
- package/cli-manifest.json +11 -11
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +3 -3
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/clis/spotify/spotify.js +11 -11
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/availability.test.js +12 -1
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +45 -34
- package/package.json +3 -2
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
|
@@ -10,7 +10,7 @@ class CloakActionError extends Error {
|
|
|
10
10
|
this.errorHint = errorHint;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
function
|
|
13
|
+
export function resolveCloakCommandProfileId(manager, command) {
|
|
14
14
|
const requested = command.profileId ?? command.contextId;
|
|
15
15
|
if (requested?.trim())
|
|
16
16
|
return requested.trim();
|
|
@@ -38,7 +38,7 @@ async function resolveLease(manager, command) {
|
|
|
38
38
|
throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`);
|
|
39
39
|
}
|
|
40
40
|
return manager.getPage({
|
|
41
|
-
profileId:
|
|
41
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
42
42
|
session: command.session,
|
|
43
43
|
surface: command.surface,
|
|
44
44
|
siteSession: command.siteSession,
|
|
@@ -109,12 +109,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
109
109
|
}
|
|
110
110
|
case 'close-window': {
|
|
111
111
|
if (command.page) {
|
|
112
|
-
const closed = await manager.closePage({ profileId:
|
|
112
|
+
const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page });
|
|
113
113
|
return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } };
|
|
114
114
|
}
|
|
115
115
|
else {
|
|
116
116
|
await manager.release({
|
|
117
|
-
profileId:
|
|
117
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
118
118
|
session: command.session,
|
|
119
119
|
surface: command.surface,
|
|
120
120
|
});
|
|
@@ -124,12 +124,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
124
124
|
case 'tabs': {
|
|
125
125
|
switch (command.op ?? 'list') {
|
|
126
126
|
case 'list': {
|
|
127
|
-
const tabs = await manager.listPages({ profileId:
|
|
127
|
+
const tabs = await manager.listPages({ profileId: resolveCloakCommandProfileId(manager, command) });
|
|
128
128
|
return { id: command.id, ok: true, data: tabs };
|
|
129
129
|
}
|
|
130
130
|
case 'new': {
|
|
131
131
|
const lease = await manager.newPage({
|
|
132
|
-
profileId:
|
|
132
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
133
133
|
session: command.session,
|
|
134
134
|
surface: command.surface,
|
|
135
135
|
siteSession: command.siteSession,
|
|
@@ -140,13 +140,13 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
140
140
|
return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
|
|
141
141
|
}
|
|
142
142
|
case 'select': {
|
|
143
|
-
const lease = await manager.selectPage({ profileId:
|
|
143
|
+
const lease = await manager.selectPage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
|
|
144
144
|
if (!lease)
|
|
145
145
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
146
146
|
return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
|
|
147
147
|
}
|
|
148
148
|
case 'close': {
|
|
149
|
-
const closed = await manager.closePage({ profileId:
|
|
149
|
+
const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index });
|
|
150
150
|
if (!closed)
|
|
151
151
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
152
152
|
return { id: command.id, ok: true, data: { closed } };
|
|
@@ -214,7 +214,7 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
214
214
|
}
|
|
215
215
|
{
|
|
216
216
|
const lease = await manager.bindPage({
|
|
217
|
-
profileId:
|
|
217
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
218
218
|
session: command.session,
|
|
219
219
|
surface: command.surface,
|
|
220
220
|
siteSession: command.siteSession,
|
|
@@ -10,6 +10,7 @@ export declare class LocalCloakRuntimeProvider implements BrowserRuntimeProvider
|
|
|
10
10
|
private readonly manager;
|
|
11
11
|
constructor(opts?: LocalCloakRuntimeProviderOptions);
|
|
12
12
|
status(_opts?: RuntimeStatusOptions): Promise<BrowserRuntimeStatus>;
|
|
13
|
+
resolveProfileId(command: BrowserRuntimeCommand): string;
|
|
13
14
|
dispatch(command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
|
|
14
15
|
shutdown(): Promise<void>;
|
|
15
16
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { dispatchCloakAction } from './actions.js';
|
|
2
|
-
import { CloakSessionManager } from './session-manager.js';
|
|
1
|
+
import { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js';
|
|
2
|
+
import { CloakSessionManager, resolveCloakBrowserVersion } from './session-manager.js';
|
|
3
3
|
export class LocalCloakRuntimeProvider {
|
|
4
4
|
opts;
|
|
5
5
|
manager;
|
|
@@ -12,12 +12,15 @@ export class LocalCloakRuntimeProvider {
|
|
|
12
12
|
return {
|
|
13
13
|
runtimeConnected: true,
|
|
14
14
|
runtimeName: 'cloak',
|
|
15
|
-
runtimeVersion:
|
|
15
|
+
runtimeVersion: resolveCloakBrowserVersion(),
|
|
16
16
|
profiles,
|
|
17
17
|
pending: 0,
|
|
18
18
|
commandResultUnknown: 0,
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
resolveProfileId(command) {
|
|
22
|
+
return resolveCloakCommandProfileId(this.manager, command);
|
|
23
|
+
}
|
|
21
24
|
async dispatch(command) {
|
|
22
25
|
return dispatchCloakAction(this.manager, command);
|
|
23
26
|
}
|
|
@@ -27,6 +27,7 @@ function fakePage(url) {
|
|
|
27
27
|
function makeProviderWithFakePage() {
|
|
28
28
|
const pages = [fakePage('https://example.com/')];
|
|
29
29
|
const context = {
|
|
30
|
+
on: vi.fn(),
|
|
30
31
|
pages: vi.fn(() => pages.filter((page) => !page.isClosed())),
|
|
31
32
|
newPage: vi.fn(async () => {
|
|
32
33
|
const page = fakePage('about:blank');
|
|
@@ -3,6 +3,8 @@ import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbr
|
|
|
3
3
|
import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js';
|
|
4
4
|
import { activateDarwinBackgroundContext } from './darwin-background-launch.js';
|
|
5
5
|
import { CloakNetworkCapture } from './network.js';
|
|
6
|
+
/** Installed `cloakbrowser` npm package version, for doctor/status display. */
|
|
7
|
+
export declare function resolveCloakBrowserVersion(): string | undefined;
|
|
6
8
|
export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
|
|
7
9
|
export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
|
|
8
10
|
export interface SessionKeyInput {
|
|
@@ -85,6 +87,9 @@ export declare class CloakSessionManager {
|
|
|
85
87
|
shutdown(): Promise<void>;
|
|
86
88
|
private getProfileRuntime;
|
|
87
89
|
private launchProfileRuntime;
|
|
90
|
+
private invalidateProfileRuntime;
|
|
91
|
+
private attachRuntimeLifecycle;
|
|
92
|
+
private createPageWithRecovery;
|
|
88
93
|
private openEntries;
|
|
89
94
|
private findEntryByPageId;
|
|
90
95
|
private refreshIdleTimer;
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
import { execFile } from 'node:child_process';
|
|
3
5
|
import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
|
|
4
6
|
import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js';
|
|
5
7
|
import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
|
|
6
8
|
import { CloakNetworkCapture } from './network.js';
|
|
9
|
+
import { findPackageRoot } from '../../../package-paths.js';
|
|
10
|
+
/** Installed `cloakbrowser` npm package version, for doctor/status display. */
|
|
11
|
+
export function resolveCloakBrowserVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const entryPath = fileURLToPath(import.meta.resolve('cloakbrowser'));
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8'));
|
|
15
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
let pageCounter = 0;
|
|
8
22
|
export function resolveLeaseKey(input) {
|
|
9
23
|
const surface = input.surface === 'adapter' ? 'adapter' : 'browser';
|
|
@@ -15,6 +29,10 @@ export function resolveLeaseKey(input) {
|
|
|
15
29
|
function pageIsClosed(page) {
|
|
16
30
|
return page.isClosed?.() === true;
|
|
17
31
|
}
|
|
32
|
+
function isClosedContextError(error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return /Target page, context or browser has been closed/i.test(message);
|
|
35
|
+
}
|
|
18
36
|
export class CloakSessionManager {
|
|
19
37
|
opts;
|
|
20
38
|
networkCapture = new CloakNetworkCapture();
|
|
@@ -37,7 +55,7 @@ export class CloakSessionManager {
|
|
|
37
55
|
return [...this.profiles.entries()].map(([contextId, runtime]) => ({
|
|
38
56
|
contextId,
|
|
39
57
|
runtimeConnected: true,
|
|
40
|
-
runtimeVersion:
|
|
58
|
+
runtimeVersion: resolveCloakBrowserVersion(),
|
|
41
59
|
pending: 0,
|
|
42
60
|
lastSeenAt: runtime.lastSeenAt,
|
|
43
61
|
}));
|
|
@@ -67,16 +85,21 @@ export class CloakSessionManager {
|
|
|
67
85
|
if (!pageIsClosed(existing.page))
|
|
68
86
|
await existing.page.close().catch(() => { });
|
|
69
87
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
return this.createPageWithRecovery(profileId, input.windowMode, (candidate) => {
|
|
89
|
+
const existingPages = candidate.context.pages();
|
|
90
|
+
// freshPage must never adopt a leftover tab — its whole point is a clean DOM.
|
|
91
|
+
return !freshPage && existingPages[0] && candidate.pages.size === 0
|
|
92
|
+
? existingPages[0]
|
|
93
|
+
: candidate.context.newPage();
|
|
94
|
+
}, (candidate, page) => {
|
|
95
|
+
const pageId = nextPageId();
|
|
96
|
+
const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
|
|
97
|
+
candidate.pages.set(leaseKey, entry);
|
|
98
|
+
this.refreshIdleTimer(candidate, leaseKey, entry);
|
|
99
|
+
candidate.selectedPageId = pageId;
|
|
100
|
+
candidate.lastSeenAt = Date.now();
|
|
101
|
+
return { profileId, leaseKey, context: candidate.context, page, pageId };
|
|
102
|
+
});
|
|
80
103
|
}
|
|
81
104
|
findPageById(pageId, opts = {}) {
|
|
82
105
|
for (const [profileId, runtime] of this.profiles.entries()) {
|
|
@@ -137,18 +160,36 @@ export class CloakSessionManager {
|
|
|
137
160
|
const profileId = normalizeProfileId(input.profileId);
|
|
138
161
|
const session = requireSession(input.session);
|
|
139
162
|
const surface = normalizeSurface(input.surface);
|
|
140
|
-
const
|
|
141
|
-
const page = await runtime.context.newPage();
|
|
163
|
+
const acquired = await this.createPageWithRecovery(profileId, input.windowMode, (candidate) => candidate.context.newPage(), (runtime, page) => ({ runtime, page }));
|
|
142
164
|
if (input.url) {
|
|
143
|
-
|
|
165
|
+
try {
|
|
166
|
+
await acquired.page.goto(input.url, { waitUntil: 'load' });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!pageIsClosed(acquired.page))
|
|
170
|
+
await acquired.page.close().catch(() => { });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (this.profiles.get(profileId) !== acquired.runtime) {
|
|
175
|
+
if (!pageIsClosed(acquired.page))
|
|
176
|
+
await acquired.page.close().catch(() => { });
|
|
177
|
+
throw new Error('Target page, context or browser has been closed');
|
|
144
178
|
}
|
|
145
179
|
const pageId = nextPageId();
|
|
146
180
|
const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`;
|
|
147
|
-
const entry = {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
181
|
+
const entry = {
|
|
182
|
+
page: acquired.page,
|
|
183
|
+
pageId,
|
|
184
|
+
session,
|
|
185
|
+
surface,
|
|
186
|
+
siteSession: input.siteSession,
|
|
187
|
+
idleTimeout: input.idleTimeout,
|
|
188
|
+
};
|
|
189
|
+
acquired.runtime.pages.set(leaseKey, entry);
|
|
190
|
+
this.refreshIdleTimer(acquired.runtime, leaseKey, entry);
|
|
191
|
+
acquired.runtime.lastSeenAt = Date.now();
|
|
192
|
+
return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId };
|
|
152
193
|
}
|
|
153
194
|
async selectPage(input) {
|
|
154
195
|
const profileId = normalizeProfileId(input.profileId);
|
|
@@ -285,9 +326,44 @@ export class CloakSessionManager {
|
|
|
285
326
|
context = await launchPersistentContext(launchOptions);
|
|
286
327
|
}
|
|
287
328
|
const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
|
|
329
|
+
this.attachRuntimeLifecycle(profileId, runtime);
|
|
288
330
|
this.profiles.set(profileId, runtime);
|
|
289
331
|
return runtime;
|
|
290
332
|
}
|
|
333
|
+
invalidateProfileRuntime(profileId, runtime) {
|
|
334
|
+
if (this.profiles.get(profileId) !== runtime)
|
|
335
|
+
return;
|
|
336
|
+
this.profiles.delete(profileId);
|
|
337
|
+
for (const entry of runtime.pages.values()) {
|
|
338
|
+
if (entry.idleTimer)
|
|
339
|
+
clearTimeout(entry.idleTimer);
|
|
340
|
+
this.networkCapture.stop(entry.page);
|
|
341
|
+
}
|
|
342
|
+
runtime.pages.clear();
|
|
343
|
+
runtime.selectedPageId = undefined;
|
|
344
|
+
}
|
|
345
|
+
attachRuntimeLifecycle(profileId, runtime) {
|
|
346
|
+
runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime));
|
|
347
|
+
}
|
|
348
|
+
async createPageWithRecovery(profileId, windowMode, createPage, commitPage, attempt = 0) {
|
|
349
|
+
const runtime = await this.getProfileRuntime(profileId, windowMode);
|
|
350
|
+
let page;
|
|
351
|
+
try {
|
|
352
|
+
page = await createPage(runtime);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (attempt !== 0 || !isClosedContextError(error))
|
|
356
|
+
throw error;
|
|
357
|
+
this.invalidateProfileRuntime(profileId, runtime);
|
|
358
|
+
return this.createPageWithRecovery(profileId, windowMode, createPage, commitPage, 1);
|
|
359
|
+
}
|
|
360
|
+
if (this.profiles.get(profileId) !== runtime) {
|
|
361
|
+
if (!pageIsClosed(page))
|
|
362
|
+
await page.close().catch(() => { });
|
|
363
|
+
throw new Error('Target page, context or browser has been closed');
|
|
364
|
+
}
|
|
365
|
+
return commitPage(runtime, page);
|
|
366
|
+
}
|
|
291
367
|
openEntries(runtime) {
|
|
292
368
|
return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page));
|
|
293
369
|
}
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { CloakSessionManager } from './session-manager.js';
|
|
4
4
|
import { dispatchCloakAction } from './actions.js';
|
|
5
5
|
function fakeContext() {
|
|
6
|
+
const listeners = new Map();
|
|
6
7
|
const page = {
|
|
7
8
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
8
9
|
evaluate: vi.fn().mockResolvedValue('ok'),
|
|
@@ -14,6 +15,15 @@ function fakeContext() {
|
|
|
14
15
|
};
|
|
15
16
|
return {
|
|
16
17
|
context: {
|
|
18
|
+
on(event, listener) {
|
|
19
|
+
const bucket = listeners.get(event) ?? new Set();
|
|
20
|
+
bucket.add(listener);
|
|
21
|
+
listeners.set(event, bucket);
|
|
22
|
+
},
|
|
23
|
+
emit(event) {
|
|
24
|
+
for (const listener of listeners.get(event) ?? [])
|
|
25
|
+
listener();
|
|
26
|
+
},
|
|
17
27
|
pages: vi.fn().mockReturnValue([page]),
|
|
18
28
|
newPage: vi.fn().mockResolvedValue(page),
|
|
19
29
|
cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]),
|
|
@@ -98,6 +108,229 @@ describe('CloakSessionManager', () => {
|
|
|
98
108
|
expect(second.context).toBe(launched.context);
|
|
99
109
|
expect(first.page).toBe(second.page);
|
|
100
110
|
});
|
|
111
|
+
it('evicts a closed runtime and clears every tracked page resource', async () => {
|
|
112
|
+
vi.useFakeTimers();
|
|
113
|
+
const launched = fakeContext();
|
|
114
|
+
const secondPage = fakeContext().page;
|
|
115
|
+
launched.context.newPage.mockResolvedValue(secondPage);
|
|
116
|
+
const manager = new CloakSessionManager({
|
|
117
|
+
baseDir: '/tmp/webcmd-test',
|
|
118
|
+
launchPersistentContext: vi.fn().mockResolvedValue(launched.context),
|
|
119
|
+
});
|
|
120
|
+
const stopCapture = vi.spyOn(manager.networkCapture, 'stop');
|
|
121
|
+
const first = await manager.getPage({ profileId: 'default', session: 'one', surface: 'browser', idleTimeout: 25 });
|
|
122
|
+
const second = await manager.newPage({ profileId: 'default', session: 'two', surface: 'browser', idleTimeout: 25 });
|
|
123
|
+
expect(manager.activeProfileIds()).toEqual(['default']);
|
|
124
|
+
expect(vi.getTimerCount()).toBe(2);
|
|
125
|
+
launched.context.emit('close');
|
|
126
|
+
expect(manager.activeProfileIds()).toEqual([]);
|
|
127
|
+
expect(manager.profileStatuses()).toEqual([]);
|
|
128
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
129
|
+
expect(stopCapture).toHaveBeenCalledTimes(2);
|
|
130
|
+
expect(stopCapture).toHaveBeenCalledWith(first.page);
|
|
131
|
+
expect(stopCapture).toHaveBeenCalledWith(second.page);
|
|
132
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
133
|
+
expect(first.page.close).not.toHaveBeenCalled();
|
|
134
|
+
expect(second.page.close).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
it('does not let a late close from an old runtime evict its replacement', async () => {
|
|
137
|
+
const first = fakeContext();
|
|
138
|
+
const replacement = fakeContext();
|
|
139
|
+
const launchPersistentContext = vi.fn()
|
|
140
|
+
.mockResolvedValueOnce(first.context)
|
|
141
|
+
.mockResolvedValueOnce(replacement.context);
|
|
142
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
143
|
+
await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' });
|
|
144
|
+
first.context.emit('close');
|
|
145
|
+
const replacementLease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' });
|
|
146
|
+
first.context.emit('close');
|
|
147
|
+
expect(manager.activeProfileIds()).toEqual(['default']);
|
|
148
|
+
expect(manager.profileStatuses()).toHaveLength(1);
|
|
149
|
+
expect((await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' })).context)
|
|
150
|
+
.toBe(replacementLease.context);
|
|
151
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
152
|
+
});
|
|
153
|
+
it('coalesces simultaneous replacement launches after a context closes', async () => {
|
|
154
|
+
const first = fakeContext();
|
|
155
|
+
const replacement = fakeContext();
|
|
156
|
+
let resolveReplacement;
|
|
157
|
+
const launchPersistentContext = vi.fn()
|
|
158
|
+
.mockResolvedValueOnce(first.context)
|
|
159
|
+
.mockImplementationOnce(() => new Promise((resolve) => {
|
|
160
|
+
resolveReplacement = resolve;
|
|
161
|
+
}));
|
|
162
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
163
|
+
await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser' });
|
|
164
|
+
first.context.emit('close');
|
|
165
|
+
const one = manager.getPage({ profileId: 'default', session: 'one', surface: 'browser' });
|
|
166
|
+
const two = manager.getPage({ profileId: 'default', session: 'two', surface: 'browser' });
|
|
167
|
+
await Promise.resolve();
|
|
168
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
169
|
+
resolveReplacement(replacement.context);
|
|
170
|
+
const leases = await Promise.all([one, two]);
|
|
171
|
+
expect(leases[0].context).toBe(replacement.context);
|
|
172
|
+
expect(leases[1].context).toBe(replacement.context);
|
|
173
|
+
});
|
|
174
|
+
it('discards a page created after its runtime closes and defers recovery to the next command', async () => {
|
|
175
|
+
const first = fakeContext();
|
|
176
|
+
first.context.pages.mockReturnValue([]);
|
|
177
|
+
let resolveFirstPage;
|
|
178
|
+
let markPageCreationStarted;
|
|
179
|
+
const pageCreationStarted = new Promise((resolve) => {
|
|
180
|
+
markPageCreationStarted = resolve;
|
|
181
|
+
});
|
|
182
|
+
first.context.newPage.mockImplementation(() => {
|
|
183
|
+
markPageCreationStarted();
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
resolveFirstPage = resolve;
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
const replacement = fakeContext();
|
|
189
|
+
replacement.context.pages.mockReturnValue([]);
|
|
190
|
+
const launchPersistentContext = vi.fn()
|
|
191
|
+
.mockResolvedValueOnce(first.context)
|
|
192
|
+
.mockResolvedValueOnce(replacement.context);
|
|
193
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
194
|
+
const pendingLease = manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
195
|
+
await pageCreationStarted;
|
|
196
|
+
first.context.emit('close');
|
|
197
|
+
resolveFirstPage(first.page);
|
|
198
|
+
await expect(pendingLease).rejects.toThrow('Target page, context or browser has been closed');
|
|
199
|
+
expect(first.page.close).toHaveBeenCalled();
|
|
200
|
+
expect(manager.activeProfileIds()).toEqual([]);
|
|
201
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(1);
|
|
202
|
+
const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
203
|
+
expect(lease.context).toBe(replacement.context);
|
|
204
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
205
|
+
});
|
|
206
|
+
it('does not publish an orphaned page after acquisition validation', async () => {
|
|
207
|
+
vi.useFakeTimers();
|
|
208
|
+
const first = fakeContext();
|
|
209
|
+
first.context.pages.mockReturnValue([]);
|
|
210
|
+
first.context.newPage.mockImplementation(() => ({
|
|
211
|
+
then(resolve) {
|
|
212
|
+
resolve(first.page);
|
|
213
|
+
queueMicrotask(() => first.context.emit('close'));
|
|
214
|
+
},
|
|
215
|
+
}));
|
|
216
|
+
const replacement = fakeContext();
|
|
217
|
+
replacement.context.pages.mockReturnValue([]);
|
|
218
|
+
const launchPersistentContext = vi.fn()
|
|
219
|
+
.mockResolvedValueOnce(first.context)
|
|
220
|
+
.mockResolvedValueOnce(replacement.context);
|
|
221
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
222
|
+
await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 });
|
|
223
|
+
expect(manager.activeProfileIds()).toEqual([]);
|
|
224
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
225
|
+
const lease = await manager.getPage({ profileId: 'default', session: 'replacement', surface: 'browser' });
|
|
226
|
+
expect(lease.context).toBe(replacement.context);
|
|
227
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
228
|
+
});
|
|
229
|
+
it('retries getPage page creation once after a closed-context failure', async () => {
|
|
230
|
+
const closed = new Error('Target page, context or browser has been closed');
|
|
231
|
+
const first = fakeContext();
|
|
232
|
+
first.context.pages.mockReturnValue([]);
|
|
233
|
+
first.context.newPage.mockRejectedValue(closed);
|
|
234
|
+
const replacement = fakeContext();
|
|
235
|
+
replacement.context.pages.mockReturnValue([]);
|
|
236
|
+
const launchPersistentContext = vi.fn()
|
|
237
|
+
.mockResolvedValueOnce(first.context)
|
|
238
|
+
.mockResolvedValueOnce(replacement.context);
|
|
239
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
240
|
+
const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
241
|
+
expect(lease.context).toBe(replacement.context);
|
|
242
|
+
expect(first.context.newPage).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
245
|
+
});
|
|
246
|
+
it('retries explicit newPage page creation once after a closed-context failure', async () => {
|
|
247
|
+
const closed = new Error('browserContext.newPage: Target page, context or browser has been closed');
|
|
248
|
+
const first = fakeContext();
|
|
249
|
+
first.context.newPage.mockRejectedValue(closed);
|
|
250
|
+
const replacement = fakeContext();
|
|
251
|
+
const launchPersistentContext = vi.fn()
|
|
252
|
+
.mockResolvedValueOnce(first.context)
|
|
253
|
+
.mockResolvedValueOnce(replacement.context);
|
|
254
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
255
|
+
const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' });
|
|
256
|
+
expect(lease.context).toBe(replacement.context);
|
|
257
|
+
expect(first.context.newPage).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
260
|
+
});
|
|
261
|
+
it('returns the second closed-context page creation failure without looping', async () => {
|
|
262
|
+
const firstFailure = new Error('Target page, context or browser has been closed');
|
|
263
|
+
const secondFailure = new Error('Target page, context or browser has been closed again');
|
|
264
|
+
const first = fakeContext();
|
|
265
|
+
first.context.newPage.mockRejectedValue(firstFailure);
|
|
266
|
+
const replacement = fakeContext();
|
|
267
|
+
replacement.context.newPage.mockRejectedValue(secondFailure);
|
|
268
|
+
const launchPersistentContext = vi.fn()
|
|
269
|
+
.mockResolvedValueOnce(first.context)
|
|
270
|
+
.mockResolvedValueOnce(replacement.context);
|
|
271
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
272
|
+
await expect(manager.newPage({ profileId: 'default', session: 'work', surface: 'browser' }))
|
|
273
|
+
.rejects.toBe(secondFailure);
|
|
274
|
+
expect(first.context.newPage).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(replacement.context.newPage).toHaveBeenCalledTimes(1);
|
|
276
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(2);
|
|
277
|
+
});
|
|
278
|
+
it('keeps an explicitly navigated page untracked until navigation succeeds', async () => {
|
|
279
|
+
vi.useFakeTimers();
|
|
280
|
+
const launched = fakeContext();
|
|
281
|
+
let resolveNavigation;
|
|
282
|
+
let markNavigationStarted;
|
|
283
|
+
const navigationStarted = new Promise((resolve) => {
|
|
284
|
+
markNavigationStarted = resolve;
|
|
285
|
+
});
|
|
286
|
+
launched.page.goto.mockImplementation(() => {
|
|
287
|
+
markNavigationStarted();
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
resolveNavigation = resolve;
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
const manager = new CloakSessionManager({
|
|
293
|
+
baseDir: '/tmp/webcmd-test',
|
|
294
|
+
launchPersistentContext: vi.fn().mockResolvedValue(launched.context),
|
|
295
|
+
});
|
|
296
|
+
const pendingLease = manager.newPage({
|
|
297
|
+
profileId: 'default',
|
|
298
|
+
session: 'work',
|
|
299
|
+
surface: 'browser',
|
|
300
|
+
idleTimeout: 25,
|
|
301
|
+
url: 'https://example.com/',
|
|
302
|
+
});
|
|
303
|
+
await navigationStarted;
|
|
304
|
+
const pagesDuringNavigation = await manager.listPages({ profileId: 'default' });
|
|
305
|
+
const pageIdDuringNavigation = manager.pageIdFor(launched.page);
|
|
306
|
+
const timersDuringNavigation = vi.getTimerCount();
|
|
307
|
+
resolveNavigation();
|
|
308
|
+
const lease = await pendingLease;
|
|
309
|
+
expect(pagesDuringNavigation).toEqual([]);
|
|
310
|
+
expect(pageIdDuringNavigation).toBeUndefined();
|
|
311
|
+
expect(timersDuringNavigation).toBe(0);
|
|
312
|
+
expect(manager.pageIdFor(launched.page)).toBe(lease.pageId);
|
|
313
|
+
expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1);
|
|
314
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
315
|
+
});
|
|
316
|
+
it('does not retry or retain a page when navigation fails after creation', async () => {
|
|
317
|
+
const navigationFailure = new Error('Target page, context or browser has been closed');
|
|
318
|
+
const launched = fakeContext();
|
|
319
|
+
launched.page.goto.mockRejectedValue(navigationFailure);
|
|
320
|
+
const launchPersistentContext = vi.fn().mockResolvedValue(launched.context);
|
|
321
|
+
const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext });
|
|
322
|
+
await expect(manager.newPage({
|
|
323
|
+
profileId: 'default',
|
|
324
|
+
session: 'work',
|
|
325
|
+
surface: 'browser',
|
|
326
|
+
url: 'https://example.com/',
|
|
327
|
+
})).rejects.toBe(navigationFailure);
|
|
328
|
+
expect(launchPersistentContext).toHaveBeenCalledTimes(1);
|
|
329
|
+
expect(launched.context.newPage).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(launched.page.goto).toHaveBeenCalledTimes(1);
|
|
331
|
+
expect(launched.page.close).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(await manager.listPages({ profileId: 'default' })).toEqual([]);
|
|
333
|
+
});
|
|
101
334
|
it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => {
|
|
102
335
|
const launched = fakeContext();
|
|
103
336
|
const launchPersistentContext = vi.fn()
|
|
@@ -125,6 +358,7 @@ describe('CloakSessionManager', () => {
|
|
|
125
358
|
});
|
|
126
359
|
const openPages = [];
|
|
127
360
|
const context = {
|
|
361
|
+
on: vi.fn(),
|
|
128
362
|
pages: vi.fn(() => openPages),
|
|
129
363
|
newPage: vi.fn(async () => {
|
|
130
364
|
const page = makePage();
|
|
@@ -159,6 +393,7 @@ describe('CloakSessionManager', () => {
|
|
|
159
393
|
close: vi.fn().mockResolvedValue(undefined),
|
|
160
394
|
};
|
|
161
395
|
const context = {
|
|
396
|
+
on: vi.fn(),
|
|
162
397
|
pages: vi.fn().mockReturnValue([leftover]),
|
|
163
398
|
newPage: vi.fn().mockResolvedValue(created),
|
|
164
399
|
cookies: vi.fn().mockResolvedValue([]),
|
|
@@ -4,6 +4,7 @@ export interface RuntimeStatusOptions {
|
|
|
4
4
|
}
|
|
5
5
|
export interface BrowserRuntimeProvider {
|
|
6
6
|
status(opts?: RuntimeStatusOptions): Promise<BrowserRuntimeStatus>;
|
|
7
|
+
resolveProfileId?(command: BrowserRuntimeCommand): string;
|
|
7
8
|
dispatch(command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
|
|
8
9
|
shutdown(): Promise<void>;
|
|
9
10
|
}
|