@redrockswebdevelopment/formstack-form-testing 0.1.1 → 0.1.3

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/dist/index.js CHANGED
@@ -16,6 +16,274 @@ export class FormScenarioRunError extends Error {
16
16
  this.result = result;
17
17
  }
18
18
  }
19
+ export const liveFormFieldTypeCoverage = {
20
+ address: {
21
+ kind: "address",
22
+ strategy: "api-safe",
23
+ apiWrite: true,
24
+ domInteraction: true,
25
+ domAssertion: true,
26
+ reason: "Compound address values can be written through Live Form API, while layout and subfield UX should be asserted through DOM controls.",
27
+ recommendedHelpers: ["buildLiveFormAddressValue", "setLiveFormFieldValueTransaction", "fillAddress", "readLiveFormDomFieldState"],
28
+ },
29
+ checkbox: {
30
+ kind: "checkbox",
31
+ strategy: "api-safe",
32
+ apiWrite: true,
33
+ domInteraction: true,
34
+ domAssertion: true,
35
+ reason: "Checkbox values are stable when using option values. Multi-select UX should still dispatch DOM events or use DOM interaction.",
36
+ recommendedHelpers: ["buildLiveFormCheckboxValue", "setLiveFormFieldValueTransaction", "setCheckboxes", "waitForLiveFormQuiet"],
37
+ },
38
+ date: {
39
+ kind: "date",
40
+ strategy: "api-with-dom-caveats",
41
+ apiWrite: true,
42
+ domInteraction: true,
43
+ domAssertion: true,
44
+ reason: "Date API state can be set reliably, but split/calendar renderer variants should be verified through DOM assertions.",
45
+ recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormFieldValueTransaction", "fillDateTime", "readLiveFormDomFieldState"],
46
+ },
47
+ datetime: {
48
+ kind: "datetime",
49
+ strategy: "api-with-dom-caveats",
50
+ apiWrite: true,
51
+ domInteraction: true,
52
+ domAssertion: true,
53
+ reason: "Date/time API state can be set, but visible split controls may lag or vary by renderer.",
54
+ recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormFieldValueTransaction", "fillDateTime", "readLiveFormDomFieldState"],
55
+ },
56
+ description: {
57
+ kind: "description",
58
+ strategy: "assertion-only",
59
+ apiWrite: false,
60
+ domInteraction: false,
61
+ domAssertion: true,
62
+ reason: "Description fields do not collect user values; test rendered content, visibility, and styling.",
63
+ recommendedHelpers: ["snapshotLiveFormDomFields", "waitForLiveFormVisibility"],
64
+ },
65
+ embed: {
66
+ kind: "embed",
67
+ strategy: "assertion-only",
68
+ apiWrite: false,
69
+ domInteraction: false,
70
+ domAssertion: true,
71
+ reason: "Embed fields generally provide scripts/styles/markup; test side effects and diagnostics, not value writes.",
72
+ recommendedHelpers: ["createFormBrowserMonitor", "waitForConsoleMessage", "waitForWindowFlag", "snapshotLiveFormDomFields"],
73
+ },
74
+ email: {
75
+ kind: "email",
76
+ strategy: "api-safe",
77
+ apiWrite: true,
78
+ domInteraction: true,
79
+ domAssertion: true,
80
+ reason: "Email fields behave like text fields with validation-specific assertions.",
81
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "fillEmail", "waitForLiveFormValidationState"],
82
+ },
83
+ file: {
84
+ kind: "file",
85
+ strategy: "dom-only",
86
+ apiWrite: false,
87
+ domInteraction: true,
88
+ domAssertion: true,
89
+ reason: "File fields must be tested through browser file upload controls, not generic Live Form API setValue payloads.",
90
+ recommendedHelpers: ["uploadFile", "liveFormFieldRecipes.uploadFileDom", "readLiveFormDomFieldState"],
91
+ },
92
+ matrix: {
93
+ kind: "matrix",
94
+ strategy: "api-safe",
95
+ apiWrite: true,
96
+ domInteraction: true,
97
+ domAssertion: true,
98
+ reason: "Matrix selections can be written by row/column labels or selected through DOM cells.",
99
+ recommendedHelpers: ["buildLiveFormMatrixValue", "setLiveFormFieldValueTransaction", "selectMatrixCell", "readLiveFormDomFieldState"],
100
+ },
101
+ name: {
102
+ kind: "name",
103
+ strategy: "api-safe",
104
+ apiWrite: true,
105
+ domInteraction: true,
106
+ domAssertion: true,
107
+ reason: "Name compound fields support direct API values and stable subfield DOM automation.",
108
+ recommendedHelpers: ["buildLiveFormNameValue", "setLiveFormFieldValueTransaction", "fillName", "readLiveFormReadiness"],
109
+ },
110
+ number: {
111
+ kind: "number",
112
+ strategy: "api-safe",
113
+ apiWrite: true,
114
+ domInteraction: true,
115
+ domAssertion: true,
116
+ reason: "Number fields use simple value payloads; use validation assertions for numeric constraints.",
117
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "fillNumber", "waitForLiveFormValidationState"],
118
+ },
119
+ pagebreak: {
120
+ kind: "pagebreak",
121
+ strategy: "layout-only",
122
+ apiWrite: false,
123
+ domInteraction: true,
124
+ domAssertion: true,
125
+ reason: "Page breaks do not collect values; test navigation, visible page state, and progress UI.",
126
+ recommendedHelpers: ["clickNext", "clickPrevious", "waitForLiveFormVisibility"],
127
+ },
128
+ payment: {
129
+ kind: "payment",
130
+ strategy: "excluded",
131
+ apiWrite: false,
132
+ domInteraction: false,
133
+ domAssertion: false,
134
+ paymentRisk: true,
135
+ reason: "Payment fields are intentionally excluded from generic helpers because custom embedded code can trigger Formstack attestation and compliance review.",
136
+ recommendedHelpers: ["createFormBrowserMonitor"],
137
+ },
138
+ phone: {
139
+ kind: "phone",
140
+ strategy: "api-safe",
141
+ apiWrite: true,
142
+ domInteraction: true,
143
+ domAssertion: true,
144
+ reason: "Phone fields use simple value payloads; use DOM and validation assertions for formatting behavior.",
145
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "fillPhone", "waitForLiveFormValidationState"],
146
+ },
147
+ product: {
148
+ kind: "product",
149
+ strategy: "api-with-dom-caveats",
150
+ apiWrite: true,
151
+ domInteraction: true,
152
+ domAssertion: true,
153
+ reason: "Product API state can be set, but visible quantity and amount UI can diverge by product configuration.",
154
+ recommendedHelpers: ["buildLiveFormProductValue", "setLiveFormFieldValueTransaction", "fillProduct", "readLiveFormDomFieldState"],
155
+ },
156
+ radio: {
157
+ kind: "radio",
158
+ strategy: "api-safe",
159
+ apiWrite: true,
160
+ domInteraction: true,
161
+ domAssertion: true,
162
+ reason: "Radio fields are stable when using option values. Dispatch DOM change for custom scripts.",
163
+ recommendedHelpers: ["buildLiveFormChoiceValue", "setLiveFormFieldValueTransaction", "chooseRadio", "waitForLiveFormQuiet"],
164
+ },
165
+ rating: {
166
+ kind: "rating",
167
+ strategy: "api-with-dom-caveats",
168
+ apiWrite: true,
169
+ domInteraction: true,
170
+ domAssertion: true,
171
+ reason: "Rating values can be written through API, but visual stars/buttons should be asserted separately.",
172
+ recommendedHelpers: ["buildLiveFormRatingValue", "setLiveFormFieldValueTransaction", "setRating", "readLiveFormDomFieldState"],
173
+ },
174
+ section: {
175
+ kind: "section",
176
+ strategy: "layout-only",
177
+ apiWrite: false,
178
+ domInteraction: false,
179
+ domAssertion: true,
180
+ reason: "Sections group content and logic visibility; test rendered content and conditional visibility.",
181
+ recommendedHelpers: ["snapshotLiveFormDomFields", "waitForLiveFormVisibility"],
182
+ },
183
+ select: {
184
+ kind: "select",
185
+ strategy: "api-safe",
186
+ apiWrite: true,
187
+ domInteraction: true,
188
+ domAssertion: true,
189
+ reason: "Select fields support stable option values through API or DOM select operations.",
190
+ recommendedHelpers: ["buildLiveFormSelectValue", "setLiveFormFieldValueTransaction", "selectOption", "readLiveFormDomFieldState"],
191
+ },
192
+ signature: {
193
+ kind: "signature",
194
+ strategy: "dom-only",
195
+ apiWrite: false,
196
+ domInteraction: true,
197
+ domAssertion: true,
198
+ reason: "Signature fields require specialized canvas/user-event automation; the portable helper safely supports clearing and DOM assertions.",
199
+ recommendedHelpers: ["clearSignature", "liveFormFieldRecipes.clearSignatureDom", "readLiveFormDomFieldState"],
200
+ },
201
+ textarea: {
202
+ kind: "textarea",
203
+ strategy: "api-safe",
204
+ apiWrite: true,
205
+ domInteraction: true,
206
+ domAssertion: true,
207
+ reason: "Textarea fields use simple value payloads and stable DOM fill operations.",
208
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "fillTextarea", "readLiveFormDomFieldState"],
209
+ },
210
+ text: {
211
+ kind: "text",
212
+ strategy: "api-safe",
213
+ apiWrite: true,
214
+ domInteraction: true,
215
+ domAssertion: true,
216
+ reason: "Text fields use simple value payloads and stable DOM fill operations.",
217
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "fillText", "readLiveFormDomFieldState"],
218
+ },
219
+ unknown: {
220
+ kind: "unknown",
221
+ strategy: "excluded",
222
+ apiWrite: false,
223
+ domInteraction: false,
224
+ domAssertion: false,
225
+ reason: "Unknown fields need discovery before using generic automation.",
226
+ recommendedHelpers: ["discoverLiveFormFieldShapes", "snapshotLiveFormDomFields"],
227
+ },
228
+ };
229
+ export function getLiveFormFieldTypeCoverage(kind) {
230
+ return liveFormFieldTypeCoverage[kind];
231
+ }
232
+ export function summarizeLiveFormFieldCoverage(reportOrFields) {
233
+ const fields = "fields" in reportOrFields ? reportOrFields.fields : reportOrFields;
234
+ const countsByKind = {};
235
+ const fieldsWithCoverage = fields.map((field) => {
236
+ countsByKind[field.inferredKind] = (countsByKind[field.inferredKind] ?? 0) + 1;
237
+ return { ...field, coverage: getLiveFormFieldTypeCoverage(field.inferredKind) };
238
+ });
239
+ const uncovered = fields.filter((field) => field.inferredKind === "unknown");
240
+ const paymentFields = fields.filter((field) => field.inferredKind === "payment");
241
+ return {
242
+ fields: fieldsWithCoverage,
243
+ countsByKind,
244
+ uncovered,
245
+ paymentFields,
246
+ hasUnknownFields: uncovered.length > 0,
247
+ hasPaymentFields: paymentFields.length > 0,
248
+ };
249
+ }
250
+ export async function readLiveFormFieldCoverage(page, options = {}) {
251
+ return summarizeLiveFormFieldCoverage(await discoverLiveFormFieldShapes(page, options));
252
+ }
253
+ function formatCoverageField(field) {
254
+ return `${field.inferredKind}:${field.internalLabel ?? field.domId ?? "unlabeled"}`;
255
+ }
256
+ export function assertLiveFormFieldCoverage(report, options = {}) {
257
+ const failures = [];
258
+ if (!options.allowUnknown && report.hasUnknownFields) {
259
+ failures.push(`unknown fields: ${report.uncovered.map(formatCoverageField).join(", ")}`);
260
+ }
261
+ if (!options.allowPayment && report.hasPaymentFields) {
262
+ failures.push(`payment fields: ${report.paymentFields.map(formatCoverageField).join(", ")}`);
263
+ }
264
+ const allowedStrategies = new Set(options.allowedStrategies ?? []);
265
+ if (allowedStrategies.size > 0) {
266
+ const invalid = report.fields.filter((field) => !allowedStrategies.has(field.coverage.strategy));
267
+ if (invalid.length > 0) {
268
+ failures.push(`fields outside allowed strategies: ${invalid.map((field) => `${formatCoverageField(field)}=${field.coverage.strategy}`).join(", ")}`);
269
+ }
270
+ }
271
+ const disallowedStrategies = new Set(options.disallowedStrategies ?? []);
272
+ if (disallowedStrategies.size > 0) {
273
+ const invalid = report.fields.filter((field) => disallowedStrategies.has(field.coverage.strategy));
274
+ if (invalid.length > 0) {
275
+ failures.push(`fields using disallowed strategies: ${invalid.map((field) => `${formatCoverageField(field)}=${field.coverage.strategy}`).join(", ")}`);
276
+ }
277
+ }
278
+ for (const requiredKind of options.requiredKinds ?? []) {
279
+ if ((report.countsByKind[requiredKind] ?? 0) === 0) {
280
+ failures.push(`missing required field kind: ${requiredKind}`);
281
+ }
282
+ }
283
+ if (failures.length > 0) {
284
+ throw new Error(`Live form field coverage assertion failed: ${failures.join("; ")}`);
285
+ }
286
+ }
19
287
  function matchesPattern(value, pattern) {
20
288
  return typeof pattern === "string" ? value.includes(pattern) : pattern.test(value);
21
289
  }
@@ -503,6 +771,8 @@ export async function waitForLiveFormQuiet(page, options = {}) {
503
771
  const waitForApiTimeoutMs = quietOptions.waitForApiTimeoutMs ?? 2_000;
504
772
  const eventTypes = quietOptions.eventTypes ?? ["input", "change", "blur", "click", "submit"];
505
773
  const formEventTypes = quietOptions.formEventTypes ?? ["change", "calculations-complete", "validation-complete", "submit", "error"];
774
+ const observeMutations = quietOptions.observeMutations ?? true;
775
+ const mutationSelector = quietOptions.mutationSelector ?? "form, [id^='fsForm-'], body";
506
776
  const includeEvents = quietOptions.includeEvents ?? true;
507
777
  const events = [];
508
778
  let lastEventAt = now();
@@ -544,6 +814,31 @@ export async function waitForLiveFormQuiet(page, options = {}) {
544
814
  };
545
815
  for (const eventType of eventTypes)
546
816
  document.addEventListener(eventType, domListener, true);
817
+ const mutationTarget = document.querySelector(mutationSelector) ?? document.body;
818
+ const mutationObserver = observeMutations && typeof MutationObserver !== "undefined" && mutationTarget
819
+ ? new MutationObserver((mutations) => {
820
+ const mutationTypes = [...new Set(mutations.map((mutation) => mutation.type))];
821
+ const changedFieldLabels = [
822
+ ...new Set(mutations
823
+ .flatMap((mutation) => [mutation.target, ...Array.from(mutation.addedNodes), ...Array.from(mutation.removedNodes)])
824
+ .filter((node) => node instanceof Element)
825
+ .map((element) => element.closest("[data-internal-label]")?.getAttribute("data-internal-label"))
826
+ .filter((label) => Boolean(label))),
827
+ ].slice(0, 20);
828
+ record("mutation", "mutation", {
829
+ count: mutations.length,
830
+ types: mutationTypes,
831
+ changedFieldLabels,
832
+ });
833
+ })
834
+ : null;
835
+ mutationObserver?.observe(mutationTarget, {
836
+ attributes: true,
837
+ childList: true,
838
+ subtree: true,
839
+ characterData: true,
840
+ attributeFilter: ["class", "style", "aria-invalid", "hidden", "data-internal-label"],
841
+ });
547
842
  const formEventListeners = apiAvailable && form && typeof form === "object"
548
843
  ? formEventTypes.map((type) => ({
549
844
  type,
@@ -592,6 +887,7 @@ export async function waitForLiveFormQuiet(page, options = {}) {
592
887
  finally {
593
888
  for (const eventType of eventTypes)
594
889
  document.removeEventListener(eventType, domListener, true);
890
+ mutationObserver?.disconnect();
595
891
  if (typeof unregisterFormEventListener === "function") {
596
892
  for (const listener of formEventListeners)
597
893
  Reflect.apply(unregisterFormEventListener, form, [listener]);
@@ -1375,10 +1671,27 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
1375
1671
  const classes = element.className.toString().toLowerCase();
1376
1672
  const types = new Set(controls.map((control) => control.type ?? control.tagName));
1377
1673
  const internalLabel = element.getAttribute("data-internal-label")?.toLowerCase() ?? "";
1674
+ const id = element.getAttribute("id")?.toLowerCase() ?? "";
1675
+ const dataTestId = element.getAttribute("data-testid")?.toLowerCase() ?? "";
1676
+ if (internalLabel.includes("payment") ||
1677
+ internalLabel.includes("credit_card") ||
1678
+ internalLabel.includes("credit-card") ||
1679
+ classes.includes("payment") ||
1680
+ text.includes("credit card") ||
1681
+ controls.some((control) => control.name?.toLowerCase().includes("payment") || control.name?.toLowerCase().includes("credit"))) {
1682
+ return "payment";
1683
+ }
1378
1684
  if (internalLabel.includes("code_embed") || text.startsWith(":root{") || text.includes("<script"))
1379
1685
  return "embed";
1380
- if (controls.length === 0)
1686
+ if (controls.length === 0) {
1687
+ if (classes.includes("pagebreak") || classes.includes("page-break") || dataTestId.includes("pagebreak") || id.includes("pagebreak")) {
1688
+ return "pagebreak";
1689
+ }
1690
+ if (classes.includes("description") || internalLabel.includes("description") || dataTestId.includes("description")) {
1691
+ return "description";
1692
+ }
1381
1693
  return "section";
1694
+ }
1382
1695
  if (classes.includes("matrix") || internalLabel.includes("matrix") || element.querySelector("[id^='matrix-row-'], table"))
1383
1696
  return "matrix";
1384
1697
  if (classes.includes("rating") || internalLabel.includes("rating"))
@@ -1407,6 +1720,8 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
1407
1720
  return "phone";
1408
1721
  if (types.has("number") || internalLabel.includes("number"))
1409
1722
  return "number";
1723
+ if (internalLabel.includes("date_time") || internalLabel.includes("datetime") || text.includes("am/pm") || text.includes("minutes"))
1724
+ return "datetime";
1410
1725
  if (types.has("date") || internalLabel.includes("date"))
1411
1726
  return "date";
1412
1727
  if (types.has("text"))
@@ -2074,6 +2389,85 @@ export async function setLiveFormFieldValueTransaction(page, options) {
2074
2389
  ...(domFields ? { domFields } : {}),
2075
2390
  };
2076
2391
  }
2392
+ export async function setLiveFormFieldValueTimedTransaction(page, options) {
2393
+ const timeline = [];
2394
+ const startedAt = Date.now();
2395
+ const runPhase = async (name, run) => {
2396
+ const phaseStartedAt = Date.now();
2397
+ try {
2398
+ const result = await run();
2399
+ const endedAt = Date.now();
2400
+ timeline.push({
2401
+ name,
2402
+ startedAtMs: phaseStartedAt - startedAt,
2403
+ endedAtMs: endedAt - startedAt,
2404
+ durationMs: endedAt - phaseStartedAt,
2405
+ ok: true,
2406
+ });
2407
+ return result;
2408
+ }
2409
+ catch (error) {
2410
+ const endedAt = Date.now();
2411
+ timeline.push({
2412
+ name,
2413
+ startedAtMs: phaseStartedAt - startedAt,
2414
+ endedAtMs: endedAt - startedAt,
2415
+ durationMs: endedAt - phaseStartedAt,
2416
+ ok: false,
2417
+ detail: { message: error instanceof Error ? error.message : String(error) },
2418
+ });
2419
+ throw error;
2420
+ }
2421
+ };
2422
+ const beforeReadiness = options.beforeReadiness
2423
+ ? await runPhase("before-readiness", () => readLiveFormReadiness(page, options.beforeReadiness))
2424
+ : undefined;
2425
+ const beforeDomFields = options.beforeSnapshot
2426
+ ? await runPhase("before-snapshot", () => snapshotLiveFormDomFields(page, typeof options.beforeSnapshot === "object" ? options.beforeSnapshot : {}))
2427
+ : undefined;
2428
+ const shouldWaitForEvent = options.waitForEvent === true || (options.waitForEvent === undefined && options.write.notify === true);
2429
+ const defaultEventOptions = {
2430
+ eventTypes: ["change"],
2431
+ target: options.write.target,
2432
+ ...(options.write.timeoutMs === undefined ? {} : { timeoutMs: options.write.timeoutMs }),
2433
+ };
2434
+ const eventOptions = typeof options.waitForEvent === "object"
2435
+ ? options.waitForEvent
2436
+ : shouldWaitForEvent
2437
+ ? defaultEventOptions
2438
+ : undefined;
2439
+ const eventPromise = eventOptions ? waitForLiveFormEvent(page, eventOptions) : null;
2440
+ const write = await runPhase("write", () => setLiveFormFieldValue(page, options.write));
2441
+ const event = eventPromise ? await runPhase("wait-for-event", () => eventPromise) : undefined;
2442
+ const domDispatch = options.dispatchDomEvents
2443
+ ? await runPhase("dispatch-dom-events", () => dispatchLiveFormFieldDomEvents(page, {
2444
+ ...(typeof options.dispatchDomEvents === "object" ? options.dispatchDomEvents : {}),
2445
+ target: options.write.target,
2446
+ }))
2447
+ : undefined;
2448
+ const quiet = options.afterQuiet
2449
+ ? await runPhase("after-quiet", () => waitForLiveFormQuiet(page, typeof options.afterQuiet === "object" ? options.afterQuiet : {}))
2450
+ : undefined;
2451
+ const readiness = options.readiness ? await runPhase("readiness", () => readLiveFormReadiness(page, options.readiness)) : undefined;
2452
+ const validation = options.afterValidation
2453
+ ? await runPhase("after-validation", () => waitForLiveFormValidationState(page, typeof options.afterValidation === "object" ? options.afterValidation : {}))
2454
+ : undefined;
2455
+ const domFields = options.snapshot
2456
+ ? await runPhase("snapshot", () => snapshotLiveFormDomFields(page, typeof options.snapshot === "object" ? options.snapshot : {}))
2457
+ : undefined;
2458
+ return {
2459
+ timeline,
2460
+ write,
2461
+ ...(event ? { event } : {}),
2462
+ ...(domDispatch ? { domDispatch } : {}),
2463
+ ...(readiness ? { readiness } : {}),
2464
+ ...(domFields ? { domFields } : {}),
2465
+ ...(beforeReadiness ? { beforeReadiness } : {}),
2466
+ ...(beforeDomFields ? { beforeDomFields } : {}),
2467
+ ...(quiet ? { quiet } : {}),
2468
+ ...(validation ? { validation } : {}),
2469
+ };
2470
+ }
2077
2471
  export async function setLiveFormFieldValuesTransaction(page, options) {
2078
2472
  if (!hasEvaluate(page)) {
2079
2473
  throw new Error("setLiveFormFieldValuesTransaction requires a Playwright page with evaluate().");
@@ -2126,6 +2520,43 @@ function productPartLocator(page, internalLabel, part) {
2126
2520
  function productQuantityLocator(page, internalLabel) {
2127
2521
  return page.locator(`${formstackSelectors.fieldByInternalLabel(internalLabel)} input[name$="[quantity]"], ${formstackSelectors.fieldByInternalLabel(internalLabel)} select[name$="[quantity]"]`).first();
2128
2522
  }
2523
+ export async function drawSignature(page, internalLabel, options = {}) {
2524
+ if (!page.mouse) {
2525
+ throw new Error("drawSignature requires a Playwright page with mouse support.");
2526
+ }
2527
+ const surface = page.locator(formstackSelectors.signatureSurfaceByInternalLabel(internalLabel)).first();
2528
+ if (typeof surface.boundingBox !== "function") {
2529
+ throw new Error("drawSignature requires a signature locator with boundingBox().");
2530
+ }
2531
+ const box = await surface.boundingBox();
2532
+ if (!box || box.width <= 0 || box.height <= 0) {
2533
+ throw new Error(`Signature field "${internalLabel}" did not expose a drawable bounding box.`);
2534
+ }
2535
+ const points = options.points ?? [
2536
+ { x: 0.15, y: 0.55 },
2537
+ { x: 0.28, y: 0.42 },
2538
+ { x: 0.42, y: 0.58 },
2539
+ { x: 0.58, y: 0.38 },
2540
+ { x: 0.75, y: 0.52 },
2541
+ ];
2542
+ if (points.length < 2) {
2543
+ throw new Error("drawSignature requires at least two points.");
2544
+ }
2545
+ const absolute = points.map((point) => ({
2546
+ x: box.x + box.width * point.x,
2547
+ y: box.y + box.height * point.y,
2548
+ }));
2549
+ const firstPoint = absolute[0];
2550
+ if (!firstPoint) {
2551
+ throw new Error("drawSignature requires at least one absolute point.");
2552
+ }
2553
+ await page.mouse.move(firstPoint.x, firstPoint.y);
2554
+ await page.mouse.down();
2555
+ for (const point of absolute.slice(1)) {
2556
+ await page.mouse.move(point.x, point.y);
2557
+ }
2558
+ await page.mouse.up();
2559
+ }
2129
2560
  export function createFormTestHarness(page, options = {}) {
2130
2561
  return {
2131
2562
  page,
@@ -2182,6 +2613,7 @@ export function createFormTestHarness(page, options = {}) {
2182
2613
  setRating: (fieldId, zeroBasedIndex) => page.locator(formstackSelectors.ratingButtonByIndex(fieldId, zeroBasedIndex)).first().click(),
2183
2614
  uploadFile: (internalLabel, files) => page.locator(formstackSelectors.fieldByInternalLabel(internalLabel)).locator("input[type=\"file\"]").first().setInputFiles(files),
2184
2615
  clearSignature: (internalLabel) => page.locator(formstackSelectors.signatureClearButtonByInternalLabel(internalLabel)).first().click(),
2616
+ drawSignature: (internalLabel, signatureOptions) => drawSignature(page, internalLabel, signatureOptions),
2185
2617
  fillProduct: async (internalLabel, value) => {
2186
2618
  await fillIfDefined(productPartLocator(page, internalLabel, "fixedAmount"), value.fixedAmount);
2187
2619
  await fillIfDefined(productQuantityLocator(page, internalLabel), value.quantity);
@@ -2310,6 +2742,13 @@ export const liveFormScenarioSteps = {
2310
2742
  }
2311
2743
  },
2312
2744
  }),
2745
+ expectFieldCoverage: (name, options = {}) => ({
2746
+ name,
2747
+ run: async (harness) => {
2748
+ const coverage = await readLiveFormFieldCoverage(harness.page, options);
2749
+ assertLiveFormFieldCoverage(coverage, options);
2750
+ },
2751
+ }),
2313
2752
  expectFieldValue: (name, options) => ({
2314
2753
  name,
2315
2754
  run: async (harness) => {
@@ -2450,6 +2889,14 @@ export const liveFormFieldRecipes = {
2450
2889
  name,
2451
2890
  run: (harness) => harness.uploadFile(internalLabel, files),
2452
2891
  }),
2892
+ clearSignatureDom: (name, internalLabel) => ({
2893
+ name,
2894
+ run: (harness) => harness.clearSignature(internalLabel),
2895
+ }),
2896
+ drawSignatureDom: (name, internalLabel, options) => ({
2897
+ name,
2898
+ run: (harness) => harness.drawSignature(internalLabel, options),
2899
+ }),
2453
2900
  setNameApi: (name, internalLabel, value, options) => apiRecipeStep(name, { internalLabel }, buildLiveFormNameValue(value), options),
2454
2901
  setAddressApi: (name, internalLabel, value, options) => apiRecipeStep(name, { internalLabel }, buildLiveFormAddressValue(value), options),
2455
2902
  setChoiceApi: (name, internalLabel, value, options) => apiRecipeStep(name, { internalLabel }, buildLiveFormChoiceValue(value), options),