@lullabot/playwright-testing 1.2.0 → 1.2.1

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.
@@ -12,6 +12,7 @@ exports.writeBaselineFile = writeBaselineFile;
12
12
  exports.buildSeed = buildSeed;
13
13
  const fs_1 = require("fs");
14
14
  const path_1 = __importDefault(require("path"));
15
+ const crypto_1 = require("crypto");
15
16
  const scanInvocationCounters = new WeakMap();
16
17
  /**
17
18
  * Increment and return the 1-indexed invocation number for this scan kind
@@ -41,9 +42,7 @@ function resetAccessibilityScanCounts(testInfo) {
41
42
  scanInvocationCounters.delete(testInfo);
42
43
  }
43
44
  /**
44
- * Slugify a test's fully qualified title the same way we use it as the
45
- * stem of both on-disk baseline filenames and (for existence checks) the
46
- * prefix of Playwright's auto-generated snapshot filenames.
45
+ * Slugify a test's fully qualified title for on-disk JSON baseline filenames.
47
46
  *
48
47
  * Implemented as a single-pass character scan to avoid regex-based
49
48
  * polynomial backtracking on library-supplied input (CodeQL
@@ -78,17 +77,20 @@ function slugifyTitle(testInfo) {
78
77
  return out;
79
78
  }
80
79
  /**
81
- * Reproduce the stem Playwright uses for auto-named snapshot files: the
82
- * title path (minus the spec file) joined with spaces, with every run of
83
- * control/punctuation characters collapsed to a single hyphen. Unlike
84
- * `slugifyTitle()` this preserves case and does not trim hyphens, so it
85
- * matches committed snapshot filenames exactly. Mirrors Playwright's
86
- * `sanitizeForFilePath()`. Very long titles, which Playwright truncates
87
- * and hashes, are not handled.
80
+ * Playwright's anonymous snapshot argument: trim the full title INCLUDING
81
+ * the counter to 100 UTF-16 code units, inserting five SHA-1 hex characters
82
+ * in the middle, then sanitize it. There is no public API for generating an
83
+ * arbitrary anonymous counter; snapshotPath() resolves this explicit argument.
84
+ * Keep this compatibility implementation covered by real runner tests.
88
85
  */
89
- function playwrightSnapshotStem(testInfo) {
86
+ function playwrightSnapshotStem(testInfo, counter) {
90
87
  const segments = testInfo.titlePath?.slice(1) ?? [];
91
- const raw = segments.length > 0 ? segments.join(" ") : testInfo.title;
88
+ const title = segments.length > 0 ? segments.join(" ") : testInfo.title;
89
+ let raw = `${title} ${counter}`;
90
+ if (raw.length > 100) {
91
+ const hash = (0, crypto_1.createHash)("sha1").update(raw).digest("hex").slice(0, 5);
92
+ raw = `${raw.slice(0, 46)}-${hash}-${raw.slice(-47)}`;
93
+ }
92
94
  let out = "";
93
95
  let lastWasReplaced = false;
94
96
  for (let i = 0; i < raw.length; i++) {
@@ -136,7 +138,7 @@ function baselineFilePath(testInfo, scan, callCount) {
136
138
  * defaulting new (snapshotless) tests into on-disk baseline mode.
137
139
  */
138
140
  async function snapshotExists(testInfo) {
139
- const dir = path_1.default.dirname(testInfo.snapshotPath("a11y-baseline-probe"));
141
+ const dir = path_1.default.dirname(testInfo.snapshotPath("a11y-baseline-probe.txt"));
140
142
  let entries;
141
143
  try {
142
144
  entries = await fs_1.promises.readdir(dir);
@@ -146,20 +148,24 @@ async function snapshotExists(testInfo) {
146
148
  return false;
147
149
  throw err;
148
150
  }
149
- // Playwright names snapshots `<stem>-<counter>[-<project>-<platform>].txt`.
150
- // Require the counter so a title that merely extends this one (e.g.
151
- // "Video" vs "Video Promo") is not mistaken for this test's snapshot.
152
- const prefix = `${playwrightSnapshotStem(testInfo)}-`;
153
- return entries.some((name) => {
154
- if (!name.startsWith(prefix) || !name.endsWith(".txt"))
155
- return false;
156
- let i = prefix.length;
157
- while (i < name.length &&
158
- name.charCodeAt(i) >= 48 &&
159
- name.charCodeAt(i) <= 57)
160
- i++;
161
- return i > prefix.length && (name[i] === "-" || name[i] === ".");
162
- });
151
+ // Extract possible counters, then compare the entire resolved path. A
152
+ // prefix match can accidentally select another test (e.g. "Video 2").
153
+ // Resolving via the public API honors project, suffix, and path templates.
154
+ for (const name of entries) {
155
+ if (!name.endsWith(".txt"))
156
+ continue;
157
+ for (const match of name.matchAll(/[0-9]+/g)) {
158
+ const counter = match[0];
159
+ if (counter.startsWith("0"))
160
+ continue;
161
+ const expected = testInfo.snapshotPath(`${playwrightSnapshotStem(testInfo, counter)}.txt`);
162
+ if (path_1.default.resolve(expected) === path_1.default.resolve(dir, name)) {
163
+ if ((await fs_1.promises.stat(expected)).isFile())
164
+ return true;
165
+ }
166
+ }
167
+ }
168
+ return false;
163
169
  }
164
170
  async function readBaselineFile(filePath) {
165
171
  let raw;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lullabot/playwright-testing",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Framework-neutral Playwright testing utilities",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { promises as fs } from "fs";
3
3
  import os from "os";
4
+ import { execFileSync } from "child_process";
5
+ import { createRequire } from "module";
4
6
  import path from "path";
5
7
  import {
6
8
  baselineFilePath,
@@ -16,6 +18,7 @@ function makeTestInfo(opts: {
16
18
  dir: string;
17
19
  title?: string;
18
20
  titlePath?: string[];
21
+ suffix?: string;
19
22
  }) {
20
23
  const title = opts.title ?? "standalone accessibility check works";
21
24
  const titlePath = opts.titlePath ?? ["file.spec.ts", title];
@@ -23,7 +26,16 @@ function makeTestInfo(opts: {
23
26
  testId: `${title}-${Math.random()}`,
24
27
  title,
25
28
  titlePath,
26
- snapshotPath: (...segs: string[]) => path.join(opts.dir, ...segs),
29
+ snapshotPath: (...segs: string[]) => {
30
+ const name = segs.join(path.sep);
31
+ const ext = path.extname(name);
32
+ return path.join(
33
+ opts.dir,
34
+ name.slice(0, name.length - ext.length) +
35
+ (ext === ".txt" ? (opts.suffix ?? "-chromium-linux") : "") +
36
+ ext,
37
+ );
38
+ },
27
39
  };
28
40
  }
29
41
 
@@ -115,37 +127,184 @@ describe("accessibility-baseline-file", () => {
115
127
  const ti = makeTestInfo({
116
128
  dir: tmpDir,
117
129
  title: "Test error pages: ID 3303",
130
+ suffix: "-desktop-chrome-linux",
118
131
  });
119
132
  expect(await snapshotExists(ti)).toBe(true);
120
133
  });
121
134
 
122
135
  it("matches with different project suffixes", async () => {
123
136
  await touch("Login-Works-1-mobile-safari-darwin.txt");
124
- const ti = makeTestInfo({ dir: tmpDir, title: "Login Works" });
137
+ const ti = makeTestInfo({
138
+ dir: tmpDir,
139
+ title: "Login Works",
140
+ suffix: "-mobile-safari-darwin",
141
+ });
125
142
  expect(await snapshotExists(ti)).toBe(true);
126
143
  });
127
144
 
145
+ it("does not match a numeric extension of the title or another project", async () => {
146
+ await touch("Login-Works-2-1-chromium-linux.txt");
147
+ await touch("Login-Works-1-other-project-linux.txt");
148
+ expect(
149
+ await snapshotExists(
150
+ makeTestInfo({ dir: tmpDir, title: "Login Works" }),
151
+ ),
152
+ ).toBe(false);
153
+ });
154
+
155
+ it("matches punctuation adjoining literal hyphens and trailing separators", async () => {
156
+ await touch(
157
+ "Landing-Page-Content-of-type-Landing-Page-2-bureau-of-sorcery-tablet-linux.txt",
158
+ );
159
+ await touch(
160
+ "admin-toolbar-hover-expansion-When-I-mouse-over-an-admin-menu-item-it-expands-consistently-3-desktop-chrome-linux.txt",
161
+ );
162
+ expect(
163
+ await snapshotExists(
164
+ makeTestInfo({
165
+ dir: tmpDir,
166
+ titlePath: [
167
+ "file.spec.ts",
168
+ "Landing Page",
169
+ "Content of type Landing Page!",
170
+ ],
171
+ suffix: "-bureau-of-sorcery-tablet-linux",
172
+ }),
173
+ ),
174
+ ).toBe(true);
175
+ expect(
176
+ await snapshotExists(
177
+ makeTestInfo({
178
+ dir: tmpDir,
179
+ titlePath: [
180
+ "file.spec.ts",
181
+ "admin toolbar hover expansion",
182
+ "When I mouse over an admin menu item it expands consistently!",
183
+ ],
184
+ suffix: "-desktop-chrome-linux",
185
+ }),
186
+ ),
187
+ ).toBe(true);
188
+ });
189
+
190
+ it.each([
191
+ undefined,
192
+ "{testDir}/snapshots/{projectName}/{arg}-{platform}{ext}",
193
+ ])(
194
+ "detects real Playwright snapshots with template %s",
195
+ async (template) => {
196
+ const require = createRequire(import.meta.url);
197
+ const runner = require.resolve("@playwright/test/cli");
198
+ const api = require.resolve("@playwright/test");
199
+ const helper = require.resolve("./accessibility-baseline-file.ts");
200
+ const cases = [
201
+ [
202
+ "News",
203
+ "The new Defense Against the Dark Arts teacher",
204
+ "4/07/26 new defense against dark arts teacher!",
205
+ ],
206
+ ["Landing Page", "Content of type Landing Page!"],
207
+ [
208
+ "admin toolbar hover expansion",
209
+ "When I mouse over an admin menu item it expands consistently!",
210
+ ],
211
+ ["Nested: suite-", "Inner / describe", "Punctuation!? - trailing-"],
212
+ ["Long " + "title!? - ".repeat(20), "Same ending"],
213
+ ["Long " + "title!? - ".repeat(20) + "different", "Same ending"],
214
+ ];
215
+ await fs.writeFile(
216
+ path.join(tmpDir, "playwright.config.cjs"),
217
+ `module.exports = {
218
+ testDir: '.', outputDir: 'output', reporter: 'line',
219
+ projects: [{name: 'bureau-of-sorcery-chrome'}, {name: 'tablet'}],
220
+ snapshotPathTemplate: ${JSON.stringify(template)}
221
+ };`,
222
+ );
223
+ await fs.writeFile(
224
+ path.join(tmpDir, "generated.spec.cjs"),
225
+ `
226
+ const {test, expect} = require(${JSON.stringify(api)});
227
+ const {snapshotExists} = require(${JSON.stringify(helper)});
228
+ const fs = require('fs');
229
+ const path = require('path');
230
+ for (const [outer, inner, title = inner] of ${JSON.stringify(cases)}) {
231
+ test.describe(outer, () => test.describe(inner, () => {
232
+ test(title, async ({}, info) => {
233
+ expect(await snapshotExists(info)).toBe(false);
234
+ for (let i = 0; i < 12; i++) expect('[]').toMatchSnapshot();
235
+ const dir = path.dirname(info.snapshotPath('probe.txt'));
236
+ const files = fs.readdirSync(dir).filter(name => name.endsWith('.txt'));
237
+ // Leave only the twelfth snapshot for this test: detection cannot
238
+ // assume that the first accessibility scan has counter one.
239
+ const own = files.filter(name => name.includes('-12-'));
240
+ for (const name of files) if (!own.includes(name)) fs.unlinkSync(path.join(dir, name));
241
+ expect(await snapshotExists(info)).toBe(true);
242
+ expect('[]').toMatchSnapshot();
243
+ const next = fs.readdirSync(dir).find(name => name.includes('-13-'));
244
+ expect(next).toBeTruthy(); // detection did not consume counters
245
+ fs.unlinkSync(path.join(dir, next));
246
+ expect(await snapshotExists({...info, titlePath: [...info.titlePath.slice(0, -1), title + ' 2'], snapshotPath: info.snapshotPath.bind(info)})).toBe(false);
247
+ expect(await snapshotExists({...info, titlePath: [info.titlePath[0], outer + 'different', ...info.titlePath.slice(2)], snapshotPath: info.snapshotPath.bind(info)})).toBe(false);
248
+ for (const name of own) fs.renameSync(path.join(dir, name), path.join(dir, name.slice(0, -4) + '.png'));
249
+ expect(await snapshotExists(info)).toBe(false);
250
+ });
251
+ }));
252
+ }
253
+ `,
254
+ );
255
+ execFileSync(
256
+ process.execPath,
257
+ [
258
+ runner,
259
+ "test",
260
+ "--config",
261
+ path.join(tmpDir, "playwright.config.cjs"),
262
+ "--update-snapshots",
263
+ "--workers=1",
264
+ ],
265
+ { encoding: "utf8", timeout: 30000 },
266
+ );
267
+ },
268
+ 40000,
269
+ );
270
+
128
271
  it("does not match a title that differs only by case", async () => {
129
272
  await touch("login-works-1-desktop-chrome-linux.txt");
130
- const ti = makeTestInfo({ dir: tmpDir, title: "Login Works" });
273
+ const ti = makeTestInfo({
274
+ dir: tmpDir,
275
+ title: "Login Works",
276
+ suffix: "-desktop-chrome-linux",
277
+ });
131
278
  expect(await snapshotExists(ti)).toBe(false);
132
279
  });
133
280
 
134
281
  it("returns false when nothing matches", async () => {
135
282
  await touch("Other-Test-1-desktop-chrome-linux.txt");
136
- const ti = makeTestInfo({ dir: tmpDir, title: "Login Works" });
283
+ const ti = makeTestInfo({
284
+ dir: tmpDir,
285
+ title: "Login Works",
286
+ suffix: "-desktop-chrome-linux",
287
+ });
137
288
  expect(await snapshotExists(ti)).toBe(false);
138
289
  });
139
290
 
140
291
  it("ignores a matching .png without a .txt", async () => {
141
292
  await touch("Login-Works-1-desktop-chrome-linux.png");
142
- const ti = makeTestInfo({ dir: tmpDir, title: "Login Works" });
293
+ const ti = makeTestInfo({
294
+ dir: tmpDir,
295
+ title: "Login Works",
296
+ suffix: "-desktop-chrome-linux",
297
+ });
143
298
  expect(await snapshotExists(ti)).toBe(false);
144
299
  });
145
300
 
146
301
  it("does not match a different test whose title extends this one", async () => {
147
302
  await touch("CRUD-tests-Video-Promo-1-desktop-chrome-linux.txt");
148
- const ti = makeTestInfo({ dir: tmpDir, title: "CRUD tests - Video" });
303
+ const ti = makeTestInfo({
304
+ dir: tmpDir,
305
+ title: "CRUD tests - Video",
306
+ suffix: "-desktop-chrome-linux",
307
+ });
149
308
  expect(await snapshotExists(ti)).toBe(false);
150
309
  });
151
310
  });
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from "fs";
2
2
  import path from "path";
3
+ import { createHash } from "crypto";
3
4
  import type { TestInfo } from "@playwright/test";
4
5
  import type { AccessibilityBaselineEntry } from "./accessibility-baseline.js";
5
6
 
@@ -45,9 +46,7 @@ export function resetAccessibilityScanCounts(testInfo: object): void {
45
46
  }
46
47
 
47
48
  /**
48
- * Slugify a test's fully qualified title the same way we use it as the
49
- * stem of both on-disk baseline filenames and (for existence checks) the
50
- * prefix of Playwright's auto-generated snapshot filenames.
49
+ * Slugify a test's fully qualified title for on-disk JSON baseline filenames.
51
50
  *
52
51
  * Implemented as a single-pass character scan to avoid regex-based
53
52
  * polynomial backtracking on library-supplied input (CodeQL
@@ -80,19 +79,23 @@ function slugifyTitle(testInfo: Pick<TestInfo, "titlePath" | "title">): string {
80
79
  }
81
80
 
82
81
  /**
83
- * Reproduce the stem Playwright uses for auto-named snapshot files: the
84
- * title path (minus the spec file) joined with spaces, with every run of
85
- * control/punctuation characters collapsed to a single hyphen. Unlike
86
- * `slugifyTitle()` this preserves case and does not trim hyphens, so it
87
- * matches committed snapshot filenames exactly. Mirrors Playwright's
88
- * `sanitizeForFilePath()`. Very long titles, which Playwright truncates
89
- * and hashes, are not handled.
82
+ * Playwright's anonymous snapshot argument: trim the full title INCLUDING
83
+ * the counter to 100 UTF-16 code units, inserting five SHA-1 hex characters
84
+ * in the middle, then sanitize it. There is no public API for generating an
85
+ * arbitrary anonymous counter; snapshotPath() resolves this explicit argument.
86
+ * Keep this compatibility implementation covered by real runner tests.
90
87
  */
91
88
  function playwrightSnapshotStem(
92
89
  testInfo: Pick<TestInfo, "titlePath" | "title">,
90
+ counter: string,
93
91
  ): string {
94
92
  const segments = testInfo.titlePath?.slice(1) ?? [];
95
- const raw = segments.length > 0 ? segments.join(" ") : testInfo.title;
93
+ const title = segments.length > 0 ? segments.join(" ") : testInfo.title;
94
+ let raw = `${title} ${counter}`;
95
+ if (raw.length > 100) {
96
+ const hash = createHash("sha1").update(raw).digest("hex").slice(0, 5);
97
+ raw = `${raw.slice(0, 46)}-${hash}-${raw.slice(-47)}`;
98
+ }
96
99
  let out = "";
97
100
  let lastWasReplaced = false;
98
101
  for (let i = 0; i < raw.length; i++) {
@@ -148,7 +151,7 @@ export function baselineFilePath(
148
151
  export async function snapshotExists(
149
152
  testInfo: Pick<TestInfo, "snapshotPath" | "titlePath" | "title">,
150
153
  ): Promise<boolean> {
151
- const dir = path.dirname(testInfo.snapshotPath("a11y-baseline-probe"));
154
+ const dir = path.dirname(testInfo.snapshotPath("a11y-baseline-probe.txt"));
152
155
  let entries: string[];
153
156
  try {
154
157
  entries = await fs.readdir(dir);
@@ -156,21 +159,23 @@ export async function snapshotExists(
156
159
  if (err?.code === "ENOENT") return false;
157
160
  throw err;
158
161
  }
159
- // Playwright names snapshots `<stem>-<counter>[-<project>-<platform>].txt`.
160
- // Require the counter so a title that merely extends this one (e.g.
161
- // "Video" vs "Video Promo") is not mistaken for this test's snapshot.
162
- const prefix = `${playwrightSnapshotStem(testInfo)}-`;
163
- return entries.some((name) => {
164
- if (!name.startsWith(prefix) || !name.endsWith(".txt")) return false;
165
- let i = prefix.length;
166
- while (
167
- i < name.length &&
168
- name.charCodeAt(i) >= 48 &&
169
- name.charCodeAt(i) <= 57
170
- )
171
- i++;
172
- return i > prefix.length && (name[i] === "-" || name[i] === ".");
173
- });
162
+ // Extract possible counters, then compare the entire resolved path. A
163
+ // prefix match can accidentally select another test (e.g. "Video 2").
164
+ // Resolving via the public API honors project, suffix, and path templates.
165
+ for (const name of entries) {
166
+ if (!name.endsWith(".txt")) continue;
167
+ for (const match of name.matchAll(/[0-9]+/g)) {
168
+ const counter = match[0];
169
+ if (counter.startsWith("0")) continue;
170
+ const expected = testInfo.snapshotPath(
171
+ `${playwrightSnapshotStem(testInfo, counter)}.txt`,
172
+ );
173
+ if (path.resolve(expected) === path.resolve(dir, name)) {
174
+ if ((await fs.stat(expected)).isFile()) return true;
175
+ }
176
+ }
177
+ }
178
+ return false;
174
179
  }
175
180
 
176
181
  export async function readBaselineFile(
@@ -959,13 +959,15 @@ describe("accessibility baseline", () => {
959
959
  });
960
960
 
961
961
  it("still uses snapshot mode when a legacy snapshot file exists for the test", async () => {
962
- mockAnalyze.mockResolvedValue(makeAxeResults({ violations: [] }));
962
+ mockAnalyze.mockResolvedValue(
963
+ makeAxeResults({ violations: [makeViolation("image-alt", [["img"]])] }),
964
+ );
963
965
 
964
966
  // Pre-create a Playwright-style snapshot file in the snapshots dir.
965
967
  const fs = await import("fs");
966
968
  const path = await import("path");
967
969
  await fs.promises.writeFile(
968
- path.join(tmpDir, "legacy-test-1-chromium-linux.txt"),
970
+ path.join(tmpDir, "legacy-test-1.txt"),
969
971
  "[]\n",
970
972
  );
971
973
 
@@ -978,6 +980,21 @@ describe("accessibility baseline", () => {
978
980
  bestPracticeMode: "off",
979
981
  });
980
982
 
983
+ // A retry must stay in snapshot mode without creating TODO metadata.
984
+ await checkAccessibility(
985
+ makePage() as any,
986
+ makeTestInfo({
987
+ updateSnapshots: "none",
988
+ snapshotsDir: tmpDir,
989
+ title: "legacy test",
990
+ }) as any,
991
+ { bestPracticeMode: "off" },
992
+ );
993
+ expect(
994
+ (await fs.promises.readdir(tmpDir)).filter((name) =>
995
+ name.endsWith(".json"),
996
+ ),
997
+ ).toEqual([]);
981
998
  // Snapshot mode -> toMatchSnapshot was invoked.
982
999
  expect(mockToMatchSnapshot).toHaveBeenCalled();
983
1000
  });