@redrockswebdevelopment/formstack-form-testing 0.1.40 → 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",
@@ -1883,7 +1901,12 @@ export async function readLiveFormStructuralState(page, options) {
1883
1901
  return "embed";
1884
1902
  if (classes.includes("pagebreak") || classes.includes("page-break") || dataTestId.includes("pagebreak") || id.includes("pagebreak"))
1885
1903
  return "pagebreak";
1886
- if (classes.includes("description") || internalLabel.includes("description") || dataTestId.includes("description"))
1904
+ if (classes.includes("description") ||
1905
+ classes.includes("richtext") ||
1906
+ internalLabel.includes("description") ||
1907
+ internalLabel.includes("richtext") ||
1908
+ dataTestId.includes("description") ||
1909
+ dataTestId.includes("richtext"))
1887
1910
  return "description";
1888
1911
  if (classes.includes("section") || id.includes("section") || element.querySelector(".fsSectionHeader, [class*='SectionHeader']"))
1889
1912
  return "section";
@@ -1920,7 +1943,9 @@ export async function readLiveFormStructuralState(page, options) {
1920
1943
  const visible = isVisible(root);
1921
1944
  const visibilityMatched = expectedVisible === undefined || visible === expectedVisible;
1922
1945
  const kindMatched = stateOptions.expectedKind === undefined || inferredKind === stateOptions.expectedKind;
1923
- const controlsMatched = stateOptions.allowControls === true || controls.length === 0;
1946
+ const controlsMatched = stateOptions.allowControls === true ||
1947
+ (stateOptions.allowControls !== false && inferredKind === "section") ||
1948
+ controls.length === 0;
1924
1949
  return {
1925
1950
  matched: Boolean(root) && kindMatched && textMatched && visibilityMatched && controlsMatched,
1926
1951
  elementMatched: Boolean(root),
@@ -1952,6 +1977,327 @@ export async function waitForLiveFormStructuralState(page, options) {
1952
1977
  }
1953
1978
  return latest;
1954
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
+ }
1955
2301
  export async function readLiveFormProductState(page, options) {
1956
2302
  if (!hasEvaluate(page)) {
1957
2303
  throw new Error("readLiveFormProductState requires a Playwright page with evaluate().");
@@ -3432,16 +3778,39 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
3432
3778
  controls.some((control) => control.name?.toLowerCase().includes("payment") || control.name?.toLowerCase().includes("credit"))) {
3433
3779
  return "payment";
3434
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
+ }
3435
3791
  if (internalLabel.includes("code_embed") || text.startsWith(":root{") || text.includes("<script"))
3436
3792
  return "embed";
3437
3793
  if (controls.length === 0) {
3438
3794
  if (classes.includes("pagebreak") || classes.includes("page-break") || dataTestId.includes("pagebreak") || id.includes("pagebreak")) {
3439
3795
  return "pagebreak";
3440
3796
  }
3441
- if (classes.includes("description") || internalLabel.includes("description") || dataTestId.includes("description")) {
3797
+ if (classes.includes("description") ||
3798
+ classes.includes("richtext") ||
3799
+ internalLabel.includes("description") ||
3800
+ internalLabel.includes("richtext") ||
3801
+ dataTestId.includes("description") ||
3802
+ dataTestId.includes("richtext")) {
3442
3803
  return "description";
3443
3804
  }
3444
- return "section";
3805
+ if (classes.includes("section") || id.includes("section") || element.querySelector(".fsSectionHeader, [class*='SectionHeader']")) {
3806
+ return "section";
3807
+ }
3808
+ return "description";
3809
+ }
3810
+ if (internalLabel.includes("hidden") ||
3811
+ classes.includes("hidden-field") ||
3812
+ controls.every((control) => control.type === "hidden")) {
3813
+ return "hidden";
3445
3814
  }
3446
3815
  if (classes.includes("matrix") || internalLabel.includes("matrix") || element.querySelector("[id^='matrix-row-'], table"))
3447
3816
  return "matrix";