@agentrhq/webcmd 0.2.0 → 0.2.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.
- package/README.md +23 -11
- package/cli-manifest.json +2 -0
- package/clis/twitter/delete.js +14 -6
- package/clis/twitter/delete.test.js +74 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/timeline.test.js +4 -0
- package/dist/src/browser/bridge-readiness.test.js +1 -1
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +6 -4
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +7 -2
- package/dist/src/browser/daemon-client.test.js +33 -30
- package/dist/src/browser/daemon-transport.js +3 -19
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +5 -1
- package/dist/src/browser/profile.d.ts +9 -0
- package/dist/src/browser/profile.js +18 -7
- package/dist/src/browser/profile.test.d.ts +1 -0
- package/dist/src/browser/profile.test.js +44 -0
- package/dist/src/browser/protocol.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +24 -10
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +3 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +87 -0
- package/dist/src/cli.js +22 -17
- package/dist/src/cli.test.js +2 -2
- package/dist/src/commands/daemon.test.js +13 -13
- package/dist/src/constants.d.ts +2 -3
- package/dist/src/constants.js +3 -10
- package/dist/src/daemon.js +1 -5
- package/dist/src/doctor.js +12 -0
- package/dist/src/doctor.test.js +30 -3
- package/dist/src/execution.js +5 -3
- package/dist/src/external.js +19 -1
- package/dist/src/external.test.js +37 -1
- package/dist/src/main.js +0 -5
- package/dist/src/node-network.test.js +3 -3
- package/dist/src/runtime-identity.test.js +0 -8
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
<img width="2054" height="766" alt="Webcmd banner" src="https://github.com/user-attachments/assets/3e5b9702-5ffd-43bd-ab1b-2319a8cc0e2a" />
|
|
2
2
|
|
|
3
|
-
# Webcmd
|
|
4
|
-
|
|
5
3
|
<p align="center">
|
|
6
4
|
<a href="https://www.npmjs.com/package/@agentrhq/webcmd">
|
|
7
5
|
<img alt="NPM version" src="https://img.shields.io/npm/v/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
|
|
8
6
|
</a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/@agentrhq/webcmd">
|
|
8
|
+
<img alt="NPM downloads" src="https://img.shields.io/npm/dt/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
|
|
9
|
+
</a>
|
|
9
10
|
<a href="https://github.com/agentrhq/webcmd/blob/main/LICENSE">
|
|
10
11
|
<img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-1E88E5.svg?style=for-the-badge&labelColor=000000">
|
|
11
12
|
</a>
|
|
12
|
-
<a href="https://
|
|
13
|
-
<img alt="Join the community on
|
|
13
|
+
<a href="https://discord.gg/9YP2C9tvMp">
|
|
14
|
+
<img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-7C3AED.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000&logoWidth=20">
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://x.com/agentrhq">
|
|
17
|
+
<img alt="Follow AgentR on X" src="https://img.shields.io/badge/Follow%20%40agentrhq-000000.svg?style=for-the-badge&logo=x&logoColor=white&labelColor=000000&logoWidth=20">
|
|
14
18
|
</a>
|
|
15
19
|
</p>
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
# Webcmd
|
|
22
|
+
|
|
23
|
+
**Self-learning browser infra for AI agents.**
|
|
24
|
+
|
|
25
|
+
WebCMD learns the navigational context of websites as agents use them, then compiles that knowledge into deterministic commands for faster, cheaper, more reliable browser automation. The goal is simple: stop making agents rediscover the same sites on every run and cut browser-agent token spend by up to 90%.
|
|
26
|
+
|
|
27
|
+
On top of vanilla browser interactions, WebCMD adds 3 layers of learnings. Each layer collapses cost and variance for the layer above it.
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
| Layer | Scenario | What Webcmd Helps With |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| 1. Live browser control | The site is unfamiliar. | Use `webcmd browser` to inspect, click, type, extract, capture network calls, and complete the task in a real browser. |
|
|
32
|
+
| 2. Sitemap memory | The site is familiar, but the action space is not fully known. | Capture an agent-facing sitemap of observed pages, states, actions, workflows, APIs, pitfalls, and fallback paths. |
|
|
33
|
+
| 3. CLI authoring | The action space is known, but the path is still too variable for one fixed sequence. | Explicitly author a reusable `webcmd <site> <command>` adapter with structured output, so future agents spend tokens on the task instead of navigation. |
|
|
34
|
+
| 4. Custom CLI commands | The workflow is deterministic enough to stop browsing. | Extend the CLI with a tailored command so the workflow runs instantly with the least amount of tokens. |
|
|
20
35
|
|
|
21
|
-
- **Use built-in adapters** for sites like Reddit, Hacker News, LinkedIn, Twitter/X, TikTok, Amazon, PubMed, ChatGPT, Claude, Gemini, NotebookLM, and many more.
|
|
22
|
-
- **Let AI agents operate a real browser** with `webcmd browser <session> ...` primitives: open pages, inspect DOM snapshots, click, type, select, extract, capture network calls, and verify flows.
|
|
23
|
-
- **Wrap local tools and desktop apps** so agents can discover and invoke `gh`, `docker`, `vercel`, Electron apps, and other command surfaces through the same `webcmd ...` entrypoint.
|
|
24
36
|
|
|
25
37
|
## Quick Start
|
|
26
38
|
|
|
@@ -139,7 +151,7 @@ webcmd <site> <command> -f json
|
|
|
139
151
|
webcmd <site> <command> --trace retain-on-failure -f json
|
|
140
152
|
```
|
|
141
153
|
|
|
142
|
-
Start with adapters. Fall back to `webcmd browser` only when no adapter covers the task or you are
|
|
154
|
+
Start with adapters. Fall back to `webcmd browser` only when no adapter covers the task or you are teaching Webcmd a new site flow.
|
|
143
155
|
|
|
144
156
|
## Browser Automation
|
|
145
157
|
|
|
@@ -235,7 +247,7 @@ Use plugins for private company workflows, community adapters, or experiments th
|
|
|
235
247
|
|
|
236
248
|
## Writing Adapters
|
|
237
249
|
|
|
238
|
-
When a site is not covered yet, author a reusable adapter instead of leaving an agent to
|
|
250
|
+
When a site is not covered yet, author a reusable adapter instead of leaving an agent to spend tokens clicking through the same browser flow every time.
|
|
239
251
|
|
|
240
252
|
```bash
|
|
241
253
|
webcmd browser init <site>/<command>
|
package/cli-manifest.json
CHANGED
|
@@ -23638,6 +23638,8 @@
|
|
|
23638
23638
|
"likes",
|
|
23639
23639
|
"retweets",
|
|
23640
23640
|
"replies",
|
|
23641
|
+
"quotes",
|
|
23642
|
+
"bookmarks",
|
|
23641
23643
|
"views",
|
|
23642
23644
|
"created_at",
|
|
23643
23645
|
"url",
|
package/clis/twitter/delete.js
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
2
2
|
import { CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
3
|
-
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult } from './shared.js';
|
|
4
4
|
|
|
5
5
|
function buildDeleteScript(tweetId) {
|
|
6
6
|
return `(async () => {
|
|
7
7
|
try {
|
|
8
8
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
9
9
|
${buildTwitterArticleScopeSource(tweetId)}
|
|
10
|
-
|
|
10
|
+
let targetArticle = findTargetArticle();
|
|
11
|
+
for (let i = 0; i < 20 && !targetArticle; i++) {
|
|
12
|
+
await new Promise(r => setTimeout(r, 250));
|
|
13
|
+
targetArticle = findTargetArticle();
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
if (!targetArticle) {
|
|
13
17
|
return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
20
|
+
const belongsToTargetArticle = (el) => el.closest('article') === targetArticle;
|
|
21
|
+
const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')).filter(belongsToTargetArticle);
|
|
22
|
+
const moreMenu = Array.from(targetArticle.querySelectorAll('[data-testid="caret"]')).filter(belongsToTargetArticle).find(visible)
|
|
23
|
+
|| buttons.find((el) => visible(el) && /^More/.test((el.getAttribute('aria-label') || '').trim()));
|
|
18
24
|
if (!moreMenu) {
|
|
19
25
|
return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' };
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
const beforeMenuItems = new Set(document.querySelectorAll('[role="menuitem"]'));
|
|
22
29
|
moreMenu.click();
|
|
23
30
|
await new Promise(r => setTimeout(r, 1000));
|
|
24
31
|
|
|
25
|
-
const items = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
|
32
|
+
const items = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
|
33
|
+
.filter((item) => visible(item) && !beforeMenuItems.has(item));
|
|
26
34
|
const deleteBtn = items.find((item) => {
|
|
27
35
|
const text = (item.textContent || '').trim();
|
|
28
36
|
return text.includes('Delete') && !text.includes('List');
|
|
@@ -69,7 +77,7 @@ cli({
|
|
|
69
77
|
const target = parseTweetUrl(kwargs.url);
|
|
70
78
|
await page.goto(target.url);
|
|
71
79
|
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
72
|
-
const result = await page.evaluate(buildDeleteScript(target.id));
|
|
80
|
+
const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id)));
|
|
73
81
|
if (result.ok) {
|
|
74
82
|
// Wait for the deletion request to be processed
|
|
75
83
|
await page.wait(2);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
2
3
|
import { ArgumentError, CommandExecutionError } from '@agentrhq/webcmd/errors';
|
|
3
4
|
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
4
|
-
import './delete.js';
|
|
5
|
+
import { __test__ } from './delete.js';
|
|
5
6
|
describe('twitter delete command', () => {
|
|
6
7
|
it('targets the matched tweet article instead of the first More button on the page', async () => {
|
|
7
8
|
const cmd = getRegistry().get('twitter/delete');
|
|
@@ -27,6 +28,12 @@ describe('twitter delete command', () => {
|
|
|
27
28
|
expect(script).toContain('__twGetStatusIdFromHref');
|
|
28
29
|
expect(script).toContain("document.querySelectorAll('article')");
|
|
29
30
|
expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
|
|
31
|
+
expect(script).toContain("closest('article') === targetArticle");
|
|
32
|
+
expect(script).toContain(".filter(belongsToTargetArticle)");
|
|
33
|
+
expect(script).toContain('[data-testid="caret"]');
|
|
34
|
+
expect(script).toContain('/^More/');
|
|
35
|
+
expect(script).toContain('i < 20');
|
|
36
|
+
expect(script).toContain('beforeMenuItems');
|
|
30
37
|
// Substring match must NOT appear — exact-id match only.
|
|
31
38
|
expect(script).not.toContain("'/status/' + tweetId");
|
|
32
39
|
expect(result).toEqual([
|
|
@@ -58,6 +65,72 @@ describe('twitter delete command', () => {
|
|
|
58
65
|
]);
|
|
59
66
|
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
60
67
|
});
|
|
68
|
+
it('unwraps browser runtime evaluate envelopes before checking delete success', async () => {
|
|
69
|
+
const cmd = getRegistry().get('twitter/delete');
|
|
70
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
71
|
+
const page = {
|
|
72
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
73
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
74
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
75
|
+
session: 'twitter',
|
|
76
|
+
data: { ok: true, message: 'Tweet successfully deleted.' },
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
const result = await cmd.func(page, {
|
|
80
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
81
|
+
});
|
|
82
|
+
expect(result).toEqual([
|
|
83
|
+
{
|
|
84
|
+
status: 'success',
|
|
85
|
+
message: 'Tweet successfully deleted.',
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
89
|
+
});
|
|
90
|
+
it('ignores stale non-target delete menu items that existed before opening the matched tweet menu', async () => {
|
|
91
|
+
const dom = new JSDOM(`
|
|
92
|
+
<body>
|
|
93
|
+
<div role="menuitem" data-stale-delete>Delete</div>
|
|
94
|
+
<article>
|
|
95
|
+
<a href="https://x.com/bob/status/999">wrong tweet</a>
|
|
96
|
+
<button data-testid="caret" aria-label="More"></button>
|
|
97
|
+
</article>
|
|
98
|
+
<article>
|
|
99
|
+
<a href="https://x.com/alice/status/2040254679301718161">target tweet</a>
|
|
100
|
+
<button data-testid="caret" aria-label="More" data-target-caret></button>
|
|
101
|
+
</article>
|
|
102
|
+
</body>
|
|
103
|
+
`, { runScripts: 'outside-only', url: 'https://x.com/alice/status/2040254679301718161' });
|
|
104
|
+
dom.window.setTimeout = (handler) => {
|
|
105
|
+
if (typeof handler === 'function') handler();
|
|
106
|
+
return 0;
|
|
107
|
+
};
|
|
108
|
+
Object.defineProperty(dom.window.HTMLElement.prototype, 'getClientRects', {
|
|
109
|
+
configurable: true,
|
|
110
|
+
value() {
|
|
111
|
+
return [{ bottom: 1, height: 1, left: 0, right: 1, top: 0, width: 1 }];
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
let staleDeleteClicked = false;
|
|
115
|
+
let targetCaretClicked = false;
|
|
116
|
+
dom.window.document.querySelector('[data-stale-delete]')?.addEventListener('click', () => {
|
|
117
|
+
staleDeleteClicked = true;
|
|
118
|
+
});
|
|
119
|
+
dom.window.document.querySelector('[data-target-caret]')?.addEventListener('click', () => {
|
|
120
|
+
targetCaretClicked = true;
|
|
121
|
+
const item = dom.window.document.createElement('div');
|
|
122
|
+
item.setAttribute('role', 'menuitem');
|
|
123
|
+
item.textContent = 'Pin to your profile';
|
|
124
|
+
dom.window.document.body.appendChild(item);
|
|
125
|
+
});
|
|
126
|
+
const result = await dom.window.eval(__test__.buildDeleteScript('2040254679301718161'));
|
|
127
|
+
expect(targetCaretClicked).toBe(true);
|
|
128
|
+
expect(staleDeleteClicked).toBe(false);
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
ok: false,
|
|
131
|
+
message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.',
|
|
132
|
+
});
|
|
133
|
+
});
|
|
61
134
|
it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
|
|
62
135
|
const cmd = getRegistry().get('twitter/delete');
|
|
63
136
|
expect(cmd?.func).toBeTypeOf('function');
|
package/clis/twitter/timeline.js
CHANGED
|
@@ -85,6 +85,8 @@ function extractTweet(result, seen) {
|
|
|
85
85
|
likes: l.favorite_count || 0,
|
|
86
86
|
retweets: l.retweet_count || 0,
|
|
87
87
|
replies: l.reply_count || 0,
|
|
88
|
+
quotes: l.quote_count || 0,
|
|
89
|
+
bookmarks: l.bookmark_count || 0,
|
|
88
90
|
views,
|
|
89
91
|
created_at: l.created_at || '',
|
|
90
92
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
@@ -156,7 +158,7 @@ cli({
|
|
|
156
158
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
|
|
157
159
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
|
|
158
160
|
],
|
|
159
|
-
columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
|
|
161
|
+
columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'replies', 'quotes', 'bookmarks', 'views', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters', 'card', 'quoted_tweet'],
|
|
160
162
|
func: async (page, kwargs) => {
|
|
161
163
|
const limit = kwargs.limit || 20;
|
|
162
164
|
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
|
@@ -50,6 +50,8 @@ describe('twitter timeline helpers', () => {
|
|
|
50
50
|
favorite_count: 3,
|
|
51
51
|
retweet_count: 2,
|
|
52
52
|
reply_count: 1,
|
|
53
|
+
quote_count: 4,
|
|
54
|
+
bookmark_count: 5,
|
|
53
55
|
created_at: 'now',
|
|
54
56
|
},
|
|
55
57
|
core: {
|
|
@@ -96,6 +98,8 @@ describe('twitter timeline helpers', () => {
|
|
|
96
98
|
likes: 3,
|
|
97
99
|
retweets: 2,
|
|
98
100
|
replies: 1,
|
|
101
|
+
quotes: 4,
|
|
102
|
+
bookmarks: 5,
|
|
99
103
|
views: 9,
|
|
100
104
|
created_at: 'now',
|
|
101
105
|
url: 'https://x.com/alice/status/1',
|
|
@@ -17,6 +17,7 @@ export declare class BrowserBridge implements IBrowserFactory {
|
|
|
17
17
|
session?: string;
|
|
18
18
|
idleTimeout?: number;
|
|
19
19
|
contextId?: string;
|
|
20
|
+
preferredContextId?: string;
|
|
20
21
|
windowMode?: 'foreground' | 'background';
|
|
21
22
|
surface?: 'browser' | 'adapter';
|
|
22
23
|
siteSession?: 'ephemeral' | 'persistent';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Browser session manager — auto-spawns daemon and provides IPage.
|
|
3
3
|
*/
|
|
4
4
|
import { Page } from './page.js';
|
|
5
|
-
import {
|
|
5
|
+
import { profileRouteParams, resolveProfileSelection } from './profile.js';
|
|
6
6
|
import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';
|
|
7
7
|
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
|
8
8
|
/**
|
|
@@ -26,11 +26,13 @@ export class BrowserBridge {
|
|
|
26
26
|
throw new Error('Session is closed');
|
|
27
27
|
this._state = 'connecting';
|
|
28
28
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const routing = opts.contextId || opts.preferredContextId
|
|
30
|
+
? { contextId: opts.contextId, preferredContextId: opts.preferredContextId }
|
|
31
|
+
: profileRouteParams(resolveProfileSelection());
|
|
32
|
+
await this._ensureDaemon(opts.timeout, routing.contextId);
|
|
31
33
|
if (!opts.session?.trim())
|
|
32
34
|
throw new Error('Browser session is required');
|
|
33
|
-
this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession);
|
|
35
|
+
this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId);
|
|
34
36
|
this._state = 'connected';
|
|
35
37
|
return this._page;
|
|
36
38
|
}
|
|
@@ -26,6 +26,7 @@ export declare class CDPBridge implements IBrowserFactory {
|
|
|
26
26
|
session?: string;
|
|
27
27
|
cdpEndpoint?: string;
|
|
28
28
|
contextId?: string;
|
|
29
|
+
preferredContextId?: string;
|
|
29
30
|
idleTimeout?: number;
|
|
30
31
|
windowMode?: 'foreground' | 'background';
|
|
31
32
|
surface?: 'browser' | 'adapter';
|
|
@@ -28,6 +28,7 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
|
|
|
28
28
|
}>;
|
|
29
29
|
export declare function bindTab(session: string, opts?: {
|
|
30
30
|
contextId?: string;
|
|
31
|
+
preferredContextId?: string;
|
|
31
32
|
page?: string;
|
|
32
33
|
index?: number;
|
|
33
34
|
}): Promise<unknown>;
|
|
@@ -7,7 +7,7 @@ import { sleep } from '../utils.js';
|
|
|
7
7
|
import { BrowserConnectError } from '../errors.js';
|
|
8
8
|
import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
|
|
9
9
|
import { classifyBrowserError } from './errors.js';
|
|
10
|
-
import {
|
|
10
|
+
import { profileRouteParams, resolveProfileSelection } from './profile.js';
|
|
11
11
|
import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
|
|
12
12
|
import { ensureBrowserBridgeReady } from './daemon-lifecycle.js';
|
|
13
13
|
import { isPreDispatchError } from './bridge-readiness.js';
|
|
@@ -90,7 +90,11 @@ async function sendCommandRaw(action, params) {
|
|
|
90
90
|
const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background'
|
|
91
91
|
? rawWindowMode
|
|
92
92
|
: undefined;
|
|
93
|
-
const
|
|
93
|
+
const routing = params.contextId || params.preferredContextId
|
|
94
|
+
? { contextId: params.contextId, preferredContextId: params.preferredContextId }
|
|
95
|
+
: profileRouteParams(resolveProfileSelection());
|
|
96
|
+
const contextId = routing.contextId;
|
|
97
|
+
const preferredContextId = routing.preferredContextId;
|
|
94
98
|
const windowMode = params.windowMode ?? envWindowMode;
|
|
95
99
|
let id = generateId();
|
|
96
100
|
let ensureUsed = false;
|
|
@@ -115,6 +119,7 @@ async function sendCommandRaw(action, params) {
|
|
|
115
119
|
timeout: timeoutSeconds,
|
|
116
120
|
deadlineAt,
|
|
117
121
|
...(contextId && { contextId }),
|
|
122
|
+
...(preferredContextId && { preferredContextId }),
|
|
118
123
|
...(windowMode && { windowMode }),
|
|
119
124
|
};
|
|
120
125
|
try {
|
|
@@ -22,7 +22,7 @@ describe('daemon-client', () => {
|
|
|
22
22
|
runtimeVersion: '1.2.3',
|
|
23
23
|
pending: 0,
|
|
24
24
|
memoryMB: 32,
|
|
25
|
-
port:
|
|
25
|
+
port: 9777,
|
|
26
26
|
};
|
|
27
27
|
const fetchMock = vi.mocked(fetch);
|
|
28
28
|
fetchMock.mockResolvedValue({
|
|
@@ -60,7 +60,7 @@ describe('daemon-client', () => {
|
|
|
60
60
|
runtimeName: 'fake',
|
|
61
61
|
pending: 0,
|
|
62
62
|
memoryMB: 16,
|
|
63
|
-
port:
|
|
63
|
+
port: 9777,
|
|
64
64
|
};
|
|
65
65
|
vi.mocked(fetch).mockResolvedValue({
|
|
66
66
|
ok: true,
|
|
@@ -78,7 +78,7 @@ describe('daemon-client', () => {
|
|
|
78
78
|
runtimeVersion: '1.2.3',
|
|
79
79
|
pending: 0,
|
|
80
80
|
memoryMB: 32,
|
|
81
|
-
port:
|
|
81
|
+
port: 9777,
|
|
82
82
|
};
|
|
83
83
|
vi.mocked(fetch).mockResolvedValue({
|
|
84
84
|
ok: true,
|
|
@@ -100,7 +100,7 @@ describe('daemon-client', () => {
|
|
|
100
100
|
],
|
|
101
101
|
pending: 0,
|
|
102
102
|
memoryMB: 32,
|
|
103
|
-
port:
|
|
103
|
+
port: 9777,
|
|
104
104
|
};
|
|
105
105
|
vi.mocked(fetch).mockResolvedValue({
|
|
106
106
|
ok: true,
|
|
@@ -119,32 +119,12 @@ describe('daemon-client', () => {
|
|
|
119
119
|
runtimeName: 'fake',
|
|
120
120
|
pending: 0,
|
|
121
121
|
memoryMB: 1,
|
|
122
|
-
port:
|
|
122
|
+
port: 9777,
|
|
123
123
|
}),
|
|
124
124
|
});
|
|
125
125
|
await fetchDaemonStatus({ contextId: 'work' });
|
|
126
126
|
expect(vi.mocked(fetch).mock.calls[0][0]).toMatch(/\/status\?contextId=work$/);
|
|
127
127
|
});
|
|
128
|
-
it('rejects WEBCMD_DAEMON_PORT so CLI and extension cannot split bridge ports', async () => {
|
|
129
|
-
vi.resetModules();
|
|
130
|
-
vi.stubEnv('WEBCMD_DAEMON_PORT', '19999');
|
|
131
|
-
vi.mocked(fetch).mockResolvedValue({
|
|
132
|
-
ok: true,
|
|
133
|
-
json: () => Promise.resolve({
|
|
134
|
-
ok: true,
|
|
135
|
-
pid: 1,
|
|
136
|
-
uptime: 0,
|
|
137
|
-
runtimeConnected: true,
|
|
138
|
-
runtimeName: 'fake',
|
|
139
|
-
pending: 0,
|
|
140
|
-
memoryMB: 1,
|
|
141
|
-
port: 19825,
|
|
142
|
-
}),
|
|
143
|
-
});
|
|
144
|
-
const freshClient = await import('./daemon-client.js');
|
|
145
|
-
await expect(freshClient.fetchDaemonStatus()).rejects.toThrow('WEBCMD_DAEMON_PORT is no longer supported');
|
|
146
|
-
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
|
147
|
-
});
|
|
148
128
|
it('sendCommand includes the current pid in generated command ids', async () => {
|
|
149
129
|
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
150
130
|
vi.mocked(fetch).mockResolvedValue({
|
|
@@ -162,7 +142,7 @@ describe('daemon-client', () => {
|
|
|
162
142
|
expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
|
|
163
143
|
expect(ids[0]).not.toBe(ids[1]);
|
|
164
144
|
});
|
|
165
|
-
it('sendCommand forwards WEBCMD_PROFILE as
|
|
145
|
+
it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
|
|
166
146
|
vi.stubEnv('WEBCMD_PROFILE', 'work');
|
|
167
147
|
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
168
148
|
vi.mocked(fetch).mockResolvedValue({
|
|
@@ -172,6 +152,29 @@ describe('daemon-client', () => {
|
|
|
172
152
|
await sendCommand('exec', { code: '1 + 1' });
|
|
173
153
|
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
174
154
|
expect(body.contextId).toBe('work');
|
|
155
|
+
expect(body.preferredContextId).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
it('sendCommand forwards the config default as preferredContextId', async () => {
|
|
158
|
+
const fs = await import('node:fs');
|
|
159
|
+
const os = await import('node:os');
|
|
160
|
+
const path = await import('node:path');
|
|
161
|
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-dc-profile-'));
|
|
162
|
+
fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify({ version: 1, aliases: {}, defaultContextId: 'profile-default' }));
|
|
163
|
+
vi.stubEnv('WEBCMD_CONFIG_DIR', configDir);
|
|
164
|
+
vi.stubEnv('WEBCMD_PROFILE', '');
|
|
165
|
+
try {
|
|
166
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
167
|
+
status: 200,
|
|
168
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
|
|
169
|
+
});
|
|
170
|
+
await sendCommand('exec', { code: '1 + 1' });
|
|
171
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
172
|
+
expect(body.contextId).toBeUndefined();
|
|
173
|
+
expect(body.preferredContextId).toBe('profile-default');
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
175
178
|
});
|
|
176
179
|
it('sendCommand uses explicit windowMode before WEBCMD_WINDOW env fallback', async () => {
|
|
177
180
|
vi.stubEnv('WEBCMD_WINDOW', 'foreground');
|
|
@@ -230,7 +233,7 @@ describe('daemon-client', () => {
|
|
|
230
233
|
runtimeName: 'fake',
|
|
231
234
|
pending: 0,
|
|
232
235
|
memoryMB: 0,
|
|
233
|
-
port:
|
|
236
|
+
port: 9777,
|
|
234
237
|
},
|
|
235
238
|
},
|
|
236
239
|
spawnedProcess: null,
|
|
@@ -269,13 +272,13 @@ describe('daemon-client', () => {
|
|
|
269
272
|
runtimeName: 'fake',
|
|
270
273
|
pending: 0,
|
|
271
274
|
memoryMB: 0,
|
|
272
|
-
port:
|
|
275
|
+
port: 9777,
|
|
273
276
|
},
|
|
274
277
|
},
|
|
275
278
|
spawnedProcess: null,
|
|
276
279
|
});
|
|
277
280
|
const refused = new TypeError('fetch failed');
|
|
278
|
-
refused.cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:
|
|
281
|
+
refused.cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:9777'), { code: 'ECONNREFUSED' });
|
|
279
282
|
const fetchMock = vi.mocked(fetch);
|
|
280
283
|
fetchMock
|
|
281
284
|
.mockRejectedValueOnce(refused)
|
|
@@ -300,7 +303,7 @@ describe('daemon-client', () => {
|
|
|
300
303
|
runtimeName: 'fake',
|
|
301
304
|
pending: 0,
|
|
302
305
|
memoryMB: 0,
|
|
303
|
-
port:
|
|
306
|
+
port: 9777,
|
|
304
307
|
},
|
|
305
308
|
},
|
|
306
309
|
spawnedProcess: null,
|
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT
|
|
1
|
+
import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
2
2
|
const DAEMON_PORT = DEFAULT_DAEMON_PORT;
|
|
3
3
|
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
4
4
|
const WEBCMD_HEADERS = { [DAEMON_HEADER_NAME]: '1' };
|
|
5
|
-
class UnsupportedDaemonPortEnvError extends Error {
|
|
6
|
-
constructor(value) {
|
|
7
|
-
super(unsupportedDaemonPortEnvMessage(value));
|
|
8
|
-
this.name = 'UnsupportedDaemonPortEnvError';
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
function assertSupportedDaemonPortEnv() {
|
|
12
|
-
const value = process.env.WEBCMD_DAEMON_PORT;
|
|
13
|
-
if (value !== undefined && value !== '')
|
|
14
|
-
throw new UnsupportedDaemonPortEnvError(value);
|
|
15
|
-
}
|
|
16
5
|
export async function requestDaemon(pathname, init) {
|
|
17
|
-
assertSupportedDaemonPortEnv();
|
|
18
6
|
const { timeout = 2000, headers, ...rest } = init ?? {};
|
|
19
7
|
const controller = new AbortController();
|
|
20
8
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
@@ -37,9 +25,7 @@ export async function fetchDaemonStatus(opts) {
|
|
|
37
25
|
return null;
|
|
38
26
|
return await res.json();
|
|
39
27
|
}
|
|
40
|
-
catch
|
|
41
|
-
if (err instanceof UnsupportedDaemonPortEnvError)
|
|
42
|
-
throw err;
|
|
28
|
+
catch {
|
|
43
29
|
return null;
|
|
44
30
|
}
|
|
45
31
|
}
|
|
@@ -60,9 +46,7 @@ export async function requestDaemonShutdown(opts) {
|
|
|
60
46
|
const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 });
|
|
61
47
|
return res.ok;
|
|
62
48
|
}
|
|
63
|
-
catch
|
|
64
|
-
if (err instanceof UnsupportedDaemonPortEnvError)
|
|
65
|
-
throw err;
|
|
49
|
+
catch {
|
|
66
50
|
return false;
|
|
67
51
|
}
|
|
68
52
|
}
|
|
@@ -19,8 +19,9 @@ export declare class Page extends BasePage {
|
|
|
19
19
|
private readonly windowMode?;
|
|
20
20
|
private readonly surface;
|
|
21
21
|
private readonly siteSession?;
|
|
22
|
+
readonly preferredContextId?: string | undefined;
|
|
22
23
|
private readonly _idleTimeout;
|
|
23
|
-
constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined);
|
|
24
|
+
constructor(session: string, idleTimeout?: number, contextId?: string | undefined, windowMode?: "foreground" | "background" | undefined, surface?: 'browser' | 'adapter', siteSession?: "ephemeral" | "persistent" | undefined, preferredContextId?: string | undefined);
|
|
24
25
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
25
26
|
private _page;
|
|
26
27
|
private _networkCaptureUnsupported;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -40,14 +40,16 @@ export class Page extends BasePage {
|
|
|
40
40
|
windowMode;
|
|
41
41
|
surface;
|
|
42
42
|
siteSession;
|
|
43
|
+
preferredContextId;
|
|
43
44
|
_idleTimeout;
|
|
44
|
-
constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession) {
|
|
45
|
+
constructor(session, idleTimeout, contextId, windowMode, surface = 'browser', siteSession, preferredContextId) {
|
|
45
46
|
super();
|
|
46
47
|
this.session = session;
|
|
47
48
|
this.contextId = contextId;
|
|
48
49
|
this.windowMode = windowMode;
|
|
49
50
|
this.surface = surface;
|
|
50
51
|
this.siteSession = siteSession;
|
|
52
|
+
this.preferredContextId = preferredContextId;
|
|
51
53
|
this._idleTimeout = idleTimeout;
|
|
52
54
|
}
|
|
53
55
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
@@ -60,6 +62,7 @@ export class Page extends BasePage {
|
|
|
60
62
|
session: this.session,
|
|
61
63
|
surface: this.surface,
|
|
62
64
|
...(this.contextId && { contextId: this.contextId }),
|
|
65
|
+
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
|
|
63
66
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
64
67
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
65
68
|
...(this.siteSession && { siteSession: this.siteSession }),
|
|
@@ -71,6 +74,7 @@ export class Page extends BasePage {
|
|
|
71
74
|
session: this.session,
|
|
72
75
|
surface: this.surface,
|
|
73
76
|
...(this.contextId && { contextId: this.contextId }),
|
|
77
|
+
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
|
|
74
78
|
...(this._page !== undefined && { page: this._page }),
|
|
75
79
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
76
80
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
@@ -8,6 +8,15 @@ export declare function normalizeContextId(value: string | undefined | null): st
|
|
|
8
8
|
export declare function emptyProfileConfig(): ProfileConfig;
|
|
9
9
|
export declare function loadProfileConfig(): ProfileConfig;
|
|
10
10
|
export declare function saveProfileConfig(config: ProfileConfig): void;
|
|
11
|
+
export type ProfileSelection = {
|
|
12
|
+
contextId: string;
|
|
13
|
+
source: 'explicit' | 'preferred';
|
|
14
|
+
};
|
|
15
|
+
export declare function resolveProfileSelection(profile?: string): ProfileSelection | undefined;
|
|
16
|
+
export declare function profileRouteParams(selection: ProfileSelection | undefined): {
|
|
17
|
+
contextId?: string;
|
|
18
|
+
preferredContextId?: string;
|
|
19
|
+
};
|
|
11
20
|
export declare function resolveProfileContextId(profile?: string): string | undefined;
|
|
12
21
|
export declare function aliasForContextId(config: ProfileConfig, contextId: string): string | undefined;
|
|
13
22
|
export declare function renameProfile(contextId: string, alias: string): ProfileConfig;
|
|
@@ -42,14 +42,25 @@ export function saveProfileConfig(config) {
|
|
|
42
42
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
43
43
|
fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
44
44
|
}
|
|
45
|
-
export function
|
|
45
|
+
export function resolveProfileSelection(profile) {
|
|
46
46
|
const config = loadProfileConfig();
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
const explicit = normalizeContextId(profile) ?? normalizeContextId(process.env[`${ENV_PREFIX}_PROFILE`]);
|
|
48
|
+
if (explicit)
|
|
49
|
+
return { contextId: config.aliases[explicit] ?? explicit, source: 'explicit' };
|
|
50
|
+
const preferred = normalizeContextId(config.defaultContextId);
|
|
51
|
+
if (preferred)
|
|
52
|
+
return { contextId: config.aliases[preferred] ?? preferred, source: 'preferred' };
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
export function profileRouteParams(selection) {
|
|
56
|
+
if (!selection)
|
|
57
|
+
return {};
|
|
58
|
+
return selection.source === 'explicit'
|
|
59
|
+
? { contextId: selection.contextId }
|
|
60
|
+
: { preferredContextId: selection.contextId };
|
|
61
|
+
}
|
|
62
|
+
export function resolveProfileContextId(profile) {
|
|
63
|
+
return resolveProfileSelection(profile)?.contextId;
|
|
53
64
|
}
|
|
54
65
|
export function aliasForContextId(config, contextId) {
|
|
55
66
|
for (const [alias, id] of Object.entries(config.aliases)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|