@geometra/mcp 1.19.9 → 1.19.11
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 +184 -7
- package/dist/__tests__/server-batch-results.test.d.ts +1 -0
- package/dist/__tests__/server-batch-results.test.js +206 -0
- package/dist/__tests__/server-filters.test.js +15 -1
- package/dist/__tests__/session-model.test.js +58 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +795 -69
- package/dist/session.d.ts +19 -0
- package/dist/session.js +60 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,13 +19,16 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
19
19
|
| Tool | Description |
|
|
20
20
|
|---|---|
|
|
21
21
|
| `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
|
|
22
|
-
| `geometra_query` | Find elements by stable id, role, name,
|
|
22
|
+
| `geometra_query` | Find elements by stable id, role, name, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
23
|
+
| `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
|
|
24
|
+
| `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
|
|
25
|
+
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result, with optional final-only output |
|
|
23
26
|
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
24
27
|
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
|
|
25
28
|
| `geometra_click` | Click an element by coordinates |
|
|
26
29
|
| `geometra_type` | Type text into the focused element |
|
|
27
30
|
| `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
|
|
28
|
-
| `geometra_upload_files` | Attach files: auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
|
|
31
|
+
| `geometra_upload_files` | Attach files: labeled field / auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
|
|
29
32
|
| `geometra_pick_listbox_option` | Pick an option from a custom dropdown/searchable combobox; can open by field label (`@geometra/proxy` only) |
|
|
30
33
|
| `geometra_select_option` | Choose an option on a native `<select>` (`@geometra/proxy` only) |
|
|
31
34
|
| `geometra_set_checked` | Set a checkbox or radio by label instead of coordinate clicks (`@geometra/proxy` only) |
|
|
@@ -36,27 +39,155 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
36
39
|
|
|
37
40
|
## Setup
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
<details>
|
|
43
|
+
<summary>Claude Code</summary>
|
|
40
44
|
|
|
45
|
+
**One-line install:**
|
|
41
46
|
```bash
|
|
42
|
-
claude mcp add geometra -- npx @geometra/mcp
|
|
47
|
+
claude mcp add geometra -- npx -y @geometra/mcp
|
|
43
48
|
```
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
**Uninstall:**
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp remove geometra
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or manually add to `.mcp.json` (project-level) or `~/.claude/settings.json` (global):
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"geometra": {
|
|
60
|
+
"command": "npx",
|
|
61
|
+
"args": ["-y", "@geometra/mcp"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To uninstall manually, remove the `geometra` entry from the config file.
|
|
46
68
|
|
|
47
|
-
|
|
69
|
+
</details>
|
|
70
|
+
|
|
71
|
+
<details>
|
|
72
|
+
<summary>Claude Desktop</summary>
|
|
73
|
+
|
|
74
|
+
Add to your Claude Desktop MCP config:
|
|
48
75
|
|
|
49
76
|
```json
|
|
50
77
|
{
|
|
51
78
|
"mcpServers": {
|
|
52
79
|
"geometra": {
|
|
53
80
|
"command": "npx",
|
|
54
|
-
"args": ["@geometra/mcp"]
|
|
81
|
+
"args": ["-y", "@geometra/mcp"]
|
|
55
82
|
}
|
|
56
83
|
}
|
|
57
84
|
}
|
|
58
85
|
```
|
|
59
86
|
|
|
87
|
+
To uninstall, remove the `geometra` entry from the config file.
|
|
88
|
+
|
|
89
|
+
</details>
|
|
90
|
+
|
|
91
|
+
<details>
|
|
92
|
+
<summary>OpenAI Codex</summary>
|
|
93
|
+
|
|
94
|
+
Add to your Codex MCP configuration:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"geometra": {
|
|
100
|
+
"command": "npx",
|
|
101
|
+
"args": ["-y", "@geometra/mcp"]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
To uninstall, remove the `geometra` entry from the config file.
|
|
108
|
+
|
|
109
|
+
</details>
|
|
110
|
+
|
|
111
|
+
<details>
|
|
112
|
+
<summary>Cursor</summary>
|
|
113
|
+
|
|
114
|
+
Open Settings → MCP → Add new MCP server, or add to `.cursor/mcp.json`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"geometra": {
|
|
120
|
+
"command": "npx",
|
|
121
|
+
"args": ["-y", "@geometra/mcp"]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
To uninstall, remove the entry from MCP settings.
|
|
128
|
+
|
|
129
|
+
</details>
|
|
130
|
+
|
|
131
|
+
<details>
|
|
132
|
+
<summary>Windsurf</summary>
|
|
133
|
+
|
|
134
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"mcpServers": {
|
|
139
|
+
"geometra": {
|
|
140
|
+
"command": "npx",
|
|
141
|
+
"args": ["-y", "@geometra/mcp"]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
To uninstall, remove the entry from the config file.
|
|
148
|
+
|
|
149
|
+
</details>
|
|
150
|
+
|
|
151
|
+
<details>
|
|
152
|
+
<summary>VS Code / Copilot</summary>
|
|
153
|
+
|
|
154
|
+
**One-line install:**
|
|
155
|
+
```bash
|
|
156
|
+
code --add-mcp '{"name":"geometra","command":"npx","args":["-y","@geometra/mcp"]}'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Or add to `.vscode/mcp.json`:
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"servers": {
|
|
163
|
+
"geometra": {
|
|
164
|
+
"command": "npx",
|
|
165
|
+
"args": ["-y", "@geometra/mcp"]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
To uninstall, remove the entry from MCP settings or delete the server from the MCP panel.
|
|
172
|
+
|
|
173
|
+
</details>
|
|
174
|
+
|
|
175
|
+
<details>
|
|
176
|
+
<summary>Other MCP clients</summary>
|
|
177
|
+
|
|
178
|
+
Any MCP client that supports stdio transport can use Geometra. The server config is:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"command": "npx",
|
|
183
|
+
"args": ["-y", "@geometra/mcp"]
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
To uninstall, remove the server entry from your client's MCP configuration.
|
|
188
|
+
|
|
189
|
+
</details>
|
|
190
|
+
|
|
60
191
|
### From source (this repo)
|
|
61
192
|
|
|
62
193
|
```bash
|
|
@@ -173,4 +304,50 @@ Agent: geometra_query({ role: "button", name: "Save" })
|
|
|
173
304
|
8. Tools expose query, click, type, snapshot, page-model, and section-expansion operations over this structured data.
|
|
174
305
|
9. After each interaction, the peer sends updated geometry (full `frame` or `patch`) — the MCP tools interpret that into compact summaries.
|
|
175
306
|
|
|
307
|
+
## Long Forms
|
|
308
|
+
|
|
309
|
+
For long application flows, prefer one of these patterns:
|
|
310
|
+
|
|
311
|
+
1. `geometra_page_model`
|
|
312
|
+
2. `geometra_expand_section`
|
|
313
|
+
3. `geometra_fill_fields` for obvious field entry
|
|
314
|
+
4. `geometra_run_actions` when you need mixed navigation + waits + field entry
|
|
315
|
+
|
|
316
|
+
Typical batch:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{
|
|
320
|
+
"actions": [
|
|
321
|
+
{ "type": "click", "x": 412, "y": 228 },
|
|
322
|
+
{ "type": "type", "text": "Taylor Applicant" },
|
|
323
|
+
{ "type": "upload_files", "paths": ["/Users/you/resume.pdf"], "fieldLabel": "Resume" },
|
|
324
|
+
{ "type": "wait_for", "text": "Parsing your resume", "present": false, "timeoutMs": 10000 }
|
|
325
|
+
]
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Single action tools now default to terse summaries. Pass `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
|
|
330
|
+
|
|
331
|
+
For the smallest long-form responses, prefer:
|
|
332
|
+
|
|
333
|
+
1. `detail: "minimal"` for structured step metadata instead of narrated deltas
|
|
334
|
+
2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
|
|
335
|
+
|
|
336
|
+
Typical field fill:
|
|
337
|
+
|
|
338
|
+
```json
|
|
339
|
+
{
|
|
340
|
+
"fields": [
|
|
341
|
+
{ "kind": "text", "fieldLabel": "Full name", "value": "Taylor Applicant" },
|
|
342
|
+
{ "kind": "text", "fieldLabel": "Email", "value": "taylor@example.com" },
|
|
343
|
+
{ "kind": "choice", "fieldLabel": "Country", "value": "Germany" },
|
|
344
|
+
{ "kind": "choice", "fieldLabel": "Will you require sponsorship?", "value": "No" },
|
|
345
|
+
{ "kind": "file", "fieldLabel": "Resume", "paths": ["/Users/you/resume.pdf"] }
|
|
346
|
+
],
|
|
347
|
+
"failOnInvalid": true,
|
|
348
|
+
"detail": "minimal",
|
|
349
|
+
"includeSteps": false
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
176
353
|
With a **native** Geometra server, layout comes from Textura/Yoga. With **`@geometra/proxy`**, layout comes from the browser’s computed DOM geometry; the MCP layer is the same.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
function node(role, name, options) {
|
|
3
|
+
return {
|
|
4
|
+
role,
|
|
5
|
+
...(name ? { name } : {}),
|
|
6
|
+
...(options?.value ? { value: options.value } : {}),
|
|
7
|
+
...(options?.state ? { state: options.state } : {}),
|
|
8
|
+
...(options?.validation ? { validation: options.validation } : {}),
|
|
9
|
+
...(options?.meta ? { meta: options.meta } : {}),
|
|
10
|
+
bounds: { x: 0, y: 0, width: 120, height: 40 },
|
|
11
|
+
path: options?.path ?? [],
|
|
12
|
+
children: options?.children ?? [],
|
|
13
|
+
focusable: role !== 'group',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const mockState = vi.hoisted(() => ({
|
|
17
|
+
currentA11yRoot: node('group', undefined, {
|
|
18
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
19
|
+
}),
|
|
20
|
+
session: {
|
|
21
|
+
tree: { kind: 'box' },
|
|
22
|
+
layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
|
|
23
|
+
url: 'ws://127.0.0.1:3200',
|
|
24
|
+
updateRevision: 1,
|
|
25
|
+
},
|
|
26
|
+
sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
27
|
+
sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
28
|
+
sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
29
|
+
sendFileUpload: vi.fn(async () => ({ status: 'updated', timeoutMs: 8000 })),
|
|
30
|
+
sendFieldText: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
31
|
+
sendFieldChoice: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
32
|
+
sendListboxPick: vi.fn(async () => ({ status: 'updated', timeoutMs: 4500 })),
|
|
33
|
+
sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
34
|
+
sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
35
|
+
sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
36
|
+
waitForUiCondition: vi.fn(async () => true),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('../session.js', () => ({
|
|
39
|
+
connect: vi.fn(),
|
|
40
|
+
connectThroughProxy: vi.fn(),
|
|
41
|
+
disconnect: vi.fn(),
|
|
42
|
+
getSession: vi.fn(() => mockState.session),
|
|
43
|
+
sendClick: mockState.sendClick,
|
|
44
|
+
sendType: mockState.sendType,
|
|
45
|
+
sendKey: mockState.sendKey,
|
|
46
|
+
sendFileUpload: mockState.sendFileUpload,
|
|
47
|
+
sendFieldText: mockState.sendFieldText,
|
|
48
|
+
sendFieldChoice: mockState.sendFieldChoice,
|
|
49
|
+
sendListboxPick: mockState.sendListboxPick,
|
|
50
|
+
sendSelectOption: mockState.sendSelectOption,
|
|
51
|
+
sendSetChecked: mockState.sendSetChecked,
|
|
52
|
+
sendWheel: mockState.sendWheel,
|
|
53
|
+
buildA11yTree: vi.fn(() => mockState.currentA11yRoot),
|
|
54
|
+
buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
|
|
55
|
+
buildPageModel: vi.fn(() => ({
|
|
56
|
+
viewport: { width: 1280, height: 800 },
|
|
57
|
+
archetypes: ['form'],
|
|
58
|
+
summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 2 },
|
|
59
|
+
primaryActions: [],
|
|
60
|
+
landmarks: [],
|
|
61
|
+
forms: [],
|
|
62
|
+
dialogs: [],
|
|
63
|
+
lists: [],
|
|
64
|
+
})),
|
|
65
|
+
expandPageSection: vi.fn(() => null),
|
|
66
|
+
buildUiDelta: vi.fn(() => ({})),
|
|
67
|
+
hasUiDelta: vi.fn(() => false),
|
|
68
|
+
nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
|
|
69
|
+
summarizeCompactIndex: vi.fn(() => ''),
|
|
70
|
+
summarizePageModel: vi.fn(() => ''),
|
|
71
|
+
summarizeUiDelta: vi.fn(() => ''),
|
|
72
|
+
waitForUiCondition: mockState.waitForUiCondition,
|
|
73
|
+
}));
|
|
74
|
+
const { createServer } = await import('../server.js');
|
|
75
|
+
function getToolHandler(name) {
|
|
76
|
+
const server = createServer();
|
|
77
|
+
return server._registeredTools[name].handler;
|
|
78
|
+
}
|
|
79
|
+
describe('batch MCP result shaping', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
83
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
|
|
84
|
+
children: [
|
|
85
|
+
node('textbox', 'Mission', {
|
|
86
|
+
value: 'Ship calm developer tools across browsers and platforms.',
|
|
87
|
+
path: [0],
|
|
88
|
+
}),
|
|
89
|
+
node('textbox', 'Email', {
|
|
90
|
+
value: 'taylor@example.com',
|
|
91
|
+
path: [1],
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
it('keeps fill_fields minimal output structured and does not echo long essay text', async () => {
|
|
97
|
+
const longAnswer = 'A'.repeat(180);
|
|
98
|
+
const handler = getToolHandler('geometra_fill_fields');
|
|
99
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
100
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
|
|
101
|
+
children: [
|
|
102
|
+
node('textbox', 'Mission', { value: longAnswer, path: [0] }),
|
|
103
|
+
node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const result = await handler({
|
|
107
|
+
fields: [
|
|
108
|
+
{ kind: 'text', fieldLabel: 'Mission', value: longAnswer },
|
|
109
|
+
{ kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
|
|
110
|
+
],
|
|
111
|
+
stopOnError: true,
|
|
112
|
+
failOnInvalid: false,
|
|
113
|
+
includeSteps: true,
|
|
114
|
+
detail: 'minimal',
|
|
115
|
+
});
|
|
116
|
+
const text = result.content[0].text;
|
|
117
|
+
const payload = JSON.parse(text);
|
|
118
|
+
const steps = payload.steps;
|
|
119
|
+
expect(text).not.toContain(longAnswer);
|
|
120
|
+
expect(payload).toMatchObject({
|
|
121
|
+
completed: true,
|
|
122
|
+
fieldCount: 2,
|
|
123
|
+
successCount: 2,
|
|
124
|
+
errorCount: 0,
|
|
125
|
+
});
|
|
126
|
+
expect(steps[0]).toMatchObject({
|
|
127
|
+
index: 0,
|
|
128
|
+
kind: 'text',
|
|
129
|
+
ok: true,
|
|
130
|
+
fieldLabel: 'Mission',
|
|
131
|
+
valueLength: 180,
|
|
132
|
+
wait: 'updated',
|
|
133
|
+
readback: { role: 'textbox', valueLength: 180 },
|
|
134
|
+
});
|
|
135
|
+
expect(steps[1]).toMatchObject({
|
|
136
|
+
index: 1,
|
|
137
|
+
kind: 'text',
|
|
138
|
+
ok: true,
|
|
139
|
+
fieldLabel: 'Email',
|
|
140
|
+
value: 'taylor@example.com',
|
|
141
|
+
wait: 'updated',
|
|
142
|
+
readback: { role: 'textbox', value: 'taylor@example.com' },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
it('lets run_actions omit step listings while keeping capped final validation state', async () => {
|
|
146
|
+
const handler = getToolHandler('geometra_run_actions');
|
|
147
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
148
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 2400 },
|
|
149
|
+
children: [
|
|
150
|
+
node('textbox', 'Full name', {
|
|
151
|
+
value: '',
|
|
152
|
+
path: [0],
|
|
153
|
+
state: { invalid: true, required: true },
|
|
154
|
+
validation: { error: 'Enter your full name.' },
|
|
155
|
+
}),
|
|
156
|
+
node('textbox', 'Email', {
|
|
157
|
+
value: '',
|
|
158
|
+
path: [1],
|
|
159
|
+
state: { invalid: true, required: true },
|
|
160
|
+
validation: { error: 'Enter your email.' },
|
|
161
|
+
}),
|
|
162
|
+
node('textbox', 'Phone', {
|
|
163
|
+
value: '',
|
|
164
|
+
path: [2],
|
|
165
|
+
state: { invalid: true, required: true },
|
|
166
|
+
validation: { error: 'Enter your phone number.' },
|
|
167
|
+
}),
|
|
168
|
+
node('textbox', 'Location', {
|
|
169
|
+
value: '',
|
|
170
|
+
path: [3],
|
|
171
|
+
state: { invalid: true, required: true },
|
|
172
|
+
validation: { error: 'Choose a location.' },
|
|
173
|
+
}),
|
|
174
|
+
node('textbox', 'LinkedIn', {
|
|
175
|
+
value: '',
|
|
176
|
+
path: [4],
|
|
177
|
+
state: { invalid: true },
|
|
178
|
+
validation: { error: 'Enter a valid URL.' },
|
|
179
|
+
}),
|
|
180
|
+
node('alert', 'Your form needs corrections', { path: [5] }),
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
const result = await handler({
|
|
184
|
+
actions: [{ type: 'click', x: 320, y: 540 }],
|
|
185
|
+
stopOnError: true,
|
|
186
|
+
includeSteps: false,
|
|
187
|
+
detail: 'minimal',
|
|
188
|
+
});
|
|
189
|
+
const payload = JSON.parse(result.content[0].text);
|
|
190
|
+
const final = payload.final;
|
|
191
|
+
expect(payload).toMatchObject({
|
|
192
|
+
completed: true,
|
|
193
|
+
stepCount: 1,
|
|
194
|
+
successCount: 1,
|
|
195
|
+
errorCount: 0,
|
|
196
|
+
});
|
|
197
|
+
expect(payload).not.toHaveProperty('steps');
|
|
198
|
+
expect(final).toMatchObject({
|
|
199
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
200
|
+
alertCount: 1,
|
|
201
|
+
invalidCount: 5,
|
|
202
|
+
});
|
|
203
|
+
expect(final.invalidFields.length).toBe(4);
|
|
204
|
+
expect(final.alerts.length).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -6,6 +6,7 @@ function node(role, bounds, options) {
|
|
|
6
6
|
...(options?.name ? { name: options.name } : {}),
|
|
7
7
|
...(options?.value ? { value: options.value } : {}),
|
|
8
8
|
...(options?.state ? { state: options.state } : {}),
|
|
9
|
+
...(options?.validation ? { validation: options.validation } : {}),
|
|
9
10
|
bounds,
|
|
10
11
|
path: options?.path ?? [],
|
|
11
12
|
children: options?.children ?? [],
|
|
@@ -22,8 +23,15 @@ describe('findNodes', () => {
|
|
|
22
23
|
value: 'Austin, Texas, United States',
|
|
23
24
|
focusable: true,
|
|
24
25
|
}),
|
|
25
|
-
node('
|
|
26
|
+
node('textbox', { x: 20, y: 120, width: 260, height: 36 }, {
|
|
26
27
|
path: [1],
|
|
28
|
+
name: 'Email',
|
|
29
|
+
focusable: true,
|
|
30
|
+
state: { invalid: true, required: true, busy: true },
|
|
31
|
+
validation: { error: 'Please enter a valid email address.' },
|
|
32
|
+
}),
|
|
33
|
+
node('checkbox', { x: 20, y: 72, width: 24, height: 24 }, {
|
|
34
|
+
path: [2],
|
|
27
35
|
name: 'Notion Website',
|
|
28
36
|
state: { checked: true },
|
|
29
37
|
focusable: true,
|
|
@@ -36,6 +44,12 @@ describe('findNodes', () => {
|
|
|
36
44
|
expect(findNodes(tree, { text: 'United States' })).toEqual([
|
|
37
45
|
expect.objectContaining({ role: 'combobox', value: 'Austin, Texas, United States' }),
|
|
38
46
|
]);
|
|
47
|
+
expect(findNodes(tree, { invalid: true, required: true, busy: true })).toEqual([
|
|
48
|
+
expect.objectContaining({ role: 'textbox', name: 'Email' }),
|
|
49
|
+
]);
|
|
50
|
+
expect(findNodes(tree, { text: 'valid email address' })).toEqual([
|
|
51
|
+
expect.objectContaining({ role: 'textbox', name: 'Email' }),
|
|
52
|
+
]);
|
|
39
53
|
expect(findNodes(tree, { role: 'checkbox', checked: true })).toEqual([
|
|
40
54
|
expect.objectContaining({ name: 'Notion Website' }),
|
|
41
55
|
]);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
2
|
+
import { buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
3
3
|
function node(role, name, bounds, options) {
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
6
6
|
...(name ? { name } : {}),
|
|
7
7
|
...(options?.value ? { value: options.value } : {}),
|
|
8
8
|
...(options?.state ? { state: options.state } : {}),
|
|
9
|
+
...(options?.validation ? { validation: options.validation } : {}),
|
|
9
10
|
...(options?.meta ? { meta: options.meta } : {}),
|
|
10
11
|
bounds,
|
|
11
12
|
path: options?.path ?? [],
|
|
@@ -87,10 +88,13 @@ describe('buildPageModel', () => {
|
|
|
87
88
|
node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
|
|
88
89
|
path: [0, 0, 1],
|
|
89
90
|
value: 'Taylor Applicant',
|
|
91
|
+
state: { required: true },
|
|
90
92
|
}),
|
|
91
93
|
node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, {
|
|
92
94
|
path: [0, 0, 2],
|
|
93
95
|
value: 'taylor@example.com',
|
|
96
|
+
state: { invalid: true, required: true },
|
|
97
|
+
validation: { error: 'Please enter a valid email address.' },
|
|
94
98
|
}),
|
|
95
99
|
node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
|
|
96
100
|
path: [0, 0, 3],
|
|
@@ -116,6 +120,9 @@ describe('buildPageModel', () => {
|
|
|
116
120
|
});
|
|
117
121
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
118
122
|
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
123
|
+
expect(detail?.fields[0]?.state).toEqual({ required: true });
|
|
124
|
+
expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
|
|
125
|
+
expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
|
|
119
126
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
120
127
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
121
128
|
});
|
|
@@ -324,3 +331,53 @@ describe('buildUiDelta', () => {
|
|
|
324
331
|
expect(summarizeUiDelta(delta)).toContain('value "Austin" -> "Austin, Texas, United States"');
|
|
325
332
|
});
|
|
326
333
|
});
|
|
334
|
+
describe('buildA11yTree', () => {
|
|
335
|
+
it('maps required, invalid, busy, and validation text from raw semantic nodes', () => {
|
|
336
|
+
const tree = {
|
|
337
|
+
kind: 'box',
|
|
338
|
+
props: {},
|
|
339
|
+
semantic: {},
|
|
340
|
+
children: [
|
|
341
|
+
{
|
|
342
|
+
kind: 'box',
|
|
343
|
+
props: { value: '' },
|
|
344
|
+
semantic: {
|
|
345
|
+
role: 'textbox',
|
|
346
|
+
ariaLabel: 'Email',
|
|
347
|
+
ariaRequired: true,
|
|
348
|
+
ariaInvalid: true,
|
|
349
|
+
ariaBusy: true,
|
|
350
|
+
validationDescription: 'We will contact you about this role.',
|
|
351
|
+
validationError: 'Please enter a valid email address.',
|
|
352
|
+
},
|
|
353
|
+
handlers: { onClick: true, onKeyDown: true },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
const layout = {
|
|
358
|
+
x: 0,
|
|
359
|
+
y: 0,
|
|
360
|
+
width: 800,
|
|
361
|
+
height: 600,
|
|
362
|
+
children: [
|
|
363
|
+
{
|
|
364
|
+
x: 24,
|
|
365
|
+
y: 40,
|
|
366
|
+
width: 320,
|
|
367
|
+
height: 36,
|
|
368
|
+
children: [],
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
};
|
|
372
|
+
const a11y = buildA11yTree(tree, layout);
|
|
373
|
+
expect(a11y.children[0]).toMatchObject({
|
|
374
|
+
role: 'textbox',
|
|
375
|
+
name: 'Email',
|
|
376
|
+
state: { required: true, invalid: true, busy: true },
|
|
377
|
+
validation: {
|
|
378
|
+
description: 'We will contact you about this role.',
|
|
379
|
+
error: 'Please enter a valid email address.',
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
package/dist/server.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ interface NodeFilter {
|
|
|
12
12
|
focused?: boolean;
|
|
13
13
|
selected?: boolean;
|
|
14
14
|
expanded?: boolean;
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
busy?: boolean;
|
|
15
18
|
}
|
|
16
19
|
export declare function createServer(): McpServer;
|
|
17
20
|
export declare function findNodes(node: A11yNode, filter: NodeFilter): A11yNode[];
|