@nxisai/nxis 2.1.2 → 2.2.2
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/.github/workflows/deploy.yml +45 -7
- package/README.md +288 -29
- package/build/api.d.ts +13 -2
- package/build/core.d.ts +1 -1
- package/build/nxis.js +1 -1
- package/build/webmcp/contract.d.ts +109 -0
- package/build/webmcp/execute.d.ts +24 -0
- package/build/webmcp/index.d.ts +37 -0
- package/build/webmcp/locate.d.ts +19 -0
- package/build/webmcp/parse.d.ts +20 -0
- package/build/webmcp/reconcile.d.ts +57 -0
- package/build/webmcp/register.d.ts +31 -0
- package/build/webmcp/schema.d.ts +30 -0
- package/build/webmcp/types.d.ts +72 -0
- package/build/webmcp.js +1 -0
- package/package.json +12 -2
- package/rollup.config.js +20 -1
- package/src/api.ts +13 -2
- package/src/client.ts +23 -13
- package/src/core.ts +1 -2
- package/src/webmcp/contract.ts +168 -0
- package/src/webmcp/execute.ts +514 -0
- package/src/webmcp/index.ts +51 -0
- package/src/webmcp/locate.ts +200 -0
- package/src/webmcp/parse.ts +276 -0
- package/src/webmcp/reconcile.ts +170 -0
- package/src/webmcp/register.ts +285 -0
- package/src/webmcp/schema.ts +201 -0
- package/src/webmcp/types.ts +86 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-shape types matching the backend JsonLdEmitter output.
|
|
3
|
+
*
|
|
4
|
+
* These types describe the JSON-LD that NxisAI's Action Specialist emits
|
|
5
|
+
* into `<script type="application/ld+json">` blocks. They are the single
|
|
6
|
+
* source of truth for the frontend consumer — any change here is a
|
|
7
|
+
* versioned contract change.
|
|
8
|
+
*
|
|
9
|
+
* @see JsonLdEmitter.scala in nxis-autofill-runner
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Value types — closed set derived from HTML input[type]
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** The closed set of inferred value types emitted by the backend. */
|
|
18
|
+
export type NxisValueType =
|
|
19
|
+
| "string"
|
|
20
|
+
| "number"
|
|
21
|
+
| "integer"
|
|
22
|
+
| "boolean"
|
|
23
|
+
| "date"
|
|
24
|
+
| "file";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// PropertyValueSpecification
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** A single option value for select/radio fields. */
|
|
31
|
+
export interface NxisValueOption {
|
|
32
|
+
"@type": "nxis:ValueOption";
|
|
33
|
+
value: string;
|
|
34
|
+
/** Human-readable label. Absent when label === value. */
|
|
35
|
+
name?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mirrors schema.org PropertyValueSpecification + nxis extensions.
|
|
40
|
+
*
|
|
41
|
+
* Every field is an inferred descriptor — the consumer verifies
|
|
42
|
+
* against the live DOM control.
|
|
43
|
+
*/
|
|
44
|
+
export interface NxisPropertyValueSpecification {
|
|
45
|
+
"@type": "PropertyValueSpecification";
|
|
46
|
+
/** REQUIRED — property key, maps to form control `name`. */
|
|
47
|
+
valueName: string;
|
|
48
|
+
/** REQUIRED — inferred from HTML input[type]; consumer verifies against DOM. */
|
|
49
|
+
"nxis:valueType": NxisValueType;
|
|
50
|
+
/** Human-readable description (from label or placeholder). */
|
|
51
|
+
description?: string;
|
|
52
|
+
/** Inferred from HTML `required` attribute. */
|
|
53
|
+
valueRequired?: boolean;
|
|
54
|
+
/** Inferred from HTML `value` attribute. */
|
|
55
|
+
defaultValue?: string;
|
|
56
|
+
/** Inferred from HTML `minlength`. */
|
|
57
|
+
valueMinLength?: number;
|
|
58
|
+
/** Inferred from HTML `maxlength`. */
|
|
59
|
+
valueMaxLength?: number;
|
|
60
|
+
/** Inferred from HTML `min`. */
|
|
61
|
+
minValue?: number;
|
|
62
|
+
/** Inferred from HTML `max`. */
|
|
63
|
+
maxValue?: number;
|
|
64
|
+
/** Inferred from HTML `step`. */
|
|
65
|
+
stepValue?: number;
|
|
66
|
+
/** Inferred from HTML `pattern` — emitted UNANCHORED; consumer anchors with ^…$. */
|
|
67
|
+
valuePattern?: string;
|
|
68
|
+
/** Inferred from HTML `multiple`. */
|
|
69
|
+
multipleValues?: boolean;
|
|
70
|
+
/** Inferred from HTML `readonly`. */
|
|
71
|
+
readonlyValue?: boolean;
|
|
72
|
+
/** REQUIRED for select/radio — actual option values from DOM. */
|
|
73
|
+
"nxis:valueOption"?: NxisValueOption[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// EntryPoint — carries the SubmitTarget discriminator in the JSON-LD shape
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mirrors schema.org EntryPoint + nxis extensions.
|
|
82
|
+
*
|
|
83
|
+
* The backend's `SubmitTarget` discriminator is expressed as:
|
|
84
|
+
* - `urlTemplate` present → Url target (real server endpoint)
|
|
85
|
+
* - `nxis:targetKind` present → endpointless (SelfPage or ClientSide)
|
|
86
|
+
*
|
|
87
|
+
* These are mutually exclusive in the emitted JSON-LD.
|
|
88
|
+
*/
|
|
89
|
+
export interface NxisEntryPoint {
|
|
90
|
+
"@type": "EntryPoint";
|
|
91
|
+
/** Form method (GET or POST), uppercase. */
|
|
92
|
+
httpMethod: string;
|
|
93
|
+
/** Form enctype (e.g., "application/x-www-form-urlencoded"). */
|
|
94
|
+
encodingType?: string;
|
|
95
|
+
|
|
96
|
+
// --- URL target (SubmitTarget.Url) ---
|
|
97
|
+
/** Real, resolvable server endpoint. May include RFC 6570 template vars for GET. */
|
|
98
|
+
urlTemplate?: string;
|
|
99
|
+
|
|
100
|
+
// --- Endpointless target (SubmitTarget.SelfPage / ClientSide) ---
|
|
101
|
+
/** Discriminator for endpointless forms. */
|
|
102
|
+
"nxis:targetKind"?: "selfPage" | "clientSide";
|
|
103
|
+
/** Page URL for staleness checking. */
|
|
104
|
+
"nxis:pageUrl"?: string;
|
|
105
|
+
/** Evidence that a JS submit/click listener exists on the form. */
|
|
106
|
+
"nxis:jsHandled"?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Action — the top-level entity
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A schema.org Action node as emitted by the backend JsonLdEmitter.
|
|
115
|
+
*
|
|
116
|
+
* Found in `<script type="application/ld+json">` blocks, either standalone
|
|
117
|
+
* or nested within a `@graph` array alongside other schema.org types.
|
|
118
|
+
*/
|
|
119
|
+
export interface NxisAction {
|
|
120
|
+
"@context": unknown;
|
|
121
|
+
/** Action or a subtype (e.g., "SearchAction", "CommunicateAction"). */
|
|
122
|
+
"@type": string;
|
|
123
|
+
/** Provenance/identity URN. NOT a DOM locator. */
|
|
124
|
+
"@id": string;
|
|
125
|
+
/** Human-readable action name (becomes the tool name). */
|
|
126
|
+
name: string;
|
|
127
|
+
/** Human-readable action description (becomes the tool description). */
|
|
128
|
+
description: string;
|
|
129
|
+
/** EntryPoint describing how to invoke the action. */
|
|
130
|
+
target: NxisEntryPoint;
|
|
131
|
+
/** Inferred field descriptors (may be empty for actions with no inputs). */
|
|
132
|
+
input?: NxisPropertyValueSpecification[];
|
|
133
|
+
/** Crawl timestamp — consumer uses to assess staleness. */
|
|
134
|
+
"nxis:extractedAt"?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Known Action subtypes
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/** The set of Action subtypes the backend classifier emits. */
|
|
142
|
+
export const ACTION_SUBTYPES = [
|
|
143
|
+
"AskAction",
|
|
144
|
+
"SearchAction",
|
|
145
|
+
"SubscribeAction",
|
|
146
|
+
"RegisterAction",
|
|
147
|
+
"BuyAction",
|
|
148
|
+
"OrderAction",
|
|
149
|
+
"AddAction",
|
|
150
|
+
"ScheduleAction",
|
|
151
|
+
"ReserveAction",
|
|
152
|
+
"RsvpAction",
|
|
153
|
+
"ApplyAction",
|
|
154
|
+
"QuoteAction",
|
|
155
|
+
"DownloadAction",
|
|
156
|
+
"ReviewAction",
|
|
157
|
+
"CommentAction",
|
|
158
|
+
"TrackAction",
|
|
159
|
+
"CommunicateAction",
|
|
160
|
+
"InteractAction",
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
export type ActionSubtype = typeof ACTION_SUBTYPES[number];
|
|
164
|
+
|
|
165
|
+
/** Check if a string is a known Action subtype. */
|
|
166
|
+
export function isActionType(type: string): type is ActionSubtype {
|
|
167
|
+
return (ACTION_SUBTYPES as readonly string[]).includes(type);
|
|
168
|
+
}
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute — fills a located form with the provided arguments and submits it.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches `input` and `change` events to trigger framework reactivity,
|
|
5
|
+
* then submits the form via `requestSubmit()` (respects validation and
|
|
6
|
+
* submit event handlers).
|
|
7
|
+
*
|
|
8
|
+
* Returns a structured JSON result with a `success` flag and actionable
|
|
9
|
+
* `message` so the agent can self-correct on failure.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NxisAction } from './contract';
|
|
15
|
+
import { locateForm } from './locate';
|
|
16
|
+
import { reconcileFields, ReconciledField } from './reconcile';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Result types
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Result of executing an action fill. */
|
|
23
|
+
interface FillResult {
|
|
24
|
+
/** Whether the fill was meaningful (at least one field filled, no fatal errors). */
|
|
25
|
+
success: boolean;
|
|
26
|
+
/** Whether the form was submitted after filling. */
|
|
27
|
+
submitted: boolean;
|
|
28
|
+
/** Human-readable summary for agent self-correction. */
|
|
29
|
+
message: string;
|
|
30
|
+
/** Fields that were successfully filled. */
|
|
31
|
+
filled: string[];
|
|
32
|
+
/** Fields that were skipped (with actionable reasons). */
|
|
33
|
+
skipped: Array<{ field: string; reason: string }>;
|
|
34
|
+
/** True when the action was extracted from a different page than the current one. */
|
|
35
|
+
stale?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Internal fill outcome for a single field. */
|
|
39
|
+
type FieldFillOutcome =
|
|
40
|
+
| { ok: true }
|
|
41
|
+
| { ok: false; reason: string };
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Public API
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Execute a form fill action.
|
|
49
|
+
*
|
|
50
|
+
* Locates the form, reconciles fields, fills values, dispatches events,
|
|
51
|
+
* waits for framework reactivity to settle, then verifies values persisted.
|
|
52
|
+
*
|
|
53
|
+
* @param action - The NxisAction describing the form.
|
|
54
|
+
* @param args - The argument values to fill, keyed by valueName.
|
|
55
|
+
* @returns A JSON string describing the fill result.
|
|
56
|
+
*/
|
|
57
|
+
export async function executeAction(
|
|
58
|
+
action: NxisAction,
|
|
59
|
+
args: Record<string, unknown>
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
// Step 0: Staleness — the action records the page it was extracted from.
|
|
62
|
+
// If the live page differs, the form may have moved or changed; surface a
|
|
63
|
+
// non-fatal warning so the agent can weigh the result.
|
|
64
|
+
const staleWarning = checkStaleness(action);
|
|
65
|
+
|
|
66
|
+
// Step 1: Locate the form
|
|
67
|
+
const form = locateForm(action);
|
|
68
|
+
|
|
69
|
+
if (!form) {
|
|
70
|
+
const base = `Form not found for action "${action.name}". The page may have changed since the tool was registered.`;
|
|
71
|
+
const result: FillResult = {
|
|
72
|
+
success: false,
|
|
73
|
+
submitted: false,
|
|
74
|
+
message: staleWarning ? `Warning: ${staleWarning} ${base}` : base,
|
|
75
|
+
filled: [],
|
|
76
|
+
skipped: [],
|
|
77
|
+
};
|
|
78
|
+
if (staleWarning) {
|
|
79
|
+
result.stale = true;
|
|
80
|
+
}
|
|
81
|
+
return JSON.stringify(result);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Step 2: Reconcile fields
|
|
85
|
+
const reconciled = reconcileFields(action, form);
|
|
86
|
+
const specs = action.input ?? [];
|
|
87
|
+
|
|
88
|
+
// Step 3: Check for missing required fields
|
|
89
|
+
const filled: string[] = [];
|
|
90
|
+
const skipped: Array<{ field: string; reason: string }> = [];
|
|
91
|
+
// Tracks whether a required field went unfilled — drives the submit gate
|
|
92
|
+
// below. Kept as structured state rather than re-parsing skip messages.
|
|
93
|
+
let requiredMissing = false;
|
|
94
|
+
|
|
95
|
+
for (const pvs of specs) {
|
|
96
|
+
if (pvs.valueRequired && (args[pvs.valueName] === undefined || args[pvs.valueName] === null)) {
|
|
97
|
+
requiredMissing = true;
|
|
98
|
+
skipped.push({
|
|
99
|
+
field: pvs.valueName,
|
|
100
|
+
reason: `Required field '${pvs.valueName}' was not provided. Expected type: ${pvs['nxis:valueType']}.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Step 4: Fill each reconciled field
|
|
106
|
+
for (const field of reconciled) {
|
|
107
|
+
const argValue = args[field.valueName];
|
|
108
|
+
|
|
109
|
+
if (argValue === undefined || argValue === null) {
|
|
110
|
+
// Already reported in required-field check above if required;
|
|
111
|
+
// for optional fields, note it but don't treat as error
|
|
112
|
+
if (!skipped.some(s => s.field === field.valueName)) {
|
|
113
|
+
skipped.push({ field: field.valueName, reason: 'No value provided (optional field).' });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const outcome = fillField(field, argValue);
|
|
119
|
+
if (outcome.ok === false) {
|
|
120
|
+
skipped.push({ field: field.valueName, reason: outcome.reason });
|
|
121
|
+
} else {
|
|
122
|
+
filled.push(field.valueName);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Step 5: Settle — yield to the microtask queue so frameworks (React, Vue,
|
|
127
|
+
// Angular) can process the dispatched input/change events before we verify.
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
129
|
+
|
|
130
|
+
// Step 6: Verify — spot-check that filled values actually persisted.
|
|
131
|
+
// Frameworks may revert values (e.g., controlled components in React).
|
|
132
|
+
// Only the single-value text path is verifiable this way; radio/checkbox/
|
|
133
|
+
// select strategies resolve to a DOM-constrained value and aren't compared.
|
|
134
|
+
for (const field of reconciled) {
|
|
135
|
+
if (field.strategy.kind === 'value' && filled.includes(field.valueName)) {
|
|
136
|
+
const el = field.strategy.element;
|
|
137
|
+
const expected = valueToString(args[field.valueName] ?? '');
|
|
138
|
+
if (el.value !== expected) {
|
|
139
|
+
skipped.push({
|
|
140
|
+
field: field.valueName,
|
|
141
|
+
reason: `Value was set to "${expected}" but the DOM now shows "${el.value}". A framework may have reverted it.`,
|
|
142
|
+
});
|
|
143
|
+
filled.splice(filled.indexOf(field.valueName), 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Step 7: Submit the form if fill was successful
|
|
149
|
+
const canSubmit = filled.length > 0 && !requiredMissing;
|
|
150
|
+
let submitted = false;
|
|
151
|
+
|
|
152
|
+
if (canSubmit) {
|
|
153
|
+
submitted = submitForm(form);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Step 8: Build result
|
|
157
|
+
const result: FillResult = {
|
|
158
|
+
success: canSubmit,
|
|
159
|
+
submitted,
|
|
160
|
+
message: buildResultMessage(filled, skipped, submitted, staleWarning),
|
|
161
|
+
filled,
|
|
162
|
+
skipped,
|
|
163
|
+
};
|
|
164
|
+
if (staleWarning) {
|
|
165
|
+
result.stale = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return JSON.stringify(result);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Internal — result message
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build a human-readable result message from the fill/skip arrays.
|
|
177
|
+
*/
|
|
178
|
+
function buildResultMessage(
|
|
179
|
+
filled: string[],
|
|
180
|
+
skipped: Array<{ field: string; reason: string }>,
|
|
181
|
+
submitted: boolean,
|
|
182
|
+
staleWarning: string | null
|
|
183
|
+
): string {
|
|
184
|
+
const parts: string[] = [];
|
|
185
|
+
if (staleWarning) {
|
|
186
|
+
parts.push(`Warning: ${staleWarning}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (filled.length === 0 && skipped.length === 0) {
|
|
190
|
+
parts.push('No fields were filled. Verify field names match the input schema.');
|
|
191
|
+
return parts.join(' ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (filled.length > 0) {
|
|
195
|
+
parts.push(`Filled ${filled.length} field(s): ${filled.join(', ')}.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (skipped.length > 0) {
|
|
199
|
+
const skipDetails = skipped
|
|
200
|
+
.map(s => `${s.field} (${s.reason})`)
|
|
201
|
+
.join('; ');
|
|
202
|
+
parts.push(`Skipped ${skipped.length}: ${skipDetails}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (submitted) {
|
|
206
|
+
parts.push('Form submitted.');
|
|
207
|
+
} else if (filled.length > 0) {
|
|
208
|
+
parts.push('Form was NOT submitted due to missing required fields or fill errors.');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return parts.join(' ');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Internal — form submission
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Submit the form using requestSubmit() which, unlike form.submit():
|
|
220
|
+
* - Fires the 'submit' event (so JS handlers run)
|
|
221
|
+
* - Runs HTML5 constraint validation
|
|
222
|
+
* - Respects the form's action/method attributes
|
|
223
|
+
*
|
|
224
|
+
* Falls back to form.submit() if requestSubmit is unavailable.
|
|
225
|
+
*
|
|
226
|
+
* @returns `true` if submission was triggered without error.
|
|
227
|
+
*/
|
|
228
|
+
function submitForm(form: HTMLFormElement): boolean {
|
|
229
|
+
try {
|
|
230
|
+
if (typeof form.requestSubmit === 'function') {
|
|
231
|
+
form.requestSubmit();
|
|
232
|
+
} else {
|
|
233
|
+
form.submit();
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.warn('[nxis/webmcp] Form submission failed:', err);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Internal — fill logic
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fill a single reconciled field with a value.
|
|
248
|
+
* Returns an outcome with an actionable reason on failure.
|
|
249
|
+
*/
|
|
250
|
+
function fillField(field: ReconciledField, value: unknown): FieldFillOutcome {
|
|
251
|
+
const { strategy } = field;
|
|
252
|
+
|
|
253
|
+
switch (strategy.kind) {
|
|
254
|
+
case 'skip':
|
|
255
|
+
return { ok: false, reason: strategy.reason };
|
|
256
|
+
|
|
257
|
+
case 'value':
|
|
258
|
+
return fillValue(strategy.element, valueToString(value));
|
|
259
|
+
|
|
260
|
+
case 'checked':
|
|
261
|
+
return fillChecked(strategy.element, value);
|
|
262
|
+
|
|
263
|
+
case 'radio':
|
|
264
|
+
return fillRadio(strategy.elements, value);
|
|
265
|
+
|
|
266
|
+
case 'checkboxGroup':
|
|
267
|
+
return fillCheckboxGroup(strategy.elements, value);
|
|
268
|
+
|
|
269
|
+
case 'select':
|
|
270
|
+
return fillSelect(strategy.element, String(value), strategy.allowedValues);
|
|
271
|
+
|
|
272
|
+
case 'multiSelect':
|
|
273
|
+
return fillMultiSelect(strategy.element, value, strategy.allowedValues);
|
|
274
|
+
|
|
275
|
+
default:
|
|
276
|
+
return { ok: false, reason: `Unsupported fill strategy for field "${field.valueName}".` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Stringify a fill value. Arrays (from `multipleValues` fields backed by a
|
|
282
|
+
* plain control, e.g. `<input type="email" multiple>`) join comma-separated.
|
|
283
|
+
*/
|
|
284
|
+
function valueToString(value: unknown): string {
|
|
285
|
+
if (Array.isArray(value)) {
|
|
286
|
+
return value.map(v => String(v)).join(',');
|
|
287
|
+
}
|
|
288
|
+
return String(value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Set .value on an input/textarea and dispatch events.
|
|
293
|
+
*/
|
|
294
|
+
function fillValue(element: HTMLInputElement | HTMLTextAreaElement, value: string): FieldFillOutcome {
|
|
295
|
+
// Use the native setter to bypass framework interceptors
|
|
296
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
297
|
+
element instanceof HTMLTextAreaElement
|
|
298
|
+
? HTMLTextAreaElement.prototype
|
|
299
|
+
: HTMLInputElement.prototype,
|
|
300
|
+
'value'
|
|
301
|
+
)?.set;
|
|
302
|
+
|
|
303
|
+
if (nativeInputValueSetter) {
|
|
304
|
+
nativeInputValueSetter.call(element, value);
|
|
305
|
+
} else {
|
|
306
|
+
element.value = value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
310
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
311
|
+
return { ok: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Toggle a single checkbox and dispatch change event.
|
|
316
|
+
*/
|
|
317
|
+
function fillChecked(element: HTMLInputElement, value: unknown): FieldFillOutcome {
|
|
318
|
+
element.checked = toBoolean(value);
|
|
319
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Select the member of a radio group whose value matches. Matching tries the
|
|
325
|
+
* exact value, then case-insensitive value, then the visible label — LLMs
|
|
326
|
+
* often send the human-readable label instead of the machine value.
|
|
327
|
+
*/
|
|
328
|
+
function fillRadio(elements: HTMLInputElement[], value: unknown): FieldFillOutcome {
|
|
329
|
+
const target = String(value);
|
|
330
|
+
const match =
|
|
331
|
+
elements.find(el => el.value === target) ??
|
|
332
|
+
elements.find(el => el.value.toLowerCase() === target.toLowerCase()) ??
|
|
333
|
+
elements.find(el => radioLabel(el).toLowerCase() === target.toLowerCase());
|
|
334
|
+
|
|
335
|
+
if (!match) {
|
|
336
|
+
const allowed = elements.map(el => el.value).join(', ');
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
reason: `Value "${target}" is not an allowed option for radio "${elements[0].name}". Allowed: [${allowed}].`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
match.checked = true;
|
|
344
|
+
match.dispatchEvent(new Event('input', { bubbles: true }));
|
|
345
|
+
match.dispatchEvent(new Event('change', { bubbles: true }));
|
|
346
|
+
return { ok: true };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Check the members of a checkbox group named by the given value(s); uncheck
|
|
351
|
+
* the rest. Accepts a single value or an array of values.
|
|
352
|
+
*/
|
|
353
|
+
function fillCheckboxGroup(elements: HTMLInputElement[], value: unknown): FieldFillOutcome {
|
|
354
|
+
const wanted = (Array.isArray(value) ? value : [value]).map(v => String(v));
|
|
355
|
+
const wantedLower = wanted.map(w => w.toLowerCase());
|
|
356
|
+
|
|
357
|
+
let matchedAny = false;
|
|
358
|
+
for (const el of elements) {
|
|
359
|
+
const selected =
|
|
360
|
+
wanted.includes(el.value) || wantedLower.includes(el.value.toLowerCase());
|
|
361
|
+
el.checked = selected;
|
|
362
|
+
if (selected) matchedAny = true;
|
|
363
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!matchedAny) {
|
|
367
|
+
const allowed = elements.map(el => el.value).join(', ');
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: `None of [${wanted.join(', ')}] match checkbox group "${elements[0].name}". Allowed: [${allowed}].`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return { ok: true };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Set .value on a single-select <select> from allowed options.
|
|
378
|
+
* Constrains to actual <option> values regardless of what the Action claimed.
|
|
379
|
+
*/
|
|
380
|
+
function fillSelect(
|
|
381
|
+
element: HTMLSelectElement,
|
|
382
|
+
value: string,
|
|
383
|
+
allowedValues: string[]
|
|
384
|
+
): FieldFillOutcome {
|
|
385
|
+
const resolved = resolveOption(element, value, allowedValues);
|
|
386
|
+
if (resolved === null) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
reason: `Value "${value}" is not an allowed option for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
element.value = resolved;
|
|
394
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
395
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
396
|
+
return { ok: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Select one or more options on a <select multiple>. Accepts a single value or
|
|
401
|
+
* an array; succeeds if at least one value resolves to a real option.
|
|
402
|
+
*/
|
|
403
|
+
function fillMultiSelect(
|
|
404
|
+
element: HTMLSelectElement,
|
|
405
|
+
value: unknown,
|
|
406
|
+
allowedValues: string[]
|
|
407
|
+
): FieldFillOutcome {
|
|
408
|
+
const requested = (Array.isArray(value) ? value : [value]).map(v => String(v));
|
|
409
|
+
const resolved = new Set<string>();
|
|
410
|
+
const unmatched: string[] = [];
|
|
411
|
+
|
|
412
|
+
for (const v of requested) {
|
|
413
|
+
const match = resolveOption(element, v, allowedValues);
|
|
414
|
+
if (match === null) {
|
|
415
|
+
unmatched.push(v);
|
|
416
|
+
} else {
|
|
417
|
+
resolved.add(match);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (resolved.size === 0) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
reason: `None of [${requested.join(', ')}] are allowed options for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for (const option of Array.from(element.options)) {
|
|
429
|
+
option.selected = resolved.has(option.value);
|
|
430
|
+
}
|
|
431
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
432
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
433
|
+
|
|
434
|
+
if (unmatched.length > 0) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
reason: `Selected [${Array.from(resolved).join(', ')}] but [${unmatched.join(', ')}] are not allowed options for "${element.name}". Allowed: [${allowedValues.join(', ')}].`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return { ok: true };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Resolve a requested value to a real <option> value: exact, then
|
|
445
|
+
* case-insensitive, then by visible option text. Returns null if no match.
|
|
446
|
+
*/
|
|
447
|
+
function resolveOption(
|
|
448
|
+
element: HTMLSelectElement,
|
|
449
|
+
value: string,
|
|
450
|
+
allowedValues: string[]
|
|
451
|
+
): string | null {
|
|
452
|
+
if (allowedValues.includes(value)) return value;
|
|
453
|
+
|
|
454
|
+
const ci = allowedValues.find(v => v.toLowerCase() === value.toLowerCase());
|
|
455
|
+
if (ci) return ci;
|
|
456
|
+
|
|
457
|
+
// LLMs often send the human-readable label instead of the machine value,
|
|
458
|
+
// e.g. "AI Summit 2026" instead of "ai-summit-2026".
|
|
459
|
+
for (const option of Array.from(element.options)) {
|
|
460
|
+
if (option.text.toLowerCase() === value.toLowerCase()) {
|
|
461
|
+
return option.value;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Coerce a JSON value to a checkbox boolean. Mirrors how an agent might encode
|
|
469
|
+
* "off": false, "false", "0", "" and null/undefined all read as unchecked.
|
|
470
|
+
*/
|
|
471
|
+
function toBoolean(value: unknown): boolean {
|
|
472
|
+
if (typeof value === 'boolean') return value;
|
|
473
|
+
if (typeof value === 'number') return value !== 0;
|
|
474
|
+
if (typeof value === 'string') {
|
|
475
|
+
const v = value.trim().toLowerCase();
|
|
476
|
+
return v !== '' && v !== 'false' && v !== '0' && v !== 'off' && v !== 'no';
|
|
477
|
+
}
|
|
478
|
+
return Boolean(value);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Best-effort visible label for a radio input — its associated <label> text,
|
|
483
|
+
* falling back to the value. Used to match agent-supplied human labels.
|
|
484
|
+
*/
|
|
485
|
+
function radioLabel(el: HTMLInputElement): string {
|
|
486
|
+
if (el.labels && el.labels.length > 0) {
|
|
487
|
+
return (el.labels[0].textContent ?? '').trim();
|
|
488
|
+
}
|
|
489
|
+
return el.value;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Compare the action's recorded extraction page against the live location.
|
|
494
|
+
* Returns a warning string when they differ (origin or path), else null.
|
|
495
|
+
*/
|
|
496
|
+
function checkStaleness(action: NxisAction): string | null {
|
|
497
|
+
const pageUrl = action.target['nxis:pageUrl'];
|
|
498
|
+
if (!pageUrl) return null;
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
const recorded = new URL(pageUrl);
|
|
502
|
+
const current = new URL(window.location.href);
|
|
503
|
+
if (recorded.origin !== current.origin || recorded.pathname !== current.pathname) {
|
|
504
|
+
return (
|
|
505
|
+
`Action "${action.name}" was extracted from ${recorded.origin}${recorded.pathname} ` +
|
|
506
|
+
`but the current page is ${current.origin}${current.pathname}. ` +
|
|
507
|
+
`The form may have moved or changed.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|