@agentrhq/webcmd 0.3.4 → 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.
- package/README.md +43 -268
- package/cli-manifest.json +184 -311
- package/clis/_shared/site-auth.js +37 -40
- package/clis/_shared/site-auth.test.js +109 -22
- package/clis/amazon/auth.js +0 -6
- package/clis/band/auth.js +0 -6
- package/clis/blinkit/auth.js +13 -40
- package/clis/blinkit/blinkit.test.js +39 -2
- package/clis/chatgpt/auth.js +0 -6
- package/clis/claude/auth.js +0 -6
- package/clis/coupang/auth.js +0 -6
- package/clis/district/auth.js +0 -2
- package/clis/facebook/auth.js +0 -6
- package/clis/gemini/auth.js +0 -6
- package/clis/github/auth.js +0 -6
- package/clis/grok/auth.js +0 -6
- package/clis/hackernews/hackernews.test.js +20 -1
- package/clis/hackernews/jobs.js +1 -1
- package/clis/hf/auth.js +2 -7
- package/clis/instagram/auth.js +0 -6
- package/clis/linkedin/auth.js +0 -6
- package/clis/linkedin-learning/auth.js +0 -6
- package/clis/manus/auth.js +0 -6
- package/clis/notebooklm/auth.js +0 -6
- package/clis/pixiv/auth.js +1 -7
- package/clis/practo/login.js +12 -38
- package/clis/practo/practo.test.js +13 -9
- package/clis/reddit/auth.js +0 -6
- package/clis/reuters/auth.js +0 -14
- package/clis/suno/auth.js +0 -6
- package/clis/tiktok/auth.js +0 -6
- package/clis/twitter/auth.js +0 -6
- package/clis/upwork/auth.js +0 -6
- package/clis/youtube/auth.js +0 -6
- package/clis/zepto/auth.js +16 -29
- package/clis/zepto/zepto.test.js +45 -2
- package/dist/src/browser/cdp.js +3 -1
- package/dist/src/browser/cdp.test.js +12 -0
- package/dist/src/browser/page.js +6 -2
- package/dist/src/browser/page.test.js +1 -0
- package/dist/src/browser/protocol.d.ts +2 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +42 -21
- package/dist/src/browser/runtime/local-cloak/provider.test.js +33 -5
- package/dist/src/build-manifest.js +3 -3
- package/dist/src/build-manifest.test.js +14 -0
- package/dist/src/docs-sync-review.js +2 -2
- package/dist/src/docs-sync-review.test.js +2 -2
- package/dist/src/hosted/main-lifecycle.test.js +2 -1
- package/dist/src/hosted/manifest.test.js +2 -1
- package/dist/src/hosted/output-parity.test.js +2 -1
- package/dist/src/hosted/root-command-surface.test.js +1 -1
- package/dist/src/hosted/runner.test.js +8 -4
- package/dist/src/skills.test.js +54 -0
- package/hosted-contract.json +191 -366
- package/package.json +3 -3
- package/skills/webcmd-adapter-author/SKILL.md +2 -0
- package/skills/webcmd-autofix/SKILL.md +5 -3
- package/skills/webcmd-browser/SKILL.md +12 -11
- package/skills/webcmd-usage/SKILL.md +8 -0
- 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
|
});
|
package/clis/manus/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/notebooklm/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/pixiv/auth.js
CHANGED
|
@@ -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
|
|
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
|
});
|
package/clis/practo/login.js
CHANGED
|
@@ -1,48 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
30
|
-
} catch
|
|
31
|
-
|
|
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('
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 () => {
|
package/clis/reddit/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/reuters/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/tiktok/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/twitter/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/upwork/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/youtube/auth.js
CHANGED
|
@@ -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
|
});
|
package/clis/zepto/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AuthRequiredError
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
package/clis/zepto/zepto.test.js
CHANGED
|
@@ -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');
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -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 =
|
|
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
|
});
|
package/dist/src/browser/page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
117
|
+
...navigateParams,
|
|
114
118
|
...this._cmdOpts(),
|
|
115
119
|
});
|
|
116
120
|
}
|
|
@@ -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
|
|
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
|
|
67
|
+
return page.screenshot(options);
|
|
63
68
|
const current = page.viewportSize();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
height: height ?? current
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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 =
|
|
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.
|
|
352
|
-
console.
|
|
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.
|
|
369
|
+
console.error(`✅ Restored executable permission: ${binPath}`);
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
}
|