@geometra/mcp 1.19.12 → 1.19.14
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 +37 -21
- package/dist/__tests__/connect-utils.test.js +44 -2
- package/dist/__tests__/proxy-session-actions.test.js +78 -1
- package/dist/__tests__/server-batch-results.test.js +195 -2
- package/dist/__tests__/session-model.test.js +130 -1
- package/dist/proxy-spawn.js +80 -4
- package/dist/server.js +369 -8
- package/dist/session.d.ts +61 -1
- package/dist/session.js +262 -13
- package/package.json +2 -2
package/dist/proxy-spawn.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync, rmSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -13,9 +13,34 @@ export function resolveProxyScriptPath() {
|
|
|
13
13
|
}
|
|
14
14
|
export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
|
|
15
15
|
const errors = [];
|
|
16
|
+
const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
|
|
17
|
+
const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
|
|
18
|
+
const packageDir = resolveProxyPackageDir(customRequire);
|
|
19
|
+
if (packageDir) {
|
|
20
|
+
if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
|
|
21
|
+
return workspaceDist;
|
|
22
|
+
}
|
|
23
|
+
const packagedDist = path.join(packageDir, 'dist/index.js');
|
|
24
|
+
if (existsSync(packagedDist))
|
|
25
|
+
return packagedDist;
|
|
26
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
|
|
27
|
+
if (builtLocalDist)
|
|
28
|
+
return builtLocalDist;
|
|
29
|
+
errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/index.js was missing`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
|
|
33
|
+
}
|
|
16
34
|
try {
|
|
17
35
|
const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
|
|
18
|
-
|
|
36
|
+
const exportPackageDir = path.dirname(pkgJson);
|
|
37
|
+
const packagedDist = path.join(exportPackageDir, 'dist/index.js');
|
|
38
|
+
if (existsSync(packagedDist))
|
|
39
|
+
return packagedDist;
|
|
40
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
|
|
41
|
+
if (builtLocalDist)
|
|
42
|
+
return builtLocalDist;
|
|
43
|
+
errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/index.js was missing`);
|
|
19
44
|
}
|
|
20
45
|
catch (err) {
|
|
21
46
|
errors.push(err instanceof Error ? err.message : String(err));
|
|
@@ -31,13 +56,64 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
|
|
|
31
56
|
return packagedSiblingDist;
|
|
32
57
|
}
|
|
33
58
|
errors.push(`Packaged sibling fallback not found at ${packagedSiblingDist}`);
|
|
34
|
-
const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
|
|
35
59
|
if (existsSync(workspaceDist)) {
|
|
36
60
|
return workspaceDist;
|
|
37
61
|
}
|
|
38
62
|
errors.push(`Workspace fallback not found at ${workspaceDist}`);
|
|
39
63
|
throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
|
|
40
64
|
}
|
|
65
|
+
function resolveProxyPackageDir(customRequire) {
|
|
66
|
+
const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
|
|
67
|
+
for (const searchRoot of searchRoots) {
|
|
68
|
+
const packageDir = path.join(searchRoot, '@geometra', 'proxy');
|
|
69
|
+
if (existsSync(path.join(packageDir, 'package.json')))
|
|
70
|
+
return packageDir;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
|
|
75
|
+
try {
|
|
76
|
+
return realpathSync(packageDir) === realpathSync(bundledDependencyDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function buildLocalProxyDistIfPossible(packageDir, errors) {
|
|
83
|
+
const distEntry = path.join(packageDir, 'dist/index.js');
|
|
84
|
+
const sourceEntry = path.join(packageDir, 'src/index.ts');
|
|
85
|
+
const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
|
|
86
|
+
if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const realPackageDir = realpathSync(packageDir);
|
|
91
|
+
const realTsconfigPath = path.join(realPackageDir, 'tsconfig.build.json');
|
|
92
|
+
const realDistDir = path.join(realPackageDir, 'dist');
|
|
93
|
+
const tscBin = require.resolve('typescript/bin/tsc');
|
|
94
|
+
rmSync(realDistDir, { recursive: true, force: true });
|
|
95
|
+
const result = spawnSync(process.execPath, [tscBin, '-p', realTsconfigPath], {
|
|
96
|
+
cwd: realPackageDir,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: 'pipe',
|
|
99
|
+
});
|
|
100
|
+
if (result.status !== 0) {
|
|
101
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
102
|
+
errors.push(`Failed to build local @geometra/proxy at ${realPackageDir}: ${detail || `exit ${result.status ?? 'unknown'}`}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(distEntry))
|
|
106
|
+
return distEntry;
|
|
107
|
+
const realDistEntry = path.join(realPackageDir, 'dist/index.js');
|
|
108
|
+
if (existsSync(realDistEntry))
|
|
109
|
+
return realDistEntry;
|
|
110
|
+
errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/index.js is still missing`);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
41
117
|
export function parseProxyReadySignalLine(line) {
|
|
42
118
|
const trimmed = line.trim();
|
|
43
119
|
if (!trimmed)
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
4
|
-
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
4
|
+
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
5
5
|
function checkedStateInput() {
|
|
6
6
|
return z
|
|
7
7
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -66,6 +66,12 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
|
|
|
66
66
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
67
67
|
}),
|
|
68
68
|
]);
|
|
69
|
+
const formValueSchema = z.union([
|
|
70
|
+
z.string(),
|
|
71
|
+
z.boolean(),
|
|
72
|
+
z.array(z.string()).min(1),
|
|
73
|
+
]);
|
|
74
|
+
const formValuesRecordSchema = z.record(z.string(), formValueSchema);
|
|
69
75
|
const batchActionSchema = z.discriminatedUnion('type', [
|
|
70
76
|
z.object({
|
|
71
77
|
type: z.literal('click'),
|
|
@@ -146,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
146
152
|
}),
|
|
147
153
|
]);
|
|
148
154
|
export function createServer() {
|
|
149
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
155
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.14' }, { capabilities: { tools: {} } });
|
|
150
156
|
// ── connect ──────────────────────────────────────────────────
|
|
151
157
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
152
158
|
|
|
@@ -184,6 +190,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
184
190
|
.nonnegative()
|
|
185
191
|
.optional()
|
|
186
192
|
.describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
|
|
193
|
+
detail: detailInput(),
|
|
187
194
|
}, async (input) => {
|
|
188
195
|
const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
|
|
189
196
|
if (!normalized.ok)
|
|
@@ -199,13 +206,23 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
199
206
|
height: input.height,
|
|
200
207
|
slowMo: input.slowMo,
|
|
201
208
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
return ok(JSON.stringify(connectPayload(session, {
|
|
210
|
+
transport: 'proxy',
|
|
211
|
+
requestedPageUrl: target.pageUrl,
|
|
212
|
+
autoCoercedFromUrl: target.autoCoercedFromUrl,
|
|
213
|
+
detail: input.detail,
|
|
214
|
+
}), null, input.detail === 'verbose' ? 2 : undefined));
|
|
205
215
|
}
|
|
206
|
-
const session = await connect(target.wsUrl
|
|
207
|
-
|
|
208
|
-
|
|
216
|
+
const session = await connect(target.wsUrl, {
|
|
217
|
+
width: input.width,
|
|
218
|
+
height: input.height,
|
|
219
|
+
});
|
|
220
|
+
return ok(JSON.stringify(connectPayload(session, {
|
|
221
|
+
transport: 'ws',
|
|
222
|
+
requestedWsUrl: target.wsUrl,
|
|
223
|
+
autoCoercedFromUrl: false,
|
|
224
|
+
detail: input.detail,
|
|
225
|
+
}), null, input.detail === 'verbose' ? 2 : undefined));
|
|
209
226
|
}
|
|
210
227
|
catch (e) {
|
|
211
228
|
return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
|
|
@@ -360,6 +377,138 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
360
377
|
}
|
|
361
378
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
362
379
|
});
|
|
380
|
+
server.tool('geometra_fill_form', `Fill a form from a compact values object instead of expanding sections first. This is the lowest-token happy path for standard application flows.
|
|
381
|
+
|
|
382
|
+
Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result.`, {
|
|
383
|
+
formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
|
|
384
|
+
valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
|
|
385
|
+
valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
|
|
386
|
+
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
|
|
387
|
+
failOnInvalid: z
|
|
388
|
+
.boolean()
|
|
389
|
+
.optional()
|
|
390
|
+
.default(false)
|
|
391
|
+
.describe('Return an error if invalid fields remain after filling'),
|
|
392
|
+
includeSteps: z
|
|
393
|
+
.boolean()
|
|
394
|
+
.optional()
|
|
395
|
+
.default(false)
|
|
396
|
+
.describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
|
|
397
|
+
detail: detailInput(),
|
|
398
|
+
}, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
|
|
399
|
+
const session = getSession();
|
|
400
|
+
if (!session)
|
|
401
|
+
return err('Not connected. Call geometra_connect first.');
|
|
402
|
+
const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
|
|
403
|
+
if (entryCount === 0) {
|
|
404
|
+
return err('Provide at least one value in valuesById or valuesByLabel');
|
|
405
|
+
}
|
|
406
|
+
const afterConnect = sessionA11y(session);
|
|
407
|
+
if (!afterConnect)
|
|
408
|
+
return err('No UI tree available for form filling');
|
|
409
|
+
const schemas = buildFormSchemas(afterConnect);
|
|
410
|
+
if (schemas.length === 0)
|
|
411
|
+
return err('No forms found in the current UI');
|
|
412
|
+
const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
|
|
413
|
+
if (!resolution.ok)
|
|
414
|
+
return err(resolution.error);
|
|
415
|
+
const schema = resolution.schema;
|
|
416
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel });
|
|
417
|
+
if (!planned.ok)
|
|
418
|
+
return err(planned.error);
|
|
419
|
+
if (!includeSteps) {
|
|
420
|
+
let usedBatch = false;
|
|
421
|
+
try {
|
|
422
|
+
const startRevision = session.updateRevision;
|
|
423
|
+
const wait = await sendFillFields(session, planned.fields);
|
|
424
|
+
const ackResult = parseProxyFillAckResult(wait.result);
|
|
425
|
+
if (ackResult && ackResult.invalidCount === 0) {
|
|
426
|
+
usedBatch = true;
|
|
427
|
+
const payload = {
|
|
428
|
+
completed: true,
|
|
429
|
+
execution: 'batched',
|
|
430
|
+
finalSource: 'proxy',
|
|
431
|
+
formId: schema.formId,
|
|
432
|
+
requestedValueCount: entryCount,
|
|
433
|
+
fieldCount: planned.fields.length,
|
|
434
|
+
successCount: planned.fields.length,
|
|
435
|
+
errorCount: 0,
|
|
436
|
+
final: ackResult,
|
|
437
|
+
};
|
|
438
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
439
|
+
}
|
|
440
|
+
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
441
|
+
await waitForBatchFieldReadback(session, planned.fields);
|
|
442
|
+
usedBatch = true;
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
if (!canFallbackToSequentialFill(e)) {
|
|
446
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
447
|
+
return err(message);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (usedBatch) {
|
|
451
|
+
const after = sessionA11y(session);
|
|
452
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
453
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
454
|
+
const payload = {
|
|
455
|
+
completed: true,
|
|
456
|
+
execution: 'batched',
|
|
457
|
+
finalSource: 'session',
|
|
458
|
+
formId: schema.formId,
|
|
459
|
+
requestedValueCount: entryCount,
|
|
460
|
+
fieldCount: planned.fields.length,
|
|
461
|
+
successCount: planned.fields.length,
|
|
462
|
+
errorCount: 0,
|
|
463
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
464
|
+
};
|
|
465
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
466
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
467
|
+
}
|
|
468
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const steps = [];
|
|
472
|
+
let stoppedAt;
|
|
473
|
+
for (let index = 0; index < planned.fields.length; index++) {
|
|
474
|
+
const field = planned.fields[index];
|
|
475
|
+
try {
|
|
476
|
+
const result = await executeFillField(session, field, detail);
|
|
477
|
+
steps.push(detail === 'verbose'
|
|
478
|
+
? { index, kind: field.kind, ok: true, summary: result.summary }
|
|
479
|
+
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
483
|
+
steps.push({ index, kind: field.kind, ok: false, error: message });
|
|
484
|
+
if (stopOnError) {
|
|
485
|
+
stoppedAt = index;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const after = sessionA11y(session);
|
|
491
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
492
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
493
|
+
const successCount = steps.filter(step => step.ok === true).length;
|
|
494
|
+
const errorCount = steps.length - successCount;
|
|
495
|
+
const payload = {
|
|
496
|
+
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
497
|
+
execution: 'sequential',
|
|
498
|
+
formId: schema.formId,
|
|
499
|
+
requestedValueCount: entryCount,
|
|
500
|
+
fieldCount: planned.fields.length,
|
|
501
|
+
successCount,
|
|
502
|
+
errorCount,
|
|
503
|
+
...(includeSteps ? { steps } : {}),
|
|
504
|
+
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
505
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
506
|
+
};
|
|
507
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
508
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
509
|
+
}
|
|
510
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
511
|
+
});
|
|
363
512
|
server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
|
|
364
513
|
|
|
365
514
|
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
|
|
@@ -436,6 +585,29 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
436
585
|
const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
|
|
437
586
|
return ok(JSON.stringify(model));
|
|
438
587
|
});
|
|
588
|
+
server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
|
|
589
|
+
|
|
590
|
+
Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
|
|
591
|
+
formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
|
|
592
|
+
maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
|
|
593
|
+
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
594
|
+
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
595
|
+
}, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
|
|
596
|
+
const session = getSession();
|
|
597
|
+
if (!session?.tree || !session?.layout)
|
|
598
|
+
return err('Not connected. Call geometra_connect first.');
|
|
599
|
+
const a11y = buildA11yTree(session.tree, session.layout);
|
|
600
|
+
const forms = buildFormSchemas(a11y, {
|
|
601
|
+
formId,
|
|
602
|
+
maxFields,
|
|
603
|
+
onlyRequiredFields,
|
|
604
|
+
onlyInvalidFields,
|
|
605
|
+
});
|
|
606
|
+
if (forms.length === 0) {
|
|
607
|
+
return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
|
|
608
|
+
}
|
|
609
|
+
return ok(JSON.stringify({ forms }));
|
|
610
|
+
});
|
|
439
611
|
server.tool('geometra_expand_section', `Expand one section from geometra_page_model by stable id. Returns richer on-demand details such as headings, fields, actions, nested lists, list items, and text preview.
|
|
440
612
|
|
|
441
613
|
Use this after geometra_page_model when you know which form/dialog/list/landmark you want to inspect more closely. Per-item bounds are omitted by default to save tokens; set includeBounds=true if you need them immediately.`, {
|
|
@@ -863,6 +1035,18 @@ function compactSessionSummary(session) {
|
|
|
863
1035
|
return 'No UI update received';
|
|
864
1036
|
return sessionOverviewFromA11y(a11y);
|
|
865
1037
|
}
|
|
1038
|
+
function connectPayload(session, opts) {
|
|
1039
|
+
const a11y = sessionA11y(session);
|
|
1040
|
+
return {
|
|
1041
|
+
connected: true,
|
|
1042
|
+
transport: opts.transport,
|
|
1043
|
+
wsUrl: session.url,
|
|
1044
|
+
...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
|
|
1045
|
+
...(opts.requestedWsUrl ? { requestedWsUrl: opts.requestedWsUrl } : {}),
|
|
1046
|
+
...(opts.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
|
|
1047
|
+
...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
866
1050
|
function sessionA11y(session) {
|
|
867
1051
|
if (!session.tree || !session.layout)
|
|
868
1052
|
return null;
|
|
@@ -1072,6 +1256,183 @@ function waitStatusPayload(wait) {
|
|
|
1072
1256
|
function compactFilterPayload(filter) {
|
|
1073
1257
|
return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
|
|
1074
1258
|
}
|
|
1259
|
+
function normalizeLookupKey(value) {
|
|
1260
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1261
|
+
}
|
|
1262
|
+
function resolveTargetFormSchema(schemas, opts) {
|
|
1263
|
+
if (opts.formId) {
|
|
1264
|
+
const matched = schemas.find(schema => schema.formId === opts.formId);
|
|
1265
|
+
return matched
|
|
1266
|
+
? { ok: true, schema: matched }
|
|
1267
|
+
: { ok: false, error: `No form schema found for id ${opts.formId}` };
|
|
1268
|
+
}
|
|
1269
|
+
if (schemas.length === 1)
|
|
1270
|
+
return { ok: true, schema: schemas[0] };
|
|
1271
|
+
const idKeys = Object.keys(opts.valuesById ?? {});
|
|
1272
|
+
const labelKeys = Object.keys(opts.valuesByLabel ?? {}).map(normalizeLookupKey);
|
|
1273
|
+
const matches = schemas.filter(schema => {
|
|
1274
|
+
const ids = new Set(schema.fields.map(field => field.id));
|
|
1275
|
+
const labels = new Set(schema.fields.map(field => normalizeLookupKey(field.label)));
|
|
1276
|
+
return idKeys.every(id => ids.has(id)) && labelKeys.every(label => labels.has(label));
|
|
1277
|
+
});
|
|
1278
|
+
if (matches.length === 1)
|
|
1279
|
+
return { ok: true, schema: matches[0] };
|
|
1280
|
+
if (matches.length === 0) {
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
error: 'Could not infer which form to fill from the provided field ids/labels. Pass formId from geometra_form_schema.',
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
error: 'Multiple forms match the provided field ids/labels. Pass formId from geometra_form_schema.',
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function coerceChoiceValue(field, value) {
|
|
1292
|
+
if (typeof value === 'string')
|
|
1293
|
+
return value;
|
|
1294
|
+
if (typeof value !== 'boolean')
|
|
1295
|
+
return null;
|
|
1296
|
+
const desired = value ? 'yes' : 'no';
|
|
1297
|
+
const option = field.options?.find(option => normalizeLookupKey(option) === desired);
|
|
1298
|
+
return option ?? (value ? 'Yes' : 'No');
|
|
1299
|
+
}
|
|
1300
|
+
function plannedFillInputsForField(field, value) {
|
|
1301
|
+
if (field.kind === 'text') {
|
|
1302
|
+
if (typeof value !== 'string')
|
|
1303
|
+
return { error: `Field "${field.label}" expects a string value` };
|
|
1304
|
+
return [{ kind: 'text', fieldLabel: field.label, value }];
|
|
1305
|
+
}
|
|
1306
|
+
if (field.kind === 'choice') {
|
|
1307
|
+
const coerced = coerceChoiceValue(field, value);
|
|
1308
|
+
if (!coerced)
|
|
1309
|
+
return { error: `Field "${field.label}" expects a string value` };
|
|
1310
|
+
return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
|
|
1311
|
+
}
|
|
1312
|
+
if (field.kind === 'toggle') {
|
|
1313
|
+
if (typeof value !== 'boolean')
|
|
1314
|
+
return { error: `Field "${field.label}" expects a boolean value` };
|
|
1315
|
+
return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
|
|
1316
|
+
}
|
|
1317
|
+
const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
1318
|
+
if (!selected || selected.length === 0)
|
|
1319
|
+
return { error: `Field "${field.label}" expects a string array value` };
|
|
1320
|
+
if (!field.options || field.options.length === 0) {
|
|
1321
|
+
return { error: `Field "${field.label}" does not expose checkbox options; use geometra_fill_fields for this field` };
|
|
1322
|
+
}
|
|
1323
|
+
const selectedKeys = new Set(selected.map(normalizeLookupKey));
|
|
1324
|
+
return field.options.map(option => ({
|
|
1325
|
+
kind: 'toggle',
|
|
1326
|
+
label: option,
|
|
1327
|
+
checked: selectedKeys.has(normalizeLookupKey(option)),
|
|
1328
|
+
controlType: 'checkbox',
|
|
1329
|
+
}));
|
|
1330
|
+
}
|
|
1331
|
+
function planFormFill(schema, opts) {
|
|
1332
|
+
const fieldById = new Map(schema.fields.map(field => [field.id, field]));
|
|
1333
|
+
const fieldsByLabel = new Map();
|
|
1334
|
+
for (const field of schema.fields) {
|
|
1335
|
+
const key = normalizeLookupKey(field.label);
|
|
1336
|
+
const existing = fieldsByLabel.get(key);
|
|
1337
|
+
if (existing)
|
|
1338
|
+
existing.push(field);
|
|
1339
|
+
else
|
|
1340
|
+
fieldsByLabel.set(key, [field]);
|
|
1341
|
+
}
|
|
1342
|
+
const planned = [];
|
|
1343
|
+
const seenFieldIds = new Set();
|
|
1344
|
+
for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
|
|
1345
|
+
const field = fieldById.get(fieldId);
|
|
1346
|
+
if (!field)
|
|
1347
|
+
return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
|
|
1348
|
+
const next = plannedFillInputsForField(field, value);
|
|
1349
|
+
if ('error' in next)
|
|
1350
|
+
return { ok: false, error: next.error };
|
|
1351
|
+
planned.push(...next);
|
|
1352
|
+
seenFieldIds.add(field.id);
|
|
1353
|
+
}
|
|
1354
|
+
for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
|
|
1355
|
+
const matches = fieldsByLabel.get(normalizeLookupKey(label)) ?? [];
|
|
1356
|
+
if (matches.length === 0)
|
|
1357
|
+
return { ok: false, error: `Unknown form field label "${label}". Refresh geometra_form_schema and try again.` };
|
|
1358
|
+
if (matches.length > 1) {
|
|
1359
|
+
return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
|
|
1360
|
+
}
|
|
1361
|
+
const field = matches[0];
|
|
1362
|
+
if (seenFieldIds.has(field.id)) {
|
|
1363
|
+
return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
|
|
1364
|
+
}
|
|
1365
|
+
const next = plannedFillInputsForField(field, value);
|
|
1366
|
+
if ('error' in next)
|
|
1367
|
+
return { ok: false, error: next.error };
|
|
1368
|
+
planned.push(...next);
|
|
1369
|
+
seenFieldIds.add(field.id);
|
|
1370
|
+
}
|
|
1371
|
+
return { ok: true, fields: planned };
|
|
1372
|
+
}
|
|
1373
|
+
function canFallbackToSequentialFill(error) {
|
|
1374
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1375
|
+
return (message.includes('Unsupported client message type "fillFields"') ||
|
|
1376
|
+
message.includes('Client message type "fillFields" is not supported'));
|
|
1377
|
+
}
|
|
1378
|
+
function parseProxyFillAckResult(value) {
|
|
1379
|
+
if (!value || typeof value !== 'object')
|
|
1380
|
+
return undefined;
|
|
1381
|
+
const candidate = value;
|
|
1382
|
+
if (typeof candidate.invalidCount !== 'number' ||
|
|
1383
|
+
typeof candidate.alertCount !== 'number' ||
|
|
1384
|
+
typeof candidate.dialogCount !== 'number' ||
|
|
1385
|
+
typeof candidate.busyCount !== 'number') {
|
|
1386
|
+
return undefined;
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
|
|
1390
|
+
invalidCount: candidate.invalidCount,
|
|
1391
|
+
alertCount: candidate.alertCount,
|
|
1392
|
+
dialogCount: candidate.dialogCount,
|
|
1393
|
+
busyCount: candidate.busyCount,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
1397
|
+
if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
|
|
1398
|
+
return;
|
|
1399
|
+
await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
|
|
1400
|
+
}
|
|
1401
|
+
async function waitForBatchFieldReadback(session, fields) {
|
|
1402
|
+
await waitForUiCondition(session, () => {
|
|
1403
|
+
const a11y = sessionA11y(session);
|
|
1404
|
+
if (!a11y)
|
|
1405
|
+
return false;
|
|
1406
|
+
return fields.every(field => batchFieldReadbackMatches(a11y, field));
|
|
1407
|
+
}, 1500);
|
|
1408
|
+
}
|
|
1409
|
+
function batchFieldReadbackMatches(a11y, field) {
|
|
1410
|
+
switch (field.kind) {
|
|
1411
|
+
case 'text': {
|
|
1412
|
+
const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
|
|
1413
|
+
return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1414
|
+
}
|
|
1415
|
+
case 'choice': {
|
|
1416
|
+
const directMatches = [
|
|
1417
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
|
|
1418
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1419
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1420
|
+
];
|
|
1421
|
+
if (directMatches.length === 0)
|
|
1422
|
+
return true;
|
|
1423
|
+
return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1424
|
+
}
|
|
1425
|
+
case 'toggle':
|
|
1426
|
+
return true;
|
|
1427
|
+
case 'file': {
|
|
1428
|
+
const matches = [
|
|
1429
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1430
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1431
|
+
];
|
|
1432
|
+
return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1075
1436
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
1076
1437
|
switch (action.type) {
|
|
1077
1438
|
case 'click': {
|
package/dist/session.d.ts
CHANGED
|
@@ -241,6 +241,30 @@ export interface PageSectionDetail {
|
|
|
241
241
|
items: PageListItemModel[];
|
|
242
242
|
textPreview: string[];
|
|
243
243
|
}
|
|
244
|
+
export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
|
|
245
|
+
export interface FormSchemaField {
|
|
246
|
+
id: string;
|
|
247
|
+
kind: FormSchemaFieldKind;
|
|
248
|
+
label: string;
|
|
249
|
+
required?: boolean;
|
|
250
|
+
invalid?: boolean;
|
|
251
|
+
controlType?: 'checkbox' | 'radio';
|
|
252
|
+
value?: string;
|
|
253
|
+
valueLength?: number;
|
|
254
|
+
checked?: boolean;
|
|
255
|
+
values?: string[];
|
|
256
|
+
optionCount?: number;
|
|
257
|
+
options?: string[];
|
|
258
|
+
context?: NodeContextModel;
|
|
259
|
+
}
|
|
260
|
+
export interface FormSchemaModel {
|
|
261
|
+
formId: string;
|
|
262
|
+
name?: string;
|
|
263
|
+
fieldCount: number;
|
|
264
|
+
requiredCount: number;
|
|
265
|
+
invalidCount: number;
|
|
266
|
+
fields: FormSchemaField[];
|
|
267
|
+
}
|
|
244
268
|
export interface UiNodeUpdate {
|
|
245
269
|
before: CompactUiNode;
|
|
246
270
|
after: CompactUiNode;
|
|
@@ -292,12 +316,40 @@ export interface Session {
|
|
|
292
316
|
export interface UpdateWaitResult {
|
|
293
317
|
status: 'updated' | 'acknowledged' | 'timed_out';
|
|
294
318
|
timeoutMs: number;
|
|
319
|
+
result?: unknown;
|
|
295
320
|
}
|
|
321
|
+
export type ProxyFillField = {
|
|
322
|
+
kind: 'text';
|
|
323
|
+
fieldLabel: string;
|
|
324
|
+
value: string;
|
|
325
|
+
exact?: boolean;
|
|
326
|
+
} | {
|
|
327
|
+
kind: 'choice';
|
|
328
|
+
fieldLabel: string;
|
|
329
|
+
value: string;
|
|
330
|
+
query?: string;
|
|
331
|
+
exact?: boolean;
|
|
332
|
+
} | {
|
|
333
|
+
kind: 'toggle';
|
|
334
|
+
label: string;
|
|
335
|
+
checked?: boolean;
|
|
336
|
+
exact?: boolean;
|
|
337
|
+
controlType?: 'checkbox' | 'radio';
|
|
338
|
+
} | {
|
|
339
|
+
kind: 'file';
|
|
340
|
+
fieldLabel: string;
|
|
341
|
+
paths: string[];
|
|
342
|
+
exact?: boolean;
|
|
343
|
+
};
|
|
296
344
|
/**
|
|
297
345
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
298
346
|
* layout/tree state is available immediately after connection.
|
|
299
347
|
*/
|
|
300
|
-
export declare function connect(url: string
|
|
348
|
+
export declare function connect(url: string, opts?: {
|
|
349
|
+
width?: number;
|
|
350
|
+
height?: number;
|
|
351
|
+
skipInitialResize?: boolean;
|
|
352
|
+
}): Promise<Session>;
|
|
301
353
|
/**
|
|
302
354
|
* Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
|
|
303
355
|
* process to the session so disconnect / reconnect can clean it up.
|
|
@@ -356,6 +408,8 @@ export declare function sendFieldChoice(session: Session, fieldLabel: string, va
|
|
|
356
408
|
exact?: boolean;
|
|
357
409
|
query?: string;
|
|
358
410
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
411
|
+
/** Fill several semantic form fields in one proxy-side batch. */
|
|
412
|
+
export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
359
413
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
360
414
|
export declare function sendListboxPick(session: Session, label: string, opts?: {
|
|
361
415
|
exact?: boolean;
|
|
@@ -413,6 +467,12 @@ export declare function buildPageModel(root: A11yNode, options?: {
|
|
|
413
467
|
maxPrimaryActions?: number;
|
|
414
468
|
maxSectionsPerKind?: number;
|
|
415
469
|
}): PageModel;
|
|
470
|
+
export declare function buildFormSchemas(root: A11yNode, options?: {
|
|
471
|
+
formId?: string;
|
|
472
|
+
maxFields?: number;
|
|
473
|
+
onlyRequiredFields?: boolean;
|
|
474
|
+
onlyInvalidFields?: boolean;
|
|
475
|
+
}): FormSchemaModel[];
|
|
416
476
|
/**
|
|
417
477
|
* Expand a page-model section by stable ID into richer, on-demand details.
|
|
418
478
|
*/
|