@redrockswebdevelopment/formstack-form-testing 0.1.18 → 0.1.20

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
@@ -3242,6 +3242,7 @@ export async function answerProgressiveLiveFormField(page, options) {
3242
3242
  },
3243
3243
  ...(options.fieldKind ? { fieldKind: options.fieldKind } : {}),
3244
3244
  waitForEvent: options.waitForEvent ?? true,
3245
+ dispatchDomEvents: options.dispatchDomEvents ?? "auto",
3245
3246
  afterQuiet: options.afterQuiet ?? true,
3246
3247
  ...(validationOptions ? { afterValidation: validationOptions } : {}),
3247
3248
  });
@@ -3358,6 +3359,7 @@ export function createLiveFormReadinessOptions(definition, options = {}) {
3358
3359
  export function createLiveFormSampleFillPlan(definition, sampleValues, options = {}) {
3359
3360
  const samples = sampleValues;
3360
3361
  const missingRequiredKeys = [];
3362
+ const writtenTargets = new Set();
3361
3363
  const plan = [];
3362
3364
  for (const [key, config] of Object.entries(definition)) {
3363
3365
  const required = config.required === true;
@@ -3373,9 +3375,16 @@ export function createLiveFormSampleFillPlan(definition, sampleValues, options =
3373
3375
  if (!required && options.includeOptional !== true && options.requiredOnly !== false) {
3374
3376
  continue;
3375
3377
  }
3378
+ const target = targetFromHandleConfig(config);
3379
+ const targetKey = liveFormFieldTargetKey(target);
3380
+ if (options.dedupeTargets !== false && targetKey && writtenTargets.has(targetKey)) {
3381
+ continue;
3382
+ }
3383
+ if (targetKey)
3384
+ writtenTargets.add(targetKey);
3376
3385
  const entry = {
3377
3386
  key,
3378
- target: targetFromHandleConfig(config),
3387
+ target,
3379
3388
  value: samples[key],
3380
3389
  ...(config.kind === undefined ? {} : { fieldKind: config.kind }),
3381
3390
  ...(config.required === undefined ? {} : { required: config.required }),
@@ -3446,6 +3455,324 @@ export async function runLiveFormSampleSmoke(page, definition, sampleValues, opt
3446
3455
  ok: (quiet?.quiet ?? true) && (afterReadiness?.isReady ?? true) && ((validation?.inlineErrors.length ?? 0) === 0) && !(coverage?.hasUnknownFields ?? false),
3447
3456
  };
3448
3457
  }
3458
+ function sampleValueShapeIssue(key, kind, value) {
3459
+ const objectValue = value && typeof value === "object" ? value : null;
3460
+ if (["radio", "select", "rating"].includes(kind ?? "") && (objectValue === null || typeof objectValue.value !== "string")) {
3461
+ return {
3462
+ kind: "invalid-value-shape",
3463
+ key,
3464
+ message: `Sample value for ${key} should be an object with a string value property.`,
3465
+ detail: { kind },
3466
+ };
3467
+ }
3468
+ if (["checkbox", "matrix"].includes(kind ?? "") && objectValue === null) {
3469
+ return {
3470
+ kind: "invalid-value-shape",
3471
+ key,
3472
+ message: `Sample value for ${key} should be an object payload for ${kind} fields.`,
3473
+ detail: { kind },
3474
+ };
3475
+ }
3476
+ if (kind === "name" && objectValue === null) {
3477
+ return {
3478
+ kind: "invalid-value-shape",
3479
+ key,
3480
+ message: `Sample value for ${key} should be an object with name subfields.`,
3481
+ detail: { kind },
3482
+ };
3483
+ }
3484
+ return null;
3485
+ }
3486
+ export function validateLiveFormSampleProfile(definition, sampleValues, options = {}) {
3487
+ const samples = sampleValues;
3488
+ const issues = [];
3489
+ const unknownKeys = [];
3490
+ const missingRequiredKeys = [];
3491
+ const duplicateTargetKeys = [];
3492
+ const targetOwners = new Map();
3493
+ for (const key of Object.keys(samples)) {
3494
+ if (!(key in definition)) {
3495
+ unknownKeys.push(key);
3496
+ issues.push({ kind: "unknown-sample-key", key, message: `Sample value key "${key}" does not exist in the field definition.` });
3497
+ }
3498
+ }
3499
+ for (const [key, config] of Object.entries(definition)) {
3500
+ const target = targetFromHandleConfig(config);
3501
+ const targetKey = liveFormFieldTargetKey(target);
3502
+ if (targetKey) {
3503
+ const owner = targetOwners.get(targetKey);
3504
+ if (owner && Object.hasOwn(samples, key) && Object.hasOwn(samples, owner) && options.allowDuplicateTargets !== true) {
3505
+ duplicateTargetKeys.push(key);
3506
+ issues.push({
3507
+ kind: "duplicate-target",
3508
+ key,
3509
+ target,
3510
+ message: `Sample value key "${key}" points to the same generated target as "${owner}".`,
3511
+ detail: { originalKey: owner },
3512
+ });
3513
+ }
3514
+ else if (!owner) {
3515
+ targetOwners.set(targetKey, key);
3516
+ }
3517
+ }
3518
+ if (config.required === true && !Object.hasOwn(samples, key)) {
3519
+ missingRequiredKeys.push(key);
3520
+ issues.push({ kind: "missing-required-sample", key, target, message: `Required field "${key}" does not have a sample value.` });
3521
+ }
3522
+ if (!Object.hasOwn(samples, key))
3523
+ continue;
3524
+ if (options.requiredOnly === true && config.required !== true)
3525
+ continue;
3526
+ const value = samples[key];
3527
+ const shapeIssue = sampleValueShapeIssue(key, config.kind, value);
3528
+ if (shapeIssue)
3529
+ issues.push({ ...shapeIssue, target });
3530
+ const allowedValues = options.optionValues?.[key];
3531
+ if (!allowedValues || !value || typeof value !== "object")
3532
+ continue;
3533
+ const valueObject = value;
3534
+ if (typeof valueObject.value === "string" && !allowedValues.includes(valueObject.value)) {
3535
+ issues.push({
3536
+ kind: "invalid-option-value",
3537
+ key,
3538
+ target,
3539
+ message: `Sample value "${valueObject.value}" is not in the allowed option values for "${key}".`,
3540
+ detail: { allowedValues },
3541
+ });
3542
+ }
3543
+ if (Array.isArray(valueObject.values)) {
3544
+ const invalid = valueObject.values.filter((entry) => typeof entry === "string" && !allowedValues.includes(entry));
3545
+ if (invalid.length > 0) {
3546
+ issues.push({
3547
+ kind: "invalid-option-values",
3548
+ key,
3549
+ target,
3550
+ message: `Sample values for "${key}" include values outside the allowed options: ${invalid.join(", ")}.`,
3551
+ detail: { allowedValues, invalid },
3552
+ });
3553
+ }
3554
+ }
3555
+ }
3556
+ return {
3557
+ issues,
3558
+ unknownKeys,
3559
+ missingRequiredKeys,
3560
+ duplicateTargetKeys,
3561
+ ok: issues.length === 0,
3562
+ };
3563
+ }
3564
+ export function createLiveFormProgressiveSamplePlan(definition, sampleValues, options = {}) {
3565
+ const basePlan = createLiveFormSampleFillPlan(definition, sampleValues, options);
3566
+ const configuredSteps = new Map((options.steps ?? []).map((step) => [step.key, step]));
3567
+ const orderedKeys = options.steps?.map((step) => step.key) ?? basePlan.map((entry) => entry.key);
3568
+ const baseByKey = new Map(basePlan.map((entry) => [entry.key, entry]));
3569
+ const plan = [];
3570
+ for (const key of orderedKeys) {
3571
+ const base = baseByKey.get(key);
3572
+ if (!base)
3573
+ continue;
3574
+ const step = configuredSteps.get(key);
3575
+ plan.push({
3576
+ ...base,
3577
+ ...(step?.expectVisibleAfter === undefined ? {} : { expectVisibleAfter: step.expectVisibleAfter }),
3578
+ ...(step?.expectHiddenAfter === undefined ? {} : { expectHiddenAfter: step.expectHiddenAfter }),
3579
+ ...(step?.expectResetAfter === undefined ? {} : { expectResetAfter: step.expectResetAfter }),
3580
+ ...(step?.continueSelector === undefined ? {} : { continueSelector: step.continueSelector }),
3581
+ ...(step?.transaction === undefined ? {} : { transaction: step.transaction }),
3582
+ });
3583
+ }
3584
+ return plan;
3585
+ }
3586
+ export async function runLiveFormProgressiveSampleFlow(page, definition, sampleValues, options = {}) {
3587
+ const plan = createLiveFormProgressiveSamplePlan(definition, sampleValues, options);
3588
+ const answers = [];
3589
+ for (const step of plan) {
3590
+ answers.push(await answerProgressiveLiveFormField(page, {
3591
+ ...(options.defaultTransaction ?? {}),
3592
+ ...(step.transaction ?? {}),
3593
+ target: step.target,
3594
+ value: step.value,
3595
+ ...(step.fieldKind === undefined ? {} : { fieldKind: step.fieldKind }),
3596
+ ...(step.expectVisibleAfter === undefined ? {} : { expectVisible: step.expectVisibleAfter.map((target) => ({ ...target, visible: true })) }),
3597
+ ...(step.expectHiddenAfter === undefined ? {} : { expectHidden: step.expectHiddenAfter }),
3598
+ ...(step.expectResetAfter === undefined ? {} : { expectReset: step.expectResetAfter }),
3599
+ }));
3600
+ if (step.continueSelector) {
3601
+ await page.locator(step.continueSelector).first().click();
3602
+ }
3603
+ }
3604
+ const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
3605
+ const readinessOptions = createSampleSmokeReadinessOptions(definition, options.afterReadiness, false);
3606
+ const validationOptions = options.afterValidation === false ? undefined : options.afterValidation === true ? { expectNoErrors: true } : options.afterValidation;
3607
+ const quiet = quietOptions ? await waitForLiveFormQuiet(page, quietOptions) : undefined;
3608
+ const readiness = readinessOptions ? await waitForLiveFormReady(page, readinessOptions) : undefined;
3609
+ const validation = validationOptions ? await waitForLiveFormValidationSettled(page, validationOptions) : undefined;
3610
+ return {
3611
+ plan,
3612
+ answers,
3613
+ ...(quiet ? { quiet } : {}),
3614
+ ...(readiness ? { readiness } : {}),
3615
+ ...(validation ? { validation } : {}),
3616
+ ok: answers.every((answer) => (answer.visibility?.matched ?? true) &&
3617
+ answer.resetValues.every((value) => value.valueMatched !== false) &&
3618
+ ((answer.validation?.inlineErrors.length ?? 0) === 0)) && (quiet?.quiet ?? true) && (readiness?.isReady ?? true) && ((validation?.inlineErrors.length ?? 0) === 0),
3619
+ };
3620
+ }
3621
+ export async function diagnoseLiveFormSubmitGate(page, options = {}) {
3622
+ if (!hasEvaluate(page)) {
3623
+ throw new Error("diagnoseLiveFormSubmitGate requires a Playwright page with evaluate().");
3624
+ }
3625
+ const selector = options.selector ?? ".fsSubmitButton, [type=submit], .fsSubmit";
3626
+ const dom = await page.evaluate((submitSelector) => {
3627
+ const element = document.querySelector(submitSelector);
3628
+ if (!(element instanceof HTMLElement)) {
3629
+ return {
3630
+ exists: false,
3631
+ visible: null,
3632
+ disabled: null,
3633
+ ariaDisabled: null,
3634
+ dataReady: null,
3635
+ hiddenReason: "missing",
3636
+ };
3637
+ }
3638
+ const style = getComputedStyle(element);
3639
+ const rect = element.getBoundingClientRect();
3640
+ const disabled = "disabled" in element ? Boolean(element.disabled) : element.getAttribute("disabled") !== null;
3641
+ const ariaDisabled = element.getAttribute("aria-disabled") === "true";
3642
+ const dataReady = element.getAttribute("data-chat-submit-ready") ?? element.getAttribute("data-submit-ready");
3643
+ const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
3644
+ const hiddenReason = visible ? null : style.display === "none" ? "display-none" : style.visibility === "hidden" ? "visibility-hidden" : rect.width <= 0 || rect.height <= 0 ? "zero-size" : "transparent";
3645
+ return { exists: true, visible, disabled, ariaDisabled, dataReady, hiddenReason };
3646
+ }, selector);
3647
+ const readiness = options.readiness ? await readLiveFormReadiness(page, options.readiness) : undefined;
3648
+ const validation = options.validation ? await readLiveFormValidationState(page, typeof options.validation === "object" ? options.validation : {}) : undefined;
3649
+ const blockingReasons = [];
3650
+ if (!dom.exists)
3651
+ blockingReasons.push("submit-control-missing");
3652
+ if (dom.visible === false)
3653
+ blockingReasons.push(`submit-control-hidden:${dom.hiddenReason ?? "unknown"}`);
3654
+ if (dom.disabled === true)
3655
+ blockingReasons.push("submit-control-disabled");
3656
+ if (dom.ariaDisabled === true)
3657
+ blockingReasons.push("submit-control-aria-disabled");
3658
+ if (dom.dataReady === "false")
3659
+ blockingReasons.push("submit-control-data-ready-false");
3660
+ if (readiness && !readiness.isReady)
3661
+ blockingReasons.push(`readiness-blocking-fields:${readiness.blockingFieldCount}`);
3662
+ if (validation && validation.inlineErrors.length > 0)
3663
+ blockingReasons.push(`validation-errors:${validation.inlineErrors.length}`);
3664
+ return {
3665
+ selector,
3666
+ ...dom,
3667
+ blockingReasons,
3668
+ ...(readiness ? { readiness } : {}),
3669
+ ...(validation ? { validation } : {}),
3670
+ ready: blockingReasons.length === 0,
3671
+ };
3672
+ }
3673
+ export async function diagnoseLiveFormLayout(page, options = {}) {
3674
+ if (!hasEvaluate(page)) {
3675
+ throw new Error("diagnoseLiveFormLayout requires a Playwright page with evaluate().");
3676
+ }
3677
+ return page.evaluate((layoutOptions) => {
3678
+ const rootSelector = layoutOptions.rootSelector ?? "body";
3679
+ const minTapTargetPx = layoutOptions.minTapTargetPx ?? 44;
3680
+ const root = document.querySelector(rootSelector);
3681
+ const viewportWidth = document.documentElement.clientWidth;
3682
+ const bodyScrollWidth = document.documentElement.scrollWidth;
3683
+ const rootScrollWidth = root instanceof HTMLElement ? root.scrollWidth : null;
3684
+ const rootClientWidth = root instanceof HTMLElement ? root.clientWidth : null;
3685
+ const issues = [];
3686
+ if (bodyScrollWidth > viewportWidth + 1) {
3687
+ issues.push({
3688
+ kind: "horizontal-overflow",
3689
+ selector: "html",
3690
+ message: `Document scroll width ${bodyScrollWidth}px exceeds viewport width ${viewportWidth}px.`,
3691
+ detail: { bodyScrollWidth, viewportWidth },
3692
+ });
3693
+ }
3694
+ if (rootScrollWidth !== null && rootClientWidth !== null && rootScrollWidth > rootClientWidth + 1) {
3695
+ issues.push({
3696
+ kind: "horizontal-overflow",
3697
+ selector: rootSelector,
3698
+ message: `Root scroll width ${rootScrollWidth}px exceeds client width ${rootClientWidth}px.`,
3699
+ detail: { rootScrollWidth, rootClientWidth },
3700
+ });
3701
+ }
3702
+ const scrollCandidates = Array.from(document.querySelectorAll(layoutOptions.includeSelectors?.join(",") ?? `${rootSelector} *`));
3703
+ for (const element of scrollCandidates) {
3704
+ if (!(element instanceof HTMLElement))
3705
+ continue;
3706
+ const style = getComputedStyle(element);
3707
+ const rect = element.getBoundingClientRect();
3708
+ const selector = element.id ? `#${CSS.escape(element.id)}` : element.getAttribute("data-internal-label") ? `[data-internal-label="${CSS.escape(element.getAttribute("data-internal-label") ?? "")}"]` : element.tagName.toLowerCase();
3709
+ if ((style.overflowY === "auto" || style.overflowY === "scroll") && element.scrollHeight > element.clientHeight + 1) {
3710
+ issues.push({
3711
+ kind: "nested-scroll",
3712
+ selector,
3713
+ message: "Element has internal vertical scrolling.",
3714
+ detail: { scrollHeight: element.scrollHeight, clientHeight: element.clientHeight },
3715
+ });
3716
+ }
3717
+ if (["BUTTON", "A", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName) && rect.width > 0 && rect.height > 0 && (rect.width < minTapTargetPx || rect.height < minTapTargetPx)) {
3718
+ issues.push({
3719
+ kind: "small-tap-target",
3720
+ selector,
3721
+ message: `Tap target is smaller than ${minTapTargetPx}px.`,
3722
+ detail: { width: Math.round(rect.width), height: Math.round(rect.height), minTapTargetPx },
3723
+ });
3724
+ }
3725
+ }
3726
+ return {
3727
+ rootSelector,
3728
+ viewportWidth,
3729
+ bodyScrollWidth,
3730
+ rootScrollWidth,
3731
+ rootClientWidth,
3732
+ issues,
3733
+ hasHorizontalOverflow: issues.some((issue) => issue.kind === "horizontal-overflow"),
3734
+ hasNestedScroll: issues.some((issue) => issue.kind === "nested-scroll"),
3735
+ hasSmallTapTargets: issues.some((issue) => issue.kind === "small-tap-target"),
3736
+ ok: issues.length === 0,
3737
+ };
3738
+ }, options);
3739
+ }
3740
+ export async function assertLiveFormChangeCascade(page, options) {
3741
+ const transaction = await performLiveFormFieldTransaction(page, {
3742
+ ...(options.trigger.transaction ?? {}),
3743
+ write: {
3744
+ ...(options.trigger.write ?? {}),
3745
+ target: options.trigger.target,
3746
+ value: options.trigger.value,
3747
+ notify: options.trigger.write?.notify ?? true,
3748
+ },
3749
+ ...(options.trigger.fieldKind === undefined ? {} : { fieldKind: options.trigger.fieldKind }),
3750
+ });
3751
+ const values = [];
3752
+ for (const expected of options.expectValues ?? []) {
3753
+ values.push(await waitForLiveFormFieldValue(page, expected));
3754
+ }
3755
+ const visibilityTargets = [
3756
+ ...(options.expectVisible ?? []).map((target) => ({ ...target, visible: true })),
3757
+ ...(options.expectHidden ?? []).map((target) => ({ ...target, visible: false })),
3758
+ ];
3759
+ const settled = await waitForLiveFormLogicSettled(page, {
3760
+ quiet: options.quiet ?? false,
3761
+ ...(visibilityTargets.length === 0 ? {} : { visibility: { targets: visibilityTargets } }),
3762
+ ...(options.validation === undefined ? {} : { validation: options.validation }),
3763
+ });
3764
+ if (!settled.settled || values.some((value) => value.valueMatched === false)) {
3765
+ throw new Error("Expected live form change cascade did not settle into the requested state.");
3766
+ }
3767
+ return { transaction, values, settled };
3768
+ }
3769
+ export async function runFormstackIframePrefillSmoke(page, options) {
3770
+ const bridge = await sendFormstackIframePrefillMessage(page, options);
3771
+ const responseData = bridge.response.record?.data;
3772
+ const responseOk = Boolean(responseData && typeof responseData === "object" && Reflect.get(responseData, "ok") === true);
3773
+ const ok = bridge.posted && bridge.response.matched && (options.expectResponseOk === false || responseOk);
3774
+ return { bridge, ok };
3775
+ }
3449
3776
  export async function assertLiveFormConditionalLogic(page, options) {
3450
3777
  const transaction = await performLiveFormFieldTransaction(page, {
3451
3778
  ...(options.trigger.transaction ?? {}),