@redrockswebdevelopment/formstack-form-testing 0.1.42 → 0.1.44

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,17 @@ export class FormScenarioRunError extends Error {
16
16
  this.result = result;
17
17
  }
18
18
  }
19
+ export class LiveFormTestingError extends Error {
20
+ code;
21
+ detail;
22
+ constructor(code, message, detail) {
23
+ super(message);
24
+ this.name = "LiveFormTestingError";
25
+ this.code = code;
26
+ if (detail)
27
+ this.detail = detail;
28
+ }
29
+ }
19
30
  export const liveFormFieldTypeCoverage = {
20
31
  address: {
21
32
  kind: "address",
@@ -247,6 +258,70 @@ export const liveFormFieldTypeCoverage = {
247
258
  export function getLiveFormFieldTypeCoverage(kind) {
248
259
  return liveFormFieldTypeCoverage[kind];
249
260
  }
261
+ export function createLiveFormTestingError(code, message, detail) {
262
+ return new LiveFormTestingError(code, message, detail);
263
+ }
264
+ export const liveFormCiProfiles = {
265
+ "fast-smoke": {
266
+ name: "fast-smoke",
267
+ projects: ["chromium"],
268
+ recommendedRetries: 1,
269
+ includeVisualBaselines: false,
270
+ includeHostedSubmissions: false,
271
+ includePaymentFields: false,
272
+ defaultTimeoutMs: 30_000,
273
+ description: "Fast deterministic checks for package and client CI on every commit.",
274
+ },
275
+ "hosted-regression": {
276
+ name: "hosted-regression",
277
+ projects: ["chromium", "mobile-chrome"],
278
+ recommendedRetries: 2,
279
+ includeVisualBaselines: false,
280
+ includeHostedSubmissions: true,
281
+ includePaymentFields: false,
282
+ defaultTimeoutMs: 60_000,
283
+ description: "Hosted Formstack checks for helper compatibility, custom code behavior, and submission payload probes.",
284
+ },
285
+ "full-browser": {
286
+ name: "full-browser",
287
+ projects: ["chromium", "mobile-chrome", "webkit"],
288
+ recommendedRetries: 2,
289
+ includeVisualBaselines: true,
290
+ includeHostedSubmissions: true,
291
+ includePaymentFields: false,
292
+ defaultTimeoutMs: 90_000,
293
+ description: "Full browser regression profile for release candidates and project acceptance.",
294
+ },
295
+ };
296
+ export function getLiveFormCiProfile(name) {
297
+ return liveFormCiProfiles[name];
298
+ }
299
+ export function createLiveFormFixtureRegistry(fixtures) {
300
+ const seen = new Set();
301
+ for (const fixture of fixtures) {
302
+ if (seen.has(fixture.key)) {
303
+ throw createLiveFormTestingError("fixture-not-found", `Duplicate live form fixture key "${fixture.key}".`, { key: fixture.key });
304
+ }
305
+ seen.add(fixture.key);
306
+ }
307
+ return { fixtures: [...fixtures] };
308
+ }
309
+ export function selectLiveFormFixture(registry, options) {
310
+ const tags = new Set(options.tags ?? []);
311
+ const fixture = registry.fixtures.find((entry) => {
312
+ if (options.key !== undefined && entry.key !== options.key)
313
+ return false;
314
+ if (options.environment !== undefined && entry.environment !== options.environment)
315
+ return false;
316
+ if (tags.size > 0 && ![...tags].every((tag) => entry.tags?.includes(tag)))
317
+ return false;
318
+ return true;
319
+ });
320
+ if (!fixture) {
321
+ throw createLiveFormTestingError("fixture-not-found", "No live form fixture matched the requested selector.", { options });
322
+ }
323
+ return fixture;
324
+ }
250
325
  export function summarizeLiveFormFieldCoverage(reportOrFields) {
251
326
  const fields = "fields" in reportOrFields ? reportOrFields.fields : reportOrFields;
252
327
  const countsByKind = {};
@@ -268,6 +343,62 @@ export function summarizeLiveFormFieldCoverage(reportOrFields) {
268
343
  export async function readLiveFormFieldCoverage(page, options = {}) {
269
344
  return summarizeLiveFormFieldCoverage(await discoverLiveFormFieldShapes(page, options));
270
345
  }
346
+ export async function diagnoseLiveFormUnknownFields(page, options = {}) {
347
+ const coverage = await readLiveFormFieldCoverage(page, options);
348
+ const unknownFields = coverage.fields.filter((field) => field.inferredKind === "unknown");
349
+ const metadata = options.includeMetadata === false || unknownFields.length === 0
350
+ ? undefined
351
+ : await readLiveFormApiMetadataSnapshot(page, {
352
+ fieldTargets: unknownFields.map((field) => ({
353
+ ...(field.fieldId == null ? {} : { fieldId: field.fieldId }),
354
+ ...(field.internalLabel == null ? {} : { internalLabel: field.internalLabel }),
355
+ })),
356
+ ...(options.includeValues === undefined ? {} : { includeValues: options.includeValues }),
357
+ });
358
+ const metadataByLabel = new Map((metadata?.fields ?? []).map((field) => [field.internalLabel, field]));
359
+ const suggestionsFor = (field) => {
360
+ const text = field.labelText.toLowerCase();
361
+ const types = new Set(field.controlTypes);
362
+ const label = field.internalLabel?.toLowerCase() ?? "";
363
+ const suggestions = [];
364
+ if (types.has("hidden") && field.controlTypes.length === 1) {
365
+ suggestions.push({ kind: "hidden", reason: "Field exposes only hidden controls.", confidence: "medium" });
366
+ }
367
+ if (label.includes("calc") || text.includes("calculation") || text.includes("total")) {
368
+ suggestions.push({ kind: "calculation", reason: "Field label or internal label suggests a derived calculation.", confidence: "medium" });
369
+ }
370
+ if (types.has("date"))
371
+ suggestions.push({ kind: "date", reason: "Field contains a native date input.", confidence: "high" });
372
+ if (types.has("number"))
373
+ suggestions.push({ kind: "number", reason: "Field contains a native number input.", confidence: "high" });
374
+ if (types.has("text"))
375
+ suggestions.push({ kind: "text", reason: "Field contains text controls but no stronger field-specific markers.", confidence: "low" });
376
+ if (field.controlCount === 0)
377
+ suggestions.push({ kind: "description", reason: "Field has no controls and may be display-only content.", confidence: "low" });
378
+ return suggestions;
379
+ };
380
+ const diagnostics = unknownFields.map((field) => {
381
+ const suggestions = suggestionsFor(field);
382
+ const fieldMetadata = field.internalLabel === null ? undefined : metadataByLabel.get(field.internalLabel);
383
+ return {
384
+ field,
385
+ suggestions,
386
+ ...(fieldMetadata === undefined ? {} : { metadata: fieldMetadata }),
387
+ recommendedNextSteps: [
388
+ "Attach the field diagnostic JSON and screenshot to the failing test artifact.",
389
+ "Inspect stable data-internal-label, controls, API methods, and Formstack field settings.",
390
+ suggestions.length > 0
391
+ ? `Try an inferred-kind override or helper for ${suggestions[0]?.kind}.`
392
+ : "Add a package-level inferred kind or a project-specific helper before automating this field.",
393
+ ],
394
+ };
395
+ });
396
+ return {
397
+ unknownFields: diagnostics,
398
+ hasUnknownFields: diagnostics.length > 0,
399
+ ...(options.screenshotPath === undefined ? {} : { screenshotPath: options.screenshotPath }),
400
+ };
401
+ }
271
402
  function formatCoverageField(field) {
272
403
  return `${field.inferredKind}:${field.internalLabel ?? field.domId ?? "unlabeled"}`;
273
404
  }
@@ -299,7 +430,12 @@ export function assertLiveFormFieldCoverage(report, options = {}) {
299
430
  }
300
431
  }
301
432
  if (failures.length > 0) {
302
- throw new Error(`Live form field coverage assertion failed: ${failures.join("; ")}`);
433
+ throw createLiveFormTestingError("field-coverage-failed", `Live form field coverage assertion failed: ${failures.join("; ")}`, {
434
+ failures,
435
+ countsByKind: report.countsByKind,
436
+ unknownFields: report.uncovered.map(formatCoverageField),
437
+ paymentFields: report.paymentFields.map(formatCoverageField),
438
+ });
303
439
  }
304
440
  }
305
441
  function matchesPattern(value, pattern) {
@@ -3730,6 +3866,121 @@ export async function readLiveFormStateDrift(page, options = {}) {
3730
3866
  hasDrift: issues.length > 0,
3731
3867
  };
3732
3868
  }
3869
+ export async function readLiveFormApiMetadataSnapshot(page, options = {}) {
3870
+ if (!hasEvaluate(page)) {
3871
+ throw new Error("readLiveFormApiMetadataSnapshot requires a Playwright page with evaluate().");
3872
+ }
3873
+ return page.evaluate((metadataOptions) => {
3874
+ const methodNames = metadataOptions.methodNames ?? [
3875
+ "getId",
3876
+ "getValue",
3877
+ "setValue",
3878
+ "getInfo",
3879
+ "getGeneralAttribute",
3880
+ "setGeneralAttribute",
3881
+ "validate",
3882
+ "setSkipValidation",
3883
+ ];
3884
+ const domSelector = metadataOptions.snapshot?.selector ?? "[data-internal-label]";
3885
+ const textMaxLength = metadataOptions.snapshot?.textMaxLength ?? 160;
3886
+ const controlSnapshot = (control) => {
3887
+ const input = control;
3888
+ return {
3889
+ tagName: control.tagName.toLowerCase(),
3890
+ type: control.getAttribute("type"),
3891
+ id: control.getAttribute("id"),
3892
+ name: control.getAttribute("name"),
3893
+ value: control.getAttribute("value"),
3894
+ currentValue: "value" in input ? input.value : null,
3895
+ checked: control instanceof HTMLInputElement && ["checkbox", "radio"].includes(input.type) ? control.checked : null,
3896
+ };
3897
+ };
3898
+ const domFields = Array.from(document.querySelectorAll(domSelector)).map((element) => ({
3899
+ internalLabel: element.getAttribute("data-internal-label"),
3900
+ id: element.id,
3901
+ text: element.textContent?.replace(/\s+/g, " ").trim().slice(0, textMaxLength) ?? "",
3902
+ controls: Array.from(element.querySelectorAll("input, textarea, select")).map(controlSnapshot),
3903
+ }));
3904
+ const domByInternalLabel = new Map(domFields.map((field) => [field.internalLabel, field]));
3905
+ const readApi = () => {
3906
+ const factory = Reflect.get(window, "fsApi");
3907
+ const api = typeof factory === "function" ? Reflect.apply(factory, window, []) : null;
3908
+ const getForms = api && typeof api === "object" ? Reflect.get(api, "getForms") : null;
3909
+ const forms = typeof getForms === "function" ? Reflect.apply(getForms, api, []) : [];
3910
+ const form = Array.isArray(forms) ? forms[0] ?? null : null;
3911
+ return { form, apiAvailable: Boolean(api && typeof api === "object") };
3912
+ };
3913
+ const { form, apiAvailable } = readApi();
3914
+ const formAvailable = Boolean(form && typeof form === "object");
3915
+ const formId = formAvailable ? Reflect.get(form, "id") ?? Reflect.get(form, "formId") ?? null : null;
3916
+ const formMethodNames = formAvailable
3917
+ ? [...new Set([
3918
+ ...Object.keys(form),
3919
+ ...Object.getOwnPropertyNames(Object.getPrototypeOf(form) ?? {}),
3920
+ ])].filter((name) => typeof Reflect.get(form, name) === "function").sort()
3921
+ : [];
3922
+ const getField = formAvailable ? Reflect.get(form, "getField") : null;
3923
+ const getFieldByInternalLabel = formAvailable ? Reflect.get(form, "getFieldByInternalLabel") : null;
3924
+ const targets = metadataOptions.fieldTargets ?? (metadataOptions.includeAllDomFields === false
3925
+ ? []
3926
+ : domFields
3927
+ .map((field) => field.internalLabel)
3928
+ .filter((internalLabel) => Boolean(internalLabel))
3929
+ .map((internalLabel) => ({ internalLabel })));
3930
+ const resolveField = (target) => target.fieldId != null && typeof getField === "function"
3931
+ ? Reflect.apply(getField, form, [String(target.fieldId)])
3932
+ : target.internalLabel && typeof getFieldByInternalLabel === "function"
3933
+ ? Reflect.apply(getFieldByInternalLabel, form, [target.internalLabel])
3934
+ : null;
3935
+ const readAttribute = (field, name) => {
3936
+ if (!field || typeof field !== "object")
3937
+ return undefined;
3938
+ const getGeneralAttribute = Reflect.get(field, "getGeneralAttribute");
3939
+ return typeof getGeneralAttribute === "function" ? Reflect.apply(getGeneralAttribute, field, [name]) : undefined;
3940
+ };
3941
+ const fields = targets.map((target) => {
3942
+ const field = resolveField(target);
3943
+ const fieldAvailable = Boolean(field && typeof field === "object");
3944
+ const getId = fieldAvailable ? Reflect.get(field, "getId") : null;
3945
+ const getValue = fieldAvailable ? Reflect.get(field, "getValue") : null;
3946
+ const getInfo = fieldAvailable ? Reflect.get(field, "getInfo") : null;
3947
+ const id = fieldAvailable
3948
+ ? typeof getId === "function"
3949
+ ? Reflect.apply(getId, field, [])
3950
+ : Reflect.get(field, "id") ?? target.fieldId ?? null
3951
+ : target.fieldId ?? null;
3952
+ const internalLabel = target.internalLabel ?? (fieldAvailable ? Reflect.get(field, "internalLabel") ?? null : null);
3953
+ const fieldMethodNames = fieldAvailable
3954
+ ? [...new Set([
3955
+ ...Object.keys(field),
3956
+ ...Object.getOwnPropertyNames(Object.getPrototypeOf(field) ?? {}),
3957
+ ])].filter((name) => typeof Reflect.get(field, name) === "function").sort()
3958
+ : [];
3959
+ const dom = internalLabel ? domByInternalLabel.get(String(internalLabel)) ?? null : null;
3960
+ return {
3961
+ target,
3962
+ fieldAvailable,
3963
+ id,
3964
+ internalLabel: internalLabel == null ? null : String(internalLabel),
3965
+ ...(metadataOptions.includeValues === false || typeof getValue !== "function" ? {} : { value: Reflect.apply(getValue, field, []) }),
3966
+ ...(typeof getInfo !== "function" ? {} : { info: Reflect.apply(getInfo, field, []) }),
3967
+ hidden: readAttribute(field, "hidden"),
3968
+ required: readAttribute(field, "required"),
3969
+ skipValidation: readAttribute(field, "skipValidation"),
3970
+ methodNames: fieldMethodNames,
3971
+ methods: methodNames.map((name) => ({ name, available: fieldAvailable && typeof Reflect.get(field, name) === "function" })),
3972
+ dom,
3973
+ };
3974
+ });
3975
+ return {
3976
+ apiAvailable,
3977
+ formAvailable,
3978
+ formId,
3979
+ formMethodNames,
3980
+ fields,
3981
+ };
3982
+ }, options);
3983
+ }
3733
3984
  export async function discoverLiveFormFieldShapes(page, options = {}) {
3734
3985
  if (!hasEvaluate(page)) {
3735
3986
  throw new Error("discoverLiveFormFieldShapes requires a Playwright page with evaluate().");
@@ -3807,11 +4058,6 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
3807
4058
  }
3808
4059
  return "description";
3809
4060
  }
3810
- if (internalLabel.includes("hidden") ||
3811
- classes.includes("hidden-field") ||
3812
- controls.every((control) => control.type === "hidden")) {
3813
- return "hidden";
3814
- }
3815
4061
  if (classes.includes("matrix") || internalLabel.includes("matrix") || element.querySelector("[id^='matrix-row-'], table"))
3816
4062
  return "matrix";
3817
4063
  if (classes.includes("rating") || internalLabel.includes("rating"))
@@ -3844,6 +4090,11 @@ export async function discoverLiveFormFieldShapes(page, options = {}) {
3844
4090
  return "datetime";
3845
4091
  if (types.has("date") || internalLabel.includes("date"))
3846
4092
  return "date";
4093
+ if (internalLabel.includes("hidden") ||
4094
+ classes.includes("hidden-field") ||
4095
+ controls.every((control) => control.type === "hidden")) {
4096
+ return "hidden";
4097
+ }
3847
4098
  if (types.has("text"))
3848
4099
  return "text";
3849
4100
  return "unknown";