@agentrhq/webcmd 0.3.3 → 0.4.0

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 (63) hide show
  1. package/README.md +45 -272
  2. package/cli-manifest.json +184 -311
  3. package/clis/_shared/site-auth.js +37 -40
  4. package/clis/_shared/site-auth.test.js +109 -22
  5. package/clis/amazon/auth.js +0 -6
  6. package/clis/band/auth.js +0 -6
  7. package/clis/blinkit/auth.js +13 -40
  8. package/clis/blinkit/blinkit.test.js +39 -2
  9. package/clis/chatgpt/auth.js +0 -6
  10. package/clis/claude/auth.js +0 -6
  11. package/clis/coupang/auth.js +0 -6
  12. package/clis/district/auth.js +0 -2
  13. package/clis/facebook/auth.js +0 -6
  14. package/clis/gemini/auth.js +0 -6
  15. package/clis/github/auth.js +0 -6
  16. package/clis/grok/auth.js +0 -6
  17. package/clis/hackernews/hackernews.test.js +20 -1
  18. package/clis/hackernews/jobs.js +1 -1
  19. package/clis/hf/auth.js +2 -7
  20. package/clis/instagram/auth.js +0 -6
  21. package/clis/linkedin/auth.js +0 -6
  22. package/clis/linkedin-learning/auth.js +0 -6
  23. package/clis/manus/auth.js +0 -6
  24. package/clis/notebooklm/auth.js +0 -6
  25. package/clis/pixiv/auth.js +1 -7
  26. package/clis/practo/login.js +12 -38
  27. package/clis/practo/practo.test.js +13 -9
  28. package/clis/reddit/auth.js +0 -6
  29. package/clis/reuters/auth.js +0 -14
  30. package/clis/suno/auth.js +0 -6
  31. package/clis/tiktok/auth.js +0 -6
  32. package/clis/twitter/auth.js +0 -6
  33. package/clis/upwork/auth.js +0 -6
  34. package/clis/youtube/auth.js +0 -6
  35. package/clis/zepto/auth.js +16 -29
  36. package/clis/zepto/zepto.test.js +45 -2
  37. package/dist/src/browser/ax-snapshot.js +15 -5
  38. package/dist/src/browser/ax-snapshot.test.js +49 -0
  39. package/dist/src/browser/cdp.js +3 -1
  40. package/dist/src/browser/cdp.test.js +12 -0
  41. package/dist/src/browser/page.js +6 -2
  42. package/dist/src/browser/page.test.js +1 -0
  43. package/dist/src/browser/protocol.d.ts +2 -0
  44. package/dist/src/browser/runtime/local-cloak/actions.js +42 -21
  45. package/dist/src/browser/runtime/local-cloak/provider.test.js +33 -5
  46. package/dist/src/build-manifest.js +3 -3
  47. package/dist/src/build-manifest.test.js +14 -0
  48. package/dist/src/docs-sync-review.js +2 -2
  49. package/dist/src/docs-sync-review.test.js +2 -2
  50. package/dist/src/hosted/main-lifecycle.test.js +2 -1
  51. package/dist/src/hosted/manifest.test.js +2 -1
  52. package/dist/src/hosted/output-parity.test.js +2 -1
  53. package/dist/src/hosted/root-command-surface.test.js +1 -1
  54. package/dist/src/hosted/runner.test.js +8 -4
  55. package/dist/src/skills.test.js +54 -0
  56. package/hosted-contract.json +191 -366
  57. package/package.json +5 -4
  58. package/skills/smart-search/SKILL.md +20 -6
  59. package/skills/webcmd-adapter-author/SKILL.md +2 -0
  60. package/skills/webcmd-autofix/SKILL.md +5 -3
  61. package/skills/webcmd-browser/SKILL.md +28 -13
  62. package/skills/webcmd-usage/SKILL.md +29 -6
  63. package/clis/practo/whoami.js +0 -28
@@ -53,10 +53,4 @@ registerSiteAuthCommands({
53
53
  columns: ['public_id', 'plain_id', 'name'],
54
54
  quickCheck: hasLinkedinSessionCookie,
55
55
  verify: verifyLinkedinLearningIdentity,
56
- poll: async (page) => {
57
- if (!await hasLinkedinSessionCookie(page)) {
58
- throw new AuthRequiredError('linkedin.com', 'Waiting for LinkedIn li_at cookie');
59
- }
60
- return verifyLinkedinLearningIdentity(page);
61
- },
62
56
  });
@@ -45,10 +45,4 @@ registerSiteAuthCommands({
45
45
  loginUrl: 'https://manus.im/login',
46
46
  columns: ['user_id', 'name'],
47
47
  verify: verifyManusIdentity,
48
- poll: async (page) => {
49
- if (!await hasManusSessionCookie(page)) {
50
- throw new AuthRequiredError('manus.im', 'Waiting for Manus session cookies');
51
- }
52
- return verifyManusIdentity(page);
53
- },
54
48
  });
@@ -46,10 +46,4 @@ registerSiteAuthCommands({
46
46
  columns: ['name', 'authuser'],
47
47
  quickCheck: hasNotebookLmSsoCookies,
48
48
  verify: verifyNotebookLmIdentity,
49
- poll: async (page) => {
50
- if (!await hasNotebookLmSsoCookies(page)) {
51
- throw new AuthRequiredError('notebooklm.google.com', 'Waiting for Google SSO cookies (SID + SAPISID)');
52
- }
53
- return verifyNotebookLmIdentity(page);
54
- },
55
49
  });
@@ -4,7 +4,7 @@ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
4
4
  async function hasPixivSessionCookie(page) {
5
5
  const cookies = await page.getCookies({ url: 'https://www.pixiv.net' });
6
6
  // Anonymous PHPSESSID is a bare hash; logged-in form is `<userId>_<hash>`.
7
- // Require the numeric uid prefix so the login poll doesn't navigate away
7
+ // Require the numeric uid prefix so identity verification does not navigate away
8
8
  // from accounts.pixiv.net while the user is still signing in.
9
9
  return cookies.some(c => c.name === 'PHPSESSID' && /^\d+_/.test(c.value || ''));
10
10
  }
@@ -55,10 +55,4 @@ registerSiteAuthCommands({
55
55
  columns: ['user_id', 'name'],
56
56
  quickCheck: hasPixivSessionCookie,
57
57
  verify: verifyPixivIdentity,
58
- poll: async (page) => {
59
- if (!await hasPixivSessionCookie(page)) {
60
- throw new AuthRequiredError('pixiv.net', 'Waiting for Pixiv PHPSESSID cookie');
61
- }
62
- return verifyPixivIdentity(page);
63
- },
64
58
  });
@@ -1,48 +1,22 @@
1
- import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
2
- import { cli, Strategy } from '@agentrhq/webcmd/registry';
1
+ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
3
2
  import { PRACTO, probeIdentity } from './utils.js';
4
3
 
5
- const DEFAULT_TIMEOUT_SECONDS = 300;
6
-
7
- function isAuthRequired(error) {
8
- return error instanceof AuthRequiredError;
9
- }
10
-
11
- cli({
4
+ registerSiteAuthCommands({
12
5
  site: 'practo',
13
- name: 'login',
14
- access: 'write',
15
- description: 'Open Practo login and wait until the browser session is authenticated',
16
6
  domain: 'www.practo.com',
17
- strategy: Strategy.COOKIE,
18
- browser: true,
19
- navigateBefore: false,
20
- defaultWindowMode: 'foreground',
21
- siteSession: 'persistent',
22
- args: [
23
- { name: 'timeout', type: 'int', default: DEFAULT_TIMEOUT_SECONDS, help: 'Maximum seconds to wait for the user to finish login' },
24
- ],
25
- columns: ['status', 'logged_in', 'site', 'name'],
26
- func: async (page, kwargs) => {
7
+ loginUrl: `${PRACTO}/login`,
8
+ columns: ['name'],
9
+ whoamiAliases: ['auth-status'],
10
+ quickCheck: async (page) => {
27
11
  try {
28
12
  const rows = await probeIdentity(page);
29
- return [{ status: 'already_logged_in', ...rows[0] }];
30
- } catch (error) {
31
- if (!isAuthRequired(error)) throw error;
13
+ return { logged_in: true, name: rows[0].name };
14
+ } catch {
15
+ return { logged_in: false };
32
16
  }
33
-
17
+ },
18
+ verify: async (page) => (await probeIdentity(page))[0],
19
+ openLogin: async (page) => {
34
20
  await page.goto(`${PRACTO}/login`, { waitUntil: 'none', settleMs: 1500 });
35
- const timeout = Number(kwargs.timeout ?? DEFAULT_TIMEOUT_SECONDS);
36
- const deadline = Date.now() + timeout * 1000;
37
- while (Date.now() < deadline) {
38
- await page.wait(2);
39
- try {
40
- const rows = await probeIdentity(page);
41
- return [{ status: 'login_complete', ...rows[0] }];
42
- } catch (error) {
43
- if (!isAuthRequired(error)) throw error;
44
- }
45
- }
46
- throw new TimeoutError('practo login', timeout, 'Complete Practo login in the browser, then retry.');
47
21
  },
48
22
  });
@@ -12,7 +12,6 @@ import './login.js';
12
12
  import './profile.js';
13
13
  import './search.js';
14
14
  import './slots.js';
15
- import './whoami.js';
16
15
  import { __test__ } from './utils.js';
17
16
 
18
17
  const {
@@ -124,22 +123,27 @@ describe('practo command registry', () => {
124
123
  expect(cancel.args.find((arg) => arg.name === 'confirm')?.type).toBe('boolean');
125
124
  });
126
125
 
127
- it('waits for manual login to complete', async () => {
126
+ it('opens login without waiting for manual authentication', async () => {
128
127
  const login = getRegistry().get('practo/login');
128
+ const whoami = getRegistry().get('practo/whoami');
129
129
  const page = {
130
130
  goto: vi.fn().mockResolvedValue(undefined),
131
131
  wait: vi.fn().mockResolvedValue(undefined),
132
132
  evaluate: vi.fn()
133
- .mockResolvedValueOnce({ __error: 'HTTP 401', status: 401 })
134
- .mockResolvedValueOnce({ name: 'Ada' }),
133
+ .mockResolvedValueOnce({ __error: 'HTTP 401', status: 401 }),
135
134
  };
136
135
 
137
- await expect(login.func(page, { timeout: 1 })).resolves.toEqual([{
138
- status: 'login_complete',
139
- logged_in: true,
136
+ expect(login.args).toEqual([]);
137
+ expect(login.columns).toEqual(expect.arrayContaining(['action', 'verify_command']));
138
+ expect(whoami).toBeDefined();
139
+ expect(getRegistry().get('practo/auth-status')).toBe(whoami);
140
+ await expect(login.func(page, {})).resolves.toEqual([expect.objectContaining({
141
+ status: 'action_required',
142
+ logged_in: false,
140
143
  site: 'practo',
141
- name: 'Ada',
142
- }]);
144
+ verify_command: 'webcmd practo whoami',
145
+ })]);
146
+ expect(page.wait).not.toHaveBeenCalledWith(2);
143
147
  });
144
148
 
145
149
  it('refuses real booking without --confirm true', async () => {
@@ -43,10 +43,4 @@ registerSiteAuthCommands({
43
43
  columns: ['username', 'id'],
44
44
  quickCheck: hasRedditSessionCookie,
45
45
  verify: verifyRedditIdentity,
46
- poll: async (page) => {
47
- if (!await hasRedditSessionCookie(page)) {
48
- throw new AuthRequiredError('reddit.com', 'Waiting for Reddit reddit_session cookie');
49
- }
50
- return verifyRedditIdentity(page);
51
- },
52
46
  });
@@ -50,18 +50,4 @@ registerSiteAuthCommands({
50
50
  loginUrl: 'https://www.reuters.com/account/sign-in/',
51
51
  columns: ['user_id', 'subscribed'],
52
52
  verify: verifyReutersIdentity,
53
- // No-navigation poll: check localStorage on the current page so login-flow
54
- // polling doesn't bounce the user off the sign-in page every interval.
55
- poll: async (page) => {
56
- const loggedIn = await page.evaluate(`(() => {
57
- try {
58
- const raw = localStorage.getItem('rcom-subscription-state');
59
- return raw ? JSON.parse(raw).isLoggedIn === true : false;
60
- } catch { return false; }
61
- })()`);
62
- if (!loggedIn) {
63
- throw new AuthRequiredError('reuters.com', 'Waiting for Reuters login');
64
- }
65
- return verifyReutersIdentity(page);
66
- },
67
53
  });
package/clis/suno/auth.js CHANGED
@@ -54,10 +54,4 @@ registerSiteAuthCommands({
54
54
  columns: ['user_id', 'name'],
55
55
  quickCheck: hasSunoClerkCookie,
56
56
  verify: verifySunoIdentity,
57
- poll: async (page) => {
58
- if (!await hasSunoClerkCookie(page)) {
59
- throw new AuthRequiredError('suno.com', 'Waiting for Suno Clerk __session/__client cookie');
60
- }
61
- return verifySunoIdentity(page);
62
- },
63
57
  });
@@ -56,10 +56,4 @@ registerSiteAuthCommands({
56
56
  columns: ['sec_uid', 'username', 'nickname'],
57
57
  quickCheck: hasTiktokSessionCookie,
58
58
  verify: verifyTiktokIdentity,
59
- poll: async (page) => {
60
- if (!await hasTiktokSessionCookie(page)) {
61
- throw new AuthRequiredError('www.tiktok.com', 'Waiting for TikTok session cookies');
62
- }
63
- return verifyTiktokIdentity(page);
64
- },
65
59
  });
@@ -32,10 +32,4 @@ registerSiteAuthCommands({
32
32
  columns: ['username', 'url'],
33
33
  quickCheck: hasTwitterSessionCookies,
34
34
  verify: verifyTwitterIdentity,
35
- poll: async (page) => {
36
- if (!await hasTwitterSessionCookies(page)) {
37
- throw new AuthRequiredError('x.com', 'Waiting for Twitter/X auth cookies');
38
- }
39
- return verifyTwitterIdentity(page);
40
- },
41
35
  });
@@ -44,10 +44,4 @@ registerSiteAuthCommands({
44
44
  columns: ['user_id', 'ciphertext'],
45
45
  quickCheck: hasUpworkSessionCookie,
46
46
  verify: verifyUpworkIdentity,
47
- poll: async (page) => {
48
- if (!await hasUpworkSessionCookie(page)) {
49
- throw new AuthRequiredError('upwork.com', 'Waiting for Upwork session cookies');
50
- }
51
- return verifyUpworkIdentity(page);
52
- },
53
47
  });
@@ -45,10 +45,4 @@ registerSiteAuthCommands({
45
45
  columns: ['name'],
46
46
  quickCheck: hasGoogleSessionCookie,
47
47
  verify: verifyYoutubeIdentity,
48
- poll: async (page) => {
49
- if (!await hasGoogleSessionCookie(page)) {
50
- throw new AuthRequiredError('www.youtube.com', 'Waiting for Google session cookies');
51
- }
52
- return verifyYoutubeIdentity(page);
53
- },
54
48
  });
@@ -1,5 +1,5 @@
1
- import { AuthRequiredError, TimeoutError } from '@agentrhq/webcmd/errors';
2
- import { cli, Strategy } from '@agentrhq/webcmd/registry';
1
+ import { AuthRequiredError } from '@agentrhq/webcmd/errors';
2
+ import { registerSiteAuthCommands } from '../_shared/site-auth.js';
3
3
  import { DOMAIN, HOME_URL, SITE, ZEPTO_NAV_OPTIONS, safeGoto } from './utils.js';
4
4
 
5
5
  function authEvaluate() {
@@ -23,36 +23,23 @@ function openLoginEvaluate() {
23
23
  `;
24
24
  }
25
25
 
26
- cli({
26
+ async function verifyZeptoIdentity(page) {
27
+ await safeGoto(page, HOME_URL, 'zepto login', ZEPTO_NAV_OPTIONS);
28
+ const result = await page.evaluate(authEvaluate());
29
+ if (!result.loggedIn) throw new AuthRequiredError(DOMAIN, 'Zepto login is required');
30
+ return {};
31
+ }
32
+
33
+ registerSiteAuthCommands({
27
34
  site: SITE,
28
- name: 'login',
29
- access: 'write',
30
- description: 'Open Zepto login and wait until the browser session is authenticated',
31
35
  domain: DOMAIN,
32
- strategy: Strategy.COOKIE,
33
- browser: true,
34
- navigateBefore: false,
35
- defaultWindowMode: 'foreground',
36
- siteSession: 'persistent',
37
- args: [
38
- { name: 'timeout', type: 'int', default: 300, help: 'Maximum seconds to wait for the user to finish login' },
39
- ],
40
- columns: ['status', 'logged_in', 'site'],
41
- func: async (page, kwargs) => {
36
+ loginUrl: HOME_URL,
37
+ columns: [],
38
+ verify: verifyZeptoIdentity,
39
+ openLogin: async (page) => {
42
40
  await safeGoto(page, HOME_URL, 'zepto login', ZEPTO_NAV_OPTIONS);
43
- if ((await page.evaluate(authEvaluate()).catch(() => ({ loggedIn: false }))).loggedIn) {
44
- return [{ status: 'already_logged_in', logged_in: true, site: SITE }];
45
- }
46
- await page.evaluate(openLoginEvaluate()).catch(() => false);
47
- const timeout = Number(kwargs.timeout ?? 300);
48
- const deadline = Date.now() + timeout * 1000;
49
- while (Date.now() < deadline) {
50
- await page.wait(2);
51
- if ((await page.evaluate(authEvaluate()).catch(() => ({ loggedIn: false }))).loggedIn) {
52
- return [{ status: 'login_complete', logged_in: true, site: SITE }];
53
- }
54
- }
55
- throw new TimeoutError('zepto login', timeout, 'Run webcmd zepto login and complete the login dialog in the browser.');
41
+ const opened = await page.evaluate(openLoginEvaluate());
42
+ if (!opened) throw new Error('Zepto login dialog did not open');
56
43
  },
57
44
  });
58
45
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { JSDOM } from 'jsdom';
3
3
  import { ArgumentError, AuthRequiredError } from '@agentrhq/webcmd/errors';
4
4
  import { getRegistry } from '@agentrhq/webcmd/registry';
@@ -193,11 +193,54 @@ describe('zepto helpers', () => {
193
193
 
194
194
  describe('zepto registry shape', () => {
195
195
  it('registers the buying-path commands', () => {
196
- for (const name of ['login', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
196
+ for (const name of ['login', 'whoami', 'location', 'search', 'product', 'add-to-cart', 'cart', 'checkout', 'place-order']) {
197
197
  expect(getRegistry().get(`zepto/${name}`)).toBeDefined();
198
198
  }
199
199
  });
200
200
 
201
+ it('opens login without waiting for manual authentication', async () => {
202
+ const login = getRegistry().get('zepto/login');
203
+ const whoami = getRegistry().get('zepto/whoami');
204
+ const page = {
205
+ goto: vi.fn().mockResolvedValue(undefined),
206
+ wait: vi.fn().mockResolvedValue(undefined),
207
+ evaluate: vi.fn().mockResolvedValue({ loggedIn: false }),
208
+ };
209
+
210
+ expect(login.args).toEqual([]);
211
+ expect(login.columns).toEqual(expect.arrayContaining(['action', 'verify_command']));
212
+ expect(whoami).toBeDefined();
213
+ await expect(login.func(page, {})).resolves.toEqual([expect.objectContaining({
214
+ status: 'action_required',
215
+ logged_in: false,
216
+ site: 'zepto',
217
+ verify_command: 'webcmd zepto whoami',
218
+ })]);
219
+ expect(page.wait).not.toHaveBeenCalledWith(2);
220
+ });
221
+
222
+ it('propagates Zepto identity probe failures', async () => {
223
+ const error = new Error('evaluate crashed');
224
+ const page = {
225
+ goto: vi.fn().mockResolvedValue(undefined),
226
+ evaluate: vi.fn().mockRejectedValue(error),
227
+ };
228
+
229
+ await expect(getRegistry().get('zepto/whoami').func(page, {})).rejects.toBe(error);
230
+ });
231
+
232
+ it('rejects when the Zepto login dialog cannot be opened', async () => {
233
+ const login = getRegistry().get('zepto/login');
234
+ const page = {
235
+ goto: vi.fn().mockResolvedValue(undefined),
236
+ evaluate: vi.fn()
237
+ .mockResolvedValueOnce({ loggedIn: false })
238
+ .mockResolvedValueOnce(false),
239
+ };
240
+
241
+ await expect(login.func(page, {})).rejects.toThrow('Zepto login dialog did not open');
242
+ });
243
+
201
244
  it('marks only cart-changing commands as write', () => {
202
245
  expect(getRegistry().get('zepto/location').access).toBe('read');
203
246
  expect(getRegistry().get('zepto/search').access).toBe('read');
@@ -71,7 +71,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
71
71
  const rawNodes = Array.isArray(axTree?.nodes)
72
72
  ? axTree.nodes
73
73
  : [];
74
- const nodes = rawNodes.filter((node) => node && !node.ignored);
74
+ const nodes = rawNodes.filter((node) => node);
75
75
  const byId = new Map();
76
76
  const parentIds = new Set();
77
77
  for (const node of nodes) {
@@ -88,11 +88,21 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
88
88
  });
89
89
  const root = roots[0] ?? nodes[0];
90
90
  const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
91
+ const maxTraversalHops = 200;
91
92
  const roleNameCounts = countRoleNames(nodes);
92
93
  const roleNameSeen = new Map();
93
- function render(node, depth) {
94
- if (!node || depth > maxDepth)
94
+ function render(node, depth, hops, path) {
95
+ if (!node || depth > maxDepth || hops > maxTraversalHops || path.has(node))
95
96
  return false;
97
+ const nextPath = new Set(path).add(node);
98
+ if (node.ignored) {
99
+ let hasVisibleChild = false;
100
+ for (const childId of node.childIds ?? []) {
101
+ if (render(byId.get(childId), depth, hops + 1, nextPath))
102
+ hasVisibleChild = true;
103
+ }
104
+ return hasVisibleChild;
105
+ }
96
106
  const role = axString(node.role) || 'generic';
97
107
  const name = cleanText(axString(node.name));
98
108
  const value = cleanText(axString(node.value) || propertyValue(node, 'value'));
@@ -109,7 +119,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
109
119
  const childStart = lines.length;
110
120
  let hasVisibleChild = false;
111
121
  for (const childId of node.childIds ?? []) {
112
- if (render(byId.get(childId), depth + 1))
122
+ if (render(byId.get(childId), depth + 1, hops + 1, nextPath))
113
123
  hasVisibleChild = true;
114
124
  }
115
125
  if (!shouldShowSelf && !hasVisibleChild) {
@@ -151,7 +161,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
151
161
  }
152
162
  return true;
153
163
  }
154
- render(root, opts.baseDepth);
164
+ render(root, opts.baseDepth, 0, new Set());
155
165
  return nextRef;
156
166
  }
157
167
  export function findAxRefReplacement(axTree, ref) {
@@ -1,6 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import { buildAxSnapshot, buildAxSnapshotFromTrees, findAxRefReplacement } from './ax-snapshot.js';
3
4
  describe('AX snapshot prototype', () => {
5
+ it('publishes the AX snapshot builder as a package subpath', () => {
6
+ const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
7
+ expect(packageJson.exports?.['./browser/ax-snapshot'])
8
+ .toBe('./dist/src/browser/ax-snapshot.js');
9
+ });
4
10
  it('builds compact refs from Accessibility.getFullAXTree output', () => {
5
11
  const result = buildAxSnapshot({
6
12
  nodes: [
@@ -25,6 +31,49 @@ describe('AX snapshot prototype', () => {
25
31
  name: 'Category',
26
32
  });
27
33
  });
34
+ it('keeps actionable descendants of ignored AX nodes', () => {
35
+ const result = buildAxSnapshot({
36
+ nodes: [
37
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
38
+ { nodeId: '2', ignored: true, childIds: ['3', '4'] },
39
+ { nodeId: '3', role: { value: 'button' }, name: { value: 'First' }, backendDOMNodeId: 30 },
40
+ { nodeId: '4', role: { value: 'button' }, name: { value: 'Second' }, backendDOMNodeId: 40 },
41
+ ],
42
+ });
43
+ expect(result.text).toContain(' [1]button "First"\n [2]button "Second"');
44
+ expect(result.text).not.toContain('ignored');
45
+ expect([...result.refs.values()]).toEqual([
46
+ { ref: '1', backendNodeId: 30, role: 'button', name: 'First' },
47
+ { ref: '2', backendNodeId: 40, role: 'button', name: 'Second' },
48
+ ]);
49
+ });
50
+ it('stops ignored-node cycles without changing promoted indentation', () => {
51
+ const result = buildAxSnapshot({
52
+ nodes: [
53
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
54
+ { nodeId: '2', ignored: true, childIds: ['2', '3'] },
55
+ { nodeId: '3', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 30 },
56
+ ],
57
+ });
58
+ expect(result.text).toContain('\n [1]button "Save"\n');
59
+ expect(result.refs).toHaveLength(1);
60
+ });
61
+ it('bounds traversal through long ignored-node chains', () => {
62
+ const ignored = Array.from({ length: 201 }, (_, index) => ({
63
+ nodeId: String(index + 2),
64
+ ignored: true,
65
+ childIds: [String(index + 3)],
66
+ }));
67
+ const result = buildAxSnapshot({
68
+ nodes: [
69
+ { nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
70
+ ...ignored,
71
+ { nodeId: '203', role: { value: 'button' }, name: { value: 'Too deep' }, backendDOMNodeId: 203 },
72
+ ],
73
+ });
74
+ expect(result.text).not.toContain('Too deep');
75
+ expect(result.refs).toHaveLength(0);
76
+ });
28
77
  it('tracks nth only for duplicate role/name pairs', () => {
29
78
  const result = buildAxSnapshot({
30
79
  nodes: [
@@ -172,7 +172,9 @@ class CDPPage extends BasePage {
172
172
  await this.bridge.send('Page.enable');
173
173
  this._pageEnabled = true;
174
174
  }
175
- const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => { });
175
+ const loadPromise = options?.waitUntil === 'none'
176
+ ? undefined
177
+ : this.bridge.waitForEvent('Page.loadEventFired', 30_000).catch(() => { });
176
178
  await this.bridge.send('Page.navigate', { url });
177
179
  await loadPromise;
178
180
  this._lastUrl = url;
@@ -76,4 +76,16 @@ describe('CDPBridge cookies', () => {
76
76
  ['Page.getLayoutMetrics', {}],
77
77
  ]);
78
78
  });
79
+ it("only waits for the load event when waitUntil is not 'none'", async () => {
80
+ vi.stubEnv('WEBCMD_CDP_ENDPOINT', 'ws://127.0.0.1:9222/devtools/page/1');
81
+ const bridge = new CDPBridge();
82
+ vi.spyOn(bridge, 'send').mockResolvedValue({});
83
+ const waitForEvent = vi.spyOn(bridge, 'waitForEvent').mockResolvedValue({});
84
+ const page = await bridge.connect();
85
+ await page.goto('https://example.com/fast', { waitUntil: 'none' });
86
+ expect(waitForEvent).not.toHaveBeenCalled();
87
+ await page.goto('https://example.com/full', { settleMs: 0 });
88
+ expect(waitForEvent).toHaveBeenCalledOnce();
89
+ expect(waitForEvent).toHaveBeenCalledWith('Page.loadEventFired', 30_000);
90
+ });
79
91
  });
@@ -94,9 +94,13 @@ export class Page extends BasePage {
94
94
  }
95
95
  async goto(url, options) {
96
96
  let result;
97
+ const navigateParams = {
98
+ url,
99
+ ...(options?.waitUntil && { waitUntil: options.waitUntil }),
100
+ };
97
101
  try {
98
102
  result = await sendCommandFull('navigate', {
99
- url,
103
+ ...navigateParams,
100
104
  ...this._cmdOpts(),
101
105
  });
102
106
  }
@@ -110,7 +114,7 @@ export class Page extends BasePage {
110
114
  throw err;
111
115
  this._page = undefined;
112
116
  result = await sendCommandFull('navigate', {
113
- url,
117
+ ...navigateParams,
114
118
  ...this._cmdOpts(),
115
119
  });
116
120
  }
@@ -50,6 +50,7 @@ describe('Page.getCurrentUrl', () => {
50
50
  session: 'site:chatgpt',
51
51
  surface: 'adapter',
52
52
  siteSession: 'persistent',
53
+ waitUntil: 'none',
53
54
  }));
54
55
  expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
55
56
  session: 'site:chatgpt',
@@ -14,6 +14,8 @@ export interface BrowserRuntimeCommand {
14
14
  /** Close any existing leased page and start on a new one (sent on the first action of a command run). */
15
15
  freshPage?: boolean;
16
16
  url?: string;
17
+ /** Navigation wait condition: 'none' returns once navigation commits instead of waiting for the load event. */
18
+ waitUntil?: 'load' | 'none';
17
19
  op?: string;
18
20
  index?: number;
19
21
  domain?: string;
@@ -55,17 +55,46 @@ function execTarget(page, frameIndex, pageId) {
55
55
  throw new CloakActionError('frame_not_found', `Frame not found: ${frameIndex}`, pageId);
56
56
  return frame;
57
57
  }
58
- async function applyScreenshotViewport(page, command) {
58
+ async function captureScreenshot(page, context, command) {
59
59
  const width = Number.isFinite(command.width) && command.width > 0 ? Math.ceil(command.width) : undefined;
60
60
  const height = !command.fullPage && Number.isFinite(command.height) && command.height > 0 ? Math.ceil(command.height) : undefined;
61
+ const options = {
62
+ type: command.format ?? 'png',
63
+ quality: command.format === 'jpeg' ? command.quality : undefined,
64
+ fullPage: command.fullPage,
65
+ };
61
66
  if (width === undefined && height === undefined)
62
- return null;
67
+ return page.screenshot(options);
63
68
  const current = page.viewportSize();
64
- await page.setViewportSize({
65
- width: width ?? current?.width ?? 1280,
66
- height: height ?? current?.height ?? 720,
67
- });
68
- return current;
69
+ if (current) {
70
+ // Emulated viewport: override for the shot, then restore the prior fixed size.
71
+ await page.setViewportSize({ width: width ?? current.width, height: height ?? current.height });
72
+ try {
73
+ return await page.screenshot(options);
74
+ }
75
+ finally {
76
+ await page.setViewportSize(current);
77
+ }
78
+ }
79
+ // Real-window context (viewport: null): setViewportSize can't return to a windowed
80
+ // state, so override reversibly via CDP and clear it so the override is per-shot only.
81
+ const windowSize = width === undefined || height === undefined
82
+ ? await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))
83
+ : { width: 0, height: 0 };
84
+ const cdp = await context.newCDPSession(page);
85
+ try {
86
+ await cdp.send('Emulation.setDeviceMetricsOverride', {
87
+ width: width ?? windowSize.width,
88
+ height: height ?? windowSize.height,
89
+ deviceScaleFactor: 0,
90
+ mobile: false,
91
+ });
92
+ return await page.screenshot(options);
93
+ }
94
+ finally {
95
+ await cdp.send('Emulation.clearDeviceMetricsOverride').catch(() => { });
96
+ await cdp.detach().catch(() => { });
97
+ }
69
98
  }
70
99
  export async function dispatchCloakAction(manager, command) {
71
100
  try {
@@ -74,7 +103,10 @@ export async function dispatchCloakAction(manager, command) {
74
103
  if (!command.url)
75
104
  return invalidRequest(command, 'Missing url');
76
105
  const lease = await resolveLease(manager, command);
77
- await lease.page.goto(command.url, { waitUntil: 'load' });
106
+ // 'none' maps to Playwright's 'commit': sites that stream analytics forever
107
+ // never fire the load event, so adapters gating readiness on their own
108
+ // selector waits must be able to skip it.
109
+ await lease.page.goto(command.url, { waitUntil: command.waitUntil === 'none' ? 'commit' : 'load' });
78
110
  return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId };
79
111
  }
80
112
  case 'exec': {
@@ -93,19 +125,8 @@ export async function dispatchCloakAction(manager, command) {
93
125
  }
94
126
  case 'screenshot': {
95
127
  const lease = await resolveLease(manager, command);
96
- const previousViewport = await applyScreenshotViewport(lease.page, command);
97
- try {
98
- const buffer = await lease.page.screenshot({
99
- type: command.format ?? 'png',
100
- quality: command.format === 'jpeg' ? command.quality : undefined,
101
- fullPage: command.fullPage,
102
- });
103
- return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId };
104
- }
105
- finally {
106
- if (previousViewport)
107
- await lease.page.setViewportSize(previousViewport);
108
- }
128
+ const buffer = await captureScreenshot(lease.page, lease.context, command);
129
+ return { id: command.id, ok: true, data: buffer.toString('base64'), page: lease.pageId };
109
130
  }
110
131
  case 'close-window': {
111
132
  if (command.page) {