@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.d.ts +101 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +289 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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", "
|
|
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", "
|
|
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"
|
|
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;
|
|
199
|
-
recommendedHelpers: ["
|
|
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 = {}) => ({
|