@redrockswebdevelopment/formstack-form-testing 0.1.19 → 0.1.21
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 +232 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +577 -1
- 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
|
});
|
|
@@ -3384,7 +3385,7 @@ export function createLiveFormSampleFillPlan(definition, sampleValues, options =
|
|
|
3384
3385
|
const entry = {
|
|
3385
3386
|
key,
|
|
3386
3387
|
target,
|
|
3387
|
-
value: samples[key],
|
|
3388
|
+
value: normalizeLiveFormSampleValue(config.kind, samples[key]),
|
|
3388
3389
|
...(config.kind === undefined ? {} : { fieldKind: config.kind }),
|
|
3389
3390
|
...(config.required === undefined ? {} : { required: config.required }),
|
|
3390
3391
|
};
|
|
@@ -3430,6 +3431,41 @@ function createSampleSmokeReadinessOptions(definition, options, defaultEnabled)
|
|
|
3430
3431
|
}
|
|
3431
3432
|
return createLiveFormReadinessOptions(definition, { ...options, requiredOnly: true });
|
|
3432
3433
|
}
|
|
3434
|
+
function stableSerializeLiveFormValue(value) {
|
|
3435
|
+
if (value === undefined)
|
|
3436
|
+
return "undefined";
|
|
3437
|
+
const normalize = (entry) => {
|
|
3438
|
+
if (Array.isArray(entry))
|
|
3439
|
+
return entry.map(normalize);
|
|
3440
|
+
if (entry && typeof entry === "object") {
|
|
3441
|
+
return Object.fromEntries(Object.entries(entry).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, normalize(child)]));
|
|
3442
|
+
}
|
|
3443
|
+
return entry;
|
|
3444
|
+
};
|
|
3445
|
+
return JSON.stringify(normalize(value));
|
|
3446
|
+
}
|
|
3447
|
+
export function normalizeLiveFormSampleValue(kind = "unknown", value) {
|
|
3448
|
+
if (value instanceof Date)
|
|
3449
|
+
return value.toISOString().slice(0, 10);
|
|
3450
|
+
if (["radio", "rating", "select"].includes(kind) && typeof value === "string") {
|
|
3451
|
+
return { value };
|
|
3452
|
+
}
|
|
3453
|
+
if (kind === "checkbox" && Array.isArray(value)) {
|
|
3454
|
+
return { values: value };
|
|
3455
|
+
}
|
|
3456
|
+
if (kind === "checkbox" && typeof value === "boolean") {
|
|
3457
|
+
return { checked: value };
|
|
3458
|
+
}
|
|
3459
|
+
return value;
|
|
3460
|
+
}
|
|
3461
|
+
export function createLiveFormFieldValueAdapter(kind = "unknown") {
|
|
3462
|
+
return {
|
|
3463
|
+
kind,
|
|
3464
|
+
normalize: (value) => normalizeLiveFormSampleValue(kind, value),
|
|
3465
|
+
isEmpty: (value) => isLiveFormFieldValueEmpty(normalizeLiveFormSampleValue(kind, value)),
|
|
3466
|
+
equals: (actual, expected) => stableSerializeLiveFormValue(normalizeLiveFormSampleValue(kind, actual)) === stableSerializeLiveFormValue(normalizeLiveFormSampleValue(kind, expected)),
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3433
3469
|
export async function runLiveFormSampleSmoke(page, definition, sampleValues, options = {}) {
|
|
3434
3470
|
const beforeReadinessOptions = createSampleSmokeReadinessOptions(definition, options.beforeReadiness, false);
|
|
3435
3471
|
const afterReadinessOptions = createSampleSmokeReadinessOptions(definition, options.afterReadiness, true);
|
|
@@ -3454,6 +3490,546 @@ export async function runLiveFormSampleSmoke(page, definition, sampleValues, opt
|
|
|
3454
3490
|
ok: (quiet?.quiet ?? true) && (afterReadiness?.isReady ?? true) && ((validation?.inlineErrors.length ?? 0) === 0) && !(coverage?.hasUnknownFields ?? false),
|
|
3455
3491
|
};
|
|
3456
3492
|
}
|
|
3493
|
+
function sampleValueShapeIssue(key, kind, value) {
|
|
3494
|
+
const normalizedValue = normalizeLiveFormSampleValue(kind, value);
|
|
3495
|
+
const objectValue = value && typeof value === "object" ? value : null;
|
|
3496
|
+
const normalizedObjectValue = normalizedValue && typeof normalizedValue === "object" ? normalizedValue : null;
|
|
3497
|
+
if (["radio", "select", "rating"].includes(kind ?? "") && (normalizedObjectValue === null || typeof normalizedObjectValue.value !== "string")) {
|
|
3498
|
+
return {
|
|
3499
|
+
kind: "invalid-value-shape",
|
|
3500
|
+
key,
|
|
3501
|
+
message: `Sample value for ${key} should be an object with a string value property.`,
|
|
3502
|
+
detail: { kind },
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3505
|
+
if (["checkbox", "matrix"].includes(kind ?? "") && normalizedObjectValue === null) {
|
|
3506
|
+
return {
|
|
3507
|
+
kind: "invalid-value-shape",
|
|
3508
|
+
key,
|
|
3509
|
+
message: `Sample value for ${key} should be an object payload for ${kind} fields.`,
|
|
3510
|
+
detail: { kind },
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
if (kind === "name" && objectValue === null) {
|
|
3514
|
+
return {
|
|
3515
|
+
kind: "invalid-value-shape",
|
|
3516
|
+
key,
|
|
3517
|
+
message: `Sample value for ${key} should be an object with name subfields.`,
|
|
3518
|
+
detail: { kind },
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
return null;
|
|
3522
|
+
}
|
|
3523
|
+
export function validateLiveFormSampleProfile(definition, sampleValues, options = {}) {
|
|
3524
|
+
const samples = sampleValues;
|
|
3525
|
+
const issues = [];
|
|
3526
|
+
const unknownKeys = [];
|
|
3527
|
+
const missingRequiredKeys = [];
|
|
3528
|
+
const duplicateTargetKeys = [];
|
|
3529
|
+
const targetOwners = new Map();
|
|
3530
|
+
for (const key of Object.keys(samples)) {
|
|
3531
|
+
if (!(key in definition)) {
|
|
3532
|
+
unknownKeys.push(key);
|
|
3533
|
+
issues.push({ kind: "unknown-sample-key", key, message: `Sample value key "${key}" does not exist in the field definition.` });
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
for (const [key, config] of Object.entries(definition)) {
|
|
3537
|
+
const target = targetFromHandleConfig(config);
|
|
3538
|
+
const targetKey = liveFormFieldTargetKey(target);
|
|
3539
|
+
if (targetKey) {
|
|
3540
|
+
const owner = targetOwners.get(targetKey);
|
|
3541
|
+
if (owner && Object.hasOwn(samples, key) && Object.hasOwn(samples, owner) && options.allowDuplicateTargets !== true) {
|
|
3542
|
+
duplicateTargetKeys.push(key);
|
|
3543
|
+
issues.push({
|
|
3544
|
+
kind: "duplicate-target",
|
|
3545
|
+
key,
|
|
3546
|
+
target,
|
|
3547
|
+
message: `Sample value key "${key}" points to the same generated target as "${owner}".`,
|
|
3548
|
+
detail: { originalKey: owner },
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
else if (!owner) {
|
|
3552
|
+
targetOwners.set(targetKey, key);
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
if (config.required === true && !Object.hasOwn(samples, key)) {
|
|
3556
|
+
missingRequiredKeys.push(key);
|
|
3557
|
+
issues.push({ kind: "missing-required-sample", key, target, message: `Required field "${key}" does not have a sample value.` });
|
|
3558
|
+
}
|
|
3559
|
+
if (!Object.hasOwn(samples, key))
|
|
3560
|
+
continue;
|
|
3561
|
+
if (options.requiredOnly === true && config.required !== true)
|
|
3562
|
+
continue;
|
|
3563
|
+
const value = normalizeLiveFormSampleValue(config.kind, samples[key]);
|
|
3564
|
+
const shapeIssue = sampleValueShapeIssue(key, config.kind, value);
|
|
3565
|
+
if (shapeIssue)
|
|
3566
|
+
issues.push({ ...shapeIssue, target });
|
|
3567
|
+
const allowedValues = options.optionValues?.[key];
|
|
3568
|
+
if (!allowedValues || !value || typeof value !== "object")
|
|
3569
|
+
continue;
|
|
3570
|
+
const valueObject = value;
|
|
3571
|
+
if (typeof valueObject.value === "string" && !allowedValues.includes(valueObject.value)) {
|
|
3572
|
+
issues.push({
|
|
3573
|
+
kind: "invalid-option-value",
|
|
3574
|
+
key,
|
|
3575
|
+
target,
|
|
3576
|
+
message: `Sample value "${valueObject.value}" is not in the allowed option values for "${key}".`,
|
|
3577
|
+
detail: { allowedValues },
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
if (Array.isArray(valueObject.values)) {
|
|
3581
|
+
const invalid = valueObject.values.filter((entry) => typeof entry === "string" && !allowedValues.includes(entry));
|
|
3582
|
+
if (invalid.length > 0) {
|
|
3583
|
+
issues.push({
|
|
3584
|
+
kind: "invalid-option-values",
|
|
3585
|
+
key,
|
|
3586
|
+
target,
|
|
3587
|
+
message: `Sample values for "${key}" include values outside the allowed options: ${invalid.join(", ")}.`,
|
|
3588
|
+
detail: { allowedValues, invalid },
|
|
3589
|
+
});
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
return {
|
|
3594
|
+
issues,
|
|
3595
|
+
unknownKeys,
|
|
3596
|
+
missingRequiredKeys,
|
|
3597
|
+
duplicateTargetKeys,
|
|
3598
|
+
ok: issues.length === 0,
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
export function createLiveFormProgressiveSamplePlan(definition, sampleValues, options = {}) {
|
|
3602
|
+
const basePlan = createLiveFormSampleFillPlan(definition, sampleValues, options);
|
|
3603
|
+
const configuredSteps = new Map((options.steps ?? []).map((step) => [step.key, step]));
|
|
3604
|
+
const orderedKeys = options.steps?.map((step) => step.key) ?? basePlan.map((entry) => entry.key);
|
|
3605
|
+
const baseByKey = new Map(basePlan.map((entry) => [entry.key, entry]));
|
|
3606
|
+
const plan = [];
|
|
3607
|
+
for (const key of orderedKeys) {
|
|
3608
|
+
const base = baseByKey.get(key);
|
|
3609
|
+
if (!base)
|
|
3610
|
+
continue;
|
|
3611
|
+
const step = configuredSteps.get(key);
|
|
3612
|
+
plan.push({
|
|
3613
|
+
...base,
|
|
3614
|
+
...(step?.expectVisibleAfter === undefined ? {} : { expectVisibleAfter: step.expectVisibleAfter }),
|
|
3615
|
+
...(step?.expectHiddenAfter === undefined ? {} : { expectHiddenAfter: step.expectHiddenAfter }),
|
|
3616
|
+
...(step?.expectResetAfter === undefined ? {} : { expectResetAfter: step.expectResetAfter }),
|
|
3617
|
+
...(step?.continueSelector === undefined ? {} : { continueSelector: step.continueSelector }),
|
|
3618
|
+
...(step?.expectVisibleAfterContinue === undefined ? {} : { expectVisibleAfterContinue: step.expectVisibleAfterContinue }),
|
|
3619
|
+
...(step?.expectHiddenAfterContinue === undefined ? {} : { expectHiddenAfterContinue: step.expectHiddenAfterContinue }),
|
|
3620
|
+
...(step?.transaction === undefined ? {} : { transaction: step.transaction }),
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
return plan;
|
|
3624
|
+
}
|
|
3625
|
+
export async function runLiveFormProgressiveSampleFlow(page, definition, sampleValues, options = {}) {
|
|
3626
|
+
const plan = createLiveFormProgressiveSamplePlan(definition, sampleValues, options);
|
|
3627
|
+
const answers = [];
|
|
3628
|
+
for (const step of plan) {
|
|
3629
|
+
answers.push(await answerProgressiveLiveFormField(page, {
|
|
3630
|
+
...(options.defaultTransaction ?? {}),
|
|
3631
|
+
...(step.transaction ?? {}),
|
|
3632
|
+
target: step.target,
|
|
3633
|
+
value: step.value,
|
|
3634
|
+
...(step.fieldKind === undefined ? {} : { fieldKind: step.fieldKind }),
|
|
3635
|
+
...(step.expectVisibleAfter === undefined ? {} : { expectVisible: step.expectVisibleAfter.map((target) => ({ ...target, visible: true })) }),
|
|
3636
|
+
...(step.expectHiddenAfter === undefined ? {} : { expectHidden: step.expectHiddenAfter }),
|
|
3637
|
+
...(step.expectResetAfter === undefined ? {} : { expectReset: step.expectResetAfter }),
|
|
3638
|
+
}));
|
|
3639
|
+
if (step.continueSelector) {
|
|
3640
|
+
await page.locator(step.continueSelector).first().click();
|
|
3641
|
+
const continueVisibilityTargets = [
|
|
3642
|
+
...(step.expectVisibleAfterContinue ?? []).map((target) => ({ ...target, visible: true })),
|
|
3643
|
+
...(step.expectHiddenAfterContinue ?? []).map((target) => ({ ...target, visible: false })),
|
|
3644
|
+
];
|
|
3645
|
+
if (continueVisibilityTargets.length > 0) {
|
|
3646
|
+
const visibility = await waitForLiveFormVisibility(page, { targets: continueVisibilityTargets });
|
|
3647
|
+
if (!visibility.matched) {
|
|
3648
|
+
throw new Error(`Progressive step "${step.key}" continue action did not settle into the requested visibility state.`);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
|
|
3654
|
+
const readinessOptions = createSampleSmokeReadinessOptions(definition, options.afterReadiness, false);
|
|
3655
|
+
const validationOptions = options.afterValidation === false ? undefined : options.afterValidation === true ? { expectNoErrors: true } : options.afterValidation;
|
|
3656
|
+
const quiet = quietOptions ? await waitForLiveFormQuiet(page, quietOptions) : undefined;
|
|
3657
|
+
const readiness = readinessOptions ? await waitForLiveFormReady(page, readinessOptions) : undefined;
|
|
3658
|
+
const validation = validationOptions ? await waitForLiveFormValidationSettled(page, validationOptions) : undefined;
|
|
3659
|
+
return {
|
|
3660
|
+
plan,
|
|
3661
|
+
answers,
|
|
3662
|
+
...(quiet ? { quiet } : {}),
|
|
3663
|
+
...(readiness ? { readiness } : {}),
|
|
3664
|
+
...(validation ? { validation } : {}),
|
|
3665
|
+
ok: answers.every((answer) => (answer.visibility?.matched ?? true) &&
|
|
3666
|
+
answer.resetValues.every((value) => value.valueMatched !== false) &&
|
|
3667
|
+
((answer.validation?.inlineErrors.length ?? 0) === 0)) && (quiet?.quiet ?? true) && (readiness?.isReady ?? true) && ((validation?.inlineErrors.length ?? 0) === 0),
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
3670
|
+
export async function diagnoseLiveFormSubmitGate(page, options = {}) {
|
|
3671
|
+
if (!hasEvaluate(page)) {
|
|
3672
|
+
throw new Error("diagnoseLiveFormSubmitGate requires a Playwright page with evaluate().");
|
|
3673
|
+
}
|
|
3674
|
+
const selector = options.selector ?? ".fsSubmitButton, [type=submit], .fsSubmit";
|
|
3675
|
+
const dom = await page.evaluate((submitSelector) => {
|
|
3676
|
+
const element = document.querySelector(submitSelector);
|
|
3677
|
+
if (!(element instanceof HTMLElement)) {
|
|
3678
|
+
return {
|
|
3679
|
+
exists: false,
|
|
3680
|
+
visible: null,
|
|
3681
|
+
disabled: null,
|
|
3682
|
+
ariaDisabled: null,
|
|
3683
|
+
dataReady: null,
|
|
3684
|
+
hiddenReason: "missing",
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
const style = getComputedStyle(element);
|
|
3688
|
+
const rect = element.getBoundingClientRect();
|
|
3689
|
+
const disabled = "disabled" in element ? Boolean(element.disabled) : element.getAttribute("disabled") !== null;
|
|
3690
|
+
const ariaDisabled = element.getAttribute("aria-disabled") === "true";
|
|
3691
|
+
const dataReady = element.getAttribute("data-chat-submit-ready") ?? element.getAttribute("data-submit-ready");
|
|
3692
|
+
const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
|
|
3693
|
+
const hiddenReason = visible ? null : style.display === "none" ? "display-none" : style.visibility === "hidden" ? "visibility-hidden" : rect.width <= 0 || rect.height <= 0 ? "zero-size" : "transparent";
|
|
3694
|
+
return { exists: true, visible, disabled, ariaDisabled, dataReady, hiddenReason };
|
|
3695
|
+
}, selector);
|
|
3696
|
+
const readiness = options.readiness ? await readLiveFormReadiness(page, options.readiness) : undefined;
|
|
3697
|
+
const validation = options.validation ? await readLiveFormValidationState(page, typeof options.validation === "object" ? options.validation : {}) : undefined;
|
|
3698
|
+
const blockingReasons = [];
|
|
3699
|
+
if (!dom.exists)
|
|
3700
|
+
blockingReasons.push("submit-control-missing");
|
|
3701
|
+
if (dom.visible === false)
|
|
3702
|
+
blockingReasons.push(`submit-control-hidden:${dom.hiddenReason ?? "unknown"}`);
|
|
3703
|
+
if (dom.disabled === true)
|
|
3704
|
+
blockingReasons.push("submit-control-disabled");
|
|
3705
|
+
if (dom.ariaDisabled === true)
|
|
3706
|
+
blockingReasons.push("submit-control-aria-disabled");
|
|
3707
|
+
if (dom.dataReady === "false")
|
|
3708
|
+
blockingReasons.push("submit-control-data-ready-false");
|
|
3709
|
+
if (readiness && !readiness.isReady)
|
|
3710
|
+
blockingReasons.push(`readiness-blocking-fields:${readiness.blockingFieldCount}`);
|
|
3711
|
+
if (validation && validation.inlineErrors.length > 0)
|
|
3712
|
+
blockingReasons.push(`validation-errors:${validation.inlineErrors.length}`);
|
|
3713
|
+
const blockingFields = readiness?.fields.filter((field) => field.isBlocking) ?? [];
|
|
3714
|
+
const validationErrors = validation?.inlineErrors ?? [];
|
|
3715
|
+
return {
|
|
3716
|
+
selector,
|
|
3717
|
+
...dom,
|
|
3718
|
+
blockingReasons,
|
|
3719
|
+
blockingFields,
|
|
3720
|
+
validationErrors,
|
|
3721
|
+
...(readiness ? { readiness } : {}),
|
|
3722
|
+
...(validation ? { validation } : {}),
|
|
3723
|
+
ready: blockingReasons.length === 0,
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
export async function diagnoseLiveFormLayout(page, options = {}) {
|
|
3727
|
+
if (!hasEvaluate(page)) {
|
|
3728
|
+
throw new Error("diagnoseLiveFormLayout requires a Playwright page with evaluate().");
|
|
3729
|
+
}
|
|
3730
|
+
return page.evaluate((layoutOptions) => {
|
|
3731
|
+
const rootSelector = layoutOptions.rootSelector ?? "body";
|
|
3732
|
+
const minTapTargetPx = layoutOptions.minTapTargetPx ?? 44;
|
|
3733
|
+
const root = document.querySelector(rootSelector);
|
|
3734
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
3735
|
+
const bodyScrollWidth = document.documentElement.scrollWidth;
|
|
3736
|
+
const rootScrollWidth = root instanceof HTMLElement ? root.scrollWidth : null;
|
|
3737
|
+
const rootClientWidth = root instanceof HTMLElement ? root.clientWidth : null;
|
|
3738
|
+
const issues = [];
|
|
3739
|
+
if (bodyScrollWidth > viewportWidth + 1) {
|
|
3740
|
+
issues.push({
|
|
3741
|
+
kind: "horizontal-overflow",
|
|
3742
|
+
selector: "html",
|
|
3743
|
+
message: `Document scroll width ${bodyScrollWidth}px exceeds viewport width ${viewportWidth}px.`,
|
|
3744
|
+
detail: { bodyScrollWidth, viewportWidth },
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
if (rootScrollWidth !== null && rootClientWidth !== null && rootScrollWidth > rootClientWidth + 1) {
|
|
3748
|
+
issues.push({
|
|
3749
|
+
kind: "horizontal-overflow",
|
|
3750
|
+
selector: rootSelector,
|
|
3751
|
+
message: `Root scroll width ${rootScrollWidth}px exceeds client width ${rootClientWidth}px.`,
|
|
3752
|
+
detail: { rootScrollWidth, rootClientWidth },
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
const scrollCandidates = Array.from(document.querySelectorAll(layoutOptions.includeSelectors?.join(",") ?? `${rootSelector} *`));
|
|
3756
|
+
for (const element of scrollCandidates) {
|
|
3757
|
+
if (!(element instanceof HTMLElement))
|
|
3758
|
+
continue;
|
|
3759
|
+
const style = getComputedStyle(element);
|
|
3760
|
+
const rect = element.getBoundingClientRect();
|
|
3761
|
+
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();
|
|
3762
|
+
if ((style.overflowY === "auto" || style.overflowY === "scroll") && element.scrollHeight > element.clientHeight + 1) {
|
|
3763
|
+
issues.push({
|
|
3764
|
+
kind: "nested-scroll",
|
|
3765
|
+
selector,
|
|
3766
|
+
message: "Element has internal vertical scrolling.",
|
|
3767
|
+
detail: { scrollHeight: element.scrollHeight, clientHeight: element.clientHeight },
|
|
3768
|
+
});
|
|
3769
|
+
}
|
|
3770
|
+
if (["BUTTON", "A", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName) && rect.width > 0 && rect.height > 0 && (rect.width < minTapTargetPx || rect.height < minTapTargetPx)) {
|
|
3771
|
+
issues.push({
|
|
3772
|
+
kind: "small-tap-target",
|
|
3773
|
+
selector,
|
|
3774
|
+
message: `Tap target is smaller than ${minTapTargetPx}px.`,
|
|
3775
|
+
detail: { width: Math.round(rect.width), height: Math.round(rect.height), minTapTargetPx },
|
|
3776
|
+
});
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
return {
|
|
3780
|
+
rootSelector,
|
|
3781
|
+
viewportWidth,
|
|
3782
|
+
bodyScrollWidth,
|
|
3783
|
+
rootScrollWidth,
|
|
3784
|
+
rootClientWidth,
|
|
3785
|
+
issues,
|
|
3786
|
+
hasHorizontalOverflow: issues.some((issue) => issue.kind === "horizontal-overflow"),
|
|
3787
|
+
hasNestedScroll: issues.some((issue) => issue.kind === "nested-scroll"),
|
|
3788
|
+
hasSmallTapTargets: issues.some((issue) => issue.kind === "small-tap-target"),
|
|
3789
|
+
ok: issues.length === 0,
|
|
3790
|
+
};
|
|
3791
|
+
}, options);
|
|
3792
|
+
}
|
|
3793
|
+
export async function diagnoseLiveFormIframeLayout(page, options) {
|
|
3794
|
+
if (!hasEvaluate(page)) {
|
|
3795
|
+
throw new Error("diagnoseLiveFormIframeLayout requires a Playwright page with evaluate().");
|
|
3796
|
+
}
|
|
3797
|
+
return page.evaluate((layoutOptions) => {
|
|
3798
|
+
const iframe = document.querySelector(layoutOptions.iframeSelector);
|
|
3799
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
3800
|
+
const viewportHeight = document.documentElement.clientHeight;
|
|
3801
|
+
const issues = [];
|
|
3802
|
+
if (!(iframe instanceof HTMLElement)) {
|
|
3803
|
+
return {
|
|
3804
|
+
iframeSelector: layoutOptions.iframeSelector,
|
|
3805
|
+
exists: false,
|
|
3806
|
+
viewportWidth,
|
|
3807
|
+
viewportHeight,
|
|
3808
|
+
rect: null,
|
|
3809
|
+
issues: [{
|
|
3810
|
+
kind: "horizontal-overflow",
|
|
3811
|
+
selector: layoutOptions.iframeSelector,
|
|
3812
|
+
message: "Iframe element was not found.",
|
|
3813
|
+
}],
|
|
3814
|
+
ok: false,
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
const rect = iframe.getBoundingClientRect();
|
|
3818
|
+
if (rect.left < -1 || rect.right > viewportWidth + 1) {
|
|
3819
|
+
issues.push({
|
|
3820
|
+
kind: "horizontal-overflow",
|
|
3821
|
+
selector: layoutOptions.iframeSelector,
|
|
3822
|
+
message: "Iframe extends outside the viewport horizontally.",
|
|
3823
|
+
detail: { left: Math.round(rect.left), right: Math.round(rect.right), viewportWidth },
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
if (layoutOptions.expectedMaxWidth !== undefined && rect.width > layoutOptions.expectedMaxWidth + 1) {
|
|
3827
|
+
issues.push({
|
|
3828
|
+
kind: "horizontal-overflow",
|
|
3829
|
+
selector: layoutOptions.iframeSelector,
|
|
3830
|
+
message: `Iframe width ${Math.round(rect.width)}px exceeds expected maximum ${layoutOptions.expectedMaxWidth}px.`,
|
|
3831
|
+
detail: { width: Math.round(rect.width), expectedMaxWidth: layoutOptions.expectedMaxWidth },
|
|
3832
|
+
});
|
|
3833
|
+
}
|
|
3834
|
+
if (layoutOptions.expectedMaxHeight !== undefined && rect.height > layoutOptions.expectedMaxHeight + 1) {
|
|
3835
|
+
issues.push({
|
|
3836
|
+
kind: "nested-scroll",
|
|
3837
|
+
selector: layoutOptions.iframeSelector,
|
|
3838
|
+
message: `Iframe height ${Math.round(rect.height)}px exceeds expected maximum ${layoutOptions.expectedMaxHeight}px.`,
|
|
3839
|
+
detail: { height: Math.round(rect.height), expectedMaxHeight: layoutOptions.expectedMaxHeight },
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
return {
|
|
3843
|
+
iframeSelector: layoutOptions.iframeSelector,
|
|
3844
|
+
exists: true,
|
|
3845
|
+
viewportWidth,
|
|
3846
|
+
viewportHeight,
|
|
3847
|
+
rect: { width: Math.round(rect.width), height: Math.round(rect.height), top: Math.round(rect.top), left: Math.round(rect.left) },
|
|
3848
|
+
issues,
|
|
3849
|
+
ok: issues.length === 0,
|
|
3850
|
+
};
|
|
3851
|
+
}, options);
|
|
3852
|
+
}
|
|
3853
|
+
function endCascadePhase(name, startedAt, ok) {
|
|
3854
|
+
const endedAt = Date.now();
|
|
3855
|
+
return { name, startedAt, endedAt, durationMs: endedAt - startedAt, ok };
|
|
3856
|
+
}
|
|
3857
|
+
export async function assertLiveFormChangeCascade(page, options) {
|
|
3858
|
+
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3859
|
+
...(options.trigger.transaction ?? {}),
|
|
3860
|
+
write: {
|
|
3861
|
+
...(options.trigger.write ?? {}),
|
|
3862
|
+
target: options.trigger.target,
|
|
3863
|
+
value: options.trigger.value,
|
|
3864
|
+
notify: options.trigger.write?.notify ?? true,
|
|
3865
|
+
},
|
|
3866
|
+
...(options.trigger.fieldKind === undefined ? {} : { fieldKind: options.trigger.fieldKind }),
|
|
3867
|
+
});
|
|
3868
|
+
const values = [];
|
|
3869
|
+
for (const expected of options.expectValues ?? []) {
|
|
3870
|
+
values.push(await waitForLiveFormFieldValue(page, expected));
|
|
3871
|
+
}
|
|
3872
|
+
const visibilityTargets = [
|
|
3873
|
+
...(options.expectVisible ?? []).map((target) => ({ ...target, visible: true })),
|
|
3874
|
+
...(options.expectHidden ?? []).map((target) => ({ ...target, visible: false })),
|
|
3875
|
+
];
|
|
3876
|
+
const settled = await waitForLiveFormLogicSettled(page, {
|
|
3877
|
+
quiet: options.quiet ?? false,
|
|
3878
|
+
...(visibilityTargets.length === 0 ? {} : { visibility: { targets: visibilityTargets } }),
|
|
3879
|
+
...(options.validation === undefined ? {} : { validation: options.validation }),
|
|
3880
|
+
});
|
|
3881
|
+
if (!settled.settled || values.some((value) => value.valueMatched === false)) {
|
|
3882
|
+
throw new Error("Expected live form change cascade did not settle into the requested state.");
|
|
3883
|
+
}
|
|
3884
|
+
return { transaction, values, settled };
|
|
3885
|
+
}
|
|
3886
|
+
export async function traceLiveFormChangeCascade(page, options) {
|
|
3887
|
+
const totalStartedAt = Date.now();
|
|
3888
|
+
const phases = [];
|
|
3889
|
+
const writeStartedAt = Date.now();
|
|
3890
|
+
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3891
|
+
...(options.trigger.transaction ?? {}),
|
|
3892
|
+
write: {
|
|
3893
|
+
...(options.trigger.write ?? {}),
|
|
3894
|
+
target: options.trigger.target,
|
|
3895
|
+
value: options.trigger.value,
|
|
3896
|
+
notify: options.trigger.write?.notify ?? true,
|
|
3897
|
+
},
|
|
3898
|
+
...(options.trigger.fieldKind === undefined ? {} : { fieldKind: options.trigger.fieldKind }),
|
|
3899
|
+
});
|
|
3900
|
+
phases.push(endCascadePhase("write", writeStartedAt, transaction.write.valueMatched !== false));
|
|
3901
|
+
const values = [];
|
|
3902
|
+
const valuesStartedAt = Date.now();
|
|
3903
|
+
for (const expected of options.expectValues ?? []) {
|
|
3904
|
+
values.push(await waitForLiveFormFieldValue(page, expected));
|
|
3905
|
+
}
|
|
3906
|
+
phases.push(endCascadePhase("values", valuesStartedAt, values.every((value) => value.valueMatched !== false)));
|
|
3907
|
+
const visibilityTargets = [
|
|
3908
|
+
...(options.expectVisible ?? []).map((target) => ({ ...target, visible: true })),
|
|
3909
|
+
...(options.expectHidden ?? []).map((target) => ({ ...target, visible: false })),
|
|
3910
|
+
];
|
|
3911
|
+
const settleStartedAt = Date.now();
|
|
3912
|
+
const settled = await waitForLiveFormLogicSettled(page, {
|
|
3913
|
+
quiet: options.quiet ?? false,
|
|
3914
|
+
...(visibilityTargets.length === 0 ? {} : { visibility: { targets: visibilityTargets } }),
|
|
3915
|
+
...(options.validation === undefined ? {} : { validation: options.validation }),
|
|
3916
|
+
});
|
|
3917
|
+
phases.push(endCascadePhase("settled", settleStartedAt, settled.settled));
|
|
3918
|
+
if (!settled.settled || values.some((value) => value.valueMatched === false)) {
|
|
3919
|
+
throw new Error("Expected live form change cascade did not settle into the requested state.");
|
|
3920
|
+
}
|
|
3921
|
+
const endedAt = Date.now();
|
|
3922
|
+
return {
|
|
3923
|
+
label: options.label ?? "live-form-change-cascade",
|
|
3924
|
+
transaction,
|
|
3925
|
+
values,
|
|
3926
|
+
settled,
|
|
3927
|
+
phases,
|
|
3928
|
+
totalMs: endedAt - totalStartedAt,
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
export async function runLiveFormRapidFieldChangeSequence(page, options) {
|
|
3932
|
+
const transactions = [];
|
|
3933
|
+
for (const write of options.writes) {
|
|
3934
|
+
transactions.push(await performLiveFormFieldTransaction(page, {
|
|
3935
|
+
...(write.transaction ?? {}),
|
|
3936
|
+
write: {
|
|
3937
|
+
...(write.write ?? {}),
|
|
3938
|
+
target: write.target,
|
|
3939
|
+
value: write.value,
|
|
3940
|
+
notify: write.write?.notify ?? true,
|
|
3941
|
+
},
|
|
3942
|
+
...(write.fieldKind === undefined ? {} : { fieldKind: write.fieldKind }),
|
|
3943
|
+
}));
|
|
3944
|
+
}
|
|
3945
|
+
const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
|
|
3946
|
+
const validationOptions = options.afterValidation === false ? undefined : options.afterValidation === true ? { expectNoErrors: true } : options.afterValidation;
|
|
3947
|
+
const quiet = quietOptions ? await waitForLiveFormQuiet(page, quietOptions) : undefined;
|
|
3948
|
+
const validation = validationOptions ? await waitForLiveFormValidationSettled(page, validationOptions) : undefined;
|
|
3949
|
+
const finalByTarget = new Map();
|
|
3950
|
+
for (const write of options.writes) {
|
|
3951
|
+
finalByTarget.set(liveFormFieldTargetKey(write.target) ?? JSON.stringify(write.target), { target: write.target, value: write.value });
|
|
3952
|
+
}
|
|
3953
|
+
const finalValues = [];
|
|
3954
|
+
for (const expected of finalByTarget.values()) {
|
|
3955
|
+
finalValues.push(await waitForLiveFormFieldValue(page, { target: expected.target, expectedValue: expected.value }));
|
|
3956
|
+
}
|
|
3957
|
+
return {
|
|
3958
|
+
transactions,
|
|
3959
|
+
...(quiet ? { quiet } : {}),
|
|
3960
|
+
...(validation ? { validation } : {}),
|
|
3961
|
+
finalValues,
|
|
3962
|
+
ok: transactions.every((transaction) => transaction.write.valueMatched !== false) &&
|
|
3963
|
+
(quiet?.quiet ?? true) &&
|
|
3964
|
+
((validation?.inlineErrors.length ?? 0) === 0) &&
|
|
3965
|
+
finalValues.every((value) => value.valueMatched !== false),
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
export async function waitForLiveFormRerenderSettled(page, options = {}) {
|
|
3969
|
+
if (!hasEvaluate(page)) {
|
|
3970
|
+
throw new Error("waitForLiveFormRerenderSettled requires a Playwright page with evaluate().");
|
|
3971
|
+
}
|
|
3972
|
+
return page.evaluate((rerenderOptions) => new Promise((resolve) => {
|
|
3973
|
+
const rootSelector = rerenderOptions.rootSelector ?? "body";
|
|
3974
|
+
const root = document.querySelector(rootSelector);
|
|
3975
|
+
const startedAt = Date.now();
|
|
3976
|
+
const quietMs = rerenderOptions.quietMs ?? 150;
|
|
3977
|
+
const timeoutMs = rerenderOptions.timeoutMs ?? 2000;
|
|
3978
|
+
let mutationCount = 0;
|
|
3979
|
+
let quietTimer;
|
|
3980
|
+
let timeoutTimer;
|
|
3981
|
+
let observer;
|
|
3982
|
+
const finish = (quiet) => {
|
|
3983
|
+
if (observer)
|
|
3984
|
+
observer.disconnect();
|
|
3985
|
+
if (quietTimer !== undefined)
|
|
3986
|
+
window.clearTimeout(quietTimer);
|
|
3987
|
+
if (timeoutTimer !== undefined)
|
|
3988
|
+
window.clearTimeout(timeoutTimer);
|
|
3989
|
+
const elapsedMs = Date.now() - startedAt;
|
|
3990
|
+
resolve({
|
|
3991
|
+
rootSelector,
|
|
3992
|
+
mutationCount,
|
|
3993
|
+
elapsedMs,
|
|
3994
|
+
quiet,
|
|
3995
|
+
ok: quiet && mutationCount >= (rerenderOptions.expectedMinMutations ?? 0),
|
|
3996
|
+
});
|
|
3997
|
+
};
|
|
3998
|
+
if (!root) {
|
|
3999
|
+
finish(false);
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
const armQuietTimer = () => {
|
|
4003
|
+
if (quietTimer !== undefined)
|
|
4004
|
+
window.clearTimeout(quietTimer);
|
|
4005
|
+
quietTimer = window.setTimeout(() => finish(true), quietMs);
|
|
4006
|
+
};
|
|
4007
|
+
observer = new MutationObserver((records) => {
|
|
4008
|
+
mutationCount += records.length;
|
|
4009
|
+
armQuietTimer();
|
|
4010
|
+
});
|
|
4011
|
+
observer.observe(root, { attributes: true, childList: true, subtree: true });
|
|
4012
|
+
timeoutTimer = window.setTimeout(() => finish(false), timeoutMs);
|
|
4013
|
+
armQuietTimer();
|
|
4014
|
+
}), options);
|
|
4015
|
+
}
|
|
4016
|
+
export async function runFormstackIframePrefillSmoke(page, options) {
|
|
4017
|
+
const bridge = await sendFormstackIframePrefillMessage(page, options);
|
|
4018
|
+
const responseData = bridge.response.record?.data;
|
|
4019
|
+
const responseOk = Boolean(responseData && typeof responseData === "object" && Reflect.get(responseData, "ok") === true);
|
|
4020
|
+
const ok = bridge.posted && bridge.response.matched && (options.expectResponseOk === false || responseOk);
|
|
4021
|
+
return { bridge, ok };
|
|
4022
|
+
}
|
|
4023
|
+
export async function runFormstackIframePrefillScenario(page, options) {
|
|
4024
|
+
const prefill = await runFormstackIframePrefillSmoke(page, options);
|
|
4025
|
+
const layoutOptions = options.layout === true ? { iframeSelector: options.iframeSelector } : options.layout || undefined;
|
|
4026
|
+
const layout = layoutOptions ? await diagnoseLiveFormIframeLayout(page, layoutOptions) : undefined;
|
|
4027
|
+
return {
|
|
4028
|
+
prefill,
|
|
4029
|
+
...(layout ? { layout } : {}),
|
|
4030
|
+
ok: prefill.ok && (layout?.ok ?? true),
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
3457
4033
|
export async function assertLiveFormConditionalLogic(page, options) {
|
|
3458
4034
|
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3459
4035
|
...(options.trigger.transaction ?? {}),
|