textweb 0.2.0 → 0.2.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 +75 -0
- package/logo.svg +30 -0
- package/mcp/index.js +220 -26
- package/package.json +13 -2
- package/src/browser.js +230 -33
- package/src/cli.js +4 -1
- package/src/ensure-browser.js +92 -0
- package/src/renderer.js +50 -6
- package/tools/tool_definitions.json +299 -24
- package/canvas/dashboard.html +0 -153
- package/docs/index.html +0 -761
package/README.md
CHANGED
|
@@ -92,6 +92,12 @@ npx textweb-mcp
|
|
|
92
92
|
|
|
93
93
|
Then just ask: *"Go to hacker news and find posts about AI"* — the agent uses text grids instead of screenshots.
|
|
94
94
|
|
|
95
|
+
**New (v0.2.1-style MCP capabilities):**
|
|
96
|
+
- `session_id` on every tool call for isolated parallel workflows
|
|
97
|
+
- `textweb_storage_save` / `textweb_storage_load` for persistent auth/session state
|
|
98
|
+
- `textweb_wait_for` for multi-step async UI transitions
|
|
99
|
+
- `textweb_assert_field` for flow guards before submit
|
|
100
|
+
|
|
95
101
|
### 🛠️ OpenAI / Anthropic Function Calling
|
|
96
102
|
|
|
97
103
|
Drop-in tool definitions for any function-calling model. See [`tools/tool_definitions.json`](tools/tool_definitions.json).
|
|
@@ -174,10 +180,18 @@ const { view, elements, meta } = await browser.navigate('https://example.com');
|
|
|
174
180
|
|
|
175
181
|
console.log(view); // The text grid
|
|
176
182
|
console.log(elements); // { 0: { selector, tag, text, href }, ... }
|
|
183
|
+
console.log(meta.stats); // { totalElements, interactiveElements, renderMs }
|
|
177
184
|
|
|
178
185
|
await browser.click(3); // Click element [3]
|
|
179
186
|
await browser.type(7, 'hello'); // Type into element [7]
|
|
180
187
|
await browser.scroll('down'); // Scroll down
|
|
188
|
+
await browser.waitFor({ selector: '.step-2.active' }); // Wait for next step
|
|
189
|
+
await browser.assertField(7, 'hello', { comparator: 'equals' }); // Validate field state
|
|
190
|
+
await browser.saveStorageState('/tmp/textweb-state.json');
|
|
191
|
+
await browser.loadStorageState('/tmp/textweb-state.json');
|
|
192
|
+
await browser.query('nav a'); // Find elements by CSS selector
|
|
193
|
+
await browser.screenshot(); // PNG buffer (for debugging)
|
|
194
|
+
console.log(browser.getCurrentUrl());// Current page URL
|
|
181
195
|
await browser.close();
|
|
182
196
|
```
|
|
183
197
|
|
|
@@ -218,6 +232,67 @@ await browser.close();
|
|
|
218
232
|
3. **Map** pixel coordinates to character grid positions (spatial layout preserved)
|
|
219
233
|
4. **Annotate** interactive elements with `[ref]` numbers for agent interaction
|
|
220
234
|
|
|
235
|
+
## Selector Strategy
|
|
236
|
+
|
|
237
|
+
TextWeb builds stable CSS selectors for each interactive element, preferring resilient strategies over brittle positional ones:
|
|
238
|
+
|
|
239
|
+
| Priority | Strategy | Example |
|
|
240
|
+
|----------|----------|---------|
|
|
241
|
+
| 1 | `#id` | `#email` |
|
|
242
|
+
| 2 | `[data-testid]` | `[data-testid="submit-btn"]` |
|
|
243
|
+
| 3 | `[aria-label]` | `input[aria-label="Search"]` |
|
|
244
|
+
| 4 | `[role]` (if unique) | `[role="navigation"]` |
|
|
245
|
+
| 5 | `[name]` | `input[name="email"]` |
|
|
246
|
+
| 6 | `a[href]` (if unique) | `a[href="/about"]` |
|
|
247
|
+
| 7 | `nth-child` (fallback) | `div > a:nth-child(3)` |
|
|
248
|
+
|
|
249
|
+
This means selectors survive DOM changes between snapshots — critical for multi-step agent workflows.
|
|
250
|
+
|
|
251
|
+
## ATS Workflow Examples (Greenhouse / Lever)
|
|
252
|
+
|
|
253
|
+
For multi-step ATS flows, use a stable `session_id` and combine wait/assert guards:
|
|
254
|
+
|
|
255
|
+
```javascript
|
|
256
|
+
// Keep one session for the whole application
|
|
257
|
+
await textweb_navigate({ url: 'https://job-boards.greenhouse.io/acme/jobs/123', session_id: 'apply-acme' });
|
|
258
|
+
|
|
259
|
+
// Fill + continue
|
|
260
|
+
await textweb_type({ ref: 12, text: 'Christopher', session_id: 'apply-acme' });
|
|
261
|
+
await textweb_type({ ref: 15, text: 'Robison', session_id: 'apply-acme' });
|
|
262
|
+
await textweb_click({ ref: 42, session_id: 'apply-acme', retries: 3, retry_delay_ms: 400 });
|
|
263
|
+
|
|
264
|
+
// Guard transition
|
|
265
|
+
await textweb_wait_for({ selector: '#step-2.active', timeout_ms: 8000, session_id: 'apply-acme', retries: 2 });
|
|
266
|
+
|
|
267
|
+
// Validate before submit
|
|
268
|
+
await textweb_assert_field({ ref: 77, expected: 'San Francisco', comparator: 'includes', session_id: 'apply-acme' });
|
|
269
|
+
|
|
270
|
+
// Persist auth/session for follow-up flow
|
|
271
|
+
await textweb_storage_save({ path: '/tmp/ats-state.json', session_id: 'apply-acme' });
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Useful session tools:
|
|
275
|
+
- `textweb_session_list` → inspect active sessions
|
|
276
|
+
- `textweb_session_close` → close one session or all
|
|
277
|
+
|
|
278
|
+
## Testing
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Run all tests (form + live + ATS e2e)
|
|
282
|
+
npm test
|
|
283
|
+
|
|
284
|
+
# Form fixture tests
|
|
285
|
+
npm run test:form
|
|
286
|
+
|
|
287
|
+
# Live site tests — example.com, HN, Wikipedia
|
|
288
|
+
npm run test:live
|
|
289
|
+
|
|
290
|
+
# ATS multi-step fixture test
|
|
291
|
+
npm run test:ats
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Test fixtures are in `test/fixtures/` — includes a comprehensive HTML form and an ATS-style multi-step application fixture.
|
|
295
|
+
|
|
221
296
|
## Design Principles
|
|
222
297
|
|
|
223
298
|
1. **Text is native to LLMs** — no vision model middleman
|
package/logo.svg
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425.697 360.796" xmlns:bx="https://boxy-svg.com">
|
|
3
|
+
<defs>
|
|
4
|
+
<style>
|
|
5
|
+
@keyframes blink {
|
|
6
|
+
0%, 100% { opacity: 1; }
|
|
7
|
+
50% { opacity: 0; }
|
|
8
|
+
}
|
|
9
|
+
.cursor {
|
|
10
|
+
animation: blink 1.1s step-start infinite;
|
|
11
|
+
}
|
|
12
|
+
</style>
|
|
13
|
+
<filter id="ds" x="-20%" y="-20%" width="140%" height="140%">
|
|
14
|
+
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
|
|
15
|
+
</filter>
|
|
16
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
17
|
+
<stop offset="0" stop-color="var(--bg)"/>
|
|
18
|
+
<stop offset="1" stop-color="var(--bg2)"/>
|
|
19
|
+
</linearGradient>
|
|
20
|
+
<bx:export>
|
|
21
|
+
<bx:file format="png"/>
|
|
22
|
+
</bx:export>
|
|
23
|
+
</defs>
|
|
24
|
+
<rect x="6.482" y="11.817" width="400" height="320" rx="72" ry="72" fill="url(#g)" filter="url(#ds)" style="stroke-width: 1;"/>
|
|
25
|
+
<rect x="26.482" y="31.817" width="360" height="280" rx="58" ry="58" fill="none" stroke-width="4" style="stroke-width: 4; stroke: rgba(255, 255, 255, 0.255);"/>
|
|
26
|
+
<g class="mono" fill="var(--green)" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;" transform="matrix(1, 0, 0, 1, -75.701332, -120.82827)">
|
|
27
|
+
<text style="fill: rgb(0, 201, 22); font-size: 56px; font-weight: 700; letter-spacing: 0.5px; white-space: pre;" x="112.671" y="313.449">> textweb<tspan class="cursor">_</tspan><tspan x="112.6709976196289" dy="1em"></tspan></text>
|
|
28
|
+
</g>
|
|
29
|
+
<circle cx="90.479" cy="155.817" r="70" fill="var(--green)" opacity="0.05" style="stroke-width: 1;"/>
|
|
30
|
+
</svg>
|
package/mcp/index.js
CHANGED
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* TextWeb MCP Server
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
6
|
* Model Context Protocol server that gives any MCP client
|
|
7
7
|
* (Claude Desktop, Cursor, Windsurf, Cline, OpenClaw, etc.)
|
|
8
8
|
* text-based web browsing capabilities.
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
10
|
* Communicates over stdio using JSON-RPC 2.0.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { AgentBrowser } = require('../src/browser');
|
|
14
|
+
const { ensureBrowser } = require('../src/ensure-browser');
|
|
14
15
|
|
|
15
16
|
const SERVER_INFO = {
|
|
16
17
|
name: 'textweb',
|
|
17
|
-
version: '0.
|
|
18
|
+
version: '0.2.2',
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
const SESSION_NOTE = 'Optional session_id to isolate state across flows. Defaults to "default".';
|
|
22
|
+
|
|
20
23
|
const TOOLS = [
|
|
21
24
|
{
|
|
22
25
|
name: 'textweb_navigate',
|
|
@@ -26,6 +29,9 @@ const TOOLS = [
|
|
|
26
29
|
properties: {
|
|
27
30
|
url: { type: 'string', description: 'The URL to navigate to' },
|
|
28
31
|
cols: { type: 'number', description: 'Grid width in characters (default: 120)' },
|
|
32
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
33
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
34
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
29
35
|
},
|
|
30
36
|
required: ['url'],
|
|
31
37
|
},
|
|
@@ -37,6 +43,9 @@ const TOOLS = [
|
|
|
37
43
|
type: 'object',
|
|
38
44
|
properties: {
|
|
39
45
|
ref: { type: 'number', description: 'Element reference number from the text grid (e.g., 3 for [3])' },
|
|
46
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
47
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
48
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
40
49
|
},
|
|
41
50
|
required: ['ref'],
|
|
42
51
|
},
|
|
@@ -49,6 +58,9 @@ const TOOLS = [
|
|
|
49
58
|
properties: {
|
|
50
59
|
ref: { type: 'number', description: 'Element reference number of the input field' },
|
|
51
60
|
text: { type: 'string', description: 'Text to type into the field' },
|
|
61
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
62
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
63
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
52
64
|
},
|
|
53
65
|
required: ['ref', 'text'],
|
|
54
66
|
},
|
|
@@ -61,6 +73,9 @@ const TOOLS = [
|
|
|
61
73
|
properties: {
|
|
62
74
|
ref: { type: 'number', description: 'Element reference number of the select/dropdown' },
|
|
63
75
|
value: { type: 'string', description: 'Value or visible text of the option to select' },
|
|
76
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
77
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
78
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
64
79
|
},
|
|
65
80
|
required: ['ref', 'value'],
|
|
66
81
|
},
|
|
@@ -73,6 +88,7 @@ const TOOLS = [
|
|
|
73
88
|
properties: {
|
|
74
89
|
direction: { type: 'string', enum: ['up', 'down', 'top'], description: 'Scroll direction' },
|
|
75
90
|
amount: { type: 'number', description: 'Number of pages to scroll (default: 1)' },
|
|
91
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
76
92
|
},
|
|
77
93
|
required: ['direction'],
|
|
78
94
|
},
|
|
@@ -82,7 +98,9 @@ const TOOLS = [
|
|
|
82
98
|
description: 'Re-render the current page as a text grid without navigating. Useful after waiting for dynamic content to load.',
|
|
83
99
|
inputSchema: {
|
|
84
100
|
type: 'object',
|
|
85
|
-
properties: {
|
|
101
|
+
properties: {
|
|
102
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
103
|
+
},
|
|
86
104
|
},
|
|
87
105
|
},
|
|
88
106
|
{
|
|
@@ -92,10 +110,32 @@ const TOOLS = [
|
|
|
92
110
|
type: 'object',
|
|
93
111
|
properties: {
|
|
94
112
|
key: { type: 'string', description: 'Key to press (e.g., "Enter", "Tab", "Escape", "ArrowDown")' },
|
|
113
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
114
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
115
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
95
116
|
},
|
|
96
117
|
required: ['key'],
|
|
97
118
|
},
|
|
98
119
|
},
|
|
120
|
+
{
|
|
121
|
+
name: 'textweb_session_list',
|
|
122
|
+
description: 'List active textweb sessions and basic metadata (url, age).',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'textweb_session_close',
|
|
130
|
+
description: 'Close one session by session_id, or all sessions when all=true.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
session_id: { type: 'string', description: 'Session id to close (default: default)' },
|
|
135
|
+
all: { type: 'boolean', description: 'Close all active sessions' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
99
139
|
{
|
|
100
140
|
name: 'textweb_upload',
|
|
101
141
|
description: 'Upload a file to a file input element by its reference number.',
|
|
@@ -104,22 +144,93 @@ const TOOLS = [
|
|
|
104
144
|
properties: {
|
|
105
145
|
ref: { type: 'number', description: 'Element reference number of the file input' },
|
|
106
146
|
path: { type: 'string', description: 'Absolute path to the file to upload' },
|
|
147
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
148
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
149
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
107
150
|
},
|
|
108
151
|
required: ['ref', 'path'],
|
|
109
152
|
},
|
|
110
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: 'textweb_storage_save',
|
|
156
|
+
description: 'Save current browser storage state (cookies/localStorage/sessionStorage) to disk for later restore.',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
path: { type: 'string', description: 'Absolute path to write storage state JSON' },
|
|
161
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
162
|
+
},
|
|
163
|
+
required: ['path'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'textweb_storage_load',
|
|
168
|
+
description: 'Load storage state from disk into a fresh browser context.',
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
path: { type: 'string', description: 'Absolute path of previously saved storage state JSON' },
|
|
173
|
+
cols: { type: 'number', description: 'Grid width in characters (default: 120)' },
|
|
174
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
175
|
+
},
|
|
176
|
+
required: ['path'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'textweb_wait_for',
|
|
181
|
+
description: 'Wait for UI state in multi-step flows. Supports selector, text, and url_includes checks.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
selector: { type: 'string', description: 'CSS selector that must appear (or match state)' },
|
|
186
|
+
text: { type: 'string', description: 'Text that must appear in page body' },
|
|
187
|
+
url_includes: { type: 'string', description: 'Substring that must appear in current URL' },
|
|
188
|
+
state: { type: 'string', enum: ['attached', 'detached', 'visible', 'hidden'], description: 'Selector wait state (default: visible)' },
|
|
189
|
+
timeout_ms: { type: 'number', description: 'Timeout in milliseconds (default: 30000)' },
|
|
190
|
+
poll_ms: { type: 'number', description: 'Polling interval for text/url waits (default: 100)' },
|
|
191
|
+
retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
|
|
192
|
+
retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
|
|
193
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'textweb_assert_field',
|
|
199
|
+
description: 'Assert a field value/text by element ref. Useful in multi-step forms before submitting.',
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
ref: { type: 'number', description: 'Element reference number from current snapshot' },
|
|
204
|
+
expected: { type: 'string', description: 'Expected value/content' },
|
|
205
|
+
comparator: { type: 'string', enum: ['equals', 'includes', 'regex', 'not_empty'], description: 'Comparison mode (default: equals)' },
|
|
206
|
+
attribute: { type: 'string', description: 'Optional DOM attribute name to validate (e.g., aria-invalid)' },
|
|
207
|
+
session_id: { type: 'string', description: SESSION_NOTE },
|
|
208
|
+
},
|
|
209
|
+
required: ['ref', 'expected'],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
111
212
|
];
|
|
112
213
|
|
|
113
|
-
// ─── Browser
|
|
214
|
+
// ─── Browser Sessions ───────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** @type {Map<string, AgentBrowser>} */
|
|
217
|
+
const sessions = new Map();
|
|
114
218
|
|
|
115
|
-
|
|
219
|
+
function resolveSessionId(args = {}) {
|
|
220
|
+
return (args.session_id || 'default').trim() || 'default';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function getBrowser(args = {}) {
|
|
224
|
+
const sessionId = resolveSessionId(args);
|
|
225
|
+
let browser = sessions.get(sessionId);
|
|
116
226
|
|
|
117
|
-
async function getBrowser(cols) {
|
|
118
227
|
if (!browser) {
|
|
119
|
-
browser = new AgentBrowser({ cols: cols || 120, headless: true });
|
|
228
|
+
browser = new AgentBrowser({ cols: args.cols || 120, headless: true });
|
|
120
229
|
await browser.launch();
|
|
230
|
+
sessions.set(sessionId, browser);
|
|
121
231
|
}
|
|
122
|
-
|
|
232
|
+
|
|
233
|
+
return { browser, sessionId };
|
|
123
234
|
}
|
|
124
235
|
|
|
125
236
|
function formatResult(result) {
|
|
@@ -130,26 +241,78 @@ function formatResult(result) {
|
|
|
130
241
|
return `URL: ${result.meta?.url || 'unknown'}\nTitle: ${result.meta?.title || 'unknown'}\nRefs: ${result.meta?.totalRefs || 0}\n\n${result.view}\n\nInteractive elements:\n${refs}`;
|
|
131
242
|
}
|
|
132
243
|
|
|
244
|
+
function retryOptions(args = {}) {
|
|
245
|
+
return {
|
|
246
|
+
retries: args.retries,
|
|
247
|
+
retryDelayMs: args.retry_delay_ms,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function listSessions() {
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const [sessionId, browser] of sessions.entries()) {
|
|
254
|
+
out.push({
|
|
255
|
+
session_id: sessionId,
|
|
256
|
+
url: browser.getCurrentUrl() || null,
|
|
257
|
+
initialized: Boolean(browser.page),
|
|
258
|
+
refs: browser.lastResult?.meta?.totalRefs ?? null,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function closeSession({ session_id, all } = {}) {
|
|
265
|
+
if (all) {
|
|
266
|
+
const closed = [];
|
|
267
|
+
for (const [sid, browser] of sessions.entries()) {
|
|
268
|
+
await browser.close();
|
|
269
|
+
closed.push(sid);
|
|
270
|
+
}
|
|
271
|
+
sessions.clear();
|
|
272
|
+
return { closed };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const sid = (session_id || 'default').trim() || 'default';
|
|
276
|
+
const browser = sessions.get(sid);
|
|
277
|
+
if (!browser) {
|
|
278
|
+
return { closed: [], missing: [sid] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await browser.close();
|
|
282
|
+
sessions.delete(sid);
|
|
283
|
+
return { closed: [sid] };
|
|
284
|
+
}
|
|
285
|
+
|
|
133
286
|
// ─── Tool Execution ──────────────────────────────────────────────────────────
|
|
134
287
|
|
|
135
|
-
async function executeTool(name, args) {
|
|
136
|
-
|
|
288
|
+
async function executeTool(name, args = {}) {
|
|
289
|
+
if (name === 'textweb_session_list') {
|
|
290
|
+
const active = await listSessions();
|
|
291
|
+
return JSON.stringify({ count: active.length, sessions: active }, null, 2);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (name === 'textweb_session_close') {
|
|
295
|
+
const out = await closeSession({ session_id: args.session_id, all: args.all });
|
|
296
|
+
return JSON.stringify(out, null, 2);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const { browser: b, sessionId } = await getBrowser(args);
|
|
137
300
|
|
|
138
301
|
switch (name) {
|
|
139
302
|
case 'textweb_navigate': {
|
|
140
|
-
const result = await b.navigate(args.url);
|
|
303
|
+
const result = await b.navigate(args.url, retryOptions(args));
|
|
141
304
|
return formatResult(result);
|
|
142
305
|
}
|
|
143
306
|
case 'textweb_click': {
|
|
144
|
-
const result = await b.click(args.ref);
|
|
307
|
+
const result = await b.click(args.ref, retryOptions(args));
|
|
145
308
|
return formatResult(result);
|
|
146
309
|
}
|
|
147
310
|
case 'textweb_type': {
|
|
148
|
-
const result = await b.type(args.ref, args.text);
|
|
311
|
+
const result = await b.type(args.ref, args.text, retryOptions(args));
|
|
149
312
|
return formatResult(result);
|
|
150
313
|
}
|
|
151
314
|
case 'textweb_select': {
|
|
152
|
-
const result = await b.select(args.ref, args.value);
|
|
315
|
+
const result = await b.select(args.ref, args.value, retryOptions(args));
|
|
153
316
|
return formatResult(result);
|
|
154
317
|
}
|
|
155
318
|
case 'textweb_scroll': {
|
|
@@ -161,13 +324,40 @@ async function executeTool(name, args) {
|
|
|
161
324
|
return formatResult(result);
|
|
162
325
|
}
|
|
163
326
|
case 'textweb_press': {
|
|
164
|
-
const result = await b.press(args.key);
|
|
327
|
+
const result = await b.press(args.key, retryOptions(args));
|
|
165
328
|
return formatResult(result);
|
|
166
329
|
}
|
|
167
330
|
case 'textweb_upload': {
|
|
168
|
-
const result = await b.upload(args.ref, args.path);
|
|
331
|
+
const result = await b.upload(args.ref, args.path, retryOptions(args));
|
|
332
|
+
return formatResult(result);
|
|
333
|
+
}
|
|
334
|
+
case 'textweb_storage_save': {
|
|
335
|
+
const out = await b.saveStorageState(args.path);
|
|
336
|
+
return `Saved storage state for session "${sessionId}" to ${out.path}`;
|
|
337
|
+
}
|
|
338
|
+
case 'textweb_storage_load': {
|
|
339
|
+
const out = await b.loadStorageState(args.path);
|
|
340
|
+
return `Loaded storage state for session "${sessionId}" from ${out.path}`;
|
|
341
|
+
}
|
|
342
|
+
case 'textweb_wait_for': {
|
|
343
|
+
const result = await b.waitFor({
|
|
344
|
+
selector: args.selector,
|
|
345
|
+
text: args.text,
|
|
346
|
+
urlIncludes: args.url_includes,
|
|
347
|
+
timeoutMs: args.timeout_ms,
|
|
348
|
+
pollMs: args.poll_ms,
|
|
349
|
+
state: args.state,
|
|
350
|
+
...retryOptions(args),
|
|
351
|
+
});
|
|
169
352
|
return formatResult(result);
|
|
170
353
|
}
|
|
354
|
+
case 'textweb_assert_field': {
|
|
355
|
+
const out = await b.assertField(args.ref, args.expected, {
|
|
356
|
+
comparator: args.comparator,
|
|
357
|
+
attribute: args.attribute,
|
|
358
|
+
});
|
|
359
|
+
return `ASSERT ${out.pass ? 'PASS' : 'FAIL'} | ref=${out.ref} | comparator=${out.comparator} | expected="${out.expected}" | actual="${out.actual}" | selector=${out.selector}`;
|
|
360
|
+
}
|
|
171
361
|
default:
|
|
172
362
|
throw new Error(`Unknown tool: ${name}`);
|
|
173
363
|
}
|
|
@@ -232,7 +422,7 @@ function main() {
|
|
|
232
422
|
process.stdin.setEncoding('utf8');
|
|
233
423
|
process.stdin.on('data', async (chunk) => {
|
|
234
424
|
buffer += chunk;
|
|
235
|
-
|
|
425
|
+
|
|
236
426
|
// Process complete lines (newline-delimited JSON)
|
|
237
427
|
const lines = buffer.split('\n');
|
|
238
428
|
buffer = lines.pop(); // Keep incomplete line in buffer
|
|
@@ -257,19 +447,23 @@ function main() {
|
|
|
257
447
|
});
|
|
258
448
|
|
|
259
449
|
process.stdin.on('end', async () => {
|
|
260
|
-
|
|
450
|
+
for (const [, browser] of sessions) {
|
|
451
|
+
await browser.close();
|
|
452
|
+
}
|
|
453
|
+
sessions.clear();
|
|
261
454
|
process.exit(0);
|
|
262
455
|
});
|
|
263
456
|
|
|
264
457
|
process.on('SIGINT', async () => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
process.on('SIGTERM', async () => {
|
|
270
|
-
if (browser) await browser.close();
|
|
458
|
+
for (const [, browser] of sessions) {
|
|
459
|
+
await browser.close();
|
|
460
|
+
}
|
|
461
|
+
sessions.clear();
|
|
271
462
|
process.exit(0);
|
|
272
463
|
});
|
|
273
464
|
}
|
|
274
465
|
|
|
275
|
-
main()
|
|
466
|
+
ensureBrowser().then(main).catch((err) => {
|
|
467
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "textweb",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A text-grid web renderer for AI agents — see the web without screenshots",
|
|
5
5
|
"main": "src/browser.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,8 +10,19 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/cli.js",
|
|
12
12
|
"serve": "node src/server.js",
|
|
13
|
-
"test": "node test/
|
|
13
|
+
"test": "node test/test-form.js && node test/test-live.js && node test/test-ats-e2e.js",
|
|
14
|
+
"test:form": "node test/test-form.js",
|
|
15
|
+
"test:live": "node test/test-live.js",
|
|
16
|
+
"test:ats": "node test/test-ats-e2e.js"
|
|
14
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src/",
|
|
20
|
+
"mcp/",
|
|
21
|
+
"tools/",
|
|
22
|
+
"logo.svg",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
15
26
|
"keywords": [
|
|
16
27
|
"ai",
|
|
17
28
|
"agent",
|