@redrockswebdevelopment/formstack-form-testing 0.1.41 → 0.1.42

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
@@ -26,6 +26,15 @@ export const liveFormFieldTypeCoverage = {
26
26
  reason: "Compound address values can be written through Live Form API, while subfield UX should be asserted for address line, city, state, ZIP, and country controls.",
27
27
  recommendedHelpers: ["buildLiveFormAddressValue", "setLiveFormAddressValue", "readLiveFormAddressState", "fillAddress"],
28
28
  },
29
+ calculation: {
30
+ kind: "calculation",
31
+ strategy: "assertion-only",
32
+ apiWrite: false,
33
+ domInteraction: false,
34
+ domAssertion: true,
35
+ reason: "Calculation fields are derived by Formstack. Test their lifecycle after source changes, numeric/date normalization, visibility, and submission inclusion instead of writing them directly.",
36
+ recommendedHelpers: ["readLiveFormCalculationState", "waitForLiveFormCalculationState", "traceLiveFormCalculationLifecycle", "readLiveFormSubmissionInclusion"],
37
+ },
29
38
  checkbox: {
30
39
  kind: "checkbox",
31
40
  strategy: "api-safe",
@@ -89,6 +98,15 @@ export const liveFormFieldTypeCoverage = {
89
98
  reason: "File fields must be tested through browser file upload controls, not generic Live Form API setValue payloads.",
90
99
  recommendedHelpers: ["uploadLiveFormFile", "readLiveFormFileState", "uploadFile", "liveFormFieldRecipes.uploadFileDom"],
91
100
  },
101
+ hidden: {
102
+ kind: "hidden",
103
+ strategy: "assertion-only",
104
+ apiWrite: true,
105
+ domInteraction: false,
106
+ domAssertion: true,
107
+ reason: "Hidden fields can be populated through API or scripts, but tests should prove visibility state and whether the populated control is included in FormData/submission surfaces.",
108
+ recommendedHelpers: ["setLiveFormFieldValueTransaction", "readLiveFormFieldValue", "readLiveFormSubmissionInclusion", "readLiveFormStateDrift"],
109
+ },
92
110
  matrix: {
93
111
  kind: "matrix",
94
112
  strategy: "api-safe",
@@ -1959,6 +1977,327 @@ export async function waitForLiveFormStructuralState(page, options) {
1959
1977
  }
1960
1978
  return latest;
1961
1979
  }
1980
+ export async function readLiveFormCalculationState(page, options) {
1981
+ if (!hasEvaluate(page)) {
1982
+ throw new Error("readLiveFormCalculationState requires a Playwright page with evaluate().");
1983
+ }
1984
+ return page.evaluate((stateOptions) => {
1985
+ const readPath = (value, path) => {
1986
+ if (!path)
1987
+ return value;
1988
+ return path.split(".").reduce((current, segment) => {
1989
+ if (current === null || current === undefined || typeof current !== "object")
1990
+ return undefined;
1991
+ return Reflect.get(current, segment);
1992
+ }, value);
1993
+ };
1994
+ const valuesEqual = (left, right) => {
1995
+ if (Object.is(left, right))
1996
+ return true;
1997
+ try {
1998
+ return JSON.stringify(left) === JSON.stringify(right);
1999
+ }
2000
+ catch {
2001
+ return false;
2002
+ }
2003
+ };
2004
+ const stringifyValue = (value) => {
2005
+ if (value === null || value === undefined)
2006
+ return "";
2007
+ if (typeof value === "string")
2008
+ return value;
2009
+ if (typeof value === "number" || typeof value === "boolean")
2010
+ return String(value);
2011
+ try {
2012
+ return JSON.stringify(value);
2013
+ }
2014
+ catch {
2015
+ return String(value);
2016
+ }
2017
+ };
2018
+ const normalizeNumber = (value) => {
2019
+ if (typeof value === "number" && Number.isFinite(value))
2020
+ return value;
2021
+ const text = stringifyValue(value).replace(/[$,%\s,]/g, "");
2022
+ if (!text)
2023
+ return null;
2024
+ const parsed = Number(text);
2025
+ return Number.isFinite(parsed) ? parsed : null;
2026
+ };
2027
+ const normalizeDate = (value) => {
2028
+ const text = stringifyValue(value).trim();
2029
+ if (!text)
2030
+ return null;
2031
+ const timestamp = Date.parse(text);
2032
+ if (Number.isNaN(timestamp))
2033
+ return null;
2034
+ return new Date(timestamp).toISOString().slice(0, 10);
2035
+ };
2036
+ const expectedDateText = stateOptions.expectedDate === undefined ? undefined : normalizeDate(stateOptions.expectedDate) ?? undefined;
2037
+ const isVisible = (element) => {
2038
+ if (!element)
2039
+ return null;
2040
+ if (!(element instanceof HTMLElement))
2041
+ return false;
2042
+ for (let current = element; current; current = current.parentElement) {
2043
+ const style = getComputedStyle(current);
2044
+ const rect = current.getBoundingClientRect();
2045
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0)
2046
+ return false;
2047
+ if (rect.width <= 0 || rect.height <= 0)
2048
+ return false;
2049
+ }
2050
+ return element.getClientRects().length > 0;
2051
+ };
2052
+ const readApi = () => {
2053
+ const factory = Reflect.get(window, "fsApi");
2054
+ const api = typeof factory === "function" ? Reflect.apply(factory, window, []) : null;
2055
+ const getForms = api && typeof api === "object" ? Reflect.get(api, "getForms") : null;
2056
+ const forms = typeof getForms === "function" ? Reflect.apply(getForms, api, []) : [];
2057
+ return Array.isArray(forms) ? forms[0] ?? null : null;
2058
+ };
2059
+ const form = readApi();
2060
+ const apiAvailable = Boolean(form && typeof form === "object");
2061
+ const getField = form && typeof form === "object" ? Reflect.get(form, "getField") : null;
2062
+ const getFieldByInternalLabel = form && typeof form === "object" ? Reflect.get(form, "getFieldByInternalLabel") : null;
2063
+ const field = stateOptions.fieldId != null && typeof getField === "function"
2064
+ ? Reflect.apply(getField, form, [String(stateOptions.fieldId)])
2065
+ : stateOptions.internalLabel && typeof getFieldByInternalLabel === "function"
2066
+ ? Reflect.apply(getFieldByInternalLabel, form, [stateOptions.internalLabel])
2067
+ : null;
2068
+ const fieldAvailable = Boolean(field && typeof field === "object");
2069
+ const getId = field && typeof field === "object" ? Reflect.get(field, "getId") : null;
2070
+ const getValue = field && typeof field === "object" ? Reflect.get(field, "getValue") : null;
2071
+ const getGeneralAttribute = field && typeof field === "object" ? Reflect.get(field, "getGeneralAttribute") : null;
2072
+ const id = fieldAvailable
2073
+ ? typeof getId === "function"
2074
+ ? Reflect.apply(getId, field, [])
2075
+ : Reflect.get(field, "id") ?? null
2076
+ : stateOptions.fieldId ?? null;
2077
+ const value = typeof getValue === "function" ? Reflect.apply(getValue, field, []) : null;
2078
+ const selectedValue = readPath(value, stateOptions.valuePath);
2079
+ const root = stateOptions.selector != null
2080
+ ? document.querySelector(stateOptions.selector)
2081
+ : stateOptions.internalLabel != null
2082
+ ? document.querySelector(`[data-internal-label="${CSS.escape(stateOptions.internalLabel)}"]`)
2083
+ : id == null
2084
+ ? null
2085
+ : document.querySelector(`#fsCell${CSS.escape(String(id))}, #fsFieldCell${CSS.escape(String(id))}, [data-testid="field-${CSS.escape(String(id))}"]`);
2086
+ const visible = isVisible(root);
2087
+ const hidden = typeof getGeneralAttribute === "function" ? Reflect.apply(getGeneralAttribute, field, ["hidden"]) === true : visible === null ? null : !visible;
2088
+ const textValue = stringifyValue(selectedValue);
2089
+ const numberValue = normalizeNumber(selectedValue);
2090
+ const dateValue = normalizeDate(selectedValue);
2091
+ const valueMatched = stateOptions.expectedValue === undefined || valuesEqual(selectedValue, stateOptions.expectedValue);
2092
+ const numberMatched = stateOptions.expectedNumber === undefined ||
2093
+ (numberValue !== null && Math.abs(numberValue - stateOptions.expectedNumber) <= (stateOptions.numberTolerance ?? 0));
2094
+ const dateMatched = expectedDateText === undefined || dateValue === expectedDateText;
2095
+ const visibilityMatched = stateOptions.expectedVisible === undefined || visible === stateOptions.expectedVisible;
2096
+ return {
2097
+ matched: apiAvailable && fieldAvailable && valueMatched && numberMatched && dateMatched && visibilityMatched,
2098
+ apiAvailable,
2099
+ fieldAvailable,
2100
+ id,
2101
+ internalLabel: root?.getAttribute("data-internal-label") ?? stateOptions.internalLabel ?? null,
2102
+ value,
2103
+ selectedValue,
2104
+ textValue,
2105
+ numberValue,
2106
+ dateValue,
2107
+ visible,
2108
+ hidden,
2109
+ ...(stateOptions.expectedValue === undefined ? {} : { expectedValue: stateOptions.expectedValue }),
2110
+ ...(stateOptions.expectedNumber === undefined ? {} : { expectedNumber: stateOptions.expectedNumber }),
2111
+ ...(expectedDateText === undefined ? {} : { expectedDate: expectedDateText }),
2112
+ valueMatched,
2113
+ numberMatched,
2114
+ dateMatched,
2115
+ visibilityMatched,
2116
+ };
2117
+ }, options);
2118
+ }
2119
+ export async function waitForLiveFormCalculationState(page, options) {
2120
+ const timeoutMs = options.timeoutMs ?? 10_000;
2121
+ const intervalMs = options.intervalMs ?? 100;
2122
+ const startedAt = Date.now();
2123
+ let latest = await readLiveFormCalculationState(page, options);
2124
+ while (!latest.matched && Date.now() - startedAt < timeoutMs) {
2125
+ await delay(intervalMs);
2126
+ latest = await readLiveFormCalculationState(page, options);
2127
+ }
2128
+ return latest;
2129
+ }
2130
+ export async function readLiveFormSubmissionInclusion(page, options) {
2131
+ if (!hasEvaluate(page)) {
2132
+ throw new Error("readLiveFormSubmissionInclusion requires a Playwright page with evaluate().");
2133
+ }
2134
+ return page.evaluate((inclusionOptions) => {
2135
+ const isEmpty = (value) => {
2136
+ if (value === null || value === undefined)
2137
+ return true;
2138
+ if (typeof value === "string")
2139
+ return value.trim().length === 0;
2140
+ if (typeof value === "number" || typeof value === "boolean")
2141
+ return false;
2142
+ if (Array.isArray(value))
2143
+ return value.length === 0 || value.every((item) => isEmpty(item));
2144
+ if (typeof value === "object")
2145
+ return Object.values(value).every((item) => isEmpty(item));
2146
+ return false;
2147
+ };
2148
+ const isVisible = (element) => {
2149
+ if (!element)
2150
+ return null;
2151
+ if (!(element instanceof HTMLElement))
2152
+ return false;
2153
+ for (let current = element; current; current = current.parentElement) {
2154
+ const style = getComputedStyle(current);
2155
+ const rect = current.getBoundingClientRect();
2156
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0)
2157
+ return false;
2158
+ if (rect.width <= 0 || rect.height <= 0)
2159
+ return false;
2160
+ }
2161
+ return element.getClientRects().length > 0;
2162
+ };
2163
+ const controlSnapshot = (control, formData) => {
2164
+ const input = control;
2165
+ const name = control.getAttribute("name");
2166
+ const type = control.getAttribute("type");
2167
+ const disabled = "disabled" in input ? input.disabled : null;
2168
+ const visible = isVisible(control) === true;
2169
+ const checked = control instanceof HTMLInputElement && ["checkbox", "radio"].includes(control.type) ? control.checked : null;
2170
+ const successful = Boolean(name) &&
2171
+ disabled !== true &&
2172
+ (!(control instanceof HTMLInputElement) || !["checkbox", "radio"].includes(control.type) || control.checked);
2173
+ const formDataIncluded = Boolean(name && formData?.has(name));
2174
+ return {
2175
+ tagName: control.tagName.toLowerCase(),
2176
+ type,
2177
+ id: control.getAttribute("id"),
2178
+ name,
2179
+ value: control.getAttribute("value"),
2180
+ currentValue: "value" in input ? input.value : null,
2181
+ checked,
2182
+ disabled,
2183
+ visible,
2184
+ successful,
2185
+ formDataIncluded,
2186
+ };
2187
+ };
2188
+ const readApi = () => {
2189
+ const factory = Reflect.get(window, "fsApi");
2190
+ const api = typeof factory === "function" ? Reflect.apply(factory, window, []) : null;
2191
+ const getForms = api && typeof api === "object" ? Reflect.get(api, "getForms") : null;
2192
+ const forms = typeof getForms === "function" ? Reflect.apply(getForms, api, []) : [];
2193
+ return Array.isArray(forms) ? forms[0] ?? null : null;
2194
+ };
2195
+ const formApi = readApi();
2196
+ const apiAvailable = Boolean(formApi && typeof formApi === "object");
2197
+ const getField = formApi && typeof formApi === "object" ? Reflect.get(formApi, "getField") : null;
2198
+ const getFieldByInternalLabel = formApi && typeof formApi === "object" ? Reflect.get(formApi, "getFieldByInternalLabel") : null;
2199
+ const field = inclusionOptions.fieldId != null && typeof getField === "function"
2200
+ ? Reflect.apply(getField, formApi, [String(inclusionOptions.fieldId)])
2201
+ : inclusionOptions.internalLabel && typeof getFieldByInternalLabel === "function"
2202
+ ? Reflect.apply(getFieldByInternalLabel, formApi, [inclusionOptions.internalLabel])
2203
+ : null;
2204
+ const fieldAvailable = Boolean(field && typeof field === "object");
2205
+ const getId = field && typeof field === "object" ? Reflect.get(field, "getId") : null;
2206
+ const getValue = field && typeof field === "object" ? Reflect.get(field, "getValue") : null;
2207
+ const getGeneralAttribute = field && typeof field === "object" ? Reflect.get(field, "getGeneralAttribute") : null;
2208
+ const id = fieldAvailable
2209
+ ? typeof getId === "function"
2210
+ ? Reflect.apply(getId, field, [])
2211
+ : Reflect.get(field, "id") ?? null
2212
+ : inclusionOptions.fieldId ?? null;
2213
+ const value = typeof getValue === "function" ? Reflect.apply(getValue, field, []) : null;
2214
+ const root = inclusionOptions.selector != null
2215
+ ? document.querySelector(inclusionOptions.selector)
2216
+ : inclusionOptions.internalLabel != null
2217
+ ? document.querySelector(`[data-internal-label="${CSS.escape(inclusionOptions.internalLabel)}"]`)
2218
+ : id == null
2219
+ ? null
2220
+ : document.querySelector(`#fsCell${CSS.escape(String(id))}, #fsFieldCell${CSS.escape(String(id))}, [data-testid="field-${CSS.escape(String(id))}"]`);
2221
+ const htmlForm = root?.closest("form") ?? document.querySelector("form");
2222
+ const formData = htmlForm instanceof HTMLFormElement ? new FormData(htmlForm) : null;
2223
+ const controls = root
2224
+ ? Array.from(root.querySelectorAll("input, textarea, select"))
2225
+ .filter((control) => inclusionOptions.includeHiddenControls !== false || control.getAttribute("type") !== "hidden")
2226
+ .map((control) => controlSnapshot(control, formData))
2227
+ : [];
2228
+ const includedNames = [...new Set(controls.filter((control) => control.formDataIncluded).map((control) => control.name).filter((name) => Boolean(name)))];
2229
+ const included = includedNames.length > 0;
2230
+ const domVisible = isVisible(root);
2231
+ const apiHidden = typeof getGeneralAttribute === "function" ? Reflect.apply(getGeneralAttribute, field, ["hidden"]) === true : null;
2232
+ const matched = Boolean(root) && (inclusionOptions.expectedIncluded === undefined || included === inclusionOptions.expectedIncluded);
2233
+ const reason = !root
2234
+ ? "field-root-missing"
2235
+ : included
2236
+ ? domVisible === false ? "included-while-dom-hidden" : "included"
2237
+ : isEmpty(value) ? "not-included-empty-value" : domVisible === false || apiHidden === true ? "not-included-hidden-by-logic-or-disabled" : "not-included-no-successful-controls";
2238
+ return {
2239
+ matched,
2240
+ fieldMatched: Boolean(root),
2241
+ apiAvailable,
2242
+ fieldAvailable,
2243
+ id,
2244
+ internalLabel: root?.getAttribute("data-internal-label") ?? inclusionOptions.internalLabel ?? null,
2245
+ value,
2246
+ isEmpty: isEmpty(value),
2247
+ apiHidden,
2248
+ domVisible,
2249
+ controls,
2250
+ included,
2251
+ includedNames,
2252
+ ...(inclusionOptions.expectedIncluded === undefined ? {} : { expectedIncluded: inclusionOptions.expectedIncluded }),
2253
+ reason,
2254
+ };
2255
+ }, options);
2256
+ }
2257
+ export async function traceLiveFormCalculationLifecycle(page, options) {
2258
+ const totalStartedAt = Date.now();
2259
+ const phases = [];
2260
+ const beforeStartedAt = Date.now();
2261
+ const before = await readLiveFormCalculationState(page, options.calculation);
2262
+ phases.push(endCascadePhase("before-calculation", beforeStartedAt, before.fieldAvailable));
2263
+ const writeStartedAt = Date.now();
2264
+ const transaction = await performLiveFormFieldTransaction(page, {
2265
+ ...(options.trigger.transaction ?? {}),
2266
+ write: {
2267
+ ...(options.trigger.write ?? {}),
2268
+ target: options.trigger.target,
2269
+ value: options.trigger.value,
2270
+ notify: options.trigger.write?.notify ?? true,
2271
+ },
2272
+ ...(options.trigger.fieldKind === undefined ? {} : { fieldKind: options.trigger.fieldKind }),
2273
+ });
2274
+ phases.push(endCascadePhase("write", writeStartedAt, transaction.write.valueMatched !== false));
2275
+ const quietOptions = options.quiet === true ? {} : options.quiet === false ? undefined : options.quiet;
2276
+ const quietStartedAt = Date.now();
2277
+ const quiet = quietOptions ? await waitForLiveFormQuiet(page, quietOptions) : undefined;
2278
+ if (quiet)
2279
+ phases.push(endCascadePhase("quiet", quietStartedAt, quiet.quiet));
2280
+ const afterStartedAt = Date.now();
2281
+ const after = await waitForLiveFormCalculationState(page, options.calculation);
2282
+ phases.push(endCascadePhase("after-calculation", afterStartedAt, after.matched));
2283
+ const visibilityStartedAt = Date.now();
2284
+ const visibility = options.visibility ? await waitForLiveFormVisibility(page, options.visibility) : undefined;
2285
+ if (visibility)
2286
+ phases.push(endCascadePhase("visibility", visibilityStartedAt, visibility.matched));
2287
+ const endedAt = Date.now();
2288
+ const ok = transaction.write.valueMatched !== false && (quiet?.quiet ?? true) && after.matched && (visibility?.matched ?? true);
2289
+ return {
2290
+ label: options.label ?? "live-form-calculation-lifecycle",
2291
+ before,
2292
+ transaction,
2293
+ after,
2294
+ ...(quiet ? { quiet } : {}),
2295
+ ...(visibility ? { visibility } : {}),
2296
+ phases,
2297
+ totalMs: endedAt - totalStartedAt,
2298
+ ok,
2299
+ };
2300
+ }
1962
2301
  export async function readLiveFormProductState(page, options) {
1963
2302
  if (!hasEvaluate(page)) {
1964
2303
  throw new Error("readLiveFormProductState requires a Playwright page with evaluate().");
@@ -3439,6 +3778,16 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
3439
3778
  controls.some((control) => control.name?.toLowerCase().includes("payment") || control.name?.toLowerCase().includes("credit"))) {
3440
3779
  return "payment";
3441
3780
  }
3781
+ if (internalLabel.includes("calculation") ||
3782
+ internalLabel.includes("calculated") ||
3783
+ internalLabel.includes("calc_") ||
3784
+ internalLabel.endsWith("_calc") ||
3785
+ classes.includes("calculation") ||
3786
+ classes.includes("calculated") ||
3787
+ dataTestId.includes("calculation") ||
3788
+ text.includes("calculation")) {
3789
+ return "calculation";
3790
+ }
3442
3791
  if (internalLabel.includes("code_embed") || text.startsWith(":root{") || text.includes("<script"))
3443
3792
  return "embed";
3444
3793
  if (controls.length === 0) {
@@ -3458,6 +3807,11 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
3458
3807
  }
3459
3808
  return "description";
3460
3809
  }
3810
+ if (internalLabel.includes("hidden") ||
3811
+ classes.includes("hidden-field") ||
3812
+ controls.every((control) => control.type === "hidden")) {
3813
+ return "hidden";
3814
+ }
3461
3815
  if (classes.includes("matrix") || internalLabel.includes("matrix") || element.querySelector("[id^='matrix-row-'], table"))
3462
3816
  return "matrix";
3463
3817
  if (classes.includes("rating") || internalLabel.includes("rating"))