@lullabot/playwright-testing 1.0.1 → 1.1.0

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.
@@ -38,6 +38,9 @@ async function checkAccessibility(page, testInfo, options) {
38
38
  if (!testInfo.annotations.some((a) => a.type === "@a11y")) {
39
39
  testInfo.annotations.push({ type: "@a11y" });
40
40
  }
41
+ // An in-code baseline is shared by both scans. Track matches across this
42
+ // check so that a rule detected by either scan is not reported as stale.
43
+ const sharedBaselineMatches = new Set();
41
44
  if (bestPracticeMode !== "off") {
42
45
  const bpResults = await runBestPracticeScan(page, testInfo, {
43
46
  exclude: [...exclude, ...bestPracticeExclude],
@@ -52,7 +55,7 @@ async function checkAccessibility(page, testInfo, options) {
52
55
  scan: "best-practice",
53
56
  expectFn: test_1.expect.soft,
54
57
  scanLabel: "Best-practice scan",
55
- }, baseline);
58
+ }, baseline, sharedBaselineMatches);
56
59
  }
57
60
  const wcagScanResults = await runWcagScan(page, testInfo, {
58
61
  wcagTags,
@@ -68,7 +71,7 @@ async function checkAccessibility(page, testInfo, options) {
68
71
  scan: "wcag",
69
72
  expectFn: test_1.expect,
70
73
  scanLabel: "WCAG scan",
71
- }, baseline);
74
+ }, baseline, sharedBaselineMatches);
72
75
  }
73
76
  /**
74
77
  * Dispatch the assertion for one scan to the correct mode:
@@ -85,9 +88,9 @@ async function checkAccessibility(page, testInfo, options) {
85
88
  * test (matching Playwright's missing-snapshot behaviour); locally,
86
89
  * seeding passes so the first run is green.
87
90
  */
88
- async function dispatchAssertion(ctx, inCodeBaseline) {
91
+ async function dispatchAssertion(ctx, inCodeBaseline, sharedBaselineMatches) {
89
92
  if (inCodeBaseline) {
90
- return assertBaseline(ctx, inCodeBaseline);
93
+ return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
91
94
  }
92
95
  if (await (0, accessibility_baseline_file_js_1.snapshotExists)(ctx.testInfo)) {
93
96
  return assertSnapshot(ctx);
@@ -214,7 +217,7 @@ async function screenshotViolatingElements(page, testInfo, results) {
214
217
  /**
215
218
  * Assert violations against a baseline allowlist (in-code or on-disk).
216
219
  */
217
- function assertBaseline(ctx, baseline) {
220
+ function assertBaseline(ctx, baseline, sharedBaselineMatches) {
218
221
  (0, accessibility_baseline_js_1.validateAccessibilityBaseline)(baseline);
219
222
  const { testInfo, results, scanLabel, expectFn } = ctx;
220
223
  const allViolations = extractNormalizedViolations(results);
@@ -229,6 +232,7 @@ function assertBaseline(ctx, baseline) {
229
232
  });
230
233
  if (baselineIndex >= 0) {
231
234
  matchedBaselineIndices.add(baselineIndex);
235
+ sharedBaselineMatches?.add(baselineIndex);
232
236
  const entry = baseline[baselineIndex];
233
237
  testInfo.annotations.push({
234
238
  type: "Baselined a11y violation",
@@ -239,15 +243,21 @@ function assertBaseline(ctx, baseline) {
239
243
  unmatchedViolations.push(violation);
240
244
  }
241
245
  }
242
- // Report stale baseline entries.
243
- baseline.forEach((entry, idx) => {
244
- if (!matchedBaselineIndices.has(idx)) {
245
- testInfo.annotations.push({
246
- type: "Stale a11y baseline entry",
247
- description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
248
- });
249
- }
250
- });
246
+ // The WCAG scan always runs last, even when best-practice is disabled.
247
+ // Report shared entries once after both scans have contributed matches,
248
+ // before the hard assertion can throw. On-disk baselines belong to one
249
+ // scan and continue to report stale entries independently.
250
+ if (!sharedBaselineMatches || ctx.scan === "wcag") {
251
+ const matches = sharedBaselineMatches ?? matchedBaselineIndices;
252
+ baseline.forEach((entry, idx) => {
253
+ if (!matches.has(idx)) {
254
+ testInfo.annotations.push({
255
+ type: "Stale a11y baseline entry",
256
+ description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
257
+ });
258
+ }
259
+ });
260
+ }
251
261
  // Summary annotation for baseline mode.
252
262
  const baselinedCount = matchedBaselineIndices.size;
253
263
  testInfo.annotations.push({
@@ -292,7 +292,7 @@ function headline(report) {
292
292
  // seeing — a baselined violation passes and is still screenshotted.
293
293
  const parts = report.totalFailed === 0
294
294
  ? [":white_check_mark: No failing tests"]
295
- : [`**${report.totalFailed}** failing test(s)`];
295
+ : [`:x: **${report.totalFailed}** failing test(s)`];
296
296
  if (report.totalFlaky > 0)
297
297
  parts.push(`:warning: **${report.totalFlaky}** flaky test(s)`);
298
298
  if (report.totalImages > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lullabot/playwright-testing",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Framework-neutral Playwright testing utilities",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -305,6 +305,175 @@ describe("accessibility baseline", () => {
305
305
  expect(staleAnnotations[0].description).toContain("#old-element");
306
306
  expect(staleAnnotations[0].description).toContain("no longer detected");
307
307
  });
308
+
309
+ it.each([
310
+ { name: "best-practice", bp: ["page-has-heading-one"], wcag: [] },
311
+ { name: "WCAG", bp: [], wcag: ["image-alt"] },
312
+ { name: "both scans", bp: ["page-has-heading-one"], wcag: ["image-alt"] },
313
+ ])(
314
+ "does not mark entries matched by $name as stale",
315
+ async ({ bp, wcag }) => {
316
+ const rules = [...bp, ...wcag];
317
+ const baseline: AccessibilityBaseline = rules.map((rule) => ({
318
+ rule,
319
+ targets: ["#known"],
320
+ reason: "Known issue",
321
+ willBeFixedIn: "PROJ-353",
322
+ }));
323
+ mockAnalyze
324
+ .mockResolvedValueOnce(
325
+ makeAxeResults({
326
+ violations: bp.map((rule) => makeViolation(rule, [["#known"]])),
327
+ }),
328
+ )
329
+ .mockResolvedValueOnce(
330
+ makeAxeResults({
331
+ violations: wcag.map((rule) => makeViolation(rule, [["#known"]])),
332
+ }),
333
+ );
334
+
335
+ const testInfo = makeTestInfo();
336
+ await checkAccessibility(makePage() as any, testInfo as any, {
337
+ baseline,
338
+ });
339
+
340
+ expect(
341
+ testInfo.annotations.filter(
342
+ (a) => a.type === "Stale a11y baseline entry",
343
+ ),
344
+ ).toEqual([]);
345
+ expect(
346
+ testInfo.annotations.filter(
347
+ (a) => a.type === "Baselined a11y violation",
348
+ ),
349
+ ).toHaveLength(rules.length);
350
+ expect(
351
+ testInfo.annotations.filter((a) =>
352
+ a.description?.includes("new violations"),
353
+ ),
354
+ ).toEqual([
355
+ {
356
+ type: "Accessibility",
357
+ description: `Best-practice scan: 0 new violations (${bp.length} baselined)`,
358
+ },
359
+ {
360
+ type: "Accessibility",
361
+ description: `WCAG scan: 0 new violations (${wcag.length} baselined)`,
362
+ },
363
+ ]);
364
+ expect(mockToBe).not.toHaveBeenCalled();
365
+ },
366
+ );
367
+
368
+ it("reports entries unmatched by either scan only once", async () => {
369
+ const testInfo = makeTestInfo();
370
+ await checkAccessibility(makePage() as any, testInfo as any, {
371
+ baseline: [
372
+ {
373
+ rule: "page-has-heading-one",
374
+ targets: ["html"],
375
+ reason: "Was broken",
376
+ willBeFixedIn: "PROJ-353",
377
+ },
378
+ ],
379
+ });
380
+
381
+ expect(
382
+ testInfo.annotations.filter(
383
+ (a) => a.type === "Stale a11y baseline entry",
384
+ ),
385
+ ).toEqual([
386
+ {
387
+ type: "Stale a11y baseline entry",
388
+ description: "page-has-heading-one on html — no longer detected",
389
+ },
390
+ ]);
391
+ });
392
+
393
+ it("does not carry baseline matches into later checks in the same test", async () => {
394
+ const baseline: AccessibilityBaseline = [
395
+ {
396
+ rule: "page-has-heading-one",
397
+ targets: ["html"],
398
+ reason: "Known issue",
399
+ willBeFixedIn: "PROJ-353",
400
+ },
401
+ ];
402
+ mockAnalyze.mockResolvedValueOnce(
403
+ makeAxeResults({
404
+ violations: [makeViolation("page-has-heading-one", [["html"]])],
405
+ }),
406
+ );
407
+ const testInfo = makeTestInfo();
408
+ await checkAccessibility(makePage() as any, testInfo as any, {
409
+ baseline,
410
+ });
411
+ expect(
412
+ testInfo.annotations.filter(
413
+ (a) => a.type === "Stale a11y baseline entry",
414
+ ),
415
+ ).toEqual([]);
416
+
417
+ await checkAccessibility(makePage() as any, testInfo as any, {
418
+ baseline,
419
+ });
420
+ expect(
421
+ testInfo.annotations.filter(
422
+ (a) => a.type === "Stale a11y baseline entry",
423
+ ),
424
+ ).toHaveLength(1);
425
+ });
426
+
427
+ it("reports stale entries before an unmatched WCAG violation throws", async () => {
428
+ const baseline: AccessibilityBaseline = [
429
+ {
430
+ rule: "page-has-heading-one",
431
+ targets: ["html"],
432
+ reason: "Known issue",
433
+ willBeFixedIn: "PROJ-353",
434
+ },
435
+ {
436
+ rule: "heading-order",
437
+ targets: ["h3"],
438
+ reason: "Was broken",
439
+ willBeFixedIn: "PROJ-354",
440
+ },
441
+ ];
442
+ mockAnalyze
443
+ .mockResolvedValueOnce(
444
+ makeAxeResults({
445
+ violations: [makeViolation("page-has-heading-one", [["html"]])],
446
+ }),
447
+ )
448
+ .mockResolvedValueOnce(
449
+ makeAxeResults({
450
+ violations: [makeViolation("image-alt", [["img"]])],
451
+ }),
452
+ );
453
+ mockToBe.mockImplementationOnce(() => {
454
+ throw new Error("new WCAG violation");
455
+ });
456
+ const testInfo = makeTestInfo();
457
+
458
+ await expect(
459
+ checkAccessibility(makePage() as any, testInfo as any, { baseline }),
460
+ ).rejects.toThrow("new WCAG violation");
461
+
462
+ expect(
463
+ testInfo.annotations.filter(
464
+ (a) => a.type === "Stale a11y baseline entry",
465
+ ),
466
+ ).toEqual([
467
+ {
468
+ type: "Stale a11y baseline entry",
469
+ description: "heading-order on h3 — no longer detected",
470
+ },
471
+ ]);
472
+ expect(mockExpectHard).toHaveBeenCalledWith(
473
+ null,
474
+ expect.stringContaining("image-alt"),
475
+ );
476
+ });
308
477
  });
309
478
 
310
479
  describe("snapshot bypass", () => {
@@ -699,6 +868,73 @@ describe("accessibility baseline", () => {
699
868
  ).rejects.toThrow("requires a reason");
700
869
  });
701
870
 
871
+ it("keeps stale detection independent for each scan-specific on-disk baseline", async () => {
872
+ const fs = await import("fs");
873
+ const path = await import("path");
874
+ await fs.promises.writeFile(
875
+ path.join(tmpDir, "separate-scans-1.a11y-baseline-best-practice.json"),
876
+ JSON.stringify({
877
+ note: "Known best-practice violation.",
878
+ violations: [
879
+ {
880
+ rule: "page-has-heading-one",
881
+ targets: ["html"],
882
+ reason: "Known",
883
+ willBeFixedIn: "PROJ-353",
884
+ },
885
+ ],
886
+ }),
887
+ );
888
+ await fs.promises.writeFile(
889
+ path.join(tmpDir, "separate-scans-1.a11y-baseline.json"),
890
+ JSON.stringify({
891
+ note: "Previously broken image.",
892
+ violations: [
893
+ {
894
+ rule: "image-alt",
895
+ targets: ["img"],
896
+ reason: "Was broken",
897
+ willBeFixedIn: "PROJ-354",
898
+ },
899
+ ],
900
+ }),
901
+ );
902
+ mockAnalyze.mockResolvedValueOnce(
903
+ makeAxeResults({
904
+ violations: [makeViolation("page-has-heading-one", [["html"]])],
905
+ }),
906
+ );
907
+
908
+ const testInfo = makeTestInfo({
909
+ updateSnapshots: "none",
910
+ snapshotsDir: tmpDir,
911
+ title: "separate scans",
912
+ });
913
+ await checkAccessibility(makePage() as any, testInfo as any);
914
+
915
+ expect(
916
+ testInfo.annotations.filter(
917
+ (a) => a.type === "Baselined a11y violation",
918
+ ),
919
+ ).toEqual([
920
+ {
921
+ type: "Baselined a11y violation",
922
+ description: "page-has-heading-one: Known — PROJ-353",
923
+ },
924
+ ]);
925
+ expect(
926
+ testInfo.annotations.filter(
927
+ (a) => a.type === "Stale a11y baseline entry",
928
+ ),
929
+ ).toEqual([
930
+ {
931
+ type: "Stale a11y baseline entry",
932
+ description: "image-alt on img — no longer detected",
933
+ },
934
+ ]);
935
+ expect(mockToBe).not.toHaveBeenCalled();
936
+ });
937
+
702
938
  it("produces per-call counter files for multi-call tests", async () => {
703
939
  mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }));
704
940
 
@@ -253,6 +253,10 @@ export async function checkAccessibility(
253
253
  testInfo.annotations.push({ type: "@a11y" });
254
254
  }
255
255
 
256
+ // An in-code baseline is shared by both scans. Track matches across this
257
+ // check so that a rule detected by either scan is not reported as stale.
258
+ const sharedBaselineMatches = new Set<number>();
259
+
256
260
  if (bestPracticeMode !== "off") {
257
261
  const bpResults = await runBestPracticeScan(page, testInfo, {
258
262
  exclude: [...exclude, ...bestPracticeExclude],
@@ -270,6 +274,7 @@ export async function checkAccessibility(
270
274
  scanLabel: "Best-practice scan",
271
275
  },
272
276
  baseline,
277
+ sharedBaselineMatches,
273
278
  );
274
279
  }
275
280
 
@@ -292,6 +297,7 @@ export async function checkAccessibility(
292
297
  scanLabel: "WCAG scan",
293
298
  },
294
299
  baseline,
300
+ sharedBaselineMatches,
295
301
  );
296
302
  }
297
303
 
@@ -321,9 +327,10 @@ interface ScanContext {
321
327
  async function dispatchAssertion(
322
328
  ctx: ScanContext,
323
329
  inCodeBaseline?: AccessibilityBaseline,
330
+ sharedBaselineMatches?: Set<number>,
324
331
  ): Promise<void> {
325
332
  if (inCodeBaseline) {
326
- return assertBaseline(ctx, inCodeBaseline);
333
+ return assertBaseline(ctx, inCodeBaseline, sharedBaselineMatches);
327
334
  }
328
335
 
329
336
  if (await snapshotExists(ctx.testInfo)) {
@@ -497,7 +504,11 @@ async function screenshotViolatingElements(
497
504
  /**
498
505
  * Assert violations against a baseline allowlist (in-code or on-disk).
499
506
  */
500
- function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
507
+ function assertBaseline(
508
+ ctx: ScanContext,
509
+ baseline: AccessibilityBaseline,
510
+ sharedBaselineMatches?: Set<number>,
511
+ ) {
501
512
  validateAccessibilityBaseline(baseline);
502
513
  const { testInfo, results, scanLabel, expectFn } = ctx;
503
514
  const allViolations = extractNormalizedViolations(results);
@@ -517,6 +528,7 @@ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
517
528
 
518
529
  if (baselineIndex >= 0) {
519
530
  matchedBaselineIndices.add(baselineIndex);
531
+ sharedBaselineMatches?.add(baselineIndex);
520
532
  const entry = baseline[baselineIndex];
521
533
  testInfo.annotations.push({
522
534
  type: "Baselined a11y violation",
@@ -527,15 +539,21 @@ function assertBaseline(ctx: ScanContext, baseline: AccessibilityBaseline) {
527
539
  }
528
540
  }
529
541
 
530
- // Report stale baseline entries.
531
- baseline.forEach((entry, idx) => {
532
- if (!matchedBaselineIndices.has(idx)) {
533
- testInfo.annotations.push({
534
- type: "Stale a11y baseline entry",
535
- description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
536
- });
537
- }
538
- });
542
+ // The WCAG scan always runs last, even when best-practice is disabled.
543
+ // Report shared entries once after both scans have contributed matches,
544
+ // before the hard assertion can throw. On-disk baselines belong to one
545
+ // scan and continue to report stale entries independently.
546
+ if (!sharedBaselineMatches || ctx.scan === "wcag") {
547
+ const matches = sharedBaselineMatches ?? matchedBaselineIndices;
548
+ baseline.forEach((entry, idx) => {
549
+ if (!matches.has(idx)) {
550
+ testInfo.annotations.push({
551
+ type: "Stale a11y baseline entry",
552
+ description: `${entry.rule} on ${entry.targets.join(", ")} — no longer detected`,
553
+ });
554
+ }
555
+ });
556
+ }
539
557
 
540
558
  // Summary annotation for baseline mode.
541
559
  const baselinedCount = matchedBaselineIndices.size;
@@ -245,6 +245,7 @@ describe("generateSummary", () => {
245
245
  // attachment resolves, and that dead render is cached for good.
246
246
  const summary = generateSummary(reportWith("https://example.test/a"));
247
247
 
248
+ expect(summary).toContain(":x: **1** failing test(s)");
248
249
  expect(summary).not.toContain("<img");
249
250
  expect(summary).toContain(
250
251
  "1 screenshot(s) uploaded — see the pull request comment.",
@@ -396,7 +397,7 @@ describe("generateComment", () => {
396
397
  summaryUrl: "https://example.test/run/1",
397
398
  });
398
399
 
399
- expect(comment).toContain("**1** failing test(s)");
400
+ expect(comment).toContain(":x: **1** failing test(s)");
400
401
  expect(comment).toContain("homepage matches");
401
402
  expect(comment).toContain("(https://example.test/run/1)");
402
403
  });
@@ -382,7 +382,7 @@ function headline(report: FailureReport): string {
382
382
  const parts =
383
383
  report.totalFailed === 0
384
384
  ? [":white_check_mark: No failing tests"]
385
- : [`**${report.totalFailed}** failing test(s)`];
385
+ : [`:x: **${report.totalFailed}** failing test(s)`];
386
386
 
387
387
  if (report.totalFlaky > 0)
388
388
  parts.push(`:warning: **${report.totalFlaky}** flaky test(s)`);