@agentrhq/webcmd 0.3.0 → 0.3.1

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 (181) hide show
  1. package/README.md +13 -2
  2. package/cli-manifest.json +217 -13
  3. package/clis/chatgpt/ask.js +1 -1
  4. package/clis/chatgpt/ask.test.js +11 -0
  5. package/clis/chatgpt/commands.test.js +102 -1
  6. package/clis/chatgpt/deep-research-result.js +45 -3
  7. package/clis/chatgpt/image.js +22 -2
  8. package/clis/chatgpt/project-file-add.js +24 -1
  9. package/clis/chatgpt/utils.js +258 -52
  10. package/clis/chatgpt/utils.test.js +259 -1
  11. package/clis/claude/ask.js +21 -1
  12. package/clis/facebook/feed.js +110 -24
  13. package/clis/facebook/feed.test.js +62 -0
  14. package/clis/facebook/search.js +185 -38
  15. package/clis/facebook/search.test.js +95 -50
  16. package/clis/instagram/explore.js +30 -12
  17. package/clis/instagram/explore.test.js +77 -0
  18. package/clis/instagram/post.js +14 -1
  19. package/clis/instagram/reel.js +13 -1
  20. package/clis/linkedin/company.js +153 -0
  21. package/clis/linkedin/company.test.js +111 -0
  22. package/clis/linkedin/connections.js +135 -0
  23. package/clis/linkedin/connections.test.js +141 -0
  24. package/clis/mercury/reimbursement-draft.js +12 -1
  25. package/clis/twitter/article-evaluate.test.js +27 -0
  26. package/clis/twitter/article.js +73 -16
  27. package/clis/twitter/article.test.js +209 -0
  28. package/clis/twitter/download.js +6 -1
  29. package/clis/twitter/post.js +14 -1
  30. package/clis/twitter/profile.js +1 -1
  31. package/clis/twitter/profile.test.js +8 -0
  32. package/clis/twitter/quote.js +11 -1
  33. package/clis/twitter/reply.js +11 -1
  34. package/dist/src/browser/base-page.d.ts +26 -0
  35. package/dist/src/browser/base-page.js +23 -8
  36. package/dist/src/browser/base-page.test.js +45 -12
  37. package/dist/src/browser/command-catalog.d.ts +6 -0
  38. package/dist/src/browser/command-catalog.js +259 -0
  39. package/dist/src/browser/command-catalog.test.d.ts +1 -0
  40. package/dist/src/browser/command-catalog.test.js +79 -0
  41. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.d.ts +17 -0
  42. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.js +106 -0
  43. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.d.ts +1 -0
  44. package/dist/src/browser/runtime/local-cloak/darwin-background-launch.test.js +65 -0
  45. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +7 -0
  46. package/dist/src/browser/runtime/local-cloak/session-manager.js +23 -9
  47. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
  48. package/dist/src/browser/target-resolver.d.ts +1 -0
  49. package/dist/src/browser/target-resolver.js +99 -4
  50. package/dist/src/browser/target-resolver.test.js +141 -1
  51. package/dist/src/build-manifest.d.ts +6 -0
  52. package/dist/src/build-manifest.js +18 -1
  53. package/dist/src/build-manifest.test.js +57 -1
  54. package/dist/src/builtin-command-surface.d.ts +9 -0
  55. package/dist/src/builtin-command-surface.js +16 -0
  56. package/dist/src/check-hosted-contract.test.d.ts +1 -0
  57. package/dist/src/check-hosted-contract.test.js +56 -0
  58. package/dist/src/cli.js +58 -106
  59. package/dist/src/cli.test.js +71 -1
  60. package/dist/src/command-presentation.d.ts +95 -0
  61. package/dist/src/command-presentation.js +486 -0
  62. package/dist/src/command-presentation.test.d.ts +1 -0
  63. package/dist/src/command-presentation.test.js +97 -0
  64. package/dist/src/command-surface.d.ts +43 -0
  65. package/dist/src/command-surface.js +205 -0
  66. package/dist/src/command-surface.test.d.ts +1 -0
  67. package/dist/src/command-surface.test.js +406 -0
  68. package/dist/src/commanderAdapter.d.ts +5 -1
  69. package/dist/src/commanderAdapter.js +12 -55
  70. package/dist/src/commanderAdapter.test.js +15 -3
  71. package/dist/src/community-plugin-sync.d.ts +11 -0
  72. package/dist/src/community-plugin-sync.js +138 -0
  73. package/dist/src/community-plugin-sync.test.d.ts +1 -0
  74. package/dist/src/community-plugin-sync.test.js +123 -0
  75. package/dist/src/completion-fast.d.ts +4 -4
  76. package/dist/src/completion-fast.js +15 -34
  77. package/dist/src/completion-shared.d.ts +4 -0
  78. package/dist/src/completion-shared.js +38 -0
  79. package/dist/src/completion.js +3 -27
  80. package/dist/src/completion.test.js +31 -2
  81. package/dist/src/docs-sync-review-cli.test.d.ts +1 -0
  82. package/dist/src/docs-sync-review-cli.test.js +322 -0
  83. package/dist/src/docs-sync-review.d.ts +117 -0
  84. package/dist/src/docs-sync-review.js +475 -0
  85. package/dist/src/docs-sync-review.test.d.ts +1 -0
  86. package/dist/src/docs-sync-review.test.js +324 -0
  87. package/dist/src/errors.d.ts +12 -3
  88. package/dist/src/errors.js +11 -7
  89. package/dist/src/errors.test.js +14 -1
  90. package/dist/src/execution.d.ts +1 -2
  91. package/dist/src/execution.js +4 -45
  92. package/dist/src/generate-release-notes-cli.test.js +7 -2
  93. package/dist/src/help.d.ts +4 -0
  94. package/dist/src/help.js +50 -255
  95. package/dist/src/help.test.js +27 -1
  96. package/dist/src/hosted/args.d.ts +2 -7
  97. package/dist/src/hosted/args.js +2 -99
  98. package/dist/src/hosted/args.test.js +15 -1
  99. package/dist/src/hosted/availability.d.ts +13 -0
  100. package/dist/src/hosted/availability.js +16 -0
  101. package/dist/src/hosted/availability.test.d.ts +1 -0
  102. package/dist/src/hosted/availability.test.js +180 -0
  103. package/dist/src/hosted/browser-args.d.ts +18 -0
  104. package/dist/src/hosted/browser-args.js +152 -0
  105. package/dist/src/hosted/browser-args.test.d.ts +1 -0
  106. package/dist/src/hosted/browser-args.test.js +182 -0
  107. package/dist/src/hosted/client.d.ts +31 -2
  108. package/dist/src/hosted/client.js +441 -53
  109. package/dist/src/hosted/client.test.js +651 -32
  110. package/dist/src/hosted/config.d.ts +7 -2
  111. package/dist/src/hosted/config.js +26 -4
  112. package/dist/src/hosted/config.test.js +34 -1
  113. package/dist/src/hosted/contract.d.ts +94 -0
  114. package/dist/src/hosted/contract.js +208 -0
  115. package/dist/src/hosted/contract.test.d.ts +1 -0
  116. package/dist/src/hosted/contract.test.js +361 -0
  117. package/dist/src/hosted/credentials.d.ts +38 -0
  118. package/dist/src/hosted/credentials.js +248 -0
  119. package/dist/src/hosted/credentials.test.d.ts +1 -0
  120. package/dist/src/hosted/credentials.test.js +93 -0
  121. package/dist/src/hosted/file-contract.test.d.ts +1 -0
  122. package/dist/src/hosted/file-contract.test.js +174 -0
  123. package/dist/src/hosted/files.d.ts +29 -0
  124. package/dist/src/hosted/files.js +296 -0
  125. package/dist/src/hosted/files.test.d.ts +1 -0
  126. package/dist/src/hosted/files.test.js +231 -0
  127. package/dist/src/hosted/main-lifecycle.test.d.ts +1 -0
  128. package/dist/src/hosted/main-lifecycle.test.js +213 -0
  129. package/dist/src/hosted/manifest.d.ts +5 -0
  130. package/dist/src/hosted/manifest.js +21 -66
  131. package/dist/src/hosted/manifest.test.js +120 -2
  132. package/dist/src/hosted/output-parity.test.d.ts +1 -0
  133. package/dist/src/hosted/output-parity.test.js +108 -0
  134. package/dist/src/hosted/root-command-surface.test.d.ts +1 -0
  135. package/dist/src/hosted/root-command-surface.test.js +646 -0
  136. package/dist/src/hosted/runner.d.ts +7 -0
  137. package/dist/src/hosted/runner.js +554 -253
  138. package/dist/src/hosted/runner.test.js +1471 -53
  139. package/dist/src/hosted/setup.d.ts +3 -2
  140. package/dist/src/hosted/setup.js +45 -15
  141. package/dist/src/hosted/setup.test.js +133 -6
  142. package/dist/src/hosted/types.d.ts +101 -23
  143. package/dist/src/main.js +120 -108
  144. package/dist/src/manifest-types.d.ts +2 -0
  145. package/dist/src/output.d.ts +17 -2
  146. package/dist/src/output.js +88 -81
  147. package/dist/src/output.test.js +141 -51
  148. package/dist/src/pipeline/executor.test.js +1 -0
  149. package/dist/src/pipeline/steps/download.test.js +1 -0
  150. package/dist/src/plugin-create-cli.test.d.ts +1 -0
  151. package/dist/src/plugin-create-cli.test.js +37 -0
  152. package/dist/src/plugin-manifest.d.ts +12 -0
  153. package/dist/src/plugin-manifest.js +16 -0
  154. package/dist/src/plugin-manifest.test.js +19 -1
  155. package/dist/src/plugin-scaffold.d.ts +4 -1
  156. package/dist/src/plugin-scaffold.js +4 -1
  157. package/dist/src/plugin-scaffold.test.js +23 -8
  158. package/dist/src/plugin.js +4 -1
  159. package/dist/src/plugin.test.js +13 -0
  160. package/dist/src/registry.d.ts +2 -0
  161. package/dist/src/release-notes.js +12 -4
  162. package/dist/src/release-notes.test.js +27 -15
  163. package/dist/src/root-command-surface.d.ts +31 -0
  164. package/dist/src/root-command-surface.js +106 -0
  165. package/dist/src/serialization.d.ts +1 -16
  166. package/dist/src/serialization.js +5 -55
  167. package/dist/src/stream-write.d.ts +12 -0
  168. package/dist/src/stream-write.js +91 -0
  169. package/dist/src/stream-write.test.d.ts +1 -0
  170. package/dist/src/stream-write.test.js +186 -0
  171. package/dist/src/types.d.ts +5 -0
  172. package/dist/src/utils.d.ts +1 -1
  173. package/dist/src/utils.js +2 -8
  174. package/dist/src/utils.test.js +50 -0
  175. package/hosted-contract.json +36748 -0
  176. package/package.json +6 -1
  177. package/scripts/check-hosted-contract.mjs +108 -0
  178. package/scripts/docs-sync-review.ts +332 -0
  179. package/scripts/sync-community-plugins.ts +14 -0
  180. package/skills/webcmd-autofix/SKILL.md +26 -37
  181. package/skills/webcmd-usage/SKILL.md +7 -0
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext, waitForDevToolsPort } from './darwin-background-launch.js';
3
+ const options = {
4
+ userDataDir: '/tmp/cloak profile',
5
+ headless: false,
6
+ humanize: true,
7
+ };
8
+ function fakeRuntime() {
9
+ const context = { close: vi.fn() };
10
+ const browser = {
11
+ contexts: vi.fn(() => [context]),
12
+ close: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ return { browser, context };
15
+ }
16
+ function fakeDependencies(browser) {
17
+ return {
18
+ buildLaunchOptions: vi.fn().mockResolvedValue({
19
+ executablePath: '/Applications/Cloak Chromium.app/Contents/MacOS/Chromium',
20
+ args: ['--fingerprint=123'],
21
+ }),
22
+ humanizeBrowser: vi.fn().mockResolvedValue(undefined),
23
+ openApplication: vi.fn().mockResolvedValue(undefined),
24
+ activateApplication: vi.fn().mockResolvedValue(undefined),
25
+ readPort: vi.fn().mockResolvedValue(43123),
26
+ connectOverCDP: vi.fn().mockResolvedValue(browser),
27
+ terminateProfile: vi.fn().mockResolvedValue(undefined),
28
+ removePortFile: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+ describe('launchDarwinBackgroundPersistentContext', () => {
32
+ it('launches the Chromium app without activation and connects through loopback CDP', async () => {
33
+ const { browser, context } = fakeRuntime();
34
+ const deps = fakeDependencies(browser);
35
+ const result = await launchDarwinBackgroundPersistentContext(options, deps);
36
+ expect(deps.removePortFile).toHaveBeenCalledWith('/tmp/cloak profile/DevToolsActivePort');
37
+ expect(deps.openApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app', [
38
+ '--fingerprint=123',
39
+ '--password-store=basic',
40
+ '--use-mock-keychain',
41
+ '--user-data-dir=/tmp/cloak profile',
42
+ '--remote-debugging-address=127.0.0.1',
43
+ '--remote-debugging-port=0',
44
+ 'about:blank',
45
+ ]);
46
+ expect(deps.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:43123');
47
+ expect(deps.humanizeBrowser).toHaveBeenCalledWith(browser, expect.objectContaining({ humanize: true }));
48
+ expect(result).toBe(context);
49
+ await activateDarwinBackgroundContext(result);
50
+ expect(deps.activateApplication).toHaveBeenCalledWith('/Applications/Cloak Chromium.app');
51
+ await result.close();
52
+ expect(browser.close).toHaveBeenCalledOnce();
53
+ expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir);
54
+ });
55
+ it('fails immediately when the CDP port file misses its deadline', async () => {
56
+ await expect(waitForDevToolsPort('/missing/DevToolsActivePort', 0)).rejects.toThrow('Timed out waiting for background Chromium CDP endpoint');
57
+ });
58
+ it('terminates the launched profile when CDP connection fails', async () => {
59
+ const { browser } = fakeRuntime();
60
+ const deps = fakeDependencies(browser);
61
+ deps.connectOverCDP.mockRejectedValueOnce(new Error('connect failed'));
62
+ await expect(launchDarwinBackgroundPersistentContext(options, deps)).rejects.toThrow('connect failed');
63
+ expect(deps.terminateProfile).toHaveBeenCalledWith(options.userDataDir);
64
+ });
65
+ });
@@ -1,6 +1,7 @@
1
1
  import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core';
2
2
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
3
3
  import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js';
4
+ import { activateDarwinBackgroundContext } from './darwin-background-launch.js';
4
5
  import { CloakNetworkCapture } from './network.js';
5
6
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
6
7
  export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
@@ -35,13 +36,19 @@ export interface CloakTabInfo {
35
36
  export interface CloakSessionManagerOptions {
36
37
  baseDir?: string;
37
38
  launchPersistentContext?: LaunchPersistentContext;
39
+ launchBackgroundPersistentContext?: LaunchPersistentContext;
40
+ activateBackgroundContext?: typeof activateDarwinBackgroundContext;
38
41
  recoverLockedProfile?: RecoverLockedProfile;
42
+ platform?: NodeJS.Platform;
39
43
  }
40
44
  export declare function resolveLeaseKey(input: SessionKeyInput): string;
41
45
  export declare class CloakSessionManager {
42
46
  private readonly opts;
43
47
  readonly networkCapture: CloakNetworkCapture;
44
48
  private readonly launchPersistentContext;
49
+ private readonly launchBackgroundPersistentContext;
50
+ private readonly activateBackgroundContext;
51
+ private readonly platform;
45
52
  private readonly recoverLockedProfile;
46
53
  private readonly profiles;
47
54
  private readonly profileLaunches;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
4
+ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js';
4
5
  import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js';
5
6
  import { CloakNetworkCapture } from './network.js';
6
7
  let pageCounter = 0;
@@ -18,12 +19,18 @@ export class CloakSessionManager {
18
19
  opts;
19
20
  networkCapture = new CloakNetworkCapture();
20
21
  launchPersistentContext;
22
+ launchBackgroundPersistentContext;
23
+ activateBackgroundContext;
24
+ platform;
21
25
  recoverLockedProfile;
22
26
  profiles = new Map();
23
27
  profileLaunches = new Map();
24
28
  constructor(opts = {}) {
25
29
  this.opts = opts;
26
30
  this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext;
31
+ this.launchBackgroundPersistentContext = opts.launchBackgroundPersistentContext ?? launchDarwinBackgroundPersistentContext;
32
+ this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext;
33
+ this.platform = opts.platform ?? process.platform;
27
34
  this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile;
28
35
  }
29
36
  profileStatuses() {
@@ -43,7 +50,7 @@ export class CloakSessionManager {
43
50
  const session = requireSession(input.session);
44
51
  const surface = normalizeSurface(input.surface);
45
52
  const leaseKey = resolveLeaseKey(input);
46
- const runtime = await this.getProfileRuntime(profileId);
53
+ const runtime = await this.getProfileRuntime(profileId, input.windowMode);
47
54
  const freshPage = input.freshPage === true;
48
55
  const existing = runtime.pages.get(leaseKey);
49
56
  if (existing && !pageIsClosed(existing.page) && !freshPage) {
@@ -130,7 +137,7 @@ export class CloakSessionManager {
130
137
  const profileId = normalizeProfileId(input.profileId);
131
138
  const session = requireSession(input.session);
132
139
  const surface = normalizeSurface(input.surface);
133
- const runtime = await this.getProfileRuntime(profileId);
140
+ const runtime = await this.getProfileRuntime(profileId, input.windowMode);
134
141
  const page = await runtime.context.newPage();
135
142
  if (input.url) {
136
143
  await page.goto(input.url, { waitUntil: 'load' });
@@ -152,8 +159,10 @@ export class CloakSessionManager {
152
159
  if (!match)
153
160
  return null;
154
161
  const [leaseKey, entry] = match;
155
- if (input.windowMode !== 'background')
162
+ if (input.windowMode !== 'background') {
156
163
  await entry.page.bringToFront?.().catch(() => { });
164
+ await this.activateBackgroundContext(runtime.context);
165
+ }
157
166
  runtime.selectedPageId = entry.pageId;
158
167
  runtime.lastSeenAt = Date.now();
159
168
  return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId };
@@ -184,8 +193,10 @@ export class CloakSessionManager {
184
193
  entry.siteSession = input.siteSession;
185
194
  entry.idleTimeout = input.idleTimeout;
186
195
  runtime.pages.set(canonicalKey, entry);
187
- if (input.windowMode !== 'background')
196
+ if (input.windowMode !== 'background') {
188
197
  await entry.page.bringToFront?.().catch(() => { });
198
+ await this.activateBackgroundContext(runtime.context);
199
+ }
189
200
  this.refreshIdleTimer(runtime, canonicalKey, entry);
190
201
  runtime.selectedPageId = entry.pageId;
191
202
  runtime.lastSeenAt = Date.now();
@@ -237,14 +248,14 @@ export class CloakSessionManager {
237
248
  }
238
249
  this.profiles.clear();
239
250
  }
240
- async getProfileRuntime(profileId) {
251
+ async getProfileRuntime(profileId, windowMode) {
241
252
  const existing = this.profiles.get(profileId);
242
253
  if (existing)
243
254
  return existing;
244
255
  const pending = this.profileLaunches.get(profileId);
245
256
  if (pending)
246
257
  return pending;
247
- const launch = this.launchProfileRuntime(profileId);
258
+ const launch = this.launchProfileRuntime(profileId, windowMode);
248
259
  this.profileLaunches.set(profileId, launch);
249
260
  try {
250
261
  return await launch;
@@ -253,7 +264,7 @@ export class CloakSessionManager {
253
264
  this.profileLaunches.delete(profileId);
254
265
  }
255
266
  }
256
- async launchProfileRuntime(profileId) {
267
+ async launchProfileRuntime(profileId, windowMode) {
257
268
  const userDataDir = resolveCloakProfileDir(profileId, { baseDir: this.opts.baseDir });
258
269
  fs.mkdirSync(userDataDir, { recursive: true });
259
270
  const launchOptions = {
@@ -261,14 +272,17 @@ export class CloakSessionManager {
261
272
  headless: false,
262
273
  humanize: true,
263
274
  };
275
+ const launchPersistentContext = this.platform === 'darwin' && windowMode === 'background'
276
+ ? this.launchBackgroundPersistentContext
277
+ : this.launchPersistentContext;
264
278
  let context;
265
279
  try {
266
- context = await this.launchPersistentContext(launchOptions);
280
+ context = await launchPersistentContext(launchOptions);
267
281
  }
268
282
  catch (err) {
269
283
  if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir)))
270
284
  throw err;
271
- context = await this.launchPersistentContext(launchOptions);
285
+ context = await launchPersistentContext(launchOptions);
272
286
  }
273
287
  const runtime = { context, pages: new Map(), lastSeenAt: Date.now() };
274
288
  this.profiles.set(profileId, runtime);
@@ -42,6 +42,42 @@ describe('CloakSessionManager', () => {
42
42
  expect(launchPersistentContext).toHaveBeenCalledTimes(1);
43
43
  expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false });
44
44
  });
45
+ it.each([
46
+ { platform: 'darwin', windowMode: 'background', backgroundCalls: 1, normalCalls: 0 },
47
+ { platform: 'darwin', windowMode: 'foreground', backgroundCalls: 0, normalCalls: 1 },
48
+ { platform: 'linux', windowMode: 'background', backgroundCalls: 0, normalCalls: 1 },
49
+ ])('routes a cold $platform $windowMode launch', async ({ platform, windowMode, backgroundCalls, normalCalls }) => {
50
+ const launched = fakeContext();
51
+ const launchPersistentContext = vi.fn().mockResolvedValue(launched.context);
52
+ const launchBackgroundPersistentContext = vi.fn().mockResolvedValue(launched.context);
53
+ const manager = new CloakSessionManager({
54
+ baseDir: '/tmp/webcmd-test',
55
+ platform,
56
+ launchPersistentContext,
57
+ launchBackgroundPersistentContext,
58
+ });
59
+ await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', windowMode });
60
+ expect(launchBackgroundPersistentContext).toHaveBeenCalledTimes(backgroundCalls);
61
+ expect(launchPersistentContext).toHaveBeenCalledTimes(normalCalls);
62
+ });
63
+ it('reactivates a background-launched context for foreground tab selection', async () => {
64
+ const launched = fakeContext();
65
+ const activateBackgroundContext = vi.fn().mockResolvedValue(undefined);
66
+ const manager = new CloakSessionManager({
67
+ baseDir: '/tmp/webcmd-test',
68
+ platform: 'darwin',
69
+ launchBackgroundPersistentContext: vi.fn().mockResolvedValue(launched.context),
70
+ activateBackgroundContext,
71
+ });
72
+ const lease = await manager.getPage({
73
+ profileId: 'default',
74
+ session: 'work',
75
+ surface: 'browser',
76
+ windowMode: 'background',
77
+ });
78
+ await manager.selectPage({ profileId: 'default', pageId: lease.pageId, windowMode: 'foreground' });
79
+ expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context);
80
+ });
45
81
  it('coalesces concurrent persistent context launches for the same profile', async () => {
46
82
  const launched = fakeContext();
47
83
  let resolveLaunch;
@@ -77,6 +77,7 @@ export declare function resolveTargetJs(ref: string, opts?: ResolveOptions): str
77
77
  */
78
78
  export declare function boundingRectResolvedJs(opts?: {
79
79
  skipScroll?: boolean;
80
+ forClick?: boolean;
80
81
  }): string;
81
82
  /**
82
83
  * Generate JS for click that uses the unified resolver.
@@ -282,18 +282,113 @@ export function resolveTargetJs(ref, opts = {}) {
282
282
  */
283
283
  export function boundingRectResolvedJs(opts = {}) {
284
284
  const shouldScroll = opts.skipScroll ? 'false' : 'true';
285
+ const forClick = opts.forClick ? 'true' : 'false';
285
286
  return `
286
287
  (() => {
287
288
  const el = window.__resolved;
288
289
  if (!el) throw new Error('No resolved element');
289
290
  if (${shouldScroll}) el.scrollIntoView({ behavior: 'instant', block: 'center' });
290
- const rect = el.getBoundingClientRect();
291
+
292
+ const FOR_CLICK = ${forClick};
293
+ // hover()/dblClick() want the plain element centre — the retarget + hit-test
294
+ // below are click-only so those actions keep their original behaviour.
295
+ if (!FOR_CLICK) {
296
+ const r0 = el.getBoundingClientRect();
297
+ return {
298
+ x: Math.round(r0.left + r0.width / 2),
299
+ y: Math.round(r0.top + r0.height / 2),
300
+ w: Math.round(r0.width),
301
+ h: Math.round(r0.height),
302
+ visible: Math.round(r0.width) > 0 && Math.round(r0.height) > 0,
303
+ };
304
+ }
305
+
306
+ // Does this node OWN a click handler? Deliberately excludes cursor:pointer,
307
+ // which is an *inherited* CSS property — an <svg> icon inside a clickable
308
+ // <div> inherits the pointer cursor but owns no handler, so cursor can't
309
+ // decide whether a node is the real click target. See issue #2071.
310
+ const ownsClickHandler = (node) => {
311
+ if (!node || node.nodeType !== 1) return false;
312
+ const tag = node.tagName.toLowerCase();
313
+ if (['a','button','input','select','textarea','label','summary'].includes(tag)) return true;
314
+ const role = node.getAttribute && node.getAttribute('role');
315
+ if (role && ['button','link','menuitem','menuitemcheckbox','menuitemradio','tab','option','checkbox','radio','switch'].includes(role)) return true;
316
+ if (typeof node.onclick === 'function') return true;
317
+ if (node.hasAttribute && (node.hasAttribute('onclick') || node.hasAttribute('jsaction'))) return true;
318
+ try {
319
+ for (const k in node) {
320
+ if (k.charCodeAt(0) === 95 && (k.indexOf('__reactProps$') === 0 || k.indexOf('__reactEventHandlers$') === 0)) {
321
+ const p = node[k];
322
+ if (p && (p.onClick || p.onMouseDown || p.onMouseUp)) return true;
323
+ }
324
+ }
325
+ } catch (e) {}
326
+ return false;
327
+ };
328
+ // A retarget destination just needs to look clickable; here cursor:pointer
329
+ // IS a useful signal (the ancestor is where it was set).
330
+ const isClickableAncestor = (node) => {
331
+ if (ownsClickHandler(node)) return true;
332
+ try { return window.getComputedStyle(node).cursor === 'pointer'; } catch (e) { return false; }
333
+ };
334
+
335
+ // #2071: if the resolved node owns no click handler but a nearby ancestor
336
+ // is clickable, aim at that ancestor so the handler fires.
337
+ let target = el;
338
+ let retargeted = false;
339
+ if (!ownsClickHandler(el)) {
340
+ let a = el.parentElement, hops = 0;
341
+ while (a && hops < 4) {
342
+ if (isClickableAncestor(a)) { target = a; retargeted = true; break; }
343
+ a = a.parentElement; hops++;
344
+ }
345
+ }
346
+
347
+ const rect = target.getBoundingClientRect();
291
348
  const w = Math.round(rect.width);
292
349
  const h = Math.round(rect.height);
293
- const x = Math.round(rect.left + rect.width / 2);
294
- const y = Math.round(rect.top + rect.height / 2);
350
+ let x = Math.round(rect.left + rect.width / 2);
351
+ let y = Math.round(rect.top + rect.height / 2);
295
352
  const visible = w > 0 && h > 0;
296
- return { x, y, w, h, visible };
353
+
354
+ // #2076: verify the click point actually lands on the target. A trusted
355
+ // CDP click hit-tests at (x,y) and delivers the event to whatever is
356
+ // topmost there — an overlay/sibling can silently swallow it. Classify:
357
+ // 'target' — the point is the target or a light-DOM descendant.
358
+ // 'ancestor' — the point is an ANCESTOR of the target. This is the
359
+ // open-shadow-DOM case (elementFromPoint returns the shadow
360
+ // *host*, not the shadow content) and the "point sits over
361
+ // the target's own wrapper background" case. In both a CDP
362
+ // click at (x,y) still reaches the target — CDP hit-testing
363
+ // pierces shadow roots, and light-DOM clicks bubble up — so
364
+ // it is trustworthy, unlike an unrelated overlay.
365
+ // 'other' — an unrelated element covers the point (the real overlay
366
+ // bug); the caller then dispatches a direct DOM click.
367
+ const hitClass = (px, py) => {
368
+ let at = null;
369
+ try { at = document.elementFromPoint(px, py); } catch (e) { return 'none'; }
370
+ if (!at) return 'none';
371
+ if (at === target || target.contains(at)) return 'target';
372
+ if (at.contains && at.contains(target)) return 'ancestor';
373
+ return 'other';
374
+ };
375
+ let hit = visible ? hitClass(x, y) : 'none';
376
+ if (visible && hit !== 'target' && hit !== 'ancestor') {
377
+ const cands = [
378
+ [rect.left + rect.width * 0.5, rect.top + rect.height * 0.25],
379
+ [rect.left + rect.width * 0.25, rect.top + rect.height * 0.5],
380
+ [rect.left + rect.width * 0.75, rect.top + rect.height * 0.5],
381
+ [rect.left + rect.width * 0.5, rect.top + rect.height * 0.75],
382
+ [rect.left + 3, rect.top + 3],
383
+ [rect.right - 3, rect.bottom - 3],
384
+ ];
385
+ for (const c of cands) {
386
+ const px = Math.round(c[0]), py = Math.round(c[1]);
387
+ const hc = hitClass(px, py);
388
+ if (hc === 'target' || hc === 'ancestor') { x = px; y = py; hit = hc; break; }
389
+ }
390
+ }
391
+ return { x, y, w, h, visible, hit, retargeted };
297
392
  })()
298
393
  `;
299
394
  }
@@ -1,5 +1,145 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { resolveTargetJs } from './target-resolver.js';
2
+ import { boundingRectResolvedJs, resolveTargetJs } from './target-resolver.js';
3
+ /**
4
+ * Build a fake element usable by the generated boundingRectResolvedJs. Rects are
5
+ * {left,top,width,height}; getBoundingClientRect derives right/bottom.
6
+ */
7
+ function makeEl(opts) {
8
+ const attrs = opts.attrs ?? {};
9
+ const el = {
10
+ nodeType: 1,
11
+ tagName: opts.tag.toUpperCase(),
12
+ _cursor: opts.cursor ?? 'auto',
13
+ onclick: opts.onclick ? () => { } : null,
14
+ parentElement: null,
15
+ children: [],
16
+ scrollIntoView() { },
17
+ getBoundingClientRect() {
18
+ const r = opts.rect;
19
+ return { left: r.left, top: r.top, width: r.width, height: r.height, right: r.left + r.width, bottom: r.top + r.height };
20
+ },
21
+ getAttribute(k) { return k === 'role' ? (opts.role ?? null) : (attrs[k] ?? null); },
22
+ hasAttribute(k) { return k === 'role' ? opts.role != null : k in attrs; },
23
+ contains(other) {
24
+ if (other === el)
25
+ return true;
26
+ for (const c of el.children) {
27
+ if (c === other || (c.contains && c.contains(other)))
28
+ return true;
29
+ }
30
+ return false;
31
+ },
32
+ };
33
+ return el;
34
+ }
35
+ /** Resolve computed cursor with CSS inheritance, so the fake DOM matches the
36
+ * browser: a child with no own cursor inherits its ancestors' pointer cursor. */
37
+ function inheritedCursor(node) {
38
+ let n = node;
39
+ while (n) {
40
+ if (n._cursor && n._cursor !== 'auto')
41
+ return n._cursor;
42
+ n = n.parentElement;
43
+ }
44
+ return 'auto';
45
+ }
46
+ /** Run the generated boundingRectResolvedJs against a fake DOM. */
47
+ function runRect(resolved, elementAt) {
48
+ const fakeWindow = { __resolved: resolved, getComputedStyle: (n) => ({ cursor: inheritedCursor(n) }) };
49
+ const fakeDoc = { elementFromPoint: (x, y) => elementAt(x, y) };
50
+ const js = boundingRectResolvedJs({ skipScroll: true, forClick: true });
51
+ // js is an IIFE expression `(() => {...})()`; arrow captures window/document
52
+ // params. Wrap in parens so ASI doesn't turn `return\n(` into `return;`.
53
+ return new Function('window', 'document', 'return (' + js + ')')(fakeWindow, fakeDoc);
54
+ }
55
+ describe('boundingRectResolvedJs runtime behavior', () => {
56
+ it('reports hit=target and no retarget when the centre lands on the element', () => {
57
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
58
+ const out = runRect(btn, () => btn);
59
+ expect(out.visible).toBe(true);
60
+ expect(out.hit).toBe('target');
61
+ expect(out.retargeted).toBe(false);
62
+ expect(out.x).toBe(50);
63
+ expect(out.y).toBe(20);
64
+ });
65
+ it('retargets an <svg> icon to its clickable <div> ancestor (#2071)', () => {
66
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 80, height: 80 }, cursor: 'pointer' });
67
+ const svg = makeEl({ tag: 'svg', rect: { left: 20, top: 20, width: 40, height: 40 } });
68
+ svg.parentElement = div;
69
+ div.children = [svg];
70
+ // elementFromPoint at the div centre returns the svg (topmost paint), which
71
+ // is contained by div → hit=target on the retargeted element.
72
+ const out = runRect(svg, () => svg);
73
+ expect(out.retargeted).toBe(true);
74
+ expect(out.hit).toBe('target');
75
+ // measured rect is the div's (80x80 → centre 40,40), not the svg's
76
+ expect(out.w).toBe(80);
77
+ expect(out.x).toBe(40);
78
+ expect(out.y).toBe(40);
79
+ });
80
+ it('retargets to an ancestor that owns an onclick handler even without cursor:pointer (#2071)', () => {
81
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 60, height: 60 }, onclick: true });
82
+ const svg = makeEl({ tag: 'svg', rect: { left: 10, top: 10, width: 40, height: 40 } });
83
+ svg.parentElement = div;
84
+ div.children = [svg];
85
+ const out = runRect(svg, () => svg);
86
+ expect(out.retargeted).toBe(true);
87
+ expect(out.w).toBe(60);
88
+ });
89
+ it('does not retarget when no ancestor is clickable', () => {
90
+ const wrap = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 60, height: 60 } });
91
+ const span = makeEl({ tag: 'span', rect: { left: 10, top: 10, width: 40, height: 40 } });
92
+ span.parentElement = wrap;
93
+ wrap.children = [span];
94
+ const out = runRect(span, () => span);
95
+ expect(out.retargeted).toBe(false);
96
+ expect(out.w).toBe(40);
97
+ });
98
+ it('treats an ancestor hit as trustworthy (open shadow-DOM host / own wrapper) and keeps the centre', () => {
99
+ // elementFromPoint returns a light-DOM ancestor (e.g. the shadow host) that
100
+ // contains the target — CDP pierces to the target, so this must NOT fall back.
101
+ const host = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 120, height: 60 } });
102
+ const btn = makeEl({ tag: 'button', rect: { left: 10, top: 10, width: 100, height: 40 } });
103
+ btn.parentElement = host;
104
+ host.children = [btn];
105
+ const out = runRect(btn, () => host); // topmost at every point is the host (ancestor)
106
+ expect(out.hit).toBe('ancestor');
107
+ expect(out.retargeted).toBe(false);
108
+ // centre kept — no wasted probing when the hit is already trustworthy
109
+ expect(out.x).toBe(60);
110
+ expect(out.y).toBe(30);
111
+ });
112
+ it('hover/dblClick mode (forClick omitted) returns the plain element centre with no hit-test', () => {
113
+ const div = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 80, height: 80 }, cursor: 'pointer' });
114
+ const svg = makeEl({ tag: 'svg', rect: { left: 20, top: 20, width: 40, height: 40 } });
115
+ svg.parentElement = div;
116
+ div.children = [svg];
117
+ const js = boundingRectResolvedJs({ skipScroll: true }); // no forClick
118
+ const out = new Function('window', 'document', 'return (' + js + ')')({ __resolved: svg, getComputedStyle: () => ({ cursor: 'pointer' }) }, { elementFromPoint: () => div });
119
+ // no retarget, no hit field — svg's own centre (40,40), unchanged behavior
120
+ expect(out.hit).toBeUndefined();
121
+ expect(out.retargeted).toBeUndefined();
122
+ expect(out.x).toBe(40);
123
+ expect(out.y).toBe(40);
124
+ expect(out.w).toBe(40);
125
+ });
126
+ it('reports hit=other when an overlay covers the element and no probe point lands (#2076)', () => {
127
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
128
+ const overlay = makeEl({ tag: 'div', rect: { left: 0, top: 0, width: 1000, height: 1000 } });
129
+ const out = runRect(btn, () => overlay); // every point hits the overlay
130
+ expect(out.hit).toBe('other');
131
+ expect(out.retargeted).toBe(false);
132
+ });
133
+ it('recovers a hitting point by probing when the centre is occluded but a corner is clear (#2076)', () => {
134
+ const btn = makeEl({ tag: 'button', rect: { left: 0, top: 0, width: 100, height: 40 } });
135
+ const overlay = makeEl({ tag: 'div', rect: { left: 40, top: 15, width: 20, height: 10 } });
136
+ // Centre (50,20) is covered by the overlay; the top-left inset (3,3) is clear.
137
+ const out = runRect(btn, (x, y) => (x === 50 && y === 20 ? overlay : btn));
138
+ expect(out.hit).toBe('target');
139
+ // the returned point moved off the occluded centre (50,20)
140
+ expect(out.x === 50 && out.y === 20).toBe(false);
141
+ });
142
+ });
3
143
  /**
4
144
  * Tests for the target resolver JS generator.
5
145
  *
@@ -22,6 +22,7 @@
22
22
  * amount) to confirm an intentional removal.
23
23
  */
24
24
  import type { ManifestEntry } from './manifest-types.js';
25
+ import { type HostedBrowserCommandContract } from './hosted/contract.js';
25
26
  export type { ManifestEntry } from './manifest-types.js';
26
27
  /**
27
28
  * Thrown by `loadManifestEntries` when an adapter file looks like a CLI
@@ -66,6 +67,11 @@ export declare function loadManifestEntries(filePath: string, site: string, impo
66
67
  export declare function scanClisDir(clisDir: string, importer?: (moduleHref: string) => Promise<unknown>): Promise<BuildManifestResult>;
67
68
  export declare function buildManifest(): Promise<BuildManifestResult>;
68
69
  export declare function serializeManifest(manifest: ManifestEntry[]): string;
70
+ export interface BuildManifestArtifacts {
71
+ manifestJson: string;
72
+ hostedContractJson: string;
73
+ }
74
+ export declare function buildManifestArtifacts(entries: ManifestEntry[], packageVersion: string, browserCatalog: readonly HostedBrowserCommandContract[]): BuildManifestArtifacts;
69
75
  /**
70
76
  * Metadata audit: every positional arg must carry a non-empty `help` string.
71
77
  *
@@ -28,10 +28,12 @@ import { getErrorMessage } from './errors.js';
28
28
  import { fullName, getRegistry } from './registry.js';
29
29
  import { findPackageRoot, getCliManifestPath } from './package-paths.js';
30
30
  import { isRecord } from './utils.js';
31
+ import { buildHostedContract, serializeHostedContract, } from './hosted/contract.js';
31
32
  const PACKAGE_ROOT = findPackageRoot(fileURLToPath(import.meta.url));
32
33
  const CLIS_DIR = path.join(PACKAGE_ROOT, 'clis');
33
34
  // Write manifest next to clis/ so both dev and installed runtime can find it.
34
35
  const OUTPUT = getCliManifestPath(CLIS_DIR);
36
+ const HOSTED_CONTRACT_OUTPUT = path.join(PACKAGE_ROOT, 'hosted-contract.json');
35
37
  // Module is treated as a CLI command source if it either:
36
38
  // 1. Calls `cli(...)` directly (the common case), or
37
39
  // 2. Calls a factory `make<Pascal>Command(...)` from clis/_shared/ that
@@ -64,6 +66,7 @@ function toManifestArgs(args) {
64
66
  positional: arg.positional || undefined,
65
67
  help: arg.help ?? '',
66
68
  choices: arg.choices,
69
+ file: arg.file,
67
70
  }));
68
71
  }
69
72
  function toModulePath(filePath, site) {
@@ -211,6 +214,12 @@ export async function buildManifest() {
211
214
  export function serializeManifest(manifest) {
212
215
  return `${JSON.stringify(manifest, null, 2)}\n`;
213
216
  }
217
+ export function buildManifestArtifacts(entries, packageVersion, browserCatalog) {
218
+ return {
219
+ manifestJson: serializeManifest(entries),
220
+ hostedContractJson: serializeHostedContract(buildHostedContract(entries, browserCatalog, packageVersion)),
221
+ };
222
+ }
214
223
  export function findManifestMetadataIssues(entries) {
215
224
  const issues = [];
216
225
  for (const entry of entries) {
@@ -330,9 +339,17 @@ async function main() {
330
339
  process.exit(1);
331
340
  }
332
341
  }
342
+ const packagePath = path.resolve(PACKAGE_ROOT, 'package.json');
343
+ const packageMetadata = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
344
+ if (typeof packageMetadata.name !== 'string' || typeof packageMetadata.version !== 'string') {
345
+ throw new Error(`Package name and version are required in ${packagePath}`);
346
+ }
347
+ const artifacts = buildManifestArtifacts(entries, packageMetadata.version, []);
333
348
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
334
- fs.writeFileSync(OUTPUT, serializeManifest(entries));
349
+ fs.writeFileSync(OUTPUT, artifacts.manifestJson);
350
+ fs.writeFileSync(HOSTED_CONTRACT_OUTPUT, artifacts.hostedContractJson);
335
351
  console.log(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
352
+ console.log(`✅ Hosted contract compiled: ${packageMetadata.name}@${packageMetadata.version} → ${HOSTED_CONTRACT_OUTPUT}`);
336
353
  // Restore executable permissions on bin entries.
337
354
  // tsc does not preserve the +x bit, so after a clean rebuild the CLI
338
355
  // entry-point loses its executable permission, causing "Permission denied".