@redrockswebdevelopment/formstack-form-testing 0.1.20 → 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 +105 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +261 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3385,7 +3385,7 @@ export function createLiveFormSampleFillPlan(definition, sampleValues, options =
|
|
|
3385
3385
|
const entry = {
|
|
3386
3386
|
key,
|
|
3387
3387
|
target,
|
|
3388
|
-
value: samples[key],
|
|
3388
|
+
value: normalizeLiveFormSampleValue(config.kind, samples[key]),
|
|
3389
3389
|
...(config.kind === undefined ? {} : { fieldKind: config.kind }),
|
|
3390
3390
|
...(config.required === undefined ? {} : { required: config.required }),
|
|
3391
3391
|
};
|
|
@@ -3431,6 +3431,41 @@ function createSampleSmokeReadinessOptions(definition, options, defaultEnabled)
|
|
|
3431
3431
|
}
|
|
3432
3432
|
return createLiveFormReadinessOptions(definition, { ...options, requiredOnly: true });
|
|
3433
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
|
+
}
|
|
3434
3469
|
export async function runLiveFormSampleSmoke(page, definition, sampleValues, options = {}) {
|
|
3435
3470
|
const beforeReadinessOptions = createSampleSmokeReadinessOptions(definition, options.beforeReadiness, false);
|
|
3436
3471
|
const afterReadinessOptions = createSampleSmokeReadinessOptions(definition, options.afterReadiness, true);
|
|
@@ -3456,8 +3491,10 @@ export async function runLiveFormSampleSmoke(page, definition, sampleValues, opt
|
|
|
3456
3491
|
};
|
|
3457
3492
|
}
|
|
3458
3493
|
function sampleValueShapeIssue(key, kind, value) {
|
|
3494
|
+
const normalizedValue = normalizeLiveFormSampleValue(kind, value);
|
|
3459
3495
|
const objectValue = value && typeof value === "object" ? value : null;
|
|
3460
|
-
|
|
3496
|
+
const normalizedObjectValue = normalizedValue && typeof normalizedValue === "object" ? normalizedValue : null;
|
|
3497
|
+
if (["radio", "select", "rating"].includes(kind ?? "") && (normalizedObjectValue === null || typeof normalizedObjectValue.value !== "string")) {
|
|
3461
3498
|
return {
|
|
3462
3499
|
kind: "invalid-value-shape",
|
|
3463
3500
|
key,
|
|
@@ -3465,7 +3502,7 @@ function sampleValueShapeIssue(key, kind, value) {
|
|
|
3465
3502
|
detail: { kind },
|
|
3466
3503
|
};
|
|
3467
3504
|
}
|
|
3468
|
-
if (["checkbox", "matrix"].includes(kind ?? "") &&
|
|
3505
|
+
if (["checkbox", "matrix"].includes(kind ?? "") && normalizedObjectValue === null) {
|
|
3469
3506
|
return {
|
|
3470
3507
|
kind: "invalid-value-shape",
|
|
3471
3508
|
key,
|
|
@@ -3523,7 +3560,7 @@ export function validateLiveFormSampleProfile(definition, sampleValues, options
|
|
|
3523
3560
|
continue;
|
|
3524
3561
|
if (options.requiredOnly === true && config.required !== true)
|
|
3525
3562
|
continue;
|
|
3526
|
-
const value = samples[key];
|
|
3563
|
+
const value = normalizeLiveFormSampleValue(config.kind, samples[key]);
|
|
3527
3564
|
const shapeIssue = sampleValueShapeIssue(key, config.kind, value);
|
|
3528
3565
|
if (shapeIssue)
|
|
3529
3566
|
issues.push({ ...shapeIssue, target });
|
|
@@ -3578,6 +3615,8 @@ export function createLiveFormProgressiveSamplePlan(definition, sampleValues, op
|
|
|
3578
3615
|
...(step?.expectHiddenAfter === undefined ? {} : { expectHiddenAfter: step.expectHiddenAfter }),
|
|
3579
3616
|
...(step?.expectResetAfter === undefined ? {} : { expectResetAfter: step.expectResetAfter }),
|
|
3580
3617
|
...(step?.continueSelector === undefined ? {} : { continueSelector: step.continueSelector }),
|
|
3618
|
+
...(step?.expectVisibleAfterContinue === undefined ? {} : { expectVisibleAfterContinue: step.expectVisibleAfterContinue }),
|
|
3619
|
+
...(step?.expectHiddenAfterContinue === undefined ? {} : { expectHiddenAfterContinue: step.expectHiddenAfterContinue }),
|
|
3581
3620
|
...(step?.transaction === undefined ? {} : { transaction: step.transaction }),
|
|
3582
3621
|
});
|
|
3583
3622
|
}
|
|
@@ -3599,6 +3638,16 @@ export async function runLiveFormProgressiveSampleFlow(page, definition, sampleV
|
|
|
3599
3638
|
}));
|
|
3600
3639
|
if (step.continueSelector) {
|
|
3601
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
|
+
}
|
|
3602
3651
|
}
|
|
3603
3652
|
}
|
|
3604
3653
|
const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
|
|
@@ -3661,10 +3710,14 @@ export async function diagnoseLiveFormSubmitGate(page, options = {}) {
|
|
|
3661
3710
|
blockingReasons.push(`readiness-blocking-fields:${readiness.blockingFieldCount}`);
|
|
3662
3711
|
if (validation && validation.inlineErrors.length > 0)
|
|
3663
3712
|
blockingReasons.push(`validation-errors:${validation.inlineErrors.length}`);
|
|
3713
|
+
const blockingFields = readiness?.fields.filter((field) => field.isBlocking) ?? [];
|
|
3714
|
+
const validationErrors = validation?.inlineErrors ?? [];
|
|
3664
3715
|
return {
|
|
3665
3716
|
selector,
|
|
3666
3717
|
...dom,
|
|
3667
3718
|
blockingReasons,
|
|
3719
|
+
blockingFields,
|
|
3720
|
+
validationErrors,
|
|
3668
3721
|
...(readiness ? { readiness } : {}),
|
|
3669
3722
|
...(validation ? { validation } : {}),
|
|
3670
3723
|
ready: blockingReasons.length === 0,
|
|
@@ -3737,6 +3790,70 @@ export async function diagnoseLiveFormLayout(page, options = {}) {
|
|
|
3737
3790
|
};
|
|
3738
3791
|
}, options);
|
|
3739
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
|
+
}
|
|
3740
3857
|
export async function assertLiveFormChangeCascade(page, options) {
|
|
3741
3858
|
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3742
3859
|
...(options.trigger.transaction ?? {}),
|
|
@@ -3766,6 +3883,136 @@ export async function assertLiveFormChangeCascade(page, options) {
|
|
|
3766
3883
|
}
|
|
3767
3884
|
return { transaction, values, settled };
|
|
3768
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
|
+
}
|
|
3769
4016
|
export async function runFormstackIframePrefillSmoke(page, options) {
|
|
3770
4017
|
const bridge = await sendFormstackIframePrefillMessage(page, options);
|
|
3771
4018
|
const responseData = bridge.response.record?.data;
|
|
@@ -3773,6 +4020,16 @@ export async function runFormstackIframePrefillSmoke(page, options) {
|
|
|
3773
4020
|
const ok = bridge.posted && bridge.response.matched && (options.expectResponseOk === false || responseOk);
|
|
3774
4021
|
return { bridge, ok };
|
|
3775
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
|
+
}
|
|
3776
4033
|
export async function assertLiveFormConditionalLogic(page, options) {
|
|
3777
4034
|
const transaction = await performLiveFormFieldTransaction(page, {
|
|
3778
4035
|
...(options.trigger.transaction ?? {}),
|