@geometra/mcp 1.19.10 → 1.19.12
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 +156 -12
- package/dist/__tests__/server-batch-results.test.d.ts +1 -0
- package/dist/__tests__/server-batch-results.test.js +311 -0
- package/dist/__tests__/session-model.test.js +100 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +459 -78
- package/dist/session.d.ts +57 -0
- package/dist/session.js +163 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,12 +19,13 @@ 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, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
22
|
+
| `geometra_query` | Find elements by stable id, role, name, text content, ancestor/prompt context, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
23
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 |
|
|
25
|
-
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result |
|
|
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 |
|
|
26
26
|
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
27
|
-
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
|
|
27
|
+
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
|
|
28
|
+
| `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas |
|
|
28
29
|
| `geometra_click` | Click an element by coordinates |
|
|
29
30
|
| `geometra_type` | Type text into the focused element |
|
|
30
31
|
| `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
|
|
@@ -39,27 +40,155 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
39
40
|
|
|
40
41
|
## Setup
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
<details>
|
|
44
|
+
<summary>Claude Code</summary>
|
|
43
45
|
|
|
46
|
+
**One-line install:**
|
|
44
47
|
```bash
|
|
45
|
-
claude mcp add geometra -- npx @geometra/mcp
|
|
48
|
+
claude mcp add geometra -- npx -y @geometra/mcp
|
|
46
49
|
```
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
**Uninstall:**
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp remove geometra
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or manually add to `.mcp.json` (project-level) or `~/.claude/settings.json` (global):
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"geometra": {
|
|
61
|
+
"command": "npx",
|
|
62
|
+
"args": ["-y", "@geometra/mcp"]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To uninstall manually, remove the `geometra` entry from the config file.
|
|
69
|
+
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary>Claude Desktop</summary>
|
|
74
|
+
|
|
75
|
+
Add to your Claude Desktop MCP config:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"geometra": {
|
|
81
|
+
"command": "npx",
|
|
82
|
+
"args": ["-y", "@geometra/mcp"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
To uninstall, remove the `geometra` entry from the config file.
|
|
89
|
+
|
|
90
|
+
</details>
|
|
91
|
+
|
|
92
|
+
<details>
|
|
93
|
+
<summary>OpenAI Codex</summary>
|
|
94
|
+
|
|
95
|
+
Add to your Codex MCP configuration:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"mcpServers": {
|
|
100
|
+
"geometra": {
|
|
101
|
+
"command": "npx",
|
|
102
|
+
"args": ["-y", "@geometra/mcp"]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To uninstall, remove the `geometra` entry from the config file.
|
|
109
|
+
|
|
110
|
+
</details>
|
|
49
111
|
|
|
50
|
-
|
|
112
|
+
<details>
|
|
113
|
+
<summary>Cursor</summary>
|
|
114
|
+
|
|
115
|
+
Open Settings → MCP → Add new MCP server, or add to `.cursor/mcp.json`:
|
|
51
116
|
|
|
52
117
|
```json
|
|
53
118
|
{
|
|
54
119
|
"mcpServers": {
|
|
55
120
|
"geometra": {
|
|
56
121
|
"command": "npx",
|
|
57
|
-
"args": ["@geometra/mcp"]
|
|
122
|
+
"args": ["-y", "@geometra/mcp"]
|
|
58
123
|
}
|
|
59
124
|
}
|
|
60
125
|
}
|
|
61
126
|
```
|
|
62
127
|
|
|
128
|
+
To uninstall, remove the entry from MCP settings.
|
|
129
|
+
|
|
130
|
+
</details>
|
|
131
|
+
|
|
132
|
+
<details>
|
|
133
|
+
<summary>Windsurf</summary>
|
|
134
|
+
|
|
135
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"geometra": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": ["-y", "@geometra/mcp"]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
To uninstall, remove the entry from the config file.
|
|
149
|
+
|
|
150
|
+
</details>
|
|
151
|
+
|
|
152
|
+
<details>
|
|
153
|
+
<summary>VS Code / Copilot</summary>
|
|
154
|
+
|
|
155
|
+
**One-line install:**
|
|
156
|
+
```bash
|
|
157
|
+
code --add-mcp '{"name":"geometra","command":"npx","args":["-y","@geometra/mcp"]}'
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Or add to `.vscode/mcp.json`:
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"servers": {
|
|
164
|
+
"geometra": {
|
|
165
|
+
"command": "npx",
|
|
166
|
+
"args": ["-y", "@geometra/mcp"]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
To uninstall, remove the entry from MCP settings or delete the server from the MCP panel.
|
|
173
|
+
|
|
174
|
+
</details>
|
|
175
|
+
|
|
176
|
+
<details>
|
|
177
|
+
<summary>Other MCP clients</summary>
|
|
178
|
+
|
|
179
|
+
Any MCP client that supports stdio transport can use Geometra. The server config is:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"command": "npx",
|
|
184
|
+
"args": ["-y", "@geometra/mcp"]
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
To uninstall, remove the server entry from your client's MCP configuration.
|
|
189
|
+
|
|
190
|
+
</details>
|
|
191
|
+
|
|
63
192
|
### From source (this repo)
|
|
64
193
|
|
|
65
194
|
```bash
|
|
@@ -182,8 +311,9 @@ For long application flows, prefer one of these patterns:
|
|
|
182
311
|
|
|
183
312
|
1. `geometra_page_model`
|
|
184
313
|
2. `geometra_expand_section`
|
|
185
|
-
3. `
|
|
186
|
-
4. `
|
|
314
|
+
3. `geometra_reveal` for far-below-fold targets such as submit buttons
|
|
315
|
+
4. `geometra_fill_fields` for obvious field entry
|
|
316
|
+
5. `geometra_run_actions` when you need mixed navigation + waits + field entry
|
|
187
317
|
|
|
188
318
|
Typical batch:
|
|
189
319
|
|
|
@@ -200,6 +330,18 @@ Typical batch:
|
|
|
200
330
|
|
|
201
331
|
Single action tools now default to terse summaries. Pass `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
|
|
202
332
|
|
|
333
|
+
For the smallest long-form responses, prefer:
|
|
334
|
+
|
|
335
|
+
1. `detail: "minimal"` for structured step metadata instead of narrated deltas
|
|
336
|
+
2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
|
|
337
|
+
|
|
338
|
+
For long single-page forms:
|
|
339
|
+
|
|
340
|
+
1. Use `geometra_expand_section` with `fieldOffset` / `actionOffset` to page through large forms instead of taking a full snapshot.
|
|
341
|
+
2. Add `onlyRequiredFields: true` or `onlyInvalidFields: true` when you want the actionable subset.
|
|
342
|
+
3. Use `contextText` in `geometra_query` / `geometra_wait_for` to disambiguate repeated `Yes` / `No` controls by question text.
|
|
343
|
+
4. Use `geometra_reveal` instead of manual wheel loops when the next target is offscreen.
|
|
344
|
+
|
|
203
345
|
Typical field fill:
|
|
204
346
|
|
|
205
347
|
```json
|
|
@@ -211,7 +353,9 @@ Typical field fill:
|
|
|
211
353
|
{ "kind": "choice", "fieldLabel": "Will you require sponsorship?", "value": "No" },
|
|
212
354
|
{ "kind": "file", "fieldLabel": "Resume", "paths": ["/Users/you/resume.pdf"] }
|
|
213
355
|
],
|
|
214
|
-
"failOnInvalid": true
|
|
356
|
+
"failOnInvalid": true,
|
|
357
|
+
"detail": "minimal",
|
|
358
|
+
"includeSteps": false
|
|
215
359
|
}
|
|
216
360
|
```
|
|
217
361
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,311 @@
|
|
|
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: options?.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
|
+
});
|
|
207
|
+
describe('query and reveal tools', () => {
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
vi.clearAllMocks();
|
|
210
|
+
});
|
|
211
|
+
it('lets query disambiguate repeated controls by context text', async () => {
|
|
212
|
+
const handler = getToolHandler('geometra_query');
|
|
213
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
214
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 900 },
|
|
215
|
+
children: [
|
|
216
|
+
node('form', 'Application', {
|
|
217
|
+
path: [0],
|
|
218
|
+
children: [
|
|
219
|
+
node('group', undefined, {
|
|
220
|
+
path: [0, 0],
|
|
221
|
+
children: [
|
|
222
|
+
node('text', 'Are you legally authorized to work here?', { path: [0, 0, 0] }),
|
|
223
|
+
node('button', 'Yes', { path: [0, 0, 1] }),
|
|
224
|
+
node('button', 'No', { path: [0, 0, 2] }),
|
|
225
|
+
],
|
|
226
|
+
}),
|
|
227
|
+
node('group', undefined, {
|
|
228
|
+
path: [0, 1],
|
|
229
|
+
children: [
|
|
230
|
+
node('text', 'Will you require sponsorship?', { path: [0, 1, 0] }),
|
|
231
|
+
node('button', 'Yes', { path: [0, 1, 1] }),
|
|
232
|
+
node('button', 'No', { path: [0, 1, 2] }),
|
|
233
|
+
],
|
|
234
|
+
}),
|
|
235
|
+
],
|
|
236
|
+
}),
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
const result = await handler({
|
|
240
|
+
role: 'button',
|
|
241
|
+
name: 'Yes',
|
|
242
|
+
contextText: 'sponsorship',
|
|
243
|
+
});
|
|
244
|
+
const payload = JSON.parse(result.content[0].text);
|
|
245
|
+
expect(payload).toHaveLength(1);
|
|
246
|
+
expect(payload[0]).toMatchObject({
|
|
247
|
+
role: 'button',
|
|
248
|
+
name: 'Yes',
|
|
249
|
+
context: {
|
|
250
|
+
prompt: 'Will you require sponsorship?',
|
|
251
|
+
section: 'Application',
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
|
|
256
|
+
const handler = getToolHandler('geometra_reveal');
|
|
257
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
258
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
259
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
260
|
+
children: [
|
|
261
|
+
node('form', 'Application', {
|
|
262
|
+
bounds: { x: 20, y: -200, width: 760, height: 1900 },
|
|
263
|
+
path: [0],
|
|
264
|
+
children: [
|
|
265
|
+
node('button', 'Submit application', {
|
|
266
|
+
bounds: { x: 60, y: 1540, width: 180, height: 40 },
|
|
267
|
+
path: [0, 0],
|
|
268
|
+
}),
|
|
269
|
+
],
|
|
270
|
+
}),
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
mockState.sendWheel.mockImplementationOnce(async () => {
|
|
274
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
275
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
276
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
|
|
277
|
+
children: [
|
|
278
|
+
node('form', 'Application', {
|
|
279
|
+
bounds: { x: 20, y: -1420, width: 760, height: 1900 },
|
|
280
|
+
path: [0],
|
|
281
|
+
children: [
|
|
282
|
+
node('button', 'Submit application', {
|
|
283
|
+
bounds: { x: 60, y: 320, width: 180, height: 40 },
|
|
284
|
+
path: [0, 0],
|
|
285
|
+
}),
|
|
286
|
+
],
|
|
287
|
+
}),
|
|
288
|
+
],
|
|
289
|
+
});
|
|
290
|
+
return { status: 'updated', timeoutMs: 2500 };
|
|
291
|
+
});
|
|
292
|
+
const result = await handler({
|
|
293
|
+
role: 'button',
|
|
294
|
+
name: 'Submit application',
|
|
295
|
+
maxSteps: 3,
|
|
296
|
+
fullyVisible: true,
|
|
297
|
+
timeoutMs: 2500,
|
|
298
|
+
});
|
|
299
|
+
const payload = JSON.parse(result.content[0].text);
|
|
300
|
+
expect(mockState.sendWheel).toHaveBeenCalledWith(mockState.session, expect.any(Number), expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }), 2500);
|
|
301
|
+
expect(payload).toMatchObject({
|
|
302
|
+
revealed: true,
|
|
303
|
+
attempts: 1,
|
|
304
|
+
target: {
|
|
305
|
+
role: 'button',
|
|
306
|
+
name: 'Submit application',
|
|
307
|
+
visibility: { fullyVisible: true },
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -115,17 +115,117 @@ describe('buildPageModel', () => {
|
|
|
115
115
|
summary: {
|
|
116
116
|
headingCount: 1,
|
|
117
117
|
fieldCount: 2,
|
|
118
|
+
requiredFieldCount: 2,
|
|
119
|
+
invalidFieldCount: 1,
|
|
118
120
|
actionCount: 1,
|
|
119
121
|
},
|
|
122
|
+
page: {
|
|
123
|
+
fields: { offset: 0, returned: 2, total: 2, hasMore: false },
|
|
124
|
+
actions: { offset: 0, returned: 1, total: 1, hasMore: false },
|
|
125
|
+
},
|
|
120
126
|
});
|
|
121
127
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
122
128
|
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
123
129
|
expect(detail?.fields[0]?.state).toEqual({ required: true });
|
|
124
130
|
expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
|
|
125
131
|
expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
|
|
132
|
+
expect(detail?.fields[1]?.visibility).toMatchObject({ intersectsViewport: true, fullyVisible: true });
|
|
133
|
+
expect(detail?.actions[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
126
134
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
127
135
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
128
136
|
});
|
|
137
|
+
it('paginates long sections and carries context on repeated answers', () => {
|
|
138
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
139
|
+
children: [
|
|
140
|
+
node('form', 'Application', { x: 20, y: -120, width: 760, height: 1800 }, {
|
|
141
|
+
path: [0],
|
|
142
|
+
children: [
|
|
143
|
+
node('heading', 'Application', { x: 40, y: 40, width: 240, height: 28 }, { path: [0, 0] }),
|
|
144
|
+
node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
|
|
145
|
+
path: [0, 1],
|
|
146
|
+
state: { required: true },
|
|
147
|
+
}),
|
|
148
|
+
node('textbox', 'Email', { x: 48, y: 176, width: 320, height: 36 }, {
|
|
149
|
+
path: [0, 2],
|
|
150
|
+
state: { required: true, invalid: true },
|
|
151
|
+
validation: { error: 'Enter a valid email.' },
|
|
152
|
+
}),
|
|
153
|
+
node('textbox', 'Phone', { x: 48, y: 232, width: 320, height: 36 }, {
|
|
154
|
+
path: [0, 3],
|
|
155
|
+
state: { required: true },
|
|
156
|
+
}),
|
|
157
|
+
node('group', undefined, { x: 40, y: 980, width: 520, height: 96 }, {
|
|
158
|
+
path: [0, 4],
|
|
159
|
+
children: [
|
|
160
|
+
node('text', 'Are you legally authorized to work here?', { x: 48, y: 980, width: 340, height: 24 }, {
|
|
161
|
+
path: [0, 4, 0],
|
|
162
|
+
}),
|
|
163
|
+
node('button', 'Yes', { x: 48, y: 1020, width: 88, height: 40 }, {
|
|
164
|
+
path: [0, 4, 1],
|
|
165
|
+
focusable: true,
|
|
166
|
+
}),
|
|
167
|
+
node('button', 'No', { x: 148, y: 1020, width: 88, height: 40 }, {
|
|
168
|
+
path: [0, 4, 2],
|
|
169
|
+
focusable: true,
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
node('group', undefined, { x: 40, y: 1120, width: 520, height: 96 }, {
|
|
174
|
+
path: [0, 5],
|
|
175
|
+
children: [
|
|
176
|
+
node('text', 'Will you require sponsorship?', { x: 48, y: 1120, width: 260, height: 24 }, {
|
|
177
|
+
path: [0, 5, 0],
|
|
178
|
+
}),
|
|
179
|
+
node('button', 'Yes', { x: 48, y: 1160, width: 88, height: 40 }, {
|
|
180
|
+
path: [0, 5, 1],
|
|
181
|
+
focusable: true,
|
|
182
|
+
}),
|
|
183
|
+
node('button', 'No', { x: 148, y: 1160, width: 88, height: 40 }, {
|
|
184
|
+
path: [0, 5, 2],
|
|
185
|
+
focusable: true,
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
node('button', 'Submit application', { x: 48, y: 1540, width: 180, height: 40 }, {
|
|
190
|
+
path: [0, 6],
|
|
191
|
+
focusable: true,
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
}),
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
const detail = expandPageSection(tree, 'fm:0', {
|
|
198
|
+
maxFields: 2,
|
|
199
|
+
fieldOffset: 1,
|
|
200
|
+
onlyRequiredFields: true,
|
|
201
|
+
});
|
|
202
|
+
expect(detail).toMatchObject({
|
|
203
|
+
summary: {
|
|
204
|
+
fieldCount: 3,
|
|
205
|
+
requiredFieldCount: 3,
|
|
206
|
+
invalidFieldCount: 1,
|
|
207
|
+
actionCount: 5,
|
|
208
|
+
},
|
|
209
|
+
page: {
|
|
210
|
+
fields: { offset: 1, returned: 2, total: 3, hasMore: false },
|
|
211
|
+
actions: { offset: 0, returned: 5, total: 5, hasMore: false },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(detail?.fields.map(field => field.name)).toEqual(['Email', 'Phone']);
|
|
215
|
+
expect(detail?.fields[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
216
|
+
const authorizedYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Are you legally authorized to work here?');
|
|
217
|
+
const sponsorshipYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Will you require sponsorship?');
|
|
218
|
+
expect(authorizedYes).toMatchObject({
|
|
219
|
+
name: 'Yes',
|
|
220
|
+
context: { prompt: 'Are you legally authorized to work here?', section: 'Application' },
|
|
221
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
222
|
+
});
|
|
223
|
+
expect(sponsorshipYes).toMatchObject({
|
|
224
|
+
name: 'Yes',
|
|
225
|
+
context: { prompt: 'Will you require sponsorship?', section: 'Application' },
|
|
226
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
129
229
|
it('drops noisy container names and falls back to unnamed summaries', () => {
|
|
130
230
|
const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
|
|
131
231
|
children: [
|