@redrockswebdevelopment/formstack-form-testing 0.1.31 → 0.1.33

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
@@ -42,7 +42,7 @@ export const liveFormFieldTypeCoverage = {
42
42
  domInteraction: true,
43
43
  domAssertion: true,
44
44
  reason: "Date API state can be set reliably, but split/calendar renderer variants should be verified through DOM assertions.",
45
- recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormFieldValueTransaction", "fillDateTime", "readLiveFormDomFieldState"],
45
+ recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormDateTimeValue", "readLiveFormDateTimeState", "fillDateTime"],
46
46
  },
47
47
  datetime: {
48
48
  kind: "datetime",
@@ -51,7 +51,7 @@ export const liveFormFieldTypeCoverage = {
51
51
  domInteraction: true,
52
52
  domAssertion: true,
53
53
  reason: "Date/time API state can be set, but visible split controls may lag or vary by renderer.",
54
- recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormFieldValueTransaction", "fillDateTime", "readLiveFormDomFieldState"],
54
+ recommendedHelpers: ["buildLiveFormDateTimeValue", "setLiveFormDateTimeValue", "readLiveFormDateTimeState", "fillDateTime"],
55
55
  },
56
56
  description: {
57
57
  kind: "description",
@@ -87,7 +87,7 @@ export const liveFormFieldTypeCoverage = {
87
87
  domInteraction: true,
88
88
  domAssertion: true,
89
89
  reason: "File fields must be tested through browser file upload controls, not generic Live Form API setValue payloads.",
90
- recommendedHelpers: ["uploadFile", "liveFormFieldRecipes.uploadFileDom", "readLiveFormDomFieldState"],
90
+ recommendedHelpers: ["uploadLiveFormFile", "readLiveFormFileState", "uploadFile", "liveFormFieldRecipes.uploadFileDom"],
91
91
  },
92
92
  matrix: {
93
93
  kind: "matrix",
@@ -195,8 +195,8 @@ export const liveFormFieldTypeCoverage = {
195
195
  apiWrite: false,
196
196
  domInteraction: true,
197
197
  domAssertion: true,
198
- reason: "Signature fields require specialized canvas/user-event automation; the portable helper safely supports clearing and DOM assertions.",
199
- recommendedHelpers: ["clearSignature", "liveFormFieldRecipes.clearSignatureDom", "readLiveFormDomFieldState"],
198
+ reason: "Signature fields require specialized canvas/user-event automation; strict helpers verify hidden signature values or canvas ink after draw/clear.",
199
+ recommendedHelpers: ["drawLiveFormSignature", "clearLiveFormSignature", "readLiveFormSignatureState", "liveFormFieldRecipes.drawSignatureAndAssertDom"],
200
200
  },
201
201
  textarea: {
202
202
  kind: "textarea",
@@ -5253,6 +5253,210 @@ function productPartLocator(page, internalLabel, part) {
5253
5253
  function productQuantityLocator(page, internalLabel) {
5254
5254
  return page.locator(`${formstackSelectors.fieldByInternalLabel(internalLabel)} input[name$="[quantity]"], ${formstackSelectors.fieldByInternalLabel(internalLabel)} select[name$="[quantity]"]`).first();
5255
5255
  }
5256
+ export function normalizeLiveFormFileNames(files) {
5257
+ const values = Array.isArray(files) ? files : [files];
5258
+ return values.map((file) => {
5259
+ const parts = String(file).split(/[\\/]/).filter(Boolean);
5260
+ return parts.length > 0 ? parts[parts.length - 1] ?? String(file) : String(file);
5261
+ });
5262
+ }
5263
+ function fileInputLocator(page, target) {
5264
+ if (target.internalLabel != null) {
5265
+ return page.locator(formstackSelectors.fieldByInternalLabel(target.internalLabel)).locator("input[type=\"file\"]").first();
5266
+ }
5267
+ if (target.fieldId != null) {
5268
+ return page.locator(formstackSelectors.fieldById(target.fieldId)).locator("input[type=\"file\"]").first();
5269
+ }
5270
+ throw new Error("File upload target requires internalLabel or fieldId.");
5271
+ }
5272
+ function liveFormSignatureInternalLabel(target) {
5273
+ if (target.internalLabel == null) {
5274
+ throw new Error("Signature target requires internalLabel.");
5275
+ }
5276
+ return target.internalLabel;
5277
+ }
5278
+ export async function readLiveFormFileState(page, options) {
5279
+ if (!hasEvaluate(page)) {
5280
+ throw new Error("readLiveFormFileState requires a Playwright page with evaluate().");
5281
+ }
5282
+ return page.evaluate((stateOptions) => {
5283
+ const root = stateOptions.selector != null
5284
+ ? document.querySelector(stateOptions.selector)
5285
+ : stateOptions.internalLabel != null
5286
+ ? document.querySelector(`[data-internal-label="${CSS.escape(stateOptions.internalLabel)}"]`)
5287
+ : stateOptions.fieldId == null
5288
+ ? null
5289
+ : document.querySelector(`#fsCell${CSS.escape(String(stateOptions.fieldId))}, #fsFieldCell${CSS.escape(String(stateOptions.fieldId))}, [data-testid="field-${CSS.escape(String(stateOptions.fieldId))}"]`);
5290
+ const controls = root
5291
+ ? Array.from(root.querySelectorAll("input[type=\"file\"]")).map((control) => {
5292
+ const files = Array.from(control.files ?? []).map((file) => file.name);
5293
+ return {
5294
+ tagName: control.tagName.toLowerCase(),
5295
+ type: control.getAttribute("type"),
5296
+ id: control.getAttribute("id"),
5297
+ name: control.getAttribute("name"),
5298
+ value: control.getAttribute("value"),
5299
+ currentValue: control.value,
5300
+ checked: null,
5301
+ files,
5302
+ accept: control.getAttribute("accept"),
5303
+ multiple: control.multiple,
5304
+ disabled: control.disabled,
5305
+ required: control.required,
5306
+ };
5307
+ })
5308
+ : [];
5309
+ const selectedFileNames = controls.flatMap((control) => control.files);
5310
+ const expectedFileNames = [...(stateOptions.expectedFileNames ?? [])];
5311
+ const missingFileNames = expectedFileNames.filter((name) => !selectedFileNames.includes(name));
5312
+ const unexpectedFileNames = selectedFileNames.filter((name) => !expectedFileNames.includes(name));
5313
+ const exact = stateOptions.exact !== false;
5314
+ return {
5315
+ fieldMatched: Boolean(root),
5316
+ controlMatched: controls.length > 0,
5317
+ selectedFileNames,
5318
+ expectedFileNames,
5319
+ missingFileNames,
5320
+ unexpectedFileNames,
5321
+ exact,
5322
+ matched: Boolean(root) &&
5323
+ controls.length > 0 &&
5324
+ missingFileNames.length === 0 &&
5325
+ (expectedFileNames.length === 0 || !exact || unexpectedFileNames.length === 0),
5326
+ controls,
5327
+ };
5328
+ }, options);
5329
+ }
5330
+ export async function waitForLiveFormFileState(page, options) {
5331
+ const timeoutMs = options.timeoutMs ?? 10_000;
5332
+ const intervalMs = options.intervalMs ?? 100;
5333
+ const startedAt = Date.now();
5334
+ let latest = await readLiveFormFileState(page, options);
5335
+ while (!latest.matched && Date.now() - startedAt < timeoutMs) {
5336
+ await delay(intervalMs);
5337
+ latest = await readLiveFormFileState(page, options);
5338
+ }
5339
+ return latest;
5340
+ }
5341
+ export async function uploadLiveFormFile(page, options) {
5342
+ const fileNames = options.expectedFileNames ?? normalizeLiveFormFileNames(options.files);
5343
+ await fileInputLocator(page, options.target).setInputFiles(options.files);
5344
+ const quiet = options.afterQuiet === false
5345
+ ? undefined
5346
+ : await waitForLiveFormQuiet(page, typeof options.afterQuiet === "object" ? options.afterQuiet : {});
5347
+ const dom = await waitForLiveFormFileState(page, {
5348
+ ...options.target,
5349
+ expectedFileNames: fileNames,
5350
+ ...(options.exact === undefined ? {} : { exact: options.exact }),
5351
+ });
5352
+ const validation = options.afterValidation === undefined
5353
+ ? undefined
5354
+ : await waitForLiveFormValidationState(page, typeof options.afterValidation === "object" ? options.afterValidation : {});
5355
+ return {
5356
+ fileNames,
5357
+ dom,
5358
+ ...(quiet ? { quiet } : {}),
5359
+ ...(validation ? { validation } : {}),
5360
+ ok: dom.matched &&
5361
+ (quiet?.quiet ?? true) &&
5362
+ ((validation?.inlineErrors.length ?? 0) === 0),
5363
+ };
5364
+ }
5365
+ export async function readLiveFormSignatureState(page, options) {
5366
+ if (!hasEvaluate(page)) {
5367
+ throw new Error("readLiveFormSignatureState requires a Playwright page with evaluate().");
5368
+ }
5369
+ return page.evaluate((stateOptions) => {
5370
+ const root = stateOptions.selector != null
5371
+ ? document.querySelector(stateOptions.selector)
5372
+ : stateOptions.internalLabel != null
5373
+ ? document.querySelector(`[data-internal-label="${CSS.escape(stateOptions.internalLabel)}"]`)
5374
+ : stateOptions.fieldId == null
5375
+ ? null
5376
+ : document.querySelector(`#fsCell${CSS.escape(String(stateOptions.fieldId))}, #fsFieldCell${CSS.escape(String(stateOptions.fieldId))}, [data-testid="field-${CSS.escape(String(stateOptions.fieldId))}"]`);
5377
+ const hiddenValues = root
5378
+ ? Array.from(root.querySelectorAll("input[type=\"hidden\"], textarea"))
5379
+ .map((control) => control.value)
5380
+ .filter((value) => value.trim().length > 0)
5381
+ : [];
5382
+ const canvases = root
5383
+ ? Array.from(root.querySelectorAll("canvas")).map((canvas) => {
5384
+ try {
5385
+ const context = canvas.getContext("2d", { willReadFrequently: true });
5386
+ if (!context || canvas.width <= 0 || canvas.height <= 0) {
5387
+ return {
5388
+ width: canvas.width,
5389
+ height: canvas.height,
5390
+ readable: false,
5391
+ inkPixelCount: null,
5392
+ dataUrlLength: null,
5393
+ error: context ? "empty-canvas" : "missing-2d-context",
5394
+ };
5395
+ }
5396
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
5397
+ let inkPixelCount = 0;
5398
+ for (let index = 0; index < pixels.length; index += 4) {
5399
+ const alpha = pixels[index + 3] ?? 0;
5400
+ const red = pixels[index] ?? 255;
5401
+ const green = pixels[index + 1] ?? 255;
5402
+ const blue = pixels[index + 2] ?? 255;
5403
+ if (alpha > 0 && (red < 245 || green < 245 || blue < 245))
5404
+ inkPixelCount += 1;
5405
+ }
5406
+ let dataUrlLength = null;
5407
+ try {
5408
+ dataUrlLength = canvas.toDataURL("image/png").length;
5409
+ }
5410
+ catch {
5411
+ dataUrlLength = null;
5412
+ }
5413
+ return {
5414
+ width: canvas.width,
5415
+ height: canvas.height,
5416
+ readable: true,
5417
+ inkPixelCount,
5418
+ dataUrlLength,
5419
+ error: null,
5420
+ };
5421
+ }
5422
+ catch (error) {
5423
+ return {
5424
+ width: canvas.width,
5425
+ height: canvas.height,
5426
+ readable: false,
5427
+ inkPixelCount: null,
5428
+ dataUrlLength: null,
5429
+ error: error instanceof Error ? error.message : String(error),
5430
+ };
5431
+ }
5432
+ })
5433
+ : [];
5434
+ const surfaceMatched = Boolean(root?.querySelector(".konvajs-content, canvas"));
5435
+ const canvasMatched = canvases.length > 0;
5436
+ const signed = hiddenValues.length > 0 || canvases.some((canvas) => (canvas.inkPixelCount ?? 0) > 0);
5437
+ return {
5438
+ fieldMatched: Boolean(root),
5439
+ surfaceMatched,
5440
+ canvasMatched,
5441
+ signed,
5442
+ ...(stateOptions.expectSigned === undefined ? {} : { expectSigned: stateOptions.expectSigned }),
5443
+ matched: Boolean(root) && surfaceMatched && (stateOptions.expectSigned === undefined || signed === stateOptions.expectSigned),
5444
+ hiddenValues,
5445
+ canvases,
5446
+ };
5447
+ }, options);
5448
+ }
5449
+ export async function waitForLiveFormSignatureState(page, options) {
5450
+ const timeoutMs = options.timeoutMs ?? 10_000;
5451
+ const intervalMs = options.intervalMs ?? 100;
5452
+ const startedAt = Date.now();
5453
+ let latest = await readLiveFormSignatureState(page, options);
5454
+ while (!latest.matched && Date.now() - startedAt < timeoutMs) {
5455
+ await delay(intervalMs);
5456
+ latest = await readLiveFormSignatureState(page, options);
5457
+ }
5458
+ return latest;
5459
+ }
5256
5460
  export async function drawSignature(page, internalLabel, options = {}) {
5257
5461
  if (!page.mouse) {
5258
5462
  throw new Error("drawSignature requires a Playwright page with mouse support.");
@@ -5290,6 +5494,49 @@ export async function drawSignature(page, internalLabel, options = {}) {
5290
5494
  }
5291
5495
  await page.mouse.up();
5292
5496
  }
5497
+ export async function drawLiveFormSignature(page, options) {
5498
+ await drawSignature(page, liveFormSignatureInternalLabel(options.target), options);
5499
+ const quiet = options.afterQuiet === false
5500
+ ? undefined
5501
+ : await waitForLiveFormQuiet(page, typeof options.afterQuiet === "object" ? options.afterQuiet : {});
5502
+ const dom = await waitForLiveFormSignatureState(page, {
5503
+ ...options.target,
5504
+ expectSigned: true,
5505
+ });
5506
+ const validation = options.afterValidation === undefined
5507
+ ? undefined
5508
+ : await waitForLiveFormValidationState(page, typeof options.afterValidation === "object" ? options.afterValidation : {});
5509
+ return {
5510
+ dom,
5511
+ ...(quiet ? { quiet } : {}),
5512
+ ...(validation ? { validation } : {}),
5513
+ ok: dom.matched &&
5514
+ (quiet?.quiet ?? true) &&
5515
+ ((validation?.inlineErrors.length ?? 0) === 0),
5516
+ };
5517
+ }
5518
+ export async function clearLiveFormSignature(page, options) {
5519
+ const internalLabel = liveFormSignatureInternalLabel(options.target);
5520
+ await page.locator(formstackSelectors.signatureClearButtonByInternalLabel(internalLabel)).first().click();
5521
+ const quiet = options.afterQuiet === false
5522
+ ? undefined
5523
+ : await waitForLiveFormQuiet(page, typeof options.afterQuiet === "object" ? options.afterQuiet : {});
5524
+ const dom = await waitForLiveFormSignatureState(page, {
5525
+ ...options.target,
5526
+ expectSigned: false,
5527
+ });
5528
+ const validation = options.afterValidation === undefined
5529
+ ? undefined
5530
+ : await waitForLiveFormValidationState(page, typeof options.afterValidation === "object" ? options.afterValidation : {});
5531
+ return {
5532
+ dom,
5533
+ ...(quiet ? { quiet } : {}),
5534
+ ...(validation ? { validation } : {}),
5535
+ ok: dom.matched &&
5536
+ (quiet?.quiet ?? true) &&
5537
+ ((validation?.inlineErrors.length ?? 0) === 0),
5538
+ };
5539
+ }
5293
5540
  export function createFormTestHarness(page, options = {}) {
5294
5541
  return {
5295
5542
  page,
@@ -5669,6 +5916,19 @@ export const liveFormFieldRecipes = {
5669
5916
  name,
5670
5917
  run: (harness) => harness.uploadFile(internalLabel, files),
5671
5918
  }),
5919
+ uploadFileAndAssertDom: (name, internalLabel, files, options = {}) => ({
5920
+ name,
5921
+ run: async (harness) => {
5922
+ const result = await uploadLiveFormFile(harness.page, {
5923
+ ...options,
5924
+ target: { internalLabel },
5925
+ files,
5926
+ });
5927
+ if (!result.ok) {
5928
+ throw new Error(`File upload "${internalLabel}" did not match expected selected files.`);
5929
+ }
5930
+ },
5931
+ }),
5672
5932
  clearSignatureDom: (name, internalLabel) => ({
5673
5933
  name,
5674
5934
  run: (harness) => harness.clearSignature(internalLabel),
@@ -5677,6 +5937,30 @@ export const liveFormFieldRecipes = {
5677
5937
  name,
5678
5938
  run: (harness) => harness.drawSignature(internalLabel, options),
5679
5939
  }),
5940
+ drawSignatureAndAssertDom: (name, internalLabel, options = {}) => ({
5941
+ name,
5942
+ run: async (harness) => {
5943
+ const result = await drawLiveFormSignature(harness.page, {
5944
+ ...options,
5945
+ target: { internalLabel },
5946
+ });
5947
+ if (!result.ok) {
5948
+ throw new Error(`Signature "${internalLabel}" did not produce a signed state.`);
5949
+ }
5950
+ },
5951
+ }),
5952
+ clearSignatureAndAssertDom: (name, internalLabel, options = {}) => ({
5953
+ name,
5954
+ run: async (harness) => {
5955
+ const result = await clearLiveFormSignature(harness.page, {
5956
+ ...options,
5957
+ target: { internalLabel },
5958
+ });
5959
+ if (!result.ok) {
5960
+ throw new Error(`Signature "${internalLabel}" did not clear to an unsigned state.`);
5961
+ }
5962
+ },
5963
+ }),
5680
5964
  setNameApi: (name, internalLabel, value, options) => apiRecipeStep(name, { internalLabel }, buildLiveFormNameValue(value), options),
5681
5965
  setAddressApi: (name, internalLabel, value, options) => apiRecipeStep(name, { internalLabel }, buildLiveFormAddressValue(value), options),
5682
5966
  setChoiceApi: (name, internalLabel, value, options = {}) => ({