@agentrhq/webcmd 0.3.4 → 0.4.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 (67) hide show
  1. package/README.md +43 -268
  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/cdp.js +3 -1
  38. package/dist/src/browser/cdp.test.js +12 -0
  39. package/dist/src/browser/page.js +6 -2
  40. package/dist/src/browser/page.test.js +1 -0
  41. package/dist/src/browser/protocol.d.ts +2 -0
  42. package/dist/src/browser/runtime/local-cloak/actions.js +42 -21
  43. package/dist/src/browser/runtime/local-cloak/provider.test.js +33 -5
  44. package/dist/src/build-manifest.js +3 -3
  45. package/dist/src/build-manifest.test.js +14 -0
  46. package/dist/src/completion-shared.js +1 -1
  47. package/dist/src/docs-sync-review.js +15 -7
  48. package/dist/src/docs-sync-review.test.js +38 -2
  49. package/dist/src/generate-release-notes-cli.test.js +1 -1
  50. package/dist/src/hosted/client.d.ts +14 -2
  51. package/dist/src/hosted/client.js +46 -5
  52. package/dist/src/hosted/client.test.js +73 -20
  53. package/dist/src/hosted/main-lifecycle.test.js +2 -1
  54. package/dist/src/hosted/manifest.test.js +4 -2
  55. package/dist/src/hosted/output-parity.test.js +2 -1
  56. package/dist/src/hosted/root-command-surface.test.js +8 -1
  57. package/dist/src/hosted/runner.js +93 -0
  58. package/dist/src/hosted/runner.test.js +134 -5
  59. package/dist/src/hosted/types.d.ts +9 -2
  60. package/dist/src/skills.test.js +54 -0
  61. package/hosted-contract.json +191 -366
  62. package/package.json +4 -4
  63. package/skills/webcmd-adapter-author/SKILL.md +2 -0
  64. package/skills/webcmd-autofix/SKILL.md +5 -3
  65. package/skills/webcmd-browser/SKILL.md +12 -11
  66. package/skills/webcmd-usage/SKILL.md +8 -0
  67. 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');
@@ -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) {
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { LocalCloakRuntimeProvider } from './provider.js';
3
- function fakePage(url) {
3
+ function fakePage(url, initialViewport = { width: 1280, height: 720 }) {
4
4
  let closed = false;
5
- let viewportSize = { width: 1280, height: 720 };
5
+ let viewportSize = initialViewport;
6
6
  return {
7
7
  isClosed: vi.fn(() => closed),
8
8
  goto: vi.fn(async (nextUrl) => {
@@ -24,8 +24,9 @@ function fakePage(url) {
24
24
  }),
25
25
  };
26
26
  }
27
- function makeProviderWithFakePage() {
28
- const pages = [fakePage('https://example.com/')];
27
+ function makeProviderWithFakePage(initialViewport = { width: 1280, height: 720 }) {
28
+ const pages = [fakePage('https://example.com/', initialViewport)];
29
+ const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) };
29
30
  const context = {
30
31
  on: vi.fn(),
31
32
  pages: vi.fn(() => pages.filter((page) => !page.isClosed())),
@@ -34,6 +35,7 @@ function makeProviderWithFakePage() {
34
35
  pages.push(page);
35
36
  return page;
36
37
  }),
38
+ newCDPSession: vi.fn().mockResolvedValue(cdpSession),
37
39
  cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]),
38
40
  close: vi.fn().mockResolvedValue(undefined),
39
41
  };
@@ -41,7 +43,7 @@ function makeProviderWithFakePage() {
41
43
  baseDir: '/tmp/webcmd-test',
42
44
  launchPersistentContext: vi.fn().mockResolvedValue(context),
43
45
  });
44
- return { provider, page: pages[0], pages, context };
46
+ return { provider, page: pages[0], pages, context, cdpSession };
45
47
  }
46
48
  describe('LocalCloakRuntimeProvider', () => {
47
49
  it('reports a runtime-named connected status before any profile launches', async () => {
@@ -66,6 +68,20 @@ describe('LocalCloakRuntimeProvider', () => {
66
68
  expect(result).toMatchObject({ id: 'cmd-1', ok: true, page: expect.any(String) });
67
69
  expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'load' }));
68
70
  });
71
+ it("maps waitUntil 'none' to a commit-only navigation wait", async () => {
72
+ const { provider, page } = makeProviderWithFakePage();
73
+ const result = await provider.dispatch({
74
+ id: 'cmd-1',
75
+ action: 'navigate',
76
+ session: 'work',
77
+ surface: 'browser',
78
+ url: 'https://example.com/',
79
+ waitUntil: 'none',
80
+ profileId: 'default',
81
+ });
82
+ expect(result).toMatchObject({ id: 'cmd-1', ok: true, page: expect.any(String) });
83
+ expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'commit' }));
84
+ });
69
85
  it('evaluates JavaScript in the resolved page', async () => {
70
86
  const { provider } = makeProviderWithFakePage();
71
87
  const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });
@@ -146,6 +162,18 @@ describe('LocalCloakRuntimeProvider', () => {
146
162
  expect(page.setViewportSize).toHaveBeenNthCalledWith(2, { width: 1280, height: 720 });
147
163
  expect(page.screenshot).toHaveBeenCalledTimes(1);
148
164
  });
165
+ it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => {
166
+ const { provider, page, cdpSession } = makeProviderWithFakePage(null);
167
+ const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });
168
+ await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' });
169
+ // The override must not permanently pin the real window via setViewportSize (#120).
170
+ expect(page.setViewportSize).not.toHaveBeenCalled();
171
+ expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 }));
172
+ // ...and it must be cleared afterward so the override is per-shot only.
173
+ expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride');
174
+ expect(cdpSession.detach).toHaveBeenCalledTimes(1);
175
+ expect(page.screenshot).toHaveBeenCalledTimes(1);
176
+ });
149
177
  it('ignores screenshot height overrides for full-page captures while applying width', async () => {
150
178
  const { provider, page } = makeProviderWithFakePage();
151
179
  const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' });
@@ -348,8 +348,8 @@ async function main() {
348
348
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
349
349
  fs.writeFileSync(OUTPUT, artifacts.manifestJson);
350
350
  fs.writeFileSync(HOSTED_CONTRACT_OUTPUT, artifacts.hostedContractJson);
351
- console.log(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
352
- console.log(`✅ Hosted contract compiled: ${packageMetadata.name}@${packageMetadata.version} → ${HOSTED_CONTRACT_OUTPUT}`);
351
+ console.error(`✅ Manifest compiled: ${entries.length} entries → ${OUTPUT}`);
352
+ console.error(`✅ Hosted contract compiled: ${packageMetadata.name}@${packageMetadata.version} → ${HOSTED_CONTRACT_OUTPUT}`);
353
353
  // Restore executable permissions on bin entries.
354
354
  // tsc does not preserve the +x bit, so after a clean rebuild the CLI
355
355
  // entry-point loses its executable permission, causing "Permission denied".
@@ -366,7 +366,7 @@ async function main() {
366
366
  const abs = path.resolve(projectRoot, binPath);
367
367
  if (fs.existsSync(abs)) {
368
368
  fs.chmodSync(abs, 0o755);
369
- console.log(`✅ Restored executable permission: ${binPath}`);
369
+ console.error(`✅ Restored executable permission: ${binPath}`);
370
370
  }
371
371
  }
372
372
  }