@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.d.ts +86 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +448 -1
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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),
|