@redrockswebdevelopment/formstack-form-testing 0.1.20 → 0.1.22

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
@@ -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,44 @@ 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 buildLiveFormCheckboxValue(value.filter((entry) => typeof entry === "string"));
3455
+ }
3456
+ if (kind === "checkbox" && value && typeof value === "object" && Array.isArray(value.values)) {
3457
+ return buildLiveFormCheckboxValue(value.values.filter((entry) => typeof entry === "string"));
3458
+ }
3459
+ if (kind === "checkbox" && typeof value === "boolean") {
3460
+ return { checked: value };
3461
+ }
3462
+ return value;
3463
+ }
3464
+ export function createLiveFormFieldValueAdapter(kind = "unknown") {
3465
+ return {
3466
+ kind,
3467
+ normalize: (value) => normalizeLiveFormSampleValue(kind, value),
3468
+ isEmpty: (value) => isLiveFormFieldValueEmpty(normalizeLiveFormSampleValue(kind, value)),
3469
+ equals: (actual, expected) => stableSerializeLiveFormValue(normalizeLiveFormSampleValue(kind, actual)) === stableSerializeLiveFormValue(normalizeLiveFormSampleValue(kind, expected)),
3470
+ };
3471
+ }
3434
3472
  export async function runLiveFormSampleSmoke(page, definition, sampleValues, options = {}) {
3435
3473
  const beforeReadinessOptions = createSampleSmokeReadinessOptions(definition, options.beforeReadiness, false);
3436
3474
  const afterReadinessOptions = createSampleSmokeReadinessOptions(definition, options.afterReadiness, true);
@@ -3456,8 +3494,10 @@ export async function runLiveFormSampleSmoke(page, definition, sampleValues, opt
3456
3494
  };
3457
3495
  }
3458
3496
  function sampleValueShapeIssue(key, kind, value) {
3497
+ const normalizedValue = normalizeLiveFormSampleValue(kind, value);
3459
3498
  const objectValue = value && typeof value === "object" ? value : null;
3460
- if (["radio", "select", "rating"].includes(kind ?? "") && (objectValue === null || typeof objectValue.value !== "string")) {
3499
+ const normalizedObjectValue = normalizedValue && typeof normalizedValue === "object" ? normalizedValue : null;
3500
+ if (["radio", "select", "rating"].includes(kind ?? "") && (normalizedObjectValue === null || typeof normalizedObjectValue.value !== "string")) {
3461
3501
  return {
3462
3502
  kind: "invalid-value-shape",
3463
3503
  key,
@@ -3465,7 +3505,7 @@ function sampleValueShapeIssue(key, kind, value) {
3465
3505
  detail: { kind },
3466
3506
  };
3467
3507
  }
3468
- if (["checkbox", "matrix"].includes(kind ?? "") && objectValue === null) {
3508
+ if (["checkbox", "matrix"].includes(kind ?? "") && normalizedObjectValue === null) {
3469
3509
  return {
3470
3510
  kind: "invalid-value-shape",
3471
3511
  key,
@@ -3523,7 +3563,7 @@ export function validateLiveFormSampleProfile(definition, sampleValues, options
3523
3563
  continue;
3524
3564
  if (options.requiredOnly === true && config.required !== true)
3525
3565
  continue;
3526
- const value = samples[key];
3566
+ const value = normalizeLiveFormSampleValue(config.kind, samples[key]);
3527
3567
  const shapeIssue = sampleValueShapeIssue(key, config.kind, value);
3528
3568
  if (shapeIssue)
3529
3569
  issues.push({ ...shapeIssue, target });
@@ -3578,6 +3618,8 @@ export function createLiveFormProgressiveSamplePlan(definition, sampleValues, op
3578
3618
  ...(step?.expectHiddenAfter === undefined ? {} : { expectHiddenAfter: step.expectHiddenAfter }),
3579
3619
  ...(step?.expectResetAfter === undefined ? {} : { expectResetAfter: step.expectResetAfter }),
3580
3620
  ...(step?.continueSelector === undefined ? {} : { continueSelector: step.continueSelector }),
3621
+ ...(step?.expectVisibleAfterContinue === undefined ? {} : { expectVisibleAfterContinue: step.expectVisibleAfterContinue }),
3622
+ ...(step?.expectHiddenAfterContinue === undefined ? {} : { expectHiddenAfterContinue: step.expectHiddenAfterContinue }),
3581
3623
  ...(step?.transaction === undefined ? {} : { transaction: step.transaction }),
3582
3624
  });
3583
3625
  }
@@ -3598,7 +3640,22 @@ export async function runLiveFormProgressiveSampleFlow(page, definition, sampleV
3598
3640
  ...(step.expectResetAfter === undefined ? {} : { expectReset: step.expectResetAfter }),
3599
3641
  }));
3600
3642
  if (step.continueSelector) {
3601
- await page.locator(step.continueSelector).first().click();
3643
+ try {
3644
+ await page.locator(step.continueSelector).first().click({ timeout: 5_000 });
3645
+ }
3646
+ catch (error) {
3647
+ throw new Error(`Progressive step "${step.key}" could not click continue selector "${step.continueSelector}". The control may still be disabled because the preceding field value did not trigger Formstack side effects. Cause: ${error.message}`);
3648
+ }
3649
+ const continueVisibilityTargets = [
3650
+ ...(step.expectVisibleAfterContinue ?? []).map((target) => ({ ...target, visible: true })),
3651
+ ...(step.expectHiddenAfterContinue ?? []).map((target) => ({ ...target, visible: false })),
3652
+ ];
3653
+ if (continueVisibilityTargets.length > 0) {
3654
+ const visibility = await waitForLiveFormVisibility(page, { targets: continueVisibilityTargets });
3655
+ if (!visibility.matched) {
3656
+ throw new Error(`Progressive step "${step.key}" continue action did not settle into the requested visibility state.`);
3657
+ }
3658
+ }
3602
3659
  }
3603
3660
  }
3604
3661
  const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
@@ -3661,10 +3718,14 @@ export async function diagnoseLiveFormSubmitGate(page, options = {}) {
3661
3718
  blockingReasons.push(`readiness-blocking-fields:${readiness.blockingFieldCount}`);
3662
3719
  if (validation && validation.inlineErrors.length > 0)
3663
3720
  blockingReasons.push(`validation-errors:${validation.inlineErrors.length}`);
3721
+ const blockingFields = readiness?.fields.filter((field) => field.isBlocking) ?? [];
3722
+ const validationErrors = validation?.inlineErrors ?? [];
3664
3723
  return {
3665
3724
  selector,
3666
3725
  ...dom,
3667
3726
  blockingReasons,
3727
+ blockingFields,
3728
+ validationErrors,
3668
3729
  ...(readiness ? { readiness } : {}),
3669
3730
  ...(validation ? { validation } : {}),
3670
3731
  ready: blockingReasons.length === 0,
@@ -3737,6 +3798,70 @@ export async function diagnoseLiveFormLayout(page, options = {}) {
3737
3798
  };
3738
3799
  }, options);
3739
3800
  }
3801
+ export async function diagnoseLiveFormIframeLayout(page, options) {
3802
+ if (!hasEvaluate(page)) {
3803
+ throw new Error("diagnoseLiveFormIframeLayout requires a Playwright page with evaluate().");
3804
+ }
3805
+ return page.evaluate((layoutOptions) => {
3806
+ const iframe = document.querySelector(layoutOptions.iframeSelector);
3807
+ const viewportWidth = document.documentElement.clientWidth;
3808
+ const viewportHeight = document.documentElement.clientHeight;
3809
+ const issues = [];
3810
+ if (!(iframe instanceof HTMLElement)) {
3811
+ return {
3812
+ iframeSelector: layoutOptions.iframeSelector,
3813
+ exists: false,
3814
+ viewportWidth,
3815
+ viewportHeight,
3816
+ rect: null,
3817
+ issues: [{
3818
+ kind: "horizontal-overflow",
3819
+ selector: layoutOptions.iframeSelector,
3820
+ message: "Iframe element was not found.",
3821
+ }],
3822
+ ok: false,
3823
+ };
3824
+ }
3825
+ const rect = iframe.getBoundingClientRect();
3826
+ if (rect.left < -1 || rect.right > viewportWidth + 1) {
3827
+ issues.push({
3828
+ kind: "horizontal-overflow",
3829
+ selector: layoutOptions.iframeSelector,
3830
+ message: "Iframe extends outside the viewport horizontally.",
3831
+ detail: { left: Math.round(rect.left), right: Math.round(rect.right), viewportWidth },
3832
+ });
3833
+ }
3834
+ if (layoutOptions.expectedMaxWidth !== undefined && rect.width > layoutOptions.expectedMaxWidth + 1) {
3835
+ issues.push({
3836
+ kind: "horizontal-overflow",
3837
+ selector: layoutOptions.iframeSelector,
3838
+ message: `Iframe width ${Math.round(rect.width)}px exceeds expected maximum ${layoutOptions.expectedMaxWidth}px.`,
3839
+ detail: { width: Math.round(rect.width), expectedMaxWidth: layoutOptions.expectedMaxWidth },
3840
+ });
3841
+ }
3842
+ if (layoutOptions.expectedMaxHeight !== undefined && rect.height > layoutOptions.expectedMaxHeight + 1) {
3843
+ issues.push({
3844
+ kind: "nested-scroll",
3845
+ selector: layoutOptions.iframeSelector,
3846
+ message: `Iframe height ${Math.round(rect.height)}px exceeds expected maximum ${layoutOptions.expectedMaxHeight}px.`,
3847
+ detail: { height: Math.round(rect.height), expectedMaxHeight: layoutOptions.expectedMaxHeight },
3848
+ });
3849
+ }
3850
+ return {
3851
+ iframeSelector: layoutOptions.iframeSelector,
3852
+ exists: true,
3853
+ viewportWidth,
3854
+ viewportHeight,
3855
+ rect: { width: Math.round(rect.width), height: Math.round(rect.height), top: Math.round(rect.top), left: Math.round(rect.left) },
3856
+ issues,
3857
+ ok: issues.length === 0,
3858
+ };
3859
+ }, options);
3860
+ }
3861
+ function endCascadePhase(name, startedAt, ok) {
3862
+ const endedAt = Date.now();
3863
+ return { name, startedAt, endedAt, durationMs: endedAt - startedAt, ok };
3864
+ }
3740
3865
  export async function assertLiveFormChangeCascade(page, options) {
3741
3866
  const transaction = await performLiveFormFieldTransaction(page, {
3742
3867
  ...(options.trigger.transaction ?? {}),
@@ -3766,6 +3891,136 @@ export async function assertLiveFormChangeCascade(page, options) {
3766
3891
  }
3767
3892
  return { transaction, values, settled };
3768
3893
  }
3894
+ export async function traceLiveFormChangeCascade(page, options) {
3895
+ const totalStartedAt = Date.now();
3896
+ const phases = [];
3897
+ const writeStartedAt = Date.now();
3898
+ const transaction = await performLiveFormFieldTransaction(page, {
3899
+ ...(options.trigger.transaction ?? {}),
3900
+ write: {
3901
+ ...(options.trigger.write ?? {}),
3902
+ target: options.trigger.target,
3903
+ value: options.trigger.value,
3904
+ notify: options.trigger.write?.notify ?? true,
3905
+ },
3906
+ ...(options.trigger.fieldKind === undefined ? {} : { fieldKind: options.trigger.fieldKind }),
3907
+ });
3908
+ phases.push(endCascadePhase("write", writeStartedAt, transaction.write.valueMatched !== false));
3909
+ const values = [];
3910
+ const valuesStartedAt = Date.now();
3911
+ for (const expected of options.expectValues ?? []) {
3912
+ values.push(await waitForLiveFormFieldValue(page, expected));
3913
+ }
3914
+ phases.push(endCascadePhase("values", valuesStartedAt, values.every((value) => value.valueMatched !== false)));
3915
+ const visibilityTargets = [
3916
+ ...(options.expectVisible ?? []).map((target) => ({ ...target, visible: true })),
3917
+ ...(options.expectHidden ?? []).map((target) => ({ ...target, visible: false })),
3918
+ ];
3919
+ const settleStartedAt = Date.now();
3920
+ const settled = await waitForLiveFormLogicSettled(page, {
3921
+ quiet: options.quiet ?? false,
3922
+ ...(visibilityTargets.length === 0 ? {} : { visibility: { targets: visibilityTargets } }),
3923
+ ...(options.validation === undefined ? {} : { validation: options.validation }),
3924
+ });
3925
+ phases.push(endCascadePhase("settled", settleStartedAt, settled.settled));
3926
+ if (!settled.settled || values.some((value) => value.valueMatched === false)) {
3927
+ throw new Error("Expected live form change cascade did not settle into the requested state.");
3928
+ }
3929
+ const endedAt = Date.now();
3930
+ return {
3931
+ label: options.label ?? "live-form-change-cascade",
3932
+ transaction,
3933
+ values,
3934
+ settled,
3935
+ phases,
3936
+ totalMs: endedAt - totalStartedAt,
3937
+ };
3938
+ }
3939
+ export async function runLiveFormRapidFieldChangeSequence(page, options) {
3940
+ const transactions = [];
3941
+ for (const write of options.writes) {
3942
+ transactions.push(await performLiveFormFieldTransaction(page, {
3943
+ ...(write.transaction ?? {}),
3944
+ write: {
3945
+ ...(write.write ?? {}),
3946
+ target: write.target,
3947
+ value: write.value,
3948
+ notify: write.write?.notify ?? true,
3949
+ },
3950
+ ...(write.fieldKind === undefined ? {} : { fieldKind: write.fieldKind }),
3951
+ }));
3952
+ }
3953
+ const quietOptions = options.afterQuiet === false ? undefined : options.afterQuiet === true ? {} : options.afterQuiet;
3954
+ const validationOptions = options.afterValidation === false ? undefined : options.afterValidation === true ? { expectNoErrors: true } : options.afterValidation;
3955
+ const quiet = quietOptions ? await waitForLiveFormQuiet(page, quietOptions) : undefined;
3956
+ const validation = validationOptions ? await waitForLiveFormValidationSettled(page, validationOptions) : undefined;
3957
+ const finalByTarget = new Map();
3958
+ for (const write of options.writes) {
3959
+ finalByTarget.set(liveFormFieldTargetKey(write.target) ?? JSON.stringify(write.target), { target: write.target, value: write.value });
3960
+ }
3961
+ const finalValues = [];
3962
+ for (const expected of finalByTarget.values()) {
3963
+ finalValues.push(await waitForLiveFormFieldValue(page, { target: expected.target, expectedValue: expected.value }));
3964
+ }
3965
+ return {
3966
+ transactions,
3967
+ ...(quiet ? { quiet } : {}),
3968
+ ...(validation ? { validation } : {}),
3969
+ finalValues,
3970
+ ok: transactions.every((transaction) => transaction.write.valueMatched !== false) &&
3971
+ (quiet?.quiet ?? true) &&
3972
+ ((validation?.inlineErrors.length ?? 0) === 0) &&
3973
+ finalValues.every((value) => value.valueMatched !== false),
3974
+ };
3975
+ }
3976
+ export async function waitForLiveFormRerenderSettled(page, options = {}) {
3977
+ if (!hasEvaluate(page)) {
3978
+ throw new Error("waitForLiveFormRerenderSettled requires a Playwright page with evaluate().");
3979
+ }
3980
+ return page.evaluate((rerenderOptions) => new Promise((resolve) => {
3981
+ const rootSelector = rerenderOptions.rootSelector ?? "body";
3982
+ const root = document.querySelector(rootSelector);
3983
+ const startedAt = Date.now();
3984
+ const quietMs = rerenderOptions.quietMs ?? 150;
3985
+ const timeoutMs = rerenderOptions.timeoutMs ?? 2000;
3986
+ let mutationCount = 0;
3987
+ let quietTimer;
3988
+ let timeoutTimer;
3989
+ let observer;
3990
+ const finish = (quiet) => {
3991
+ if (observer)
3992
+ observer.disconnect();
3993
+ if (quietTimer !== undefined)
3994
+ window.clearTimeout(quietTimer);
3995
+ if (timeoutTimer !== undefined)
3996
+ window.clearTimeout(timeoutTimer);
3997
+ const elapsedMs = Date.now() - startedAt;
3998
+ resolve({
3999
+ rootSelector,
4000
+ mutationCount,
4001
+ elapsedMs,
4002
+ quiet,
4003
+ ok: quiet && mutationCount >= (rerenderOptions.expectedMinMutations ?? 0),
4004
+ });
4005
+ };
4006
+ if (!root) {
4007
+ finish(false);
4008
+ return;
4009
+ }
4010
+ const armQuietTimer = () => {
4011
+ if (quietTimer !== undefined)
4012
+ window.clearTimeout(quietTimer);
4013
+ quietTimer = window.setTimeout(() => finish(true), quietMs);
4014
+ };
4015
+ observer = new MutationObserver((records) => {
4016
+ mutationCount += records.length;
4017
+ armQuietTimer();
4018
+ });
4019
+ observer.observe(root, { attributes: true, childList: true, subtree: true });
4020
+ timeoutTimer = window.setTimeout(() => finish(false), timeoutMs);
4021
+ armQuietTimer();
4022
+ }), options);
4023
+ }
3769
4024
  export async function runFormstackIframePrefillSmoke(page, options) {
3770
4025
  const bridge = await sendFormstackIframePrefillMessage(page, options);
3771
4026
  const responseData = bridge.response.record?.data;
@@ -3773,6 +4028,16 @@ export async function runFormstackIframePrefillSmoke(page, options) {
3773
4028
  const ok = bridge.posted && bridge.response.matched && (options.expectResponseOk === false || responseOk);
3774
4029
  return { bridge, ok };
3775
4030
  }
4031
+ export async function runFormstackIframePrefillScenario(page, options) {
4032
+ const prefill = await runFormstackIframePrefillSmoke(page, options);
4033
+ const layoutOptions = options.layout === true ? { iframeSelector: options.iframeSelector } : options.layout || undefined;
4034
+ const layout = layoutOptions ? await diagnoseLiveFormIframeLayout(page, layoutOptions) : undefined;
4035
+ return {
4036
+ prefill,
4037
+ ...(layout ? { layout } : {}),
4038
+ ok: prefill.ok && (layout?.ok ?? true),
4039
+ };
4040
+ }
3776
4041
  export async function assertLiveFormConditionalLogic(page, options) {
3777
4042
  const transaction = await performLiveFormFieldTransaction(page, {
3778
4043
  ...(options.trigger.transaction ?? {}),