@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.
Files changed (56) hide show
  1. package/README.md +13 -6
  2. package/cli-manifest.json +11 -11
  3. package/clis/producthunt/browse.js +9 -2
  4. package/clis/producthunt/browser-commands.test.js +66 -0
  5. package/clis/producthunt/hot.js +3 -3
  6. package/clis/producthunt/utils.js +27 -0
  7. package/clis/producthunt/utils.test.js +8 -0
  8. package/clis/spotify/spotify.js +11 -11
  9. package/dist/src/browser/daemon-client.d.ts +1 -0
  10. package/dist/src/browser/daemon-client.js +25 -1
  11. package/dist/src/browser/daemon-client.test.js +86 -1
  12. package/dist/src/browser/daemon-transport.d.ts +2 -0
  13. package/dist/src/browser/protocol.d.ts +12 -1
  14. package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
  15. package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
  16. package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
  17. package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
  18. package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
  19. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
  20. package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
  21. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
  22. package/dist/src/browser/runtime/provider.d.ts +1 -0
  23. package/dist/src/cli.js +36 -12
  24. package/dist/src/cli.test.js +5 -0
  25. package/dist/src/daemon/server.js +65 -24
  26. package/dist/src/daemon/server.test.js +211 -2
  27. package/dist/src/docs-sync-review-cli.test.js +1 -1
  28. package/dist/src/errors.d.ts +5 -0
  29. package/dist/src/errors.js +23 -0
  30. package/dist/src/errors.test.js +58 -1
  31. package/dist/src/execution.js +57 -6
  32. package/dist/src/execution.test.js +426 -2
  33. package/dist/src/hosted/availability.test.js +12 -1
  34. package/dist/src/hosted/client.js +3 -1
  35. package/dist/src/hosted/client.test.js +62 -0
  36. package/dist/src/hosted/main-lifecycle.test.js +66 -5
  37. package/dist/src/hosted/types.d.ts +1 -0
  38. package/dist/src/main.js +4 -0
  39. package/dist/src/package-bin-process.d.ts +13 -0
  40. package/dist/src/package-bin-process.js +24 -0
  41. package/dist/src/package-bin-process.test.d.ts +1 -0
  42. package/dist/src/package-bin-process.test.js +22 -0
  43. package/dist/src/session-lease.d.ts +82 -0
  44. package/dist/src/session-lease.js +163 -0
  45. package/dist/src/session-lease.test.d.ts +1 -0
  46. package/dist/src/session-lease.test.js +217 -0
  47. package/dist/src/skills.d.ts +8 -4
  48. package/dist/src/skills.js +30 -1
  49. package/dist/src/skills.test.js +40 -12
  50. package/hosted-contract.json +45 -34
  51. package/package.json +3 -2
  52. package/scripts/check-package-bin.mjs +7 -6
  53. package/scripts/collect-ci-diagnostics.ps1 +274 -0
  54. package/scripts/docs-sync-review.ts +1 -1
  55. package/skills/webcmd-adapter-author/SKILL.md +1 -1
  56. 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 commandProfileId(manager, command) {
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: commandProfileId(manager, command),
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: commandProfileId(manager, command), pageId: command.page });
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: commandProfileId(manager, command),
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: commandProfileId(manager, command) });
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: commandProfileId(manager, command),
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: commandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
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: commandProfileId(manager, command), pageId: command.page, index: command.index });
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: commandProfileId(manager, command),
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: undefined,
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: runtime.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
- const existingPages = runtime.context.pages();
71
- // freshPage must never adopt a leftover tab — its whole point is a clean DOM.
72
- const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
73
- const pageId = nextPageId();
74
- const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
75
- runtime.pages.set(leaseKey, entry);
76
- this.refreshIdleTimer(runtime, leaseKey, entry);
77
- runtime.selectedPageId = pageId;
78
- runtime.lastSeenAt = Date.now();
79
- return { profileId, leaseKey, context: runtime.context, page, pageId };
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 runtime = await this.getProfileRuntime(profileId, input.windowMode);
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
- await page.goto(input.url, { waitUntil: 'load' });
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 = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
148
- runtime.pages.set(leaseKey, entry);
149
- this.refreshIdleTimer(runtime, leaseKey, entry);
150
- runtime.lastSeenAt = Date.now();
151
- return { profileId, leaseKey, context: runtime.context, page, pageId };
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
  }