@agentrhq/webcmd 0.2.0 → 0.2.2
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 +58 -31
- package/cli-manifest.json +506 -0
- package/clis/_shared/site-auth.js +6 -1
- package/clis/district/_lib.js +566 -0
- package/clis/district/auth.js +49 -0
- package/clis/district/checkout.js +278 -0
- package/clis/district/listings.js +158 -0
- package/clis/district/locations.js +211 -0
- package/clis/district/search.js +218 -0
- package/clis/district/seats.js +233 -0
- package/clis/district/set-location.js +82 -0
- package/clis/district/showtimes.js +433 -0
- package/clis/reddit/popular.js +12 -1
- package/clis/reddit/popular.test.js +12 -3
- 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 +2 -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 +5 -1
- package/dist/src/browser/page.js +16 -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 +3 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +25 -10
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +15 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +145 -0
- package/dist/src/build-manifest.js +1 -0
- package/dist/src/build-manifest.test.js +34 -0
- package/dist/src/cli.js +133 -45
- 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/discovery.js +1 -0
- package/dist/src/doctor.js +12 -0
- package/dist/src/doctor.test.js +30 -3
- package/dist/src/engine.test.js +62 -0
- 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/manifest-types.d.ts +2 -0
- package/dist/src/node-network.test.js +3 -3
- package/dist/src/registry.d.ts +10 -0
- package/dist/src/registry.js +5 -3
- package/dist/src/registry.test.js +25 -0
- package/dist/src/runtime-identity.test.js +0 -8
- package/dist/src/runtime.d.ts +4 -0
- package/dist/src/runtime.js +2 -0
- package/dist/src/skills.d.ts +23 -5
- package/dist/src/skills.js +87 -45
- package/dist/src/skills.test.js +80 -23
- package/package.json +3 -3
- package/skills/smart-search/SKILL.md +156 -0
- package/skills/smart-search/references/sources-ai.md +74 -0
- package/skills/smart-search/references/sources-info.md +43 -0
- package/skills/smart-search/references/sources-media.md +40 -0
- package/skills/smart-search/references/sources-other.md +32 -0
- package/skills/smart-search/references/sources-shopping.md +21 -0
- package/skills/smart-search/references/sources-social.md +36 -0
- package/skills/smart-search/references/sources-tech.md +38 -0
- package/skills/smart-search/references/sources-travel.md +26 -0
- package/skills/webcmd-adapter-author/SKILL.md +1 -0
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
- package/skills/webcmd-autofix/SKILL.md +8 -0
- package/skills/webcmd-sitemap-author/SKILL.md +1 -1
|
@@ -17,9 +17,11 @@ 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';
|
|
24
|
+
freshPage?: boolean;
|
|
23
25
|
}): Promise<IPage>;
|
|
24
26
|
close(): Promise<void>;
|
|
25
27
|
private _ensureDaemon;
|
|
@@ -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, opts.freshPage);
|
|
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,12 @@ 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, freshPage?: boolean);
|
|
25
|
+
/** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
|
|
26
|
+
private _freshPagePending;
|
|
27
|
+
private _freshPageOpts;
|
|
24
28
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
25
29
|
private _page;
|
|
26
30
|
private _networkCaptureUnsupported;
|
package/dist/src/browser/page.js
CHANGED
|
@@ -40,15 +40,26 @@ 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, freshPage) {
|
|
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;
|
|
54
|
+
this._freshPagePending = freshPage === true;
|
|
55
|
+
}
|
|
56
|
+
/** When set, the next daemon command asks for a fresh leased page; consumed on first send. */
|
|
57
|
+
_freshPagePending;
|
|
58
|
+
_freshPageOpts() {
|
|
59
|
+
if (!this._freshPagePending)
|
|
60
|
+
return {};
|
|
61
|
+
this._freshPagePending = false;
|
|
62
|
+
return { freshPage: true };
|
|
52
63
|
}
|
|
53
64
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
54
65
|
_page;
|
|
@@ -60,9 +71,11 @@ export class Page extends BasePage {
|
|
|
60
71
|
session: this.session,
|
|
61
72
|
surface: this.surface,
|
|
62
73
|
...(this.contextId && { contextId: this.contextId }),
|
|
74
|
+
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
|
|
63
75
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
64
76
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
65
77
|
...(this.siteSession && { siteSession: this.siteSession }),
|
|
78
|
+
...this._freshPageOpts(),
|
|
66
79
|
};
|
|
67
80
|
}
|
|
68
81
|
/** Helper: spread session + page identity into command params */
|
|
@@ -71,10 +84,12 @@ export class Page extends BasePage {
|
|
|
71
84
|
session: this.session,
|
|
72
85
|
surface: this.surface,
|
|
73
86
|
...(this.contextId && { contextId: this.contextId }),
|
|
87
|
+
...(this.preferredContextId && { preferredContextId: this.preferredContextId }),
|
|
74
88
|
...(this._page !== undefined && { page: this._page }),
|
|
75
89
|
...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }),
|
|
76
90
|
...(this.windowMode && { windowMode: this.windowMode }),
|
|
77
91
|
...(this.siteSession && { siteSession: this.siteSession }),
|
|
92
|
+
...this._freshPageOpts(),
|
|
78
93
|
};
|
|
79
94
|
}
|
|
80
95
|
async goto(url, options) {
|
|
@@ -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 {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { ENV_PREFIX } from '../brand.js';
|
|
6
|
+
import { profileRouteParams, resolveProfileSelection } from './profile.js';
|
|
7
|
+
describe('profile selection', () => {
|
|
8
|
+
let configDir;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-profile-test-'));
|
|
11
|
+
vi.stubEnv(`${ENV_PREFIX}_CONFIG_DIR`, configDir);
|
|
12
|
+
vi.stubEnv(`${ENV_PREFIX}_PROFILE`, '');
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllEnvs();
|
|
16
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
function writeConfig(config) {
|
|
19
|
+
fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify(config));
|
|
20
|
+
}
|
|
21
|
+
it('tags an explicit profile argument as explicit and resolves aliases', () => {
|
|
22
|
+
writeConfig({ version: 1, aliases: { work: 'profile-work' } });
|
|
23
|
+
expect(resolveProfileSelection('work')).toEqual({ contextId: 'profile-work', source: 'explicit' });
|
|
24
|
+
});
|
|
25
|
+
it('tags WEBCMD_PROFILE as explicit', () => {
|
|
26
|
+
vi.stubEnv(`${ENV_PREFIX}_PROFILE`, 'profile-env');
|
|
27
|
+
expect(resolveProfileSelection()).toEqual({ contextId: 'profile-env', source: 'explicit' });
|
|
28
|
+
});
|
|
29
|
+
it('tags the persisted config default as preferred', () => {
|
|
30
|
+
writeConfig({ version: 1, aliases: {}, defaultContextId: 'profile-default' });
|
|
31
|
+
expect(resolveProfileSelection()).toEqual({ contextId: 'profile-default', source: 'preferred' });
|
|
32
|
+
});
|
|
33
|
+
it('explicit argument beats env, and env beats config default', () => {
|
|
34
|
+
vi.stubEnv(`${ENV_PREFIX}_PROFILE`, 'from-env');
|
|
35
|
+
writeConfig({ version: 1, aliases: {}, defaultContextId: 'from-config' });
|
|
36
|
+
expect(resolveProfileSelection('from-arg')).toEqual({ contextId: 'from-arg', source: 'explicit' });
|
|
37
|
+
expect(resolveProfileSelection()).toEqual({ contextId: 'from-env', source: 'explicit' });
|
|
38
|
+
});
|
|
39
|
+
it('maps explicit routes to contextId and preferred routes to preferredContextId', () => {
|
|
40
|
+
expect(profileRouteParams({ contextId: 'a', source: 'explicit' })).toEqual({ contextId: 'a' });
|
|
41
|
+
expect(profileRouteParams({ contextId: 'b', source: 'preferred' })).toEqual({ preferredContextId: 'b' });
|
|
42
|
+
expect(profileRouteParams(undefined)).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -10,6 +10,8 @@ export interface BrowserRuntimeCommand {
|
|
|
10
10
|
session?: string;
|
|
11
11
|
surface?: BrowserSurface;
|
|
12
12
|
siteSession?: SiteSessionMode;
|
|
13
|
+
/** Close any existing leased page and start on a new one (sent on the first action of a command run). */
|
|
14
|
+
freshPage?: boolean;
|
|
13
15
|
url?: string;
|
|
14
16
|
op?: string;
|
|
15
17
|
index?: number;
|
|
@@ -34,6 +36,7 @@ export interface BrowserRuntimeCommand {
|
|
|
34
36
|
idleTimeout?: number;
|
|
35
37
|
frameIndex?: number;
|
|
36
38
|
contextId?: string;
|
|
39
|
+
preferredContextId?: string;
|
|
37
40
|
profileId?: string;
|
|
38
41
|
}
|
|
39
42
|
export interface BrowserRuntimeResult {
|
|
@@ -10,8 +10,22 @@ class CloakActionError extends Error {
|
|
|
10
10
|
this.errorHint = errorHint;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
function commandProfileId(command) {
|
|
14
|
-
|
|
13
|
+
function commandProfileId(manager, command) {
|
|
14
|
+
const requested = command.profileId ?? command.contextId;
|
|
15
|
+
if (requested?.trim())
|
|
16
|
+
return requested.trim();
|
|
17
|
+
const preferred = command.preferredContextId?.trim();
|
|
18
|
+
if (!preferred)
|
|
19
|
+
return 'default';
|
|
20
|
+
const active = manager.activeProfileIds();
|
|
21
|
+
if (active.includes(preferred))
|
|
22
|
+
return preferred;
|
|
23
|
+
if (active.length === 1)
|
|
24
|
+
return active[0];
|
|
25
|
+
if (active.length > 1) {
|
|
26
|
+
throw new CloakActionError('profile_required', `Default Cloak profile "${preferred}" is not active and multiple profiles are running; choose one with --profile.`, undefined, 'Run webcmd profile list, then update the default with webcmd profile use <name> or pass --profile <name>.');
|
|
27
|
+
}
|
|
28
|
+
return preferred;
|
|
15
29
|
}
|
|
16
30
|
function invalidRequest(command, error) {
|
|
17
31
|
return { id: command.id, ok: false, errorCode: 'invalid_request', error };
|
|
@@ -24,11 +38,12 @@ async function resolveLease(manager, command) {
|
|
|
24
38
|
throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`);
|
|
25
39
|
}
|
|
26
40
|
return manager.getPage({
|
|
27
|
-
profileId: commandProfileId(command),
|
|
41
|
+
profileId: commandProfileId(manager, command),
|
|
28
42
|
session: command.session,
|
|
29
43
|
surface: command.surface,
|
|
30
44
|
siteSession: command.siteSession,
|
|
31
45
|
idleTimeout: command.idleTimeout,
|
|
46
|
+
freshPage: command.freshPage,
|
|
32
47
|
});
|
|
33
48
|
}
|
|
34
49
|
function execTarget(page, frameIndex, pageId) {
|
|
@@ -93,12 +108,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
93
108
|
}
|
|
94
109
|
case 'close-window': {
|
|
95
110
|
if (command.page) {
|
|
96
|
-
const closed = await manager.closePage({ profileId: commandProfileId(command), pageId: command.page });
|
|
111
|
+
const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page });
|
|
97
112
|
return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } };
|
|
98
113
|
}
|
|
99
114
|
else {
|
|
100
115
|
await manager.release({
|
|
101
|
-
profileId: commandProfileId(command),
|
|
116
|
+
profileId: commandProfileId(manager, command),
|
|
102
117
|
session: command.session,
|
|
103
118
|
surface: command.surface,
|
|
104
119
|
});
|
|
@@ -108,12 +123,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
108
123
|
case 'tabs': {
|
|
109
124
|
switch (command.op ?? 'list') {
|
|
110
125
|
case 'list': {
|
|
111
|
-
const tabs = await manager.listPages({ profileId: commandProfileId(command) });
|
|
126
|
+
const tabs = await manager.listPages({ profileId: commandProfileId(manager, command) });
|
|
112
127
|
return { id: command.id, ok: true, data: tabs };
|
|
113
128
|
}
|
|
114
129
|
case 'new': {
|
|
115
130
|
const lease = await manager.newPage({
|
|
116
|
-
profileId: commandProfileId(command),
|
|
131
|
+
profileId: commandProfileId(manager, command),
|
|
117
132
|
session: command.session,
|
|
118
133
|
surface: command.surface,
|
|
119
134
|
siteSession: command.siteSession,
|
|
@@ -123,13 +138,13 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
123
138
|
return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
|
|
124
139
|
}
|
|
125
140
|
case 'select': {
|
|
126
|
-
const lease = await manager.selectPage({ profileId: commandProfileId(command), pageId: command.page, index: command.index });
|
|
141
|
+
const lease = await manager.selectPage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
|
|
127
142
|
if (!lease)
|
|
128
143
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
129
144
|
return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
|
|
130
145
|
}
|
|
131
146
|
case 'close': {
|
|
132
|
-
const closed = await manager.closePage({ profileId: commandProfileId(command), pageId: command.page, index: command.index });
|
|
147
|
+
const closed = await manager.closePage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
|
|
133
148
|
if (!closed)
|
|
134
149
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
135
150
|
return { id: command.id, ok: true, data: { closed } };
|
|
@@ -197,7 +212,7 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
197
212
|
}
|
|
198
213
|
{
|
|
199
214
|
const lease = await manager.bindPage({
|
|
200
|
-
profileId: commandProfileId(command),
|
|
215
|
+
profileId: commandProfileId(manager, command),
|
|
201
216
|
session: command.session,
|
|
202
217
|
surface: command.surface,
|
|
203
218
|
siteSession: command.siteSession,
|
|
@@ -9,6 +9,8 @@ export interface SessionKeyInput {
|
|
|
9
9
|
surface?: BrowserSurface;
|
|
10
10
|
siteSession?: SiteSessionMode;
|
|
11
11
|
idleTimeout?: number;
|
|
12
|
+
/** Discard the existing leased page (if any) and create a new one under the same lease. */
|
|
13
|
+
freshPage?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface CloakPageLease {
|
|
14
16
|
profileId: string;
|
|
@@ -46,6 +48,7 @@ export declare class CloakSessionManager {
|
|
|
46
48
|
pending: number;
|
|
47
49
|
lastSeenAt: number;
|
|
48
50
|
}[];
|
|
51
|
+
activeProfileIds(): string[];
|
|
49
52
|
getPage(input: SessionKeyInput): Promise<CloakPageLease>;
|
|
50
53
|
findPageById(pageId: string, opts?: Pick<SessionKeyInput, 'idleTimeout'>): CloakPageLease | null;
|
|
51
54
|
pageIdFor(page: PlaywrightPage): string | undefined;
|
|
@@ -31,21 +31,34 @@ export class CloakSessionManager {
|
|
|
31
31
|
lastSeenAt: runtime.lastSeenAt,
|
|
32
32
|
}));
|
|
33
33
|
}
|
|
34
|
+
activeProfileIds() {
|
|
35
|
+
return [...this.profiles.keys()];
|
|
36
|
+
}
|
|
34
37
|
async getPage(input) {
|
|
35
38
|
const profileId = normalizeProfileId(input.profileId);
|
|
36
39
|
const session = requireSession(input.session);
|
|
37
40
|
const surface = normalizeSurface(input.surface);
|
|
38
41
|
const leaseKey = resolveLeaseKey(input);
|
|
39
42
|
const runtime = await this.getProfileRuntime(profileId);
|
|
43
|
+
const freshPage = input.freshPage === true;
|
|
40
44
|
const existing = runtime.pages.get(leaseKey);
|
|
41
|
-
if (existing && !pageIsClosed(existing.page)) {
|
|
45
|
+
if (existing && !pageIsClosed(existing.page) && !freshPage) {
|
|
42
46
|
runtime.lastSeenAt = Date.now();
|
|
43
47
|
existing.idleTimeout = input.idleTimeout;
|
|
44
48
|
this.refreshIdleTimer(runtime, leaseKey, existing);
|
|
45
49
|
return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId };
|
|
46
50
|
}
|
|
51
|
+
if (existing && freshPage) {
|
|
52
|
+
runtime.pages.delete(leaseKey);
|
|
53
|
+
this.clearIdleTimer(existing);
|
|
54
|
+
if (runtime.selectedPageId === existing.pageId)
|
|
55
|
+
runtime.selectedPageId = undefined;
|
|
56
|
+
if (!pageIsClosed(existing.page))
|
|
57
|
+
await existing.page.close().catch(() => { });
|
|
58
|
+
}
|
|
47
59
|
const existingPages = runtime.context.pages();
|
|
48
|
-
|
|
60
|
+
// freshPage must never adopt a leftover tab — its whole point is a clean DOM.
|
|
61
|
+
const page = !freshPage && existingPages[0] && runtime.pages.size === 0 ? existingPages[0] : await runtime.context.newPage();
|
|
49
62
|
const pageId = nextPageId();
|
|
50
63
|
const entry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout };
|
|
51
64
|
runtime.pages.set(leaseKey, entry);
|