@redrockswebdevelopment/formstack-form-testing 0.1.19 → 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.d.ts +127 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +319 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
});
|
|
@@ -3454,6 +3455,324 @@ export async function runLiveFormSampleSmoke(page, definition, sampleValues, opt
|
|
|
3454
3455
|
ok: (quiet?.quiet ?? true) && (afterReadiness?.isReady ?? true) && ((validation?.inlineErrors.length ?? 0) === 0) && !(coverage?.hasUnknownFields ?? false),
|
|
3455
3456
|
};
|
|
3456
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
|
+
}
|
|
3457
3776
|
export async function assertLiveFormConditionalLogic(page, options) {
|
|
3458
3777
|
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3459
3778
|
...(options.trigger.transaction ?? {}),
|