@agentrhq/webcmd 0.3.2 → 0.3.4
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 +12 -7
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +2 -1
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/dist/src/browser/ax-snapshot.js +15 -5
- package/dist/src/browser/ax-snapshot.test.js +49 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +1 -1
- package/package.json +3 -1
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/smart-search/SKILL.md +20 -6
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
- package/skills/webcmd-browser/SKILL.md +16 -2
- package/skills/webcmd-usage/SKILL.md +21 -6
package/README.md
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
<a href="https://www.npmjs.com/package/@agentrhq/webcmd">
|
|
9
9
|
<img alt="NPM downloads" src="https://img.shields.io/npm/dt/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
|
|
10
10
|
</a>
|
|
11
|
+
<a href="https://docs.webcmd.dev">
|
|
12
|
+
<img alt="Documentation" src="https://img.shields.io/badge/docs-webcmd.dev-7C3AED.svg?style=for-the-badge&labelColor=000000">
|
|
13
|
+
</a>
|
|
11
14
|
<a href="https://github.com/agentrhq/webcmd/blob/main/LICENSE">
|
|
12
15
|
<img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-1E88E5.svg?style=for-the-badge&labelColor=000000">
|
|
13
16
|
</a>
|
|
14
17
|
<a href="https://discord.gg/9YP2C9tvMp">
|
|
15
|
-
<img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-
|
|
18
|
+
<img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white&labelColor=000000&logoWidth=20">
|
|
16
19
|
</a>
|
|
17
20
|
<a href="https://x.com/agentrhq">
|
|
18
21
|
<img alt="Follow AgentR on X" src="https://img.shields.io/badge/Built%20by%20%40agentrhq-000000.svg?style=for-the-badge&logo=x&logoColor=white&labelColor=000000&logoWidth=20">
|
|
@@ -123,15 +126,17 @@ Agents usually want `-f json`; humans usually want table (default) or yaml.
|
|
|
123
126
|
|
|
124
127
|
Webcmd is designed to be driven by coding agents such as Codex, Claude Code, Cursor, and similar tools.
|
|
125
128
|
|
|
126
|
-
##
|
|
129
|
+
## Add skills (also refreshes existing links)
|
|
127
130
|
|
|
128
|
-
|
|
131
|
+
Add Webcmd skills to your agent environment:
|
|
129
132
|
|
|
130
133
|
```bash
|
|
131
|
-
webcmd skills
|
|
134
|
+
webcmd skills add
|
|
132
135
|
```
|
|
133
136
|
|
|
134
|
-
The
|
|
137
|
+
The command asks whether to add globally or locally, then asks for the coding agent (`agents`, `codex`, `claude`) or a custom skills path. For scripts, pass flags such as `--scope project --provider codex` or `--path ./my-skills`.
|
|
138
|
+
|
|
139
|
+
Remove Webcmd skill symlinks from all supported global and current-project locations with `webcmd skills remove`. Pass `--path ./my-skills` to include a custom directory.
|
|
135
140
|
|
|
136
141
|
### Which skill to use
|
|
137
142
|
|
|
@@ -312,8 +317,8 @@ Common paths:
|
|
|
312
317
|
| `~/.webcmd/clis/` | Private adapters. |
|
|
313
318
|
| `~/.webcmd/cache/browser-network/` | Cached browser network captures. |
|
|
314
319
|
| `~/.webcmd/external-clis.yaml` | User external CLI registry entries. |
|
|
315
|
-
| `~/.agents/skills/` | Common global skills
|
|
316
|
-
| `.agents/skills/` | Common workspace-local skills
|
|
320
|
+
| `~/.agents/skills/` | Common global skills directory for agent skill managers. |
|
|
321
|
+
| `.agents/skills/` | Common workspace-local skills directory. |
|
|
317
322
|
|
|
318
323
|
## Troubleshooting
|
|
319
324
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
8
8
|
import { CliError } from '@agentrhq/webcmd/errors';
|
|
9
|
-
import { PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
9
|
+
import { assertProductHuntAccessible, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'producthunt',
|
|
12
12
|
name: 'browse',
|
|
@@ -30,7 +30,14 @@ cli({
|
|
|
30
30
|
const slug = String(args.category || '').trim().toLowerCase();
|
|
31
31
|
await page.installInterceptor('producthunt.com');
|
|
32
32
|
await page.goto(`https://www.producthunt.com/categories/${slug}`);
|
|
33
|
-
|
|
33
|
+
try {
|
|
34
|
+
await page.waitForCapture(5);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await assertProductHuntAccessible(page);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
await assertProductHuntAccessible(page);
|
|
34
41
|
const domItems = await page.evaluate(`
|
|
35
42
|
(() => {
|
|
36
43
|
const seen = new Set();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import './hot.js';
|
|
4
|
+
import './browse.js';
|
|
5
|
+
|
|
6
|
+
function pageMock({ evaluations, captureError } = {}) {
|
|
7
|
+
const queue = [...(evaluations ?? [])];
|
|
8
|
+
return {
|
|
9
|
+
goto: async () => undefined,
|
|
10
|
+
wait: async () => undefined,
|
|
11
|
+
installInterceptor: async () => undefined,
|
|
12
|
+
waitForCapture: async () => {
|
|
13
|
+
if (captureError) throw captureError;
|
|
14
|
+
},
|
|
15
|
+
evaluate: async () => queue.shift(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const challenge = {
|
|
20
|
+
title: 'Just a moment...',
|
|
21
|
+
body: 'Performing security verification before proceeding',
|
|
22
|
+
cloudflare: true,
|
|
23
|
+
};
|
|
24
|
+
const accessible = {
|
|
25
|
+
title: 'Product Hunt',
|
|
26
|
+
body: 'Discover your next favorite thing',
|
|
27
|
+
cloudflare: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('Product Hunt browser commands', () => {
|
|
31
|
+
it.each([
|
|
32
|
+
['producthunt/hot', {}],
|
|
33
|
+
['producthunt/browse', { category: 'developer-tools' }],
|
|
34
|
+
])('%s reports verification pages as SITE_BLOCKED', async (name, args) => {
|
|
35
|
+
const command = getRegistry().get(name);
|
|
36
|
+
|
|
37
|
+
await expect(command.func(pageMock({ evaluations: [challenge] }), args)).rejects.toMatchObject({
|
|
38
|
+
code: 'SITE_BLOCKED',
|
|
39
|
+
exitCode: 69,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports a challenge when category capture fails on the verification page', async () => {
|
|
44
|
+
const command = getRegistry().get('producthunt/browse');
|
|
45
|
+
const captureError = new Error('No network capture within 5s');
|
|
46
|
+
|
|
47
|
+
await expect(command.func(pageMock({ evaluations: [challenge], captureError }), {
|
|
48
|
+
category: 'developer-tools',
|
|
49
|
+
})).rejects.toMatchObject({
|
|
50
|
+
code: 'SITE_BLOCKED',
|
|
51
|
+
exitCode: 69,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
['producthunt/hot', {}, 'Could not retrieve Product Hunt top posts'],
|
|
57
|
+
['producthunt/browse', { category: 'developer-tools' }, 'No products found for category'],
|
|
58
|
+
])('%s preserves NO_DATA for an accessible empty page', async (name, args, message) => {
|
|
59
|
+
const command = getRegistry().get(name);
|
|
60
|
+
|
|
61
|
+
await expect(command.func(pageMock({ evaluations: [accessible, []] }), args)).rejects.toMatchObject({
|
|
62
|
+
code: 'NO_DATA',
|
|
63
|
+
message: expect.stringContaining(message),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
package/clis/producthunt/hot.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
7
7
|
import { CliError } from '@agentrhq/webcmd/errors';
|
|
8
|
-
import { pickVoteCount } from './utils.js';
|
|
8
|
+
import { assertProductHuntAccessible, pickVoteCount } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'producthunt',
|
|
11
11
|
name: 'hot',
|
|
@@ -21,6 +21,7 @@ cli({
|
|
|
21
21
|
const count = Math.min(Number(args.limit) || 20, 50);
|
|
22
22
|
await page.goto('https://www.producthunt.com');
|
|
23
23
|
await page.wait(2);
|
|
24
|
+
await assertProductHuntAccessible(page);
|
|
24
25
|
const domItems = await page.evaluate(`
|
|
25
26
|
(() => {
|
|
26
27
|
const seen = new Set();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Product Hunt shared helpers.
|
|
3
3
|
*/
|
|
4
|
+
import { CliError, EXIT_CODES } from '@agentrhq/webcmd/errors';
|
|
5
|
+
|
|
4
6
|
export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
5
7
|
'ai-agents',
|
|
6
8
|
'ai-coding-agents',
|
|
@@ -19,6 +21,31 @@ export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
|
19
21
|
'engineering-development',
|
|
20
22
|
];
|
|
21
23
|
const UA = 'Mozilla/5.0 (compatible; webcmd/1.0)';
|
|
24
|
+
|
|
25
|
+
export function isProductHuntVerificationPage(value) {
|
|
26
|
+
const title = String(value?.title ?? '');
|
|
27
|
+
const body = String(value?.body ?? '');
|
|
28
|
+
return value?.cloudflare === true
|
|
29
|
+
|| /just a moment|security verification/i.test(title)
|
|
30
|
+
|| /performing security verification|verify you are human|enable javascript and cookies/i.test(body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function assertProductHuntAccessible(page) {
|
|
34
|
+
const state = await page.evaluate(`(() => ({
|
|
35
|
+
title: document.title || '',
|
|
36
|
+
body: (document.body?.innerText || '').slice(0, 2000),
|
|
37
|
+
cloudflare: !!document.querySelector('[id^="cf-"], [class*="cf-challenge"]')
|
|
38
|
+
}))()`);
|
|
39
|
+
if (!isProductHuntVerificationPage(state))
|
|
40
|
+
return;
|
|
41
|
+
throw new CliError(
|
|
42
|
+
'SITE_BLOCKED',
|
|
43
|
+
'Product Hunt served a security verification page',
|
|
44
|
+
'Complete permitted verification in the active browser when available, then retry. Webcmd will not bypass site verification.',
|
|
45
|
+
EXIT_CODES.SERVICE_UNAVAIL,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
22
49
|
/**
|
|
23
50
|
* Fetch Product Hunt Atom RSS feed.
|
|
24
51
|
* @param category Optional category slug (e.g. "ai", "developer-tools")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { parseFeed, pickVoteCount, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
3
|
+
import * as productHuntUtils from './utils.js';
|
|
3
4
|
const SAMPLE_ATOM = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
5
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
5
6
|
<title>Product Hunt</title>
|
|
@@ -61,4 +62,11 @@ describe('parseFeed', () => {
|
|
|
61
62
|
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('developer-tools');
|
|
62
63
|
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('ai-agents');
|
|
63
64
|
});
|
|
65
|
+
it('recognizes explicit Product Hunt verification signals', () => {
|
|
66
|
+
expect(productHuntUtils.isProductHuntVerificationPage).toBeTypeOf('function');
|
|
67
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Just a moment...', body: '', cloudflare: false })).toBe(true);
|
|
68
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Performing security verification', cloudflare: false })).toBe(true);
|
|
69
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: '', cloudflare: true })).toBe(true);
|
|
70
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Discover your next favorite thing', cloudflare: false })).toBe(false);
|
|
71
|
+
});
|
|
64
72
|
});
|
|
@@ -71,7 +71,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
|
|
|
71
71
|
const rawNodes = Array.isArray(axTree?.nodes)
|
|
72
72
|
? axTree.nodes
|
|
73
73
|
: [];
|
|
74
|
-
const nodes = rawNodes.filter((node) => node
|
|
74
|
+
const nodes = rawNodes.filter((node) => node);
|
|
75
75
|
const byId = new Map();
|
|
76
76
|
const parentIds = new Set();
|
|
77
77
|
for (const node of nodes) {
|
|
@@ -88,11 +88,21 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
|
|
|
88
88
|
});
|
|
89
89
|
const root = roots[0] ?? nodes[0];
|
|
90
90
|
const maxDepth = Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200));
|
|
91
|
+
const maxTraversalHops = 200;
|
|
91
92
|
const roleNameCounts = countRoleNames(nodes);
|
|
92
93
|
const roleNameSeen = new Map();
|
|
93
|
-
function render(node, depth) {
|
|
94
|
-
if (!node || depth > maxDepth)
|
|
94
|
+
function render(node, depth, hops, path) {
|
|
95
|
+
if (!node || depth > maxDepth || hops > maxTraversalHops || path.has(node))
|
|
95
96
|
return false;
|
|
97
|
+
const nextPath = new Set(path).add(node);
|
|
98
|
+
if (node.ignored) {
|
|
99
|
+
let hasVisibleChild = false;
|
|
100
|
+
for (const childId of node.childIds ?? []) {
|
|
101
|
+
if (render(byId.get(childId), depth, hops + 1, nextPath))
|
|
102
|
+
hasVisibleChild = true;
|
|
103
|
+
}
|
|
104
|
+
return hasVisibleChild;
|
|
105
|
+
}
|
|
96
106
|
const role = axString(node.role) || 'generic';
|
|
97
107
|
const name = cleanText(axString(node.name));
|
|
98
108
|
const value = cleanText(axString(node.value) || propertyValue(node, 'value'));
|
|
@@ -109,7 +119,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
|
|
|
109
119
|
const childStart = lines.length;
|
|
110
120
|
let hasVisibleChild = false;
|
|
111
121
|
for (const childId of node.childIds ?? []) {
|
|
112
|
-
if (render(byId.get(childId), depth + 1))
|
|
122
|
+
if (render(byId.get(childId), depth + 1, hops + 1, nextPath))
|
|
113
123
|
hasVisibleChild = true;
|
|
114
124
|
}
|
|
115
125
|
if (!shouldShowSelf && !hasVisibleChild) {
|
|
@@ -151,7 +161,7 @@ function renderAxTree(axTree, lines, refs, nextRef, opts) {
|
|
|
151
161
|
}
|
|
152
162
|
return true;
|
|
153
163
|
}
|
|
154
|
-
render(root, opts.baseDepth);
|
|
164
|
+
render(root, opts.baseDepth, 0, new Set());
|
|
155
165
|
return nextRef;
|
|
156
166
|
}
|
|
157
167
|
export function findAxRefReplacement(axTree, ref) {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { buildAxSnapshot, buildAxSnapshotFromTrees, findAxRefReplacement } from './ax-snapshot.js';
|
|
3
4
|
describe('AX snapshot prototype', () => {
|
|
5
|
+
it('publishes the AX snapshot builder as a package subpath', () => {
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
7
|
+
expect(packageJson.exports?.['./browser/ax-snapshot'])
|
|
8
|
+
.toBe('./dist/src/browser/ax-snapshot.js');
|
|
9
|
+
});
|
|
4
10
|
it('builds compact refs from Accessibility.getFullAXTree output', () => {
|
|
5
11
|
const result = buildAxSnapshot({
|
|
6
12
|
nodes: [
|
|
@@ -25,6 +31,49 @@ describe('AX snapshot prototype', () => {
|
|
|
25
31
|
name: 'Category',
|
|
26
32
|
});
|
|
27
33
|
});
|
|
34
|
+
it('keeps actionable descendants of ignored AX nodes', () => {
|
|
35
|
+
const result = buildAxSnapshot({
|
|
36
|
+
nodes: [
|
|
37
|
+
{ nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
|
|
38
|
+
{ nodeId: '2', ignored: true, childIds: ['3', '4'] },
|
|
39
|
+
{ nodeId: '3', role: { value: 'button' }, name: { value: 'First' }, backendDOMNodeId: 30 },
|
|
40
|
+
{ nodeId: '4', role: { value: 'button' }, name: { value: 'Second' }, backendDOMNodeId: 40 },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
expect(result.text).toContain(' [1]button "First"\n [2]button "Second"');
|
|
44
|
+
expect(result.text).not.toContain('ignored');
|
|
45
|
+
expect([...result.refs.values()]).toEqual([
|
|
46
|
+
{ ref: '1', backendNodeId: 30, role: 'button', name: 'First' },
|
|
47
|
+
{ ref: '2', backendNodeId: 40, role: 'button', name: 'Second' },
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
it('stops ignored-node cycles without changing promoted indentation', () => {
|
|
51
|
+
const result = buildAxSnapshot({
|
|
52
|
+
nodes: [
|
|
53
|
+
{ nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
|
|
54
|
+
{ nodeId: '2', ignored: true, childIds: ['2', '3'] },
|
|
55
|
+
{ nodeId: '3', role: { value: 'button' }, name: { value: 'Save' }, backendDOMNodeId: 30 },
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
expect(result.text).toContain('\n [1]button "Save"\n');
|
|
59
|
+
expect(result.refs).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
it('bounds traversal through long ignored-node chains', () => {
|
|
62
|
+
const ignored = Array.from({ length: 201 }, (_, index) => ({
|
|
63
|
+
nodeId: String(index + 2),
|
|
64
|
+
ignored: true,
|
|
65
|
+
childIds: [String(index + 3)],
|
|
66
|
+
}));
|
|
67
|
+
const result = buildAxSnapshot({
|
|
68
|
+
nodes: [
|
|
69
|
+
{ nodeId: '1', role: { value: 'RootWebArea' }, childIds: ['2'] },
|
|
70
|
+
...ignored,
|
|
71
|
+
{ nodeId: '203', role: { value: 'button' }, name: { value: 'Too deep' }, backendDOMNodeId: 203 },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
expect(result.text).not.toContain('Too deep');
|
|
75
|
+
expect(result.refs).toHaveLength(0);
|
|
76
|
+
});
|
|
28
77
|
it('tracks nth only for duplicate role/name pairs', () => {
|
|
29
78
|
const result = buildAxSnapshot({
|
|
30
79
|
nodes: [
|
|
@@ -26,6 +26,7 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
|
|
|
26
26
|
data: unknown;
|
|
27
27
|
page?: string;
|
|
28
28
|
}>;
|
|
29
|
+
export declare function releaseSiteSessionLease(runId: string): Promise<void>;
|
|
29
30
|
export declare function bindTab(session: string, opts?: {
|
|
30
31
|
contextId?: string;
|
|
31
32
|
preferredContextId?: string;
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
6
|
import { sleep } from '../utils.js';
|
|
7
|
-
import { BrowserConnectError } from '../errors.js';
|
|
7
|
+
import { BrowserConnectError, SessionBusyError } from '../errors.js';
|
|
8
8
|
import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
|
|
9
|
+
import { getDaemonRunContext } from '../session-lease.js';
|
|
9
10
|
import { classifyBrowserError } from './errors.js';
|
|
10
11
|
import { profileRouteParams, resolveProfileSelection } from './profile.js';
|
|
11
12
|
import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
|
|
@@ -112,6 +113,7 @@ async function sendCommandRaw(action, params) {
|
|
|
112
113
|
throw new BrowserCommandError('Browser command deadline exhausted across transport retries.', COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT);
|
|
113
114
|
}
|
|
114
115
|
const remainingMs = Math.max(1000, deadlineAt - Date.now());
|
|
116
|
+
const run = action === 'lease-release' ? undefined : getDaemonRunContext();
|
|
115
117
|
const command = {
|
|
116
118
|
id,
|
|
117
119
|
action,
|
|
@@ -121,6 +123,12 @@ async function sendCommandRaw(action, params) {
|
|
|
121
123
|
...(contextId && { contextId }),
|
|
122
124
|
...(preferredContextId && { preferredContextId }),
|
|
123
125
|
...(windowMode && { windowMode }),
|
|
126
|
+
...(run && {
|
|
127
|
+
runId: run.runId,
|
|
128
|
+
command: run.command,
|
|
129
|
+
access: run.access,
|
|
130
|
+
pid: process.pid,
|
|
131
|
+
}),
|
|
124
132
|
};
|
|
125
133
|
try {
|
|
126
134
|
const res = await requestDaemon('/command', {
|
|
@@ -132,6 +140,9 @@ async function sendCommandRaw(action, params) {
|
|
|
132
140
|
const result = (await res.json());
|
|
133
141
|
if (result.ok)
|
|
134
142
|
return result;
|
|
143
|
+
if (res.status === 409 && result.code === 'session_busy' && result.holder) {
|
|
144
|
+
throw new SessionBusyError(result.holder);
|
|
145
|
+
}
|
|
135
146
|
if (result.errorCode && UNKNOWN_OUTCOME_CODES.has(result.errorCode)) {
|
|
136
147
|
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
|
|
137
148
|
}
|
|
@@ -187,6 +198,19 @@ export async function sendCommandFull(action, params = {}) {
|
|
|
187
198
|
const result = await sendCommandRaw(action, params);
|
|
188
199
|
return { data: result.data, page: result.page };
|
|
189
200
|
}
|
|
201
|
+
export async function releaseSiteSessionLease(runId) {
|
|
202
|
+
const command = {
|
|
203
|
+
id: generateId(),
|
|
204
|
+
action: 'lease-release',
|
|
205
|
+
runId,
|
|
206
|
+
};
|
|
207
|
+
await requestDaemon('/command', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify(command),
|
|
211
|
+
timeout: 2_000,
|
|
212
|
+
}).catch(() => undefined);
|
|
213
|
+
}
|
|
190
214
|
export async function bindTab(session, opts = {}) {
|
|
191
215
|
return sendCommand('bind', { session, surface: 'browser', ...opts });
|
|
192
216
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
|
|
2
|
+
import { fetchDaemonStatus, getDaemonHealth, releaseSiteSessionLease, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
|
|
3
3
|
import * as daemonLifecycle from './daemon-lifecycle.js';
|
|
4
|
+
import { clearDaemonRunContext, getDaemonRunContext, setDaemonRunContext } from '../session-lease.js';
|
|
4
5
|
describe('daemon-client', () => {
|
|
5
6
|
beforeEach(() => {
|
|
6
7
|
vi.stubGlobal('fetch', vi.fn());
|
|
7
8
|
});
|
|
8
9
|
afterEach(() => {
|
|
10
|
+
const run = getDaemonRunContext();
|
|
11
|
+
if (run)
|
|
12
|
+
clearDaemonRunContext(run.runId);
|
|
9
13
|
if (typeof setDaemonCommandTimeoutSeconds === 'function') {
|
|
10
14
|
setDaemonCommandTimeoutSeconds(null);
|
|
11
15
|
}
|
|
@@ -142,6 +146,25 @@ describe('daemon-client', () => {
|
|
|
142
146
|
expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
|
|
143
147
|
expect(ids[0]).not.toBe(ids[1]);
|
|
144
148
|
});
|
|
149
|
+
it('sendCommand binds the active logical run metadata to each daemon operation', async () => {
|
|
150
|
+
setDaemonRunContext({
|
|
151
|
+
runId: 'run_4242_1000_1',
|
|
152
|
+
command: 'example write',
|
|
153
|
+
access: 'write',
|
|
154
|
+
});
|
|
155
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
156
|
+
status: 200,
|
|
157
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
|
|
158
|
+
});
|
|
159
|
+
await sendCommand('exec', { code: '1' });
|
|
160
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
161
|
+
expect(body).toMatchObject({
|
|
162
|
+
runId: 'run_4242_1000_1',
|
|
163
|
+
command: 'example write',
|
|
164
|
+
access: 'write',
|
|
165
|
+
pid: process.pid,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
145
168
|
it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
|
|
146
169
|
vi.stubEnv('WEBCMD_PROFILE', 'work');
|
|
147
170
|
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
@@ -200,6 +223,68 @@ describe('daemon-client', () => {
|
|
|
200
223
|
});
|
|
201
224
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
202
225
|
});
|
|
226
|
+
it('maps daemon session_busy conflicts to SessionBusyError without retrying', async () => {
|
|
227
|
+
const fetchMock = vi.mocked(fetch);
|
|
228
|
+
fetchMock.mockResolvedValueOnce({
|
|
229
|
+
ok: false,
|
|
230
|
+
status: 409,
|
|
231
|
+
json: () => Promise.resolve({
|
|
232
|
+
ok: false,
|
|
233
|
+
code: 'session_busy',
|
|
234
|
+
holder: {
|
|
235
|
+
command: 'other write',
|
|
236
|
+
pid: 4242,
|
|
237
|
+
acquiredAt: 1_000,
|
|
238
|
+
heartbeatAt: 2_000,
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({
|
|
243
|
+
name: 'SessionBusyError',
|
|
244
|
+
code: 'SESSION_BUSY',
|
|
245
|
+
message: expect.stringContaining('other write'),
|
|
246
|
+
});
|
|
247
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
it('releaseSiteSessionLease makes one best-effort POST without starting or retrying the daemon', async () => {
|
|
250
|
+
setDaemonRunContext({
|
|
251
|
+
runId: 'run_9999_newer_2',
|
|
252
|
+
command: 'newer write',
|
|
253
|
+
access: 'write',
|
|
254
|
+
});
|
|
255
|
+
const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady');
|
|
256
|
+
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
257
|
+
await expect(releaseSiteSessionLease('run_4242_1000_1')).resolves.toBeUndefined();
|
|
258
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(ensureSpy).not.toHaveBeenCalled();
|
|
260
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
261
|
+
expect(body).toMatchObject({
|
|
262
|
+
action: 'lease-release',
|
|
263
|
+
runId: 'run_4242_1000_1',
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
it('releaseSiteSessionLease aborts its best-effort POST after two seconds', async () => {
|
|
267
|
+
vi.useFakeTimers();
|
|
268
|
+
try {
|
|
269
|
+
let aborted = false;
|
|
270
|
+
vi.mocked(fetch).mockImplementationOnce((_url, init) => new Promise((_, reject) => {
|
|
271
|
+
init?.signal?.addEventListener('abort', () => {
|
|
272
|
+
aborted = true;
|
|
273
|
+
reject(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }));
|
|
274
|
+
});
|
|
275
|
+
}));
|
|
276
|
+
const pending = releaseSiteSessionLease('run_4242_1000_1');
|
|
277
|
+
await vi.advanceTimersByTimeAsync(1_999);
|
|
278
|
+
expect(aborted).toBe(false);
|
|
279
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
280
|
+
expect(aborted).toBe(true);
|
|
281
|
+
await expect(pending).resolves.toBeUndefined();
|
|
282
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
vi.useRealTimers();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
203
288
|
it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
|
|
204
289
|
const fetchMock = vi.mocked(fetch);
|
|
205
290
|
fetchMock.mockResolvedValue({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionLeaseStatus } from '../session-lease.js';
|
|
1
2
|
export interface DaemonStatus {
|
|
2
3
|
ok: boolean;
|
|
3
4
|
pid: number;
|
|
@@ -12,6 +13,7 @@ export interface DaemonStatus {
|
|
|
12
13
|
profiles?: BrowserProfileStatus[];
|
|
13
14
|
pending: number;
|
|
14
15
|
commandResultUnknown?: number;
|
|
16
|
+
sessionLeases?: SessionLeaseStatus[];
|
|
15
17
|
memoryMB: number;
|
|
16
18
|
port: number;
|
|
17
19
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { SessionLeaseStatus } from '../session-lease.js';
|
|
2
|
+
export type BrowserRuntimeAction = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames' | 'lease-release';
|
|
2
3
|
export type BrowserSurface = 'browser' | 'adapter';
|
|
3
4
|
export type SiteSessionMode = 'ephemeral' | 'persistent';
|
|
4
5
|
export type BrowserWindowMode = 'foreground' | 'background';
|
|
@@ -38,6 +39,14 @@ export interface BrowserRuntimeCommand {
|
|
|
38
39
|
contextId?: string;
|
|
39
40
|
preferredContextId?: string;
|
|
40
41
|
profileId?: string;
|
|
42
|
+
/** Stable identity for the complete logical CLI command run. */
|
|
43
|
+
runId?: string;
|
|
44
|
+
/** Human-readable canonical command that owns the logical run. */
|
|
45
|
+
command?: string;
|
|
46
|
+
/** Access classification used by daemon-local lease arbitration. */
|
|
47
|
+
access?: 'read' | 'write';
|
|
48
|
+
/** Originating CLI process, used only for actionable local busy guidance. */
|
|
49
|
+
pid?: number;
|
|
41
50
|
}
|
|
42
51
|
export interface BrowserRuntimeResult {
|
|
43
52
|
id: string;
|
|
@@ -65,4 +74,6 @@ export interface BrowserRuntimeStatus {
|
|
|
65
74
|
profiles: BrowserRuntimeProfileStatus[];
|
|
66
75
|
pending: number;
|
|
67
76
|
commandResultUnknown?: number;
|
|
77
|
+
/** Active local leases with internal run ownership tokens removed. */
|
|
78
|
+
sessionLeases?: SessionLeaseStatus[];
|
|
68
79
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js';
|
|
2
2
|
import type { CloakSessionManager } from './session-manager.js';
|
|
3
|
+
export declare function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string;
|
|
3
4
|
export declare function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
|
|
@@ -10,7 +10,7 @@ class CloakActionError extends Error {
|
|
|
10
10
|
this.errorHint = errorHint;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
-
function
|
|
13
|
+
export function resolveCloakCommandProfileId(manager, command) {
|
|
14
14
|
const requested = command.profileId ?? command.contextId;
|
|
15
15
|
if (requested?.trim())
|
|
16
16
|
return requested.trim();
|
|
@@ -38,7 +38,7 @@ async function resolveLease(manager, command) {
|
|
|
38
38
|
throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`);
|
|
39
39
|
}
|
|
40
40
|
return manager.getPage({
|
|
41
|
-
profileId:
|
|
41
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
42
42
|
session: command.session,
|
|
43
43
|
surface: command.surface,
|
|
44
44
|
siteSession: command.siteSession,
|
|
@@ -109,12 +109,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
109
109
|
}
|
|
110
110
|
case 'close-window': {
|
|
111
111
|
if (command.page) {
|
|
112
|
-
const closed = await manager.closePage({ profileId:
|
|
112
|
+
const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page });
|
|
113
113
|
return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } };
|
|
114
114
|
}
|
|
115
115
|
else {
|
|
116
116
|
await manager.release({
|
|
117
|
-
profileId:
|
|
117
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
118
118
|
session: command.session,
|
|
119
119
|
surface: command.surface,
|
|
120
120
|
});
|
|
@@ -124,12 +124,12 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
124
124
|
case 'tabs': {
|
|
125
125
|
switch (command.op ?? 'list') {
|
|
126
126
|
case 'list': {
|
|
127
|
-
const tabs = await manager.listPages({ profileId:
|
|
127
|
+
const tabs = await manager.listPages({ profileId: resolveCloakCommandProfileId(manager, command) });
|
|
128
128
|
return { id: command.id, ok: true, data: tabs };
|
|
129
129
|
}
|
|
130
130
|
case 'new': {
|
|
131
131
|
const lease = await manager.newPage({
|
|
132
|
-
profileId:
|
|
132
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
133
133
|
session: command.session,
|
|
134
134
|
surface: command.surface,
|
|
135
135
|
siteSession: command.siteSession,
|
|
@@ -140,13 +140,13 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
140
140
|
return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
|
|
141
141
|
}
|
|
142
142
|
case 'select': {
|
|
143
|
-
const lease = await manager.selectPage({ profileId:
|
|
143
|
+
const lease = await manager.selectPage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
|
|
144
144
|
if (!lease)
|
|
145
145
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
146
146
|
return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
|
|
147
147
|
}
|
|
148
148
|
case 'close': {
|
|
149
|
-
const closed = await manager.closePage({ profileId:
|
|
149
|
+
const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index });
|
|
150
150
|
if (!closed)
|
|
151
151
|
return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
|
|
152
152
|
return { id: command.id, ok: true, data: { closed } };
|
|
@@ -214,7 +214,7 @@ export async function dispatchCloakAction(manager, command) {
|
|
|
214
214
|
}
|
|
215
215
|
{
|
|
216
216
|
const lease = await manager.bindPage({
|
|
217
|
-
profileId:
|
|
217
|
+
profileId: resolveCloakCommandProfileId(manager, command),
|
|
218
218
|
session: command.session,
|
|
219
219
|
surface: command.surface,
|
|
220
220
|
siteSession: command.siteSession,
|
|
@@ -10,6 +10,7 @@ export declare class LocalCloakRuntimeProvider implements BrowserRuntimeProvider
|
|
|
10
10
|
private readonly manager;
|
|
11
11
|
constructor(opts?: LocalCloakRuntimeProviderOptions);
|
|
12
12
|
status(_opts?: RuntimeStatusOptions): Promise<BrowserRuntimeStatus>;
|
|
13
|
+
resolveProfileId(command: BrowserRuntimeCommand): string;
|
|
13
14
|
dispatch(command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
|
|
14
15
|
shutdown(): Promise<void>;
|
|
15
16
|
}
|