@testrelic/playwright-analytics 2.16.1 → 2.16.2-next.152
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/api-fixture.cjs +7 -6
- package/dist/api-fixture.cjs.map +1 -1
- package/dist/api-fixture.js +7 -6
- package/dist/api-fixture.js.map +1 -1
- package/dist/cli.cjs +15 -5
- package/dist/fixture.cjs +7 -6
- package/dist/fixture.cjs.map +1 -1
- package/dist/fixture.js +7 -6
- package/dist/fixture.js.map +1 -1
- package/dist/index.cjs +112 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +112 -107
- package/dist/index.js.map +1 -1
- package/dist/merge.cjs +1 -1
- package/dist/merge.cjs.map +1 -1
- package/dist/merge.js +1 -1
- package/dist/merge.js.map +1 -1
- package/dist/reporter-entry.cjs +55 -51
- package/dist/reporter-entry.cjs.map +1 -1
- package/dist/reporter-entry.d.cts +7 -0
- package/dist/reporter-entry.d.ts +7 -0
- package/dist/reporter-entry.js +55 -51
- package/dist/reporter-entry.js.map +1 -1
- package/dist/visual.cjs +2 -2
- package/dist/visual.cjs.map +1 -1
- package/dist/visual.d.cts +1 -0
- package/dist/visual.d.ts +1 -0
- package/dist/visual.js +2 -2
- package/dist/visual.js.map +1 -1
- package/package.json +2 -2
package/dist/visual.cjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
'use strict';var test=require('@playwright/test'),
|
|
2
|
-
`));}var
|
|
1
|
+
'use strict';var test=require('@playwright/test'),fs=require('fs'),path=require('path');Buffer.from([137,80,78,71,13,10,26,10]);var k="__testrelic_snapshot_path_probe__.png",y=Symbol.for("testrelic.snapshotPathWarned");function T(){let t=globalThis,e=t[y];return e||(e={warned:false},t[y]=e),e}function f(t){try{let e=t.snapshotPath(k,{kind:"screenshot"});return typeof e=="string"&&!e.includes("[object Object]")}catch{return false}}function m(t){let e=T();e.warned||(e.warned=true,process.stderr.write(`[testrelic] ${t} needs Playwright >= 1.51 for testInfo.snapshotPath(name, { kind }); this project's Playwright resolves that call the older way. Everything else in the reporter is unaffected \u2014 upgrade Playwright to enable it.
|
|
2
|
+
`));}var b="testrelic-visual";var E=[".png",".jpg",".jpeg"],B=/A snapshot doesn't exist at /;function C(t){return t.wrote?t.existedBefore?"updated":"new":t.pass?"passed":"failed"}function v(t){return t.status==="failed"&&!t.hadBaseline}function x(t){if(!t)return null;try{let e=fs.statSync(t);return {size:e.size,mtimeMs:e.mtimeMs}}catch{return null}}function _(t,e){if(!f(t))return null;try{return t.snapshotPath(e,{kind:"screenshot"})}catch{return null}}function O(t){let e=t.toLowerCase();return E.some(o=>e.endsWith(o))?t:`${t}.png`}function V(){try{return test.test.info()}catch{return null}}function I(t,e,o){let r=n=>/^(.+)-(expected|actual|diff|previous)\.[A-Za-z0-9]+$/.exec(n)?.[1]??null,i=t.slice(e);if(o){for(let n of i)if(n.path===o&&/-expected\.[A-Za-z0-9]+$/.test(n.name))return r(n.name)}for(let n of i){let c=r(n.name);if(c)return c}return null}function L(t,e){return e?path.basename(e,path.extname(e)||".png"):t.replace(/\.[A-Za-z0-9]+$/,"")}async function j(t,e){if(e===null)return m("Attaching the baseline of a passing comparison"),null;try{let o=path.extname(e)||".png",r=path.basename(e,o);return await t.attach(`${r}-expected${o}`,{path:e}),r}catch{return null}}async function H(t,e){try{await t.attach(b,{body:Buffer.from(JSON.stringify([e])),contentType:"application/json"});}catch{}}async function p(t,e,o={}){let r={...o};delete r.tags;let i=O(e),n=V(),c=n?.attachments.length??0,D=n?.errors?.length??0,a=n?_(n,i):null,u=x(a),l=true,h="";try{await test.expect(t).toHaveScreenshot(i,r);}catch(s){l=false,h=s instanceof Error?s.message:String(s);}if(n){let s=x(a),P=a!==null?s!==null&&(u===null||u.size!==s.size||u.mtimeMs!==s.mtimeMs):(n.errors??[]).slice(D).some(M=>B.test(M.message??"")),d=C({pass:l,wrote:P,existedBefore:u!==null}),g=(d==="passed"||d==="updated"?await j(n,a):I(n.attachments,c,a))??(v({status:d,hadBaseline:u!==null})?L(i,a):null);g&&await H(n,{stem:g,name:e,status:d,...a?{committedPath:a}:{}});}return {pass:l,name:"toMatchVisualBaseline",message:()=>l?`Expected "${e}" to differ from its visual baseline, but it matched.`:h}}test.expect.extend({toMatchVisualBaseline:p});exports.toMatchVisualBaseline=p;//# sourceMappingURL=visual.cjs.map
|
|
3
3
|
//# sourceMappingURL=visual.cjs.map
|
package/dist/visual.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/visual-metrics.ts","../src/snapshot-path-support.ts","../src/visual-capture.ts","../src/visual-matcher.ts","../src/visual.ts"],"names":["SIZE_MISMATCH_RE","isSizeMismatchFailure","message","PROBE_NAME","WARNED_KEY","warnSlot","g","slot","supportsSnapshotPathOptions","testInfo","resolved","warnSnapshotPathUnsupported","feature","VISUAL_META_ATTACHMENT","IMAGE_EXTENSIONS","normalizeSnapshotName","name","lower","ext","currentTestInfo","playwrightTest","stemOfNewAttachments","attachments","addedAfter","i","match","attachPassingBaseline","snapshotName","path","extname","stem","basename","attachMeta","record","toMatchVisualBaseline","target","options","screenshotOptions","attachedBefore","pass","playwrightExpect","error","wroteNewBaseline","a"],"mappings":"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","file":"visual.cjs","sourcesContent":["/**\n * Numbers for a visual comparison, recovered without decoding an image.\n *\n * Playwright compares images internally and keeps the result to itself: the\n * differing-pixel count exists only as prose inside the failure message, and\n * the image dimensions only inside the PNG. Rather than decode a PNG (which\n * would mean a dependency, and Principle V says no), both are read back — the\n * count from the message Playwright already formats, the dimensions from the\n * 8 bytes of header every PNG carries.\n */\n\n/** PNG magic number: \\x89 P N G \\r \\n \\x1a \\n */\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\n\n/** Byte offsets of width and height inside the IHDR chunk of a PNG. */\nconst IHDR_WIDTH_OFFSET = 16;\nconst IHDR_HEIGHT_OFFSET = 20;\n\n/** Enough bytes to cover the signature, the chunk header and both dimensions. */\nexport const PNG_HEADER_BYTES = 24;\n\nexport interface ImageDimensions {\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Read a PNG's pixel dimensions from its IHDR chunk.\n *\n * Only the first 24 bytes are consulted, so the caller can hand over a partial\n * read of a large file. Returns `null` for anything that is not a PNG — JPEG\n * baselines are legal in Playwright and simply go un-measured.\n */\nexport function readPngDimensions(header: Buffer): ImageDimensions | null {\n if (header.length < PNG_HEADER_BYTES) return null;\n if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return null;\n\n const width = header.readUInt32BE(IHDR_WIDTH_OFFSET);\n const height = header.readUInt32BE(IHDR_HEIGHT_OFFSET);\n\n // A zero dimension is not a valid PNG, and would make a ratio divide by zero.\n if (width === 0 || height === 0) return null;\n\n return { width, height };\n}\n\nexport interface ParsedDiffMessage {\n /** Differing pixel count, from `\"N pixels (ratio …) are different.\"` */\n readonly diffPixels?: number;\n /** Baseline dimensions, present only when the two images differ in size. */\n readonly expectedWidth?: number;\n readonly expectedHeight?: number;\n /** Actual dimensions, present only when the two images differ in size. */\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The snapshot name Playwright echoed, when the assertion supplied one. */\n readonly snapshotName?: string;\n}\n\n/**\n * `123 pixels (ratio 0.02 of all image pixels) are different.`\n *\n * The ratio in that sentence is rounded up to two decimals, so it is read past\n * deliberately — the caller recomputes it from the count and the dimensions.\n */\nconst PIXELS_DIFFERENT_RE = /(\\d+) pixels \\(ratio [\\d.]+ of all image pixels\\) are different/;\n\n/** `Expected an image 800px by 600px, received 800px by 640px.` */\nconst SIZE_MISMATCH_RE = /Expected an image (\\d+)px by (\\d+)px, received (\\d+)px by (\\d+)px/;\n\n/**\n * Whether a failure message is a size mismatch.\n *\n * The comparator cannot draw a diff across differing dimensions, so it\n * attaches expected + actual and throws instead. Callers that would otherwise\n * infer \"no baseline existed\" from a missing diff need this to tell the two\n * apart — without it a real failure was reported as a freshly written\n * baseline.\n */\nexport function isSizeMismatchFailure(message: string | null | undefined): boolean {\n if (!message) return false;\n return SIZE_MISMATCH_RE.test(message);\n}\n\n/** ` Snapshot: home.png` — only emitted when the assertion named the snapshot. */\nconst SNAPSHOT_NAME_RE = /^\\s*Snapshot:\\s*(.+?)\\s*$/m;\n\n/**\n * Pull the comparison numbers out of a Playwright snapshot failure message.\n *\n * Every field is optional and a malformed message yields an empty object: a\n * report that is missing a pixel count is a far better outcome than a reporter\n * that throws while assembling one.\n */\nexport function parseDiffMessage(message: string | null | undefined): ParsedDiffMessage {\n if (!message) return {};\n\n const result: {\n diffPixels?: number;\n expectedWidth?: number;\n expectedHeight?: number;\n actualWidth?: number;\n actualHeight?: number;\n snapshotName?: string;\n } = {};\n\n const pixels = PIXELS_DIFFERENT_RE.exec(message);\n if (pixels) {\n const count = Number.parseInt(pixels[1] as string, 10);\n if (Number.isFinite(count)) result.diffPixels = count;\n }\n\n const sizes = SIZE_MISMATCH_RE.exec(message);\n if (sizes) {\n const [, ew, eh, aw, ah] = sizes;\n const parsed = [ew, eh, aw, ah].map((v) => Number.parseInt(v as string, 10));\n if (parsed.every((v) => Number.isFinite(v))) {\n result.expectedWidth = parsed[0];\n result.expectedHeight = parsed[1];\n result.actualWidth = parsed[2];\n result.actualHeight = parsed[3];\n }\n }\n\n const name = SNAPSHOT_NAME_RE.exec(message);\n if (name) result.snapshotName = name[1];\n\n return result;\n}\n\n/**\n * Differing pixels as a fraction of the image, at full precision.\n *\n * Returns `undefined` rather than a wrong number when either input is missing,\n * so a consumer can tell \"no measurement\" apart from \"measured zero\".\n */\nexport function computeDiffRatio(\n diffPixels: number | undefined,\n dimensions: ImageDimensions | null | undefined,\n): number | undefined {\n if (diffPixels === undefined || !dimensions) return undefined;\n const total = dimensions.width * dimensions.height;\n if (total <= 0) return undefined;\n return diffPixels / total;\n}\n","/**\n * Whether this Playwright supports `snapshotPath(name, { kind })`.\n *\n * The options form arrived in Playwright 1.51. Before that `snapshotPath` took\n * variadic path segments, so an options object is consumed as a segment and\n * the resolved path is wrong — and because both call sites sit inside a\n * `try`/`catch` that swallows everything, DOM comparison and the\n * passing-baseline attachment simply never worked and said nothing about it.\n *\n * The package's peer range is `>=1.35.0` and stays that way deliberately:\n * everything except these two visual extras works on the older versions, and\n * raising the floor would lock those users out of the whole reporter over one\n * feature. So the loss is detected and *announced* once, instead of being\n * silent.\n *\n * The check is a feature probe rather than a version comparison. Reading\n * `@playwright/test/package.json` meant resolving a module at runtime from a\n * file that has to stay importable from both the CJS and the ESM build, and\n * the only spelling that worked in both reached the module loader through a\n * dynamically evaluated string. The repo's payload scanner rejects that shape\n * on sight, and rightly — this package is published to npm, and the scanner\n * does not exempt code that means well. Asking the API what it does needs no\n * module resolution, no dynamic execution and no version table, and it tests\n * the behaviour actually depended on rather than a number that correlates\n * with it.\n */\n\n/**\n * The one method probed. Both call sites already hold a `testInfo`; this is the\n * narrowest shape that covers the two call forms.\n */\nexport interface SnapshotPathCapable {\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n}\n\n/**\n * Passed to the probe. Never written to disk — `snapshotPath` only computes a\n * path — and named so it is unmistakable in a stack trace or a log line.\n */\nconst PROBE_NAME = '__testrelic_snapshot_path_probe__.png';\n\n/** Printed at most once per process, however many tests hit the path. */\nconst WARNED_KEY = Symbol.for('testrelic.snapshotPathWarned');\n\ninterface WarnSlot {\n warned: boolean;\n}\n\n/**\n * The latch lives on `globalThis`, not in a module variable.\n *\n * tsup builds with `splitting: false`, so every entry point bundles its own\n * copy of this module — and this module is reached from five of them. A\n * module-level `let` would therefore be five separate latches, and the\n * \"at most once per process\" promise above would print up to five times. This\n * is the same fix, and the same reason, as the active context in\n * `assertion-tracker.ts`.\n */\nfunction warnSlot(): WarnSlot {\n const g = globalThis as unknown as Record<symbol, WarnSlot | undefined>;\n let slot = g[WARNED_KEY];\n if (!slot) {\n slot = { warned: false };\n g[WARNED_KEY] = slot;\n }\n return slot;\n}\n\n/**\n * True when `snapshotPath(name, { kind })` resolves the way this code needs.\n *\n * The probe asks what the OLD form would have done wrong, not what the new one\n * returns verbatim. Before 1.51 the options object is consumed as another path\n * segment: `path.join` throws on a non-string, and a version that coerced it\n * instead would stringify it into the path as `[object Object]`. Either shape\n * answers `false`.\n *\n * It deliberately does NOT check that the result ends with the name it was\n * given. It did, and that was wrong: `snapshotPathTemplate` legitimately\n * inserts the project and platform before the extension, so\n * `toHaveScreenshot('dashboard.png')` resolves to `dashboard-win32.png` on\n * every default Playwright project. The probe therefore answered `false`\n * everywhere and silently disabled BOTH of its callers — passing-baseline\n * capture and DOM comparison — which shipped in 2.16.0 and is why a green\n * visual run recorded no comparisons at all and no element changes ever\n * appeared. Never assert on the shape of a path a template owns.\n */\nexport function supportsSnapshotPathOptions(testInfo: SnapshotPathCapable): boolean {\n try {\n const resolved = testInfo.snapshotPath(PROBE_NAME, { kind: 'screenshot' });\n return typeof resolved === 'string' && !resolved.includes('[object Object]');\n } catch {\n return false;\n }\n}\n\n/**\n * Say once that a visual extra is unavailable on this Playwright, and why.\n *\n * `feature` names what the user loses, so the line is actionable rather than\n * an abstract version complaint.\n */\nexport function warnSnapshotPathUnsupported(feature: string): void {\n const slot = warnSlot();\n if (slot.warned) return;\n slot.warned = true;\n process.stderr.write(\n `[testrelic] ${feature} needs Playwright >= 1.51 for ` +\n `testInfo.snapshotPath(name, { kind }); this project's Playwright ` +\n `resolves that call the older way. Everything else in the reporter is ` +\n `unaffected — upgrade Playwright to enable it.\\n`,\n );\n}\n\n/** Reset the once-per-process latch. Test-only. */\nexport function resetSnapshotPathWarning(): void {\n warnSlot().warned = false;\n}\n","/**\n * Recover visual baseline comparisons from a Playwright test result.\n *\n * Playwright's snapshot matchers do not report what they compared; they only\n * leave attachments behind. `toHaveScreenshot()` / `toMatchSnapshot()` attach\n * `<stem>-expected`, `<stem>-actual`, `<stem>-diff` and, on a retried failure,\n * `<stem>-previous`. Those four share a stem, and the stem is the snapshot's\n * identity within the test.\n *\n * This module is pure — it groups names and reads a message, and touches no\n * filesystem. Copying the images out is `artifact-manager.copyVisualArtifacts`.\n */\n\nimport type { VisualSource, VisualStatus } from '@testrelic/core';\nimport { parseDiffMessage, isSizeMismatchFailure } from './visual-metrics.js';\nimport { VISUAL_DOM_ATTACHMENT, type VisualDomRecord } from './visual-expect.js';\n\ninterface Attachment {\n name: string;\n contentType: string;\n path?: string;\n body?: Buffer;\n}\n\n/**\n * A comparison found in the attachments, still pointing at Playwright's temp\n * files. `artifact-manager` turns this into the report-facing\n * `VisualComparison` once the images have been copied somewhere durable.\n */\nexport interface VisualCandidate {\n readonly name: string;\n readonly status: VisualStatus;\n readonly source: VisualSource;\n readonly expectedPath?: string;\n readonly actualPath?: string;\n readonly diffPath?: string;\n readonly previousPath?: string;\n readonly diffPixels?: number;\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The DOM comparison for this snapshot, when one was made. */\n readonly dom?: VisualDomRecord;\n}\n\n/** Attachment name carrying the explicit matcher's JSON side-channel. */\nexport const VISUAL_META_ATTACHMENT = 'testrelic-visual';\n\n/** The four roles a snapshot image can play, as Playwright suffixes them. */\ntype VisualRole = 'expected' | 'actual' | 'diff' | 'previous';\n\nconst VISUAL_SUFFIX_RE = /^(.+)-(expected|actual|diff|previous)(\\.[A-Za-z0-9]+)$/;\n\n/** One stem's worth of images, before a status is decided. */\ninterface StemGroup {\n expected?: string;\n actual?: string;\n diff?: string;\n previous?: string;\n}\n\n/**\n * Metadata the `toMatchVisualBaseline` matcher attaches alongside its images.\n *\n * It exists because a passing comparison is otherwise invisible: Playwright\n * attaches nothing at all when the images match, so without this the report\n * could only ever show the failures.\n */\nexport interface VisualMetaRecord {\n readonly stem: string;\n readonly name: string;\n readonly status: VisualStatus;\n}\n\n/** Read and validate the explicit matcher's metadata attachment. */\nexport function parseVisualMeta(attachments: readonly Attachment[]): Map<string, VisualMetaRecord> {\n const byStem = new Map<string, VisualMetaRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_META_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n const record = toMetaRecord(entry);\n if (record) byStem.set(record.stem, record);\n }\n } catch {\n // A malformed side-channel costs us the pass-case detail, nothing more.\n }\n }\n\n return byStem;\n}\n\nconst VALID_STATUSES: readonly string[] = ['passed', 'failed', 'new', 'updated'];\n\n// SAFETY: parsed from an attachment body written by the worker process; every\n// field is checked before use.\nfunction toMetaRecord(entry: unknown): VisualMetaRecord | null {\n if (typeof entry !== 'object' || entry === null) return null;\n const e = entry as Record<string, unknown>;\n if (typeof e.stem !== 'string' || !e.stem) return null;\n if (typeof e.name !== 'string' || !e.name) return null;\n if (typeof e.status !== 'string' || !VALID_STATUSES.includes(e.status)) return null;\n return { stem: e.stem, name: e.name, status: e.status as VisualStatus };\n}\n\n/**\n * Read the DOM comparison side-channel, keyed by the same stem the images use.\n *\n * Written by `visual-expect`; absent whenever the assertion used Playwright's\n * own `expect`, or the page had no DOM baseline yet.\n */\nexport function parseVisualDom(\n attachments: readonly Attachment[],\n): Map<string, VisualDomRecord> {\n const byStem = new Map<string, VisualDomRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_DOM_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n // SAFETY: written by the worker process; the two fields read before\n // anything else uses it are checked here.\n if (typeof entry !== 'object' || entry === null) continue;\n const record = entry as Record<string, unknown>;\n if (typeof record.stem !== 'string' || !Array.isArray(record.changes)) continue;\n byStem.set(record.stem, entry as VisualDomRecord);\n }\n } catch {\n // A malformed side-channel costs the element list, nothing more.\n }\n }\n\n return byStem;\n}\n\n/** Group every `<stem>-<role>.<ext>` attachment that has a file behind it. */\nfunction groupBySnapshotStem(attachments: readonly Attachment[]): Map<string, StemGroup> {\n const groups = new Map<string, StemGroup>();\n\n for (const attachment of attachments) {\n if (!attachment.path) continue;\n const match = VISUAL_SUFFIX_RE.exec(attachment.name);\n if (!match) continue;\n\n const [, stem, role] = match;\n const group = groups.get(stem as string) ?? {};\n group[role as VisualRole] = attachment.path;\n groups.set(stem as string, group);\n }\n\n return groups;\n}\n\n/**\n * Decide whether a stem is really a snapshot comparison.\n *\n * An ordinary user attachment named `report-actual.json` would otherwise be\n * mistaken for one. A genuine comparison always produces an `actual` next to\n * either the baseline it was compared against or the diff it produced, so\n * requiring that pair costs nothing real and rejects the lookalikes. Stems the\n * explicit matcher has vouched for skip this check — it attaches a lone\n * baseline on a pass, which is the one legitimate single-image case.\n */\nfunction isCredibleComparison(group: StemGroup): boolean {\n return Boolean(group.actual && (group.expected || group.diff));\n}\n\n/** Derive a status from the images alone, for natively-produced comparisons. */\nfunction deriveStatus(\n group: StemGroup,\n updatingSnapshots: boolean,\n failureMessage: string | null | undefined,\n): VisualStatus {\n if (group.diff) return 'failed';\n // A size mismatch also produces no diff — the comparator cannot draw one\n // when the two images have different dimensions, so it attaches expected +\n // actual and throws. Absence of a diff therefore does NOT imply \"no baseline\n // existed\": read the message before concluding that. Without this a genuine\n // failure was reported as a freshly written baseline, it was missing from\n // visualFailures / visualFailedCount / visualDiffPath, and because\n // applyMetrics only targets failed stems the parsed dimensions were dropped\n // too (making SIZE_MISMATCH_RE dead in practice).\n if (isSizeMismatchFailure(failureMessage)) return 'failed';\n // Playwright attached a baseline and an actual but no diff: it had no\n // baseline and wrote one. That is not a pass — nothing was compared.\n return updatingSnapshots ? 'updated' : 'new';\n}\n\n/**\n * Attach the pixel count to the comparison it actually describes.\n *\n * The failure message belongs to whichever assertion threw. When it echoes a\n * snapshot name, that name is matched against the stems; otherwise the numbers\n * are only trustworthy if exactly one comparison failed, and are dropped when\n * several did rather than being pinned on an arbitrary one.\n */\nfunction selectMetricsTarget(\n failed: readonly string[],\n snapshotName: string | undefined,\n): string | null {\n if (failed.length === 0) return null;\n if (failed.length === 1) return failed[0] as string;\n if (!snapshotName) return null;\n\n const base = snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n const matches = failed.filter((stem) => stem === base || stem.endsWith(`/${base}`));\n return matches.length === 1 ? (matches[0] as string) : null;\n}\n\nexport interface ParseVisualOptions {\n /** True when the run was invoked with `--update-snapshots`. */\n readonly updatingSnapshots?: boolean;\n /** Concatenated failure messages for the test attempt. */\n readonly failureMessage?: string | null;\n}\n\n/**\n * Extract every visual comparison recorded against one test attempt.\n *\n * Returns an empty array — never throws — for a test that made no visual\n * assertions, which is the overwhelming majority of them.\n */\nexport function parseVisualAttachments(\n attachments: readonly Attachment[],\n options: ParseVisualOptions = {},\n): VisualCandidate[] {\n if (attachments.length === 0) return [];\n\n const meta = parseVisualMeta(attachments);\n const dom = parseVisualDom(attachments);\n const groups = groupBySnapshotStem(attachments);\n if (groups.size === 0) return [];\n\n const updating = options.updatingSnapshots === true;\n const candidates: VisualCandidate[] = [];\n\n for (const [stem, group] of groups) {\n const vouched = meta.get(stem);\n if (!vouched && !isCredibleComparison(group)) continue;\n\n candidates.push({\n name: vouched?.name ?? stem,\n status: vouched?.status ?? deriveStatus(group, updating, options.failureMessage),\n source: vouched ? 'toMatchVisualBaseline' : 'toHaveScreenshot',\n ...(group.expected ? { expectedPath: group.expected } : {}),\n ...(group.actual ? { actualPath: group.actual } : {}),\n ...(group.diff ? { diffPath: group.diff } : {}),\n ...(group.previous ? { previousPath: group.previous } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n return applyMetrics(candidates, groups, options.failureMessage);\n}\n\n/** Fold the parsed failure numbers into the one comparison they belong to. */\nfunction applyMetrics(\n candidates: readonly VisualCandidate[],\n groups: ReadonlyMap<string, StemGroup>,\n failureMessage: string | null | undefined,\n): VisualCandidate[] {\n const parsed = parseDiffMessage(failureMessage);\n if (parsed.diffPixels === undefined && parsed.actualWidth === undefined) {\n return [...candidates];\n }\n\n const failedStems = candidates.filter((c) => c.status === 'failed').map((c) => stemOf(c, groups));\n const target = selectMetricsTarget(failedStems, parsed.snapshotName);\n if (target === null) return [...candidates];\n\n return candidates.map((candidate) =>\n stemOf(candidate, groups) === target\n ? {\n ...candidate,\n ...(parsed.diffPixels !== undefined ? { diffPixels: parsed.diffPixels } : {}),\n ...(parsed.actualWidth !== undefined ? { actualWidth: parsed.actualWidth } : {}),\n ...(parsed.actualHeight !== undefined ? { actualHeight: parsed.actualHeight } : {}),\n }\n : candidate,\n );\n}\n\n/**\n * Recover the stem a candidate came from.\n *\n * The explicit matcher renames a candidate to the author's chosen name, so the\n * name cannot be used to look it back up among the groups.\n */\nfunction stemOf(candidate: VisualCandidate, groups: ReadonlyMap<string, StemGroup>): string {\n if (groups.has(candidate.name)) return candidate.name;\n for (const [stem, group] of groups) {\n if (\n group.actual === candidate.actualPath &&\n group.expected === candidate.expectedPath &&\n group.diff === candidate.diffPath\n ) {\n return stem;\n }\n }\n return candidate.name;\n}\n","/**\n * `toMatchVisualBaseline` — TestRelic's visual assertion.\n *\n * It does not compare images itself. Playwright already ships a comparator\n * (pixelmatch, or SSIM-CIE94 when asked) and bundles the decoders it needs, so\n * this delegates to `toHaveScreenshot` and adds the two things Playwright does\n * not give a reporter:\n *\n * 1. **A name that survives into the report.** Playwright's attachments are\n * named after the resolved snapshot path, which carries project and\n * platform suffixes. The author's own name is recorded alongside.\n * 2. **Evidence for a passing comparison.** Playwright attaches nothing at all\n * when the images match, so a green visual check is invisible to any\n * reporter. The baseline is attached here so the report can show what was\n * actually asserted against, not just what broke.\n *\n * Both ride the same `-expected` / `-actual` / `-diff` attachment convention\n * the reporter already groups by, so nothing downstream needs a second path.\n */\n\nimport { expect as playwrightExpect, test as playwrightTest } from '@playwright/test';\nimport { basename, extname } from 'node:path';\nimport { VISUAL_META_ATTACHMENT, type VisualMetaRecord } from './visual-capture.js';\nimport { isSizeMismatchFailure } from './visual-metrics.js';\nimport { supportsSnapshotPathOptions, warnSnapshotPathUnsupported } from './snapshot-path-support.js';\n\n/** Options accepted on top of Playwright's own screenshot options. */\nexport interface VisualBaselineOptions {\n /**\n * Labels recorded with the comparison. Reserved for the cloud baseline store,\n * where they will select which baselines a branch is allowed to promote.\n */\n readonly tags?: readonly string[];\n /** Anything else is forwarded to `toHaveScreenshot` untouched. */\n readonly [key: string]: unknown;\n}\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n name: string;\n}\n\n// SAFETY: Playwright's TestInfo is reached through `test.info()`, which throws\n// outside a running test. Only the three members used here are described.\ninterface MinimalTestInfo {\n attachments: Array<{ name: string; contentType: string; path?: string; body?: Buffer }>;\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n attach(\n name: string,\n options: { path?: string; body?: Buffer; contentType?: string },\n ): Promise<void>;\n}\n\n/** Image extensions Playwright will accept for a screenshot baseline. */\nconst IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];\n\n/**\n * Give the snapshot name a file extension if the author left it off.\n *\n * `toHaveScreenshot` rejects a bare name outright (\"must have '.png'\n * extension\"). Requiring the suffix here would be the sort of papercut that\n * makes a wrapper worse than the thing it wraps, so `'home'` and `'home.png'`\n * both work and resolve to the same baseline.\n */\nexport function normalizeSnapshotName(name: string): string {\n const lower = name.toLowerCase();\n return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : `${name}.png`;\n}\n\n/** `test.info()` throws when no test is running; a matcher must not. */\nfunction currentTestInfo(): MinimalTestInfo | null {\n try {\n return playwrightTest.info() as unknown as MinimalTestInfo;\n } catch {\n return null;\n }\n}\n\n/**\n * The stem Playwright used for the attachments this assertion just produced.\n *\n * Reading it back off `testInfo.attachments` is the only reliable way to learn\n * it: the stem comes from the resolved output path, which the snapshot path\n * template controls, and reversing that template would be guesswork.\n */\nfunction stemOfNewAttachments(\n attachments: readonly { name: string }[],\n addedAfter: number,\n): string | null {\n for (let i = addedAfter; i < attachments.length; i++) {\n const match = /^(.+)-(expected|actual|diff|previous)\\.[A-Za-z0-9]+$/.exec(\n attachments[i]?.name ?? '',\n );\n if (match) return match[1] as string;\n }\n return null;\n}\n\n/** Attach the baseline for a comparison that passed and left no evidence. */\nasync function attachPassingBaseline(\n testInfo: MinimalTestInfo,\n snapshotName: string,\n): Promise<string | null> {\n if (!supportsSnapshotPathOptions(testInfo)) {\n warnSnapshotPathUnsupported('Attaching the baseline of a passing comparison');\n return null;\n }\n try {\n const path = testInfo.snapshotPath(snapshotName, { kind: 'screenshot' });\n const ext = extname(path) || '.png';\n const stem = basename(path, ext);\n await testInfo.attach(`${stem}-expected${ext}`, { path });\n return stem;\n } catch {\n // No baseline on disk, or an unwritable output dir. The comparison still\n // passed; it simply has no picture to show for it.\n return null;\n }\n}\n\n/** Record the author's name and outcome against the stem the reporter will see. */\nasync function attachMeta(testInfo: MinimalTestInfo, record: VisualMetaRecord): Promise<void> {\n try {\n await testInfo.attach(VISUAL_META_ATTACHMENT, {\n body: Buffer.from(JSON.stringify([record])),\n contentType: 'application/json',\n });\n } catch {\n // Without this the comparison still appears, just under Playwright's own\n // name and inferred status.\n }\n}\n\n/**\n * Assert a page or locator against its committed visual baseline.\n *\n * Registered onto `expect` by `./visual`; see that module for the type\n * declaration that makes it visible to TypeScript.\n */\nexport async function toMatchVisualBaseline(\n this: { isNot?: boolean },\n target: unknown,\n name: string,\n options: VisualBaselineOptions = {},\n): Promise<MatcherResult> {\n // `tags` is ours; everything else belongs to Playwright and is forwarded\n // verbatim, so that every screenshot option keeps working here.\n const screenshotOptions: Record<string, unknown> = { ...options };\n delete screenshotOptions.tags;\n const snapshotName = normalizeSnapshotName(name);\n const testInfo = currentTestInfo();\n const attachedBefore = testInfo?.attachments.length ?? 0;\n\n let pass = true;\n let message = '';\n try {\n await (\n playwrightExpect(target) as unknown as {\n toHaveScreenshot(n: string, o: Record<string, unknown>): Promise<void>;\n }\n ).toHaveScreenshot(snapshotName, screenshotOptions);\n } catch (error) {\n pass = false;\n message = error instanceof Error ? error.message : String(error);\n }\n\n if (testInfo) {\n const stem = pass\n ? await attachPassingBaseline(testInfo, snapshotName)\n : stemOfNewAttachments(testInfo.attachments, attachedBefore);\n\n if (stem) {\n // A failure that wrote no diff wrote a first baseline instead — the\n // distinction matters, because nothing was compared in that case.\n //\n // But a size mismatch also writes no diff: the comparator cannot draw\n // one across different dimensions. Absence of a diff alone therefore\n // misreported a real failure as a new baseline, so the message decides.\n const wroteNewBaseline =\n !pass &&\n !isSizeMismatchFailure(message) &&\n !testInfo.attachments.some(\n (a, i) => i >= attachedBefore && a.name.startsWith(`${stem}-diff.`),\n );\n await attachMeta(testInfo, {\n stem,\n name,\n status: pass ? 'passed' : wroteNewBaseline ? 'new' : 'failed',\n });\n }\n }\n\n return {\n pass,\n name: 'toMatchVisualBaseline',\n message: () =>\n pass ? `Expected \"${name}\" to differ from its visual baseline, but it matched.` : message,\n };\n}\n","/**\n * @testrelic/playwright-analytics/visual\n *\n * Registers `toMatchVisualBaseline` onto Playwright's `expect` and declares it\n * to TypeScript.\n *\n * Importing this module is only necessary when using Playwright's own `expect`.\n * The SDK fixture (`@testrelic/playwright-analytics/fixture`) already imports\n * it, so `expect` from there has the matcher without a second import.\n *\n * Native `toHaveScreenshot()` needs none of this — the reporter recovers those\n * comparisons from the attachments on its own.\n */\n\nimport { expect as playwrightExpect } from '@playwright/test';\nimport { toMatchVisualBaseline } from './visual-matcher.js';\n\nexport type { VisualBaselineOptions } from './visual-matcher.js';\nexport { toMatchVisualBaseline } from './visual-matcher.js';\n\ndeclare global {\n // eslint-disable-next-line @typescript-eslint/no-namespace\n namespace PlaywrightTest {\n // `T` is unused here but structurally required: declaration merging only\n // works against Playwright's `Matchers<R, T = unknown>` if the parameter\n // list matches, and dropping it silently stops the merge.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n interface Matchers<R, T = unknown> {\n /**\n * Compare a page or locator against its committed visual baseline.\n *\n * Uses Playwright's own comparator, so every `toHaveScreenshot` option\n * (`threshold`, `maxDiffPixels`, `maxDiffPixelRatio`, `mask`, `clip`,\n * `fullPage`, `animations`, …) applies unchanged. Baselines live where\n * Playwright puts them and are updated with `--update-snapshots`.\n *\n * ```ts\n * await expect(page).toMatchVisualBaseline('home', {\n * maxDiffPixelRatio: 0.01,\n * mask: [page.locator('.live-ticker')],\n * });\n * ```\n */\n toMatchVisualBaseline(\n name: string,\n options?: import('./visual-matcher.js').VisualBaselineOptions,\n ): Promise<R>;\n }\n }\n}\n\n// Registered at module scope so a bare import is enough to install it.\n// `expect.extend` is additive and idempotent here: the fixture and a direct\n// import of this module both land on the same registration.\nplaywrightExpect.extend({ toMatchVisualBaseline });\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/visual-metrics.ts","../src/snapshot-path-support.ts","../src/visual-capture.ts","../src/visual-matcher.ts","../src/visual.ts"],"names":["PROBE_NAME","WARNED_KEY","warnSlot","g","slot","supportsSnapshotPathOptions","testInfo","resolved","warnSnapshotPathUnsupported","feature","VISUAL_META_ATTACHMENT","IMAGE_EXTENSIONS","MISSING_SNAPSHOT_RE","decideStatus","o","recordsWithoutImages","markOf","path","st","statSync","expectedPathOf","snapshotName","normalizeSnapshotName","name","lower","ext","currentTestInfo","playwrightTest","stemOfNewAttachments","attachments","addedAfter","expectedPath","stemOf","added","a","stem","unattachedStem","basename","extname","attachBaselineCopy","attachMeta","record","toMatchVisualBaseline","target","options","screenshotOptions","attachedBefore","errorsBefore","before","pass","message","playwrightExpect","error","after","wrote","e","status","recordStem"],"mappings":"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","file":"visual.cjs","sourcesContent":["/**\n * Numbers for a visual comparison, recovered without decoding an image.\n *\n * Playwright compares images internally and keeps the result to itself: the\n * differing-pixel count exists only as prose inside the failure message, and\n * the image dimensions only inside the PNG. Rather than decode a PNG (which\n * would mean a dependency, and Principle V says no), both are read back — the\n * count from the message Playwright already formats, the dimensions from the\n * 8 bytes of header every PNG carries.\n */\n\n/** PNG magic number: \\x89 P N G \\r \\n \\x1a \\n */\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\n\n/** Byte offsets of width and height inside the IHDR chunk of a PNG. */\nconst IHDR_WIDTH_OFFSET = 16;\nconst IHDR_HEIGHT_OFFSET = 20;\n\n/** Enough bytes to cover the signature, the chunk header and both dimensions. */\nexport const PNG_HEADER_BYTES = 24;\n\nexport interface ImageDimensions {\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Read a PNG's pixel dimensions from its IHDR chunk.\n *\n * Only the first 24 bytes are consulted, so the caller can hand over a partial\n * read of a large file. Returns `null` for anything that is not a PNG — JPEG\n * baselines are legal in Playwright and simply go un-measured.\n */\nexport function readPngDimensions(header: Buffer): ImageDimensions | null {\n if (header.length < PNG_HEADER_BYTES) return null;\n if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return null;\n\n const width = header.readUInt32BE(IHDR_WIDTH_OFFSET);\n const height = header.readUInt32BE(IHDR_HEIGHT_OFFSET);\n\n // A zero dimension is not a valid PNG, and would make a ratio divide by zero.\n if (width === 0 || height === 0) return null;\n\n return { width, height };\n}\n\nexport interface ParsedDiffMessage {\n /** Differing pixel count, from `\"N pixels (ratio …) are different.\"` */\n readonly diffPixels?: number;\n /** Baseline dimensions, present only when the two images differ in size. */\n readonly expectedWidth?: number;\n readonly expectedHeight?: number;\n /** Actual dimensions, present only when the two images differ in size. */\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The snapshot name Playwright echoed, when the assertion supplied one. */\n readonly snapshotName?: string;\n}\n\n/**\n * `123 pixels (ratio 0.02 of all image pixels) are different.`\n *\n * The ratio in that sentence is rounded up to two decimals, so it is read past\n * deliberately — the caller recomputes it from the count and the dimensions.\n */\nconst PIXELS_DIFFERENT_RE = /(\\d+) pixels \\(ratio [\\d.]+ of all image pixels\\) are different/;\n\n/** `Expected an image 800px by 600px, received 800px by 640px.` */\nconst SIZE_MISMATCH_RE = /Expected an image (\\d+)px by (\\d+)px, received (\\d+)px by (\\d+)px/;\n\n/**\n * Whether a failure message is a size mismatch.\n *\n * The comparator cannot draw a diff across differing dimensions, so it\n * attaches expected + actual and throws instead. Callers that would otherwise\n * infer \"no baseline existed\" from a missing diff need this to tell the two\n * apart — without it a real failure was reported as a freshly written\n * baseline.\n */\nexport function isSizeMismatchFailure(message: string | null | undefined): boolean {\n if (!message) return false;\n return SIZE_MISMATCH_RE.test(message);\n}\n\n/** ` Snapshot: home.png` — only emitted when the assertion named the snapshot. */\nconst SNAPSHOT_NAME_RE = /^\\s*Snapshot:\\s*(.+?)\\s*$/m;\n\n/**\n * Pull the comparison numbers out of a Playwright snapshot failure message.\n *\n * Every field is optional and a malformed message yields an empty object: a\n * report that is missing a pixel count is a far better outcome than a reporter\n * that throws while assembling one.\n */\nexport function parseDiffMessage(message: string | null | undefined): ParsedDiffMessage {\n if (!message) return {};\n\n const result: {\n diffPixels?: number;\n expectedWidth?: number;\n expectedHeight?: number;\n actualWidth?: number;\n actualHeight?: number;\n snapshotName?: string;\n } = {};\n\n const pixels = PIXELS_DIFFERENT_RE.exec(message);\n if (pixels) {\n const count = Number.parseInt(pixels[1] as string, 10);\n if (Number.isFinite(count)) result.diffPixels = count;\n }\n\n const sizes = SIZE_MISMATCH_RE.exec(message);\n if (sizes) {\n const [, ew, eh, aw, ah] = sizes;\n const parsed = [ew, eh, aw, ah].map((v) => Number.parseInt(v as string, 10));\n if (parsed.every((v) => Number.isFinite(v))) {\n result.expectedWidth = parsed[0];\n result.expectedHeight = parsed[1];\n result.actualWidth = parsed[2];\n result.actualHeight = parsed[3];\n }\n }\n\n const name = SNAPSHOT_NAME_RE.exec(message);\n if (name) result.snapshotName = name[1];\n\n return result;\n}\n\n/**\n * Differing pixels as a fraction of the image, at full precision.\n *\n * Returns `undefined` rather than a wrong number when either input is missing,\n * so a consumer can tell \"no measurement\" apart from \"measured zero\".\n */\nexport function computeDiffRatio(\n diffPixels: number | undefined,\n dimensions: ImageDimensions | null | undefined,\n): number | undefined {\n if (diffPixels === undefined || !dimensions) return undefined;\n const total = dimensions.width * dimensions.height;\n if (total <= 0) return undefined;\n return diffPixels / total;\n}\n","/**\n * Whether this Playwright supports `snapshotPath(name, { kind })`.\n *\n * The options form arrived in Playwright 1.51. Before that `snapshotPath` took\n * variadic path segments, so an options object is consumed as a segment and\n * the resolved path is wrong — and because both call sites sit inside a\n * `try`/`catch` that swallows everything, DOM comparison and the\n * passing-baseline attachment simply never worked and said nothing about it.\n *\n * The package's peer range is `>=1.35.0` and stays that way deliberately:\n * everything except these two visual extras works on the older versions, and\n * raising the floor would lock those users out of the whole reporter over one\n * feature. So the loss is detected and *announced* once, instead of being\n * silent.\n *\n * The check is a feature probe rather than a version comparison. Reading\n * `@playwright/test/package.json` meant resolving a module at runtime from a\n * file that has to stay importable from both the CJS and the ESM build, and\n * the only spelling that worked in both reached the module loader through a\n * dynamically evaluated string. The repo's payload scanner rejects that shape\n * on sight, and rightly — this package is published to npm, and the scanner\n * does not exempt code that means well. Asking the API what it does needs no\n * module resolution, no dynamic execution and no version table, and it tests\n * the behaviour actually depended on rather than a number that correlates\n * with it.\n */\n\n/**\n * The one method probed. Both call sites already hold a `testInfo`; this is the\n * narrowest shape that covers the two call forms.\n */\nexport interface SnapshotPathCapable {\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n}\n\n/**\n * Passed to the probe. Never written to disk — `snapshotPath` only computes a\n * path — and named so it is unmistakable in a stack trace or a log line.\n */\nconst PROBE_NAME = '__testrelic_snapshot_path_probe__.png';\n\n/** Printed at most once per process, however many tests hit the path. */\nconst WARNED_KEY = Symbol.for('testrelic.snapshotPathWarned');\n\ninterface WarnSlot {\n warned: boolean;\n}\n\n/**\n * The latch lives on `globalThis`, not in a module variable.\n *\n * tsup builds with `splitting: false`, so every entry point bundles its own\n * copy of this module — and this module is reached from five of them. A\n * module-level `let` would therefore be five separate latches, and the\n * \"at most once per process\" promise above would print up to five times. This\n * is the same fix, and the same reason, as the active context in\n * `assertion-tracker.ts`.\n */\nfunction warnSlot(): WarnSlot {\n const g = globalThis as unknown as Record<symbol, WarnSlot | undefined>;\n let slot = g[WARNED_KEY];\n if (!slot) {\n slot = { warned: false };\n g[WARNED_KEY] = slot;\n }\n return slot;\n}\n\n/**\n * True when `snapshotPath(name, { kind })` resolves the way this code needs.\n *\n * The probe asks what the OLD form would have done wrong, not what the new one\n * returns verbatim. Before 1.51 the options object is consumed as another path\n * segment: `path.join` throws on a non-string, and a version that coerced it\n * instead would stringify it into the path as `[object Object]`. Either shape\n * answers `false`.\n *\n * It deliberately does NOT check that the result ends with the name it was\n * given. It did, and that was wrong: `snapshotPathTemplate` legitimately\n * inserts the project and platform before the extension, so\n * `toHaveScreenshot('dashboard.png')` resolves to `dashboard-win32.png` on\n * every default Playwright project. The probe therefore answered `false`\n * everywhere and silently disabled BOTH of its callers — passing-baseline\n * capture and DOM comparison — which shipped in 2.16.0 and is why a green\n * visual run recorded no comparisons at all and no element changes ever\n * appeared. Never assert on the shape of a path a template owns.\n */\nexport function supportsSnapshotPathOptions(testInfo: SnapshotPathCapable): boolean {\n try {\n const resolved = testInfo.snapshotPath(PROBE_NAME, { kind: 'screenshot' });\n return typeof resolved === 'string' && !resolved.includes('[object Object]');\n } catch {\n return false;\n }\n}\n\n/**\n * Say once that a visual extra is unavailable on this Playwright, and why.\n *\n * `feature` names what the user loses, so the line is actionable rather than\n * an abstract version complaint.\n */\nexport function warnSnapshotPathUnsupported(feature: string): void {\n const slot = warnSlot();\n if (slot.warned) return;\n slot.warned = true;\n process.stderr.write(\n `[testrelic] ${feature} needs Playwright >= 1.51 for ` +\n `testInfo.snapshotPath(name, { kind }); this project's Playwright ` +\n `resolves that call the older way. Everything else in the reporter is ` +\n `unaffected — upgrade Playwright to enable it.\\n`,\n );\n}\n\n/** Reset the once-per-process latch. Test-only. */\nexport function resetSnapshotPathWarning(): void {\n warnSlot().warned = false;\n}\n","/**\n * Recover visual baseline comparisons from a Playwright test result.\n *\n * Playwright's snapshot matchers do not report what they compared; they only\n * leave attachments behind. `toHaveScreenshot()` / `toMatchSnapshot()` attach\n * `<stem>-expected`, `<stem>-actual`, `<stem>-diff` and, on a retried failure,\n * `<stem>-previous`. Those four share a stem, and the stem is the snapshot's\n * identity within the test.\n *\n * This module is pure — it groups names and reads a message, and touches no\n * filesystem. Copying the images out is `artifact-manager.copyVisualArtifacts`.\n */\n\nimport type { VisualSource, VisualStatus } from '@testrelic/core';\nimport { parseDiffMessage, isSizeMismatchFailure } from './visual-metrics.js';\nimport { VISUAL_DOM_ATTACHMENT, type VisualDomRecord } from './visual-expect.js';\n\ninterface Attachment {\n name: string;\n contentType: string;\n path?: string;\n body?: Buffer;\n}\n\n/**\n * A comparison found in the attachments, still pointing at Playwright's temp\n * files. `artifact-manager` turns this into the report-facing\n * `VisualComparison` once the images have been copied somewhere durable.\n */\nexport interface VisualCandidate {\n readonly name: string;\n readonly status: VisualStatus;\n readonly source: VisualSource;\n readonly expectedPath?: string;\n /** The committed baseline itself, when the matcher could name it. */\n readonly committedPath?: string;\n readonly actualPath?: string;\n readonly diffPath?: string;\n readonly previousPath?: string;\n readonly diffPixels?: number;\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The DOM comparison for this snapshot, when one was made. */\n readonly dom?: VisualDomRecord;\n}\n\n/** Attachment name carrying the explicit matcher's JSON side-channel. */\nexport const VISUAL_META_ATTACHMENT = 'testrelic-visual';\n\n/** The four roles a snapshot image can play, as Playwright suffixes them. */\ntype VisualRole = 'expected' | 'actual' | 'diff' | 'previous';\n\nconst VISUAL_SUFFIX_RE = /^(.+)-(expected|actual|diff|previous)(\\.[A-Za-z0-9]+)$/;\n\n/** One stem's worth of images, before a status is decided. */\ninterface StemGroup {\n expected?: string;\n actual?: string;\n diff?: string;\n previous?: string;\n}\n\n/**\n * Metadata the `toMatchVisualBaseline` matcher attaches alongside its images.\n *\n * It exists because a passing comparison is otherwise invisible: Playwright\n * attaches nothing at all when the images match, so without this the report\n * could only ever show the failures.\n */\nexport interface VisualMetaRecord {\n readonly stem: string;\n readonly name: string;\n readonly status: VisualStatus;\n /**\n * The committed baseline, absolute. `testInfo.attach({ path })` COPIES the\n * file into the test's attachments dir, so on a pass the `-expected`\n * attachment's path is a per-run copy — this is where the real one is.\n */\n readonly committedPath?: string;\n}\n\n/** Read and validate the explicit matcher's metadata attachment. */\nexport function parseVisualMeta(attachments: readonly Attachment[]): Map<string, VisualMetaRecord> {\n const byStem = new Map<string, VisualMetaRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_META_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n const record = toMetaRecord(entry);\n if (record) byStem.set(record.stem, record);\n }\n } catch {\n // A malformed side-channel costs us the pass-case detail, nothing more.\n }\n }\n\n return byStem;\n}\n\nconst VALID_STATUSES: readonly string[] = ['passed', 'failed', 'new', 'updated'];\n\n// SAFETY: parsed from an attachment body written by the worker process; every\n// field is checked before use.\nfunction toMetaRecord(entry: unknown): VisualMetaRecord | null {\n if (typeof entry !== 'object' || entry === null) return null;\n const e = entry as Record<string, unknown>;\n if (typeof e.stem !== 'string' || !e.stem) return null;\n if (typeof e.name !== 'string' || !e.name) return null;\n if (typeof e.status !== 'string' || !VALID_STATUSES.includes(e.status)) return null;\n return {\n stem: e.stem,\n name: e.name,\n status: e.status as VisualStatus,\n ...(typeof e.committedPath === 'string' && e.committedPath ? { committedPath: e.committedPath } : {}),\n };\n}\n\n/**\n * Read the DOM comparison side-channel, keyed by the same stem the images use.\n *\n * Written by `visual-expect`; absent whenever the assertion used Playwright's\n * own `expect`, or the page had no DOM baseline yet.\n */\nexport function parseVisualDom(\n attachments: readonly Attachment[],\n): Map<string, VisualDomRecord> {\n const byStem = new Map<string, VisualDomRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_DOM_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n // SAFETY: written by the worker process; the two fields read before\n // anything else uses it are checked here.\n if (typeof entry !== 'object' || entry === null) continue;\n const record = entry as Record<string, unknown>;\n if (typeof record.stem !== 'string' || !Array.isArray(record.changes)) continue;\n byStem.set(record.stem, entry as VisualDomRecord);\n }\n } catch {\n // A malformed side-channel costs the element list, nothing more.\n }\n }\n\n return byStem;\n}\n\n/** Group every `<stem>-<role>.<ext>` attachment that has a file behind it. */\nfunction groupBySnapshotStem(attachments: readonly Attachment[]): Map<string, StemGroup> {\n const groups = new Map<string, StemGroup>();\n\n for (const attachment of attachments) {\n if (!attachment.path) continue;\n const match = VISUAL_SUFFIX_RE.exec(attachment.name);\n if (!match) continue;\n\n const [, stem, role] = match;\n const group = groups.get(stem as string) ?? {};\n group[role as VisualRole] = attachment.path;\n groups.set(stem as string, group);\n }\n\n return groups;\n}\n\n/**\n * Decide whether a stem is really a snapshot comparison.\n *\n * An ordinary user attachment named `report-actual.json` would otherwise be\n * mistaken for one. A genuine comparison always produces an `actual` next to\n * either the baseline it was compared against or the diff it produced, so\n * requiring that pair costs nothing real and rejects the lookalikes. Stems the\n * explicit matcher has vouched for skip this check — it attaches a lone\n * baseline on a pass, which is the one legitimate single-image case.\n */\nfunction isCredibleComparison(group: StemGroup): boolean {\n return Boolean(group.actual && (group.expected || group.diff));\n}\n\n/** Derive a status from the images alone, for natively-produced comparisons. */\nfunction deriveStatus(\n group: StemGroup,\n updatingSnapshots: boolean,\n failureMessage: string | null | undefined,\n): VisualStatus {\n if (group.diff) return 'failed';\n // A size mismatch also produces no diff — the comparator cannot draw one\n // when the two images have different dimensions, so it attaches expected +\n // actual and throws. Absence of a diff therefore does NOT imply \"no baseline\n // existed\": read the message before concluding that. Without this a genuine\n // failure was reported as a freshly written baseline, it was missing from\n // visualFailures / visualFailedCount / visualDiffPath, and because\n // applyMetrics only targets failed stems the parsed dimensions were dropped\n // too (making SIZE_MISMATCH_RE dead in practice).\n if (isSizeMismatchFailure(failureMessage)) return 'failed';\n // Playwright attached a baseline and an actual but no diff: it had no\n // baseline and wrote one. That is not a pass — nothing was compared.\n return updatingSnapshots ? 'updated' : 'new';\n}\n\n/**\n * Attach the pixel count to the comparison it actually describes.\n *\n * The failure message belongs to whichever assertion threw. When it echoes a\n * snapshot name, that name is matched against the stems; otherwise the numbers\n * are only trustworthy if exactly one comparison failed, and are dropped when\n * several did rather than being pinned on an arbitrary one.\n */\nfunction selectMetricsTarget(\n failed: readonly string[],\n snapshotName: string | undefined,\n): string | null {\n if (failed.length === 0) return null;\n if (failed.length === 1) return failed[0] as string;\n if (!snapshotName) return null;\n\n const base = snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n const matches = failed.filter((stem) => stem === base || stem.endsWith(`/${base}`));\n return matches.length === 1 ? (matches[0] as string) : null;\n}\n\nexport interface ParseVisualOptions {\n /** True when the run was invoked with `--update-snapshots`. */\n readonly updatingSnapshots?: boolean;\n /** Concatenated failure messages for the test attempt. */\n readonly failureMessage?: string | null;\n}\n\n/**\n * Extract every visual comparison recorded against one test attempt.\n *\n * Returns an empty array — never throws — for a test that made no visual\n * assertions, which is the overwhelming majority of them.\n */\nexport function parseVisualAttachments(\n attachments: readonly Attachment[],\n options: ParseVisualOptions = {},\n): VisualCandidate[] {\n if (attachments.length === 0) return [];\n\n const meta = parseVisualMeta(attachments);\n const dom = parseVisualDom(attachments);\n const groups = groupBySnapshotStem(attachments);\n if (groups.size === 0 && meta.size === 0) return [];\n\n const updating = options.updatingSnapshots === true;\n const candidates: VisualCandidate[] = [];\n\n for (const [stem, group] of groups) {\n const vouched = meta.get(stem);\n if (!vouched && !isCredibleComparison(group)) continue;\n\n candidates.push({\n name: vouched?.name ?? stem,\n status: vouched?.status ?? deriveStatus(group, updating, options.failureMessage),\n source: vouched ? 'toMatchVisualBaseline' : 'toHaveScreenshot',\n ...(group.expected ? { expectedPath: group.expected } : {}),\n ...(vouched?.committedPath ? { committedPath: vouched.committedPath } : {}),\n ...(group.actual ? { actualPath: group.actual } : {}),\n ...(group.diff ? { diffPath: group.diff } : {}),\n ...(group.previous ? { previousPath: group.previous } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n // A vouched comparison the loop above can never reach, because it produced\n // no images to group. Under `updateSnapshots: 'none'` a missing baseline\n // throws before anything is rendered and Playwright attaches nothing, so the\n // snapshot that broke the run was the one the report did not mention.\n for (const [stem, vouched] of meta) {\n if (groups.has(stem)) continue;\n candidates.push({\n name: vouched.name,\n status: vouched.status,\n source: 'toMatchVisualBaseline',\n ...(vouched.committedPath ? { committedPath: vouched.committedPath } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n return applyMetrics(candidates, groups, options.failureMessage);\n}\n\n/** Fold the parsed failure numbers into the one comparison they belong to. */\nfunction applyMetrics(\n candidates: readonly VisualCandidate[],\n groups: ReadonlyMap<string, StemGroup>,\n failureMessage: string | null | undefined,\n): VisualCandidate[] {\n const parsed = parseDiffMessage(failureMessage);\n if (parsed.diffPixels === undefined && parsed.actualWidth === undefined) {\n return [...candidates];\n }\n\n // Only a comparison with images can be what a pixel count describes; an\n // imageless record neither claims the numbers nor makes the choice ambiguous.\n const failedStems = candidates\n .filter((c) => c.status === 'failed' && hasImages(c))\n .map((c) => stemOf(c, groups));\n const target = selectMetricsTarget(failedStems, parsed.snapshotName);\n if (target === null) return [...candidates];\n\n return candidates.map((candidate) =>\n hasImages(candidate) && stemOf(candidate, groups) === target\n ? {\n ...candidate,\n ...(parsed.diffPixels !== undefined ? { diffPixels: parsed.diffPixels } : {}),\n ...(parsed.actualWidth !== undefined ? { actualWidth: parsed.actualWidth } : {}),\n ...(parsed.actualHeight !== undefined ? { actualHeight: parsed.actualHeight } : {}),\n }\n : candidate,\n );\n}\n\n/** Whether any image was recorded for a candidate. */\nfunction hasImages(candidate: VisualCandidate): boolean {\n return Boolean(\n candidate.expectedPath || candidate.actualPath || candidate.diffPath || candidate.previousPath,\n );\n}\n\n/**\n * Recover the stem a candidate came from.\n *\n * The explicit matcher renames a candidate to the author's chosen name, so the\n * name cannot be used to look it back up among the groups.\n */\nfunction stemOf(candidate: VisualCandidate, groups: ReadonlyMap<string, StemGroup>): string {\n if (groups.has(candidate.name)) return candidate.name;\n for (const [stem, group] of groups) {\n if (\n group.actual === candidate.actualPath &&\n group.expected === candidate.expectedPath &&\n group.diff === candidate.diffPath\n ) {\n return stem;\n }\n }\n return candidate.name;\n}\n","/**\n * `toMatchVisualBaseline` — TestRelic's visual assertion.\n *\n * It does not compare images itself. Playwright already ships a comparator\n * (pixelmatch, or SSIM-CIE94 when asked) and bundles the decoders it needs, so\n * this delegates to `toHaveScreenshot` and adds the two things Playwright does\n * not give a reporter:\n *\n * 1. **A name that survives into the report.** Playwright's attachments are\n * named after the resolved snapshot path, which carries project and\n * platform suffixes. The author's own name is recorded alongside.\n * 2. **Evidence for a passing comparison.** Playwright attaches nothing at all\n * when the images match, so a green visual check is invisible to any\n * reporter. The baseline is attached here so the report can show what was\n * actually asserted against, not just what broke.\n *\n * Both ride the same `-expected` / `-actual` / `-diff` attachment convention\n * the reporter already groups by, so nothing downstream needs a second path.\n */\n\nimport { expect as playwrightExpect, test as playwrightTest } from '@playwright/test';\nimport { statSync } from 'node:fs';\nimport { basename, extname } from 'node:path';\nimport type { VisualStatus } from '@testrelic/core';\nimport { VISUAL_META_ATTACHMENT, type VisualMetaRecord } from './visual-capture.js';\nimport { supportsSnapshotPathOptions, warnSnapshotPathUnsupported } from './snapshot-path-support.js';\n\n/** Options accepted on top of Playwright's own screenshot options. */\nexport interface VisualBaselineOptions {\n /**\n * Labels recorded with the comparison. Reserved for the cloud baseline store,\n * where they will select which baselines a branch is allowed to promote.\n */\n readonly tags?: readonly string[];\n /** Anything else is forwarded to `toHaveScreenshot` untouched. */\n readonly [key: string]: unknown;\n}\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n name: string;\n}\n\n// SAFETY: Playwright's TestInfo is reached through `test.info()`, which throws\n// outside a running test. Only the three members used here are described.\ninterface MinimalTestInfo {\n attachments: Array<{ name: string; contentType: string; path?: string; body?: Buffer }>;\n /**\n * Playwright records a first-run write here and then RESOLVES the matcher.\n * Read only as a fallback, on a Playwright too old to name the committed\n * file (see `expectedPathOf`).\n */\n errors: ReadonlyArray<{ message?: string }>;\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n attach(\n name: string,\n options: { path?: string; body?: Buffer; contentType?: string },\n ): Promise<void>;\n}\n\n/** Image extensions Playwright will accept for a screenshot baseline. */\nconst IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];\n\n/**\n * What Playwright records when `toHaveScreenshot` finds no baseline under the\n * default `updateSnapshots: 'missing'`: it writes the actual AS the baseline,\n * pushes this error onto `testInfo.errors`, and then resolves the matcher with\n * `pass: true`. The test fails at the end; the matcher call does not.\n *\n * Only the fallback signal — `testInfo.errors` is shared by every assertion in\n * the test, so two comparisons resolving at once could read each other's.\n */\nconst MISSING_SNAPSHOT_RE = /A snapshot doesn't exist at /;\n\n/**\n * The verdict for one `toMatchVisualBaseline` call.\n *\n * A written baseline is not a pass, whatever the matcher resolved: nothing was\n * compared. Playwright writes one on a first run (any mode but `'none'`) and\n * rewrites one under `--update-snapshots`, and resolves as a pass both times.\n * The first run of a suite once recorded every one of these as `passed`.\n */\nexport function decideStatus(o: {\n pass: boolean;\n /** The committed baseline was written or rewritten during this call. */\n wrote: boolean;\n /** A committed baseline existed before this call. */\n existedBefore: boolean;\n}): VisualStatus {\n if (o.wrote) return o.existedBefore ? 'updated' : 'new';\n return o.pass ? 'passed' : 'failed';\n}\n\n/**\n * Whether a comparison that left NO attachment behind should still be recorded.\n *\n * Only one situation deserves a record with no images: the baseline was missing\n * and the run was not allowed to write one, so Playwright rendered nothing,\n * attached nothing, and failed. Without this the snapshot that broke the run is\n * the one the report never mentions.\n *\n * The `hadBaseline` half is the guard, and it is load-bearing. A `failed`\n * verdict means only that `toHaveScreenshot` threw, and several of its throws\n * happen before a pixel is taken — a negative `maxDiffPixels`, a\n * `maxDiffPixelRatio` outside 0..1, a receiver that is not a Page or Locator,\n * an unreadable `stylePath`, or the page closing mid-call. Recording those\n * would invent a visual regression against a baseline that is perfectly fine,\n * and put a config typo in the run's visual-failure count.\n */\nexport function recordsWithoutImages(o: {\n status: VisualStatus;\n hadBaseline: boolean;\n}): boolean {\n return o.status === 'failed' && !o.hadBaseline;\n}\n\n/** Enough of a file to notice that it was rewritten. */\ninterface FileMark {\n size: number;\n mtimeMs: number;\n}\n\nfunction markOf(path: string | null): FileMark | null {\n if (!path) return null;\n try {\n const st = statSync(path);\n return { size: st.size, mtimeMs: st.mtimeMs };\n } catch {\n return null;\n }\n}\n\n/**\n * The committed baseline this call resolves to — the same file Playwright\n * will read, write, or rewrite — or null on a Playwright too old to say.\n */\nfunction expectedPathOf(testInfo: MinimalTestInfo, snapshotName: string): string | null {\n if (!supportsSnapshotPathOptions(testInfo)) return null;\n try {\n return testInfo.snapshotPath(snapshotName, { kind: 'screenshot' });\n } catch {\n return null;\n }\n}\n\n/**\n * Give the snapshot name a file extension if the author left it off.\n *\n * `toHaveScreenshot` rejects a bare name outright (\"must have '.png'\n * extension\"). Requiring the suffix here would be the sort of papercut that\n * makes a wrapper worse than the thing it wraps, so `'home'` and `'home.png'`\n * both work and resolve to the same baseline.\n */\nexport function normalizeSnapshotName(name: string): string {\n const lower = name.toLowerCase();\n return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : `${name}.png`;\n}\n\n/** `test.info()` throws when no test is running; a matcher must not. */\nfunction currentTestInfo(): MinimalTestInfo | null {\n try {\n return playwrightTest.info() as unknown as MinimalTestInfo;\n } catch {\n return null;\n }\n}\n\n/**\n * The stem Playwright used for the attachments this assertion just produced.\n *\n * Reading it back off `testInfo.attachments` is the only reliable way to learn\n * it: the stem comes from the resolved output path, which the snapshot path\n * template controls, and reversing that template would be guesswork. When the\n * committed file can be named, the `-expected` attachment that points at it is\n * the one that belongs to this call — so two assertions resolving at once\n * cannot borrow each other's stem. Otherwise the first new comparison\n * attachment is taken.\n */\nfunction stemOfNewAttachments(\n attachments: readonly { name: string; path?: string }[],\n addedAfter: number,\n expectedPath: string | null,\n): string | null {\n const stemOf = (name: string): string | null =>\n /^(.+)-(expected|actual|diff|previous)\\.[A-Za-z0-9]+$/.exec(name)?.[1] ?? null;\n const added = attachments.slice(addedAfter);\n if (expectedPath) {\n for (const a of added) {\n if (a.path === expectedPath && /-expected\\.[A-Za-z0-9]+$/.test(a.name)) return stemOf(a.name);\n }\n }\n for (const a of added) {\n const stem = stemOf(a.name);\n if (stem) return stem;\n }\n return null;\n}\n\n/**\n * The identity to record for a failed comparison Playwright attached nothing\n * for — a missing baseline under `updateSnapshots: 'none'`, where it throws\n * before anything is rendered.\n *\n * Nothing points at this stem, so it only has to name the comparison: the\n * resolved baseline's filename, which is the stem Playwright itself would have\n * used, or the author's own name on a Playwright too old to resolve it.\n */\nexport function unattachedStem(snapshotName: string, expectedPath: string | null): string {\n if (expectedPath) return basename(expectedPath, extname(expectedPath) || '.png');\n return snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n}\n\n/**\n * Attach the committed baseline for a comparison that left no evidence — a\n * pass, or a rewrite under `--update-snapshots`, where Playwright writes the\n * file and attaches nothing.\n */\nasync function attachBaselineCopy(\n testInfo: MinimalTestInfo,\n expectedPath: string | null,\n): Promise<string | null> {\n if (expectedPath === null) {\n warnSnapshotPathUnsupported('Attaching the baseline of a passing comparison');\n return null;\n }\n try {\n const ext = extname(expectedPath) || '.png';\n const stem = basename(expectedPath, ext);\n await testInfo.attach(`${stem}-expected${ext}`, { path: expectedPath });\n return stem;\n } catch {\n // No baseline on disk, or an unwritable output dir. The comparison still\n // passed; it simply has no picture to show for it.\n return null;\n }\n}\n\n/** Record the author's name and outcome against the stem the reporter will see. */\nasync function attachMeta(testInfo: MinimalTestInfo, record: VisualMetaRecord): Promise<void> {\n try {\n await testInfo.attach(VISUAL_META_ATTACHMENT, {\n body: Buffer.from(JSON.stringify([record])),\n contentType: 'application/json',\n });\n } catch {\n // Without this the comparison still appears, just under Playwright's own\n // name and inferred status.\n }\n}\n\n/**\n * Assert a page or locator against its committed visual baseline.\n *\n * Registered onto `expect` by `./visual`; see that module for the type\n * declaration that makes it visible to TypeScript.\n */\nexport async function toMatchVisualBaseline(\n this: { isNot?: boolean },\n target: unknown,\n name: string,\n options: VisualBaselineOptions = {},\n): Promise<MatcherResult> {\n // `tags` is ours; everything else belongs to Playwright and is forwarded\n // verbatim, so that every screenshot option keeps working here.\n const screenshotOptions: Record<string, unknown> = { ...options };\n delete screenshotOptions.tags;\n const snapshotName = normalizeSnapshotName(name);\n const testInfo = currentTestInfo();\n const attachedBefore = testInfo?.attachments.length ?? 0;\n const errorsBefore = testInfo?.errors?.length ?? 0;\n const expectedPath = testInfo ? expectedPathOf(testInfo, snapshotName) : null;\n const before = markOf(expectedPath);\n\n let pass = true;\n let message = '';\n try {\n await (\n playwrightExpect(target) as unknown as {\n toHaveScreenshot(n: string, o: Record<string, unknown>): Promise<void>;\n }\n ).toHaveScreenshot(snapshotName, screenshotOptions);\n } catch (error) {\n pass = false;\n message = error instanceof Error ? error.message : String(error);\n }\n\n if (testInfo) {\n // `pass` is true whenever Playwright wrote the committed file itself — a\n // first run, or `--update-snapshots` — and nothing was compared then. The\n // file is the witness: it is there now and was not, or it is no longer\n // the file it was. Under `updateSnapshots: 'none'` a missing baseline is\n // a plain failure, and nothing is written.\n const after = markOf(expectedPath);\n const wrote =\n expectedPath !== null\n ? after !== null &&\n (before === null || before.size !== after.size || before.mtimeMs !== after.mtimeMs)\n : (testInfo.errors ?? [])\n .slice(errorsBefore)\n .some((e) => MISSING_SNAPSHOT_RE.test(e.message ?? ''));\n const status = decideStatus({ pass, wrote, existedBefore: before !== null });\n\n // A pass and a rewrite leave no attachment behind, so the committed file\n // is attached here; a first write and a failure are attached by\n // Playwright under its own stem, which is read back rather than guessed.\n const stem =\n status === 'passed' || status === 'updated'\n ? await attachBaselineCopy(testInfo, expectedPath)\n : stemOfNewAttachments(testInfo.attachments, attachedBefore, expectedPath);\n\n // A failure can leave nothing behind at all: with no baseline to compare\n // against and no mode that would write one, Playwright renders nothing and\n // attaches nothing, and the snapshot that broke the run was the one the\n // report never mentioned. The record carries the verdict without images.\n //\n // `before === null` is the whole gate, and it has to be. `status` is\n // 'failed' for ANY throw out of `toHaveScreenshot`, and several of those\n // happen before a pixel is taken — a negative `maxDiffPixels`, a\n // `maxDiffPixelRatio` outside 0..1, a receiver that is not a Page or\n // Locator, an unreadable `stylePath`, or the page closing mid-call. With a\n // perfectly good committed baseline on disk, synthesising a record there\n // would report a visual regression that never happened and put a config\n // typo in the run's visual-failure count. A missing baseline is the only\n // case this exists for, and Playwright's own missing-snapshot path runs\n // only when there was no file — so `before` is necessarily null there.\n const recordStem =\n stem ??\n (recordsWithoutImages({ status, hadBaseline: before !== null })\n ? unattachedStem(snapshotName, expectedPath)\n : null);\n\n if (recordStem) {\n await attachMeta(testInfo, {\n stem: recordStem,\n name,\n status,\n ...(expectedPath ? { committedPath: expectedPath } : {}),\n });\n }\n }\n\n return {\n pass,\n name: 'toMatchVisualBaseline',\n message: () =>\n pass ? `Expected \"${name}\" to differ from its visual baseline, but it matched.` : message,\n };\n}\n","/**\n * @testrelic/playwright-analytics/visual\n *\n * Registers `toMatchVisualBaseline` onto Playwright's `expect` and declares it\n * to TypeScript.\n *\n * Importing this module is only necessary when using Playwright's own `expect`.\n * The SDK fixture (`@testrelic/playwright-analytics/fixture`) already imports\n * it, so `expect` from there has the matcher without a second import.\n *\n * Native `toHaveScreenshot()` needs none of this — the reporter recovers those\n * comparisons from the attachments on its own.\n */\n\nimport { expect as playwrightExpect } from '@playwright/test';\nimport { toMatchVisualBaseline } from './visual-matcher.js';\n\nexport type { VisualBaselineOptions } from './visual-matcher.js';\nexport { toMatchVisualBaseline } from './visual-matcher.js';\n\ndeclare global {\n // eslint-disable-next-line @typescript-eslint/no-namespace\n namespace PlaywrightTest {\n // `T` is unused here but structurally required: declaration merging only\n // works against Playwright's `Matchers<R, T = unknown>` if the parameter\n // list matches, and dropping it silently stops the merge.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n interface Matchers<R, T = unknown> {\n /**\n * Compare a page or locator against its committed visual baseline.\n *\n * Uses Playwright's own comparator, so every `toHaveScreenshot` option\n * (`threshold`, `maxDiffPixels`, `maxDiffPixelRatio`, `mask`, `clip`,\n * `fullPage`, `animations`, …) applies unchanged. Baselines live where\n * Playwright puts them and are updated with `--update-snapshots`.\n *\n * ```ts\n * await expect(page).toMatchVisualBaseline('home', {\n * maxDiffPixelRatio: 0.01,\n * mask: [page.locator('.live-ticker')],\n * });\n * ```\n */\n toMatchVisualBaseline(\n name: string,\n options?: import('./visual-matcher.js').VisualBaselineOptions,\n ): Promise<R>;\n }\n }\n}\n\n// Registered at module scope so a bare import is enough to install it.\n// `expect.extend` is additive and idempotent here: the fixture and a direct\n// import of this module both land on the same registration.\nplaywrightExpect.extend({ toMatchVisualBaseline });\n"]}
|
package/dist/visual.d.cts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Both ride the same `-expected` / `-actual` / `-diff` attachment convention
|
|
18
18
|
* the reporter already groups by, so nothing downstream needs a second path.
|
|
19
19
|
*/
|
|
20
|
+
|
|
20
21
|
/** Options accepted on top of Playwright's own screenshot options. */
|
|
21
22
|
interface VisualBaselineOptions {
|
|
22
23
|
/**
|
package/dist/visual.d.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Both ride the same `-expected` / `-actual` / `-diff` attachment convention
|
|
18
18
|
* the reporter already groups by, so nothing downstream needs a second path.
|
|
19
19
|
*/
|
|
20
|
+
|
|
20
21
|
/** Options accepted on top of Playwright's own screenshot options. */
|
|
21
22
|
interface VisualBaselineOptions {
|
|
22
23
|
/**
|
package/dist/visual.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {expect,test}from'@playwright/test';import {extname,basename}from'path';
|
|
2
|
-
`));}var
|
|
1
|
+
import {expect,test}from'@playwright/test';import {statSync}from'fs';import {extname,basename}from'path';Buffer.from([137,80,78,71,13,10,26,10]);var k="__testrelic_snapshot_path_probe__.png",y=Symbol.for("testrelic.snapshotPathWarned");function T(){let t=globalThis,e=t[y];return e||(e={warned:false},t[y]=e),e}function f(t){try{let e=t.snapshotPath(k,{kind:"screenshot"});return typeof e=="string"&&!e.includes("[object Object]")}catch{return false}}function m(t){let e=T();e.warned||(e.warned=true,process.stderr.write(`[testrelic] ${t} needs Playwright >= 1.51 for testInfo.snapshotPath(name, { kind }); this project's Playwright resolves that call the older way. Everything else in the reporter is unaffected \u2014 upgrade Playwright to enable it.
|
|
2
|
+
`));}var b="testrelic-visual";var E=[".png",".jpg",".jpeg"],B=/A snapshot doesn't exist at /;function C(t){return t.wrote?t.existedBefore?"updated":"new":t.pass?"passed":"failed"}function v(t){return t.status==="failed"&&!t.hadBaseline}function x(t){if(!t)return null;try{let e=statSync(t);return {size:e.size,mtimeMs:e.mtimeMs}}catch{return null}}function _(t,e){if(!f(t))return null;try{return t.snapshotPath(e,{kind:"screenshot"})}catch{return null}}function O(t){let e=t.toLowerCase();return E.some(o=>e.endsWith(o))?t:`${t}.png`}function V(){try{return test.info()}catch{return null}}function I(t,e,o){let r=n=>/^(.+)-(expected|actual|diff|previous)\.[A-Za-z0-9]+$/.exec(n)?.[1]??null,i=t.slice(e);if(o){for(let n of i)if(n.path===o&&/-expected\.[A-Za-z0-9]+$/.test(n.name))return r(n.name)}for(let n of i){let c=r(n.name);if(c)return c}return null}function L(t,e){return e?basename(e,extname(e)||".png"):t.replace(/\.[A-Za-z0-9]+$/,"")}async function j(t,e){if(e===null)return m("Attaching the baseline of a passing comparison"),null;try{let o=extname(e)||".png",r=basename(e,o);return await t.attach(`${r}-expected${o}`,{path:e}),r}catch{return null}}async function H(t,e){try{await t.attach(b,{body:Buffer.from(JSON.stringify([e])),contentType:"application/json"});}catch{}}async function p(t,e,o={}){let r={...o};delete r.tags;let i=O(e),n=V(),c=n?.attachments.length??0,D=n?.errors?.length??0,a=n?_(n,i):null,u=x(a),l=true,h="";try{await expect(t).toHaveScreenshot(i,r);}catch(s){l=false,h=s instanceof Error?s.message:String(s);}if(n){let s=x(a),P=a!==null?s!==null&&(u===null||u.size!==s.size||u.mtimeMs!==s.mtimeMs):(n.errors??[]).slice(D).some(M=>B.test(M.message??"")),d=C({pass:l,wrote:P,existedBefore:u!==null}),g=(d==="passed"||d==="updated"?await j(n,a):I(n.attachments,c,a))??(v({status:d,hadBaseline:u!==null})?L(i,a):null);g&&await H(n,{stem:g,name:e,status:d,...a?{committedPath:a}:{}});}return {pass:l,name:"toMatchVisualBaseline",message:()=>l?`Expected "${e}" to differ from its visual baseline, but it matched.`:h}}expect.extend({toMatchVisualBaseline:p});export{p as toMatchVisualBaseline};//# sourceMappingURL=visual.js.map
|
|
3
3
|
//# sourceMappingURL=visual.js.map
|
package/dist/visual.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/visual-metrics.ts","../src/snapshot-path-support.ts","../src/visual-capture.ts","../src/visual-matcher.ts","../src/visual.ts"],"names":["SIZE_MISMATCH_RE","isSizeMismatchFailure","message","PROBE_NAME","WARNED_KEY","warnSlot","g","slot","supportsSnapshotPathOptions","testInfo","resolved","warnSnapshotPathUnsupported","feature","VISUAL_META_ATTACHMENT","IMAGE_EXTENSIONS","normalizeSnapshotName","name","lower","ext","currentTestInfo","playwrightTest","stemOfNewAttachments","attachments","addedAfter","i","match","attachPassingBaseline","snapshotName","path","extname","stem","basename","attachMeta","record","toMatchVisualBaseline","target","options","screenshotOptions","attachedBefore","pass","playwrightExpect","error","wroteNewBaseline","a"],"mappings":"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","file":"visual.js","sourcesContent":["/**\n * Numbers for a visual comparison, recovered without decoding an image.\n *\n * Playwright compares images internally and keeps the result to itself: the\n * differing-pixel count exists only as prose inside the failure message, and\n * the image dimensions only inside the PNG. Rather than decode a PNG (which\n * would mean a dependency, and Principle V says no), both are read back — the\n * count from the message Playwright already formats, the dimensions from the\n * 8 bytes of header every PNG carries.\n */\n\n/** PNG magic number: \\x89 P N G \\r \\n \\x1a \\n */\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\n\n/** Byte offsets of width and height inside the IHDR chunk of a PNG. */\nconst IHDR_WIDTH_OFFSET = 16;\nconst IHDR_HEIGHT_OFFSET = 20;\n\n/** Enough bytes to cover the signature, the chunk header and both dimensions. */\nexport const PNG_HEADER_BYTES = 24;\n\nexport interface ImageDimensions {\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Read a PNG's pixel dimensions from its IHDR chunk.\n *\n * Only the first 24 bytes are consulted, so the caller can hand over a partial\n * read of a large file. Returns `null` for anything that is not a PNG — JPEG\n * baselines are legal in Playwright and simply go un-measured.\n */\nexport function readPngDimensions(header: Buffer): ImageDimensions | null {\n if (header.length < PNG_HEADER_BYTES) return null;\n if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return null;\n\n const width = header.readUInt32BE(IHDR_WIDTH_OFFSET);\n const height = header.readUInt32BE(IHDR_HEIGHT_OFFSET);\n\n // A zero dimension is not a valid PNG, and would make a ratio divide by zero.\n if (width === 0 || height === 0) return null;\n\n return { width, height };\n}\n\nexport interface ParsedDiffMessage {\n /** Differing pixel count, from `\"N pixels (ratio …) are different.\"` */\n readonly diffPixels?: number;\n /** Baseline dimensions, present only when the two images differ in size. */\n readonly expectedWidth?: number;\n readonly expectedHeight?: number;\n /** Actual dimensions, present only when the two images differ in size. */\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The snapshot name Playwright echoed, when the assertion supplied one. */\n readonly snapshotName?: string;\n}\n\n/**\n * `123 pixels (ratio 0.02 of all image pixels) are different.`\n *\n * The ratio in that sentence is rounded up to two decimals, so it is read past\n * deliberately — the caller recomputes it from the count and the dimensions.\n */\nconst PIXELS_DIFFERENT_RE = /(\\d+) pixels \\(ratio [\\d.]+ of all image pixels\\) are different/;\n\n/** `Expected an image 800px by 600px, received 800px by 640px.` */\nconst SIZE_MISMATCH_RE = /Expected an image (\\d+)px by (\\d+)px, received (\\d+)px by (\\d+)px/;\n\n/**\n * Whether a failure message is a size mismatch.\n *\n * The comparator cannot draw a diff across differing dimensions, so it\n * attaches expected + actual and throws instead. Callers that would otherwise\n * infer \"no baseline existed\" from a missing diff need this to tell the two\n * apart — without it a real failure was reported as a freshly written\n * baseline.\n */\nexport function isSizeMismatchFailure(message: string | null | undefined): boolean {\n if (!message) return false;\n return SIZE_MISMATCH_RE.test(message);\n}\n\n/** ` Snapshot: home.png` — only emitted when the assertion named the snapshot. */\nconst SNAPSHOT_NAME_RE = /^\\s*Snapshot:\\s*(.+?)\\s*$/m;\n\n/**\n * Pull the comparison numbers out of a Playwright snapshot failure message.\n *\n * Every field is optional and a malformed message yields an empty object: a\n * report that is missing a pixel count is a far better outcome than a reporter\n * that throws while assembling one.\n */\nexport function parseDiffMessage(message: string | null | undefined): ParsedDiffMessage {\n if (!message) return {};\n\n const result: {\n diffPixels?: number;\n expectedWidth?: number;\n expectedHeight?: number;\n actualWidth?: number;\n actualHeight?: number;\n snapshotName?: string;\n } = {};\n\n const pixels = PIXELS_DIFFERENT_RE.exec(message);\n if (pixels) {\n const count = Number.parseInt(pixels[1] as string, 10);\n if (Number.isFinite(count)) result.diffPixels = count;\n }\n\n const sizes = SIZE_MISMATCH_RE.exec(message);\n if (sizes) {\n const [, ew, eh, aw, ah] = sizes;\n const parsed = [ew, eh, aw, ah].map((v) => Number.parseInt(v as string, 10));\n if (parsed.every((v) => Number.isFinite(v))) {\n result.expectedWidth = parsed[0];\n result.expectedHeight = parsed[1];\n result.actualWidth = parsed[2];\n result.actualHeight = parsed[3];\n }\n }\n\n const name = SNAPSHOT_NAME_RE.exec(message);\n if (name) result.snapshotName = name[1];\n\n return result;\n}\n\n/**\n * Differing pixels as a fraction of the image, at full precision.\n *\n * Returns `undefined` rather than a wrong number when either input is missing,\n * so a consumer can tell \"no measurement\" apart from \"measured zero\".\n */\nexport function computeDiffRatio(\n diffPixels: number | undefined,\n dimensions: ImageDimensions | null | undefined,\n): number | undefined {\n if (diffPixels === undefined || !dimensions) return undefined;\n const total = dimensions.width * dimensions.height;\n if (total <= 0) return undefined;\n return diffPixels / total;\n}\n","/**\n * Whether this Playwright supports `snapshotPath(name, { kind })`.\n *\n * The options form arrived in Playwright 1.51. Before that `snapshotPath` took\n * variadic path segments, so an options object is consumed as a segment and\n * the resolved path is wrong — and because both call sites sit inside a\n * `try`/`catch` that swallows everything, DOM comparison and the\n * passing-baseline attachment simply never worked and said nothing about it.\n *\n * The package's peer range is `>=1.35.0` and stays that way deliberately:\n * everything except these two visual extras works on the older versions, and\n * raising the floor would lock those users out of the whole reporter over one\n * feature. So the loss is detected and *announced* once, instead of being\n * silent.\n *\n * The check is a feature probe rather than a version comparison. Reading\n * `@playwright/test/package.json` meant resolving a module at runtime from a\n * file that has to stay importable from both the CJS and the ESM build, and\n * the only spelling that worked in both reached the module loader through a\n * dynamically evaluated string. The repo's payload scanner rejects that shape\n * on sight, and rightly — this package is published to npm, and the scanner\n * does not exempt code that means well. Asking the API what it does needs no\n * module resolution, no dynamic execution and no version table, and it tests\n * the behaviour actually depended on rather than a number that correlates\n * with it.\n */\n\n/**\n * The one method probed. Both call sites already hold a `testInfo`; this is the\n * narrowest shape that covers the two call forms.\n */\nexport interface SnapshotPathCapable {\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n}\n\n/**\n * Passed to the probe. Never written to disk — `snapshotPath` only computes a\n * path — and named so it is unmistakable in a stack trace or a log line.\n */\nconst PROBE_NAME = '__testrelic_snapshot_path_probe__.png';\n\n/** Printed at most once per process, however many tests hit the path. */\nconst WARNED_KEY = Symbol.for('testrelic.snapshotPathWarned');\n\ninterface WarnSlot {\n warned: boolean;\n}\n\n/**\n * The latch lives on `globalThis`, not in a module variable.\n *\n * tsup builds with `splitting: false`, so every entry point bundles its own\n * copy of this module — and this module is reached from five of them. A\n * module-level `let` would therefore be five separate latches, and the\n * \"at most once per process\" promise above would print up to five times. This\n * is the same fix, and the same reason, as the active context in\n * `assertion-tracker.ts`.\n */\nfunction warnSlot(): WarnSlot {\n const g = globalThis as unknown as Record<symbol, WarnSlot | undefined>;\n let slot = g[WARNED_KEY];\n if (!slot) {\n slot = { warned: false };\n g[WARNED_KEY] = slot;\n }\n return slot;\n}\n\n/**\n * True when `snapshotPath(name, { kind })` resolves the way this code needs.\n *\n * The probe asks what the OLD form would have done wrong, not what the new one\n * returns verbatim. Before 1.51 the options object is consumed as another path\n * segment: `path.join` throws on a non-string, and a version that coerced it\n * instead would stringify it into the path as `[object Object]`. Either shape\n * answers `false`.\n *\n * It deliberately does NOT check that the result ends with the name it was\n * given. It did, and that was wrong: `snapshotPathTemplate` legitimately\n * inserts the project and platform before the extension, so\n * `toHaveScreenshot('dashboard.png')` resolves to `dashboard-win32.png` on\n * every default Playwright project. The probe therefore answered `false`\n * everywhere and silently disabled BOTH of its callers — passing-baseline\n * capture and DOM comparison — which shipped in 2.16.0 and is why a green\n * visual run recorded no comparisons at all and no element changes ever\n * appeared. Never assert on the shape of a path a template owns.\n */\nexport function supportsSnapshotPathOptions(testInfo: SnapshotPathCapable): boolean {\n try {\n const resolved = testInfo.snapshotPath(PROBE_NAME, { kind: 'screenshot' });\n return typeof resolved === 'string' && !resolved.includes('[object Object]');\n } catch {\n return false;\n }\n}\n\n/**\n * Say once that a visual extra is unavailable on this Playwright, and why.\n *\n * `feature` names what the user loses, so the line is actionable rather than\n * an abstract version complaint.\n */\nexport function warnSnapshotPathUnsupported(feature: string): void {\n const slot = warnSlot();\n if (slot.warned) return;\n slot.warned = true;\n process.stderr.write(\n `[testrelic] ${feature} needs Playwright >= 1.51 for ` +\n `testInfo.snapshotPath(name, { kind }); this project's Playwright ` +\n `resolves that call the older way. Everything else in the reporter is ` +\n `unaffected — upgrade Playwright to enable it.\\n`,\n );\n}\n\n/** Reset the once-per-process latch. Test-only. */\nexport function resetSnapshotPathWarning(): void {\n warnSlot().warned = false;\n}\n","/**\n * Recover visual baseline comparisons from a Playwright test result.\n *\n * Playwright's snapshot matchers do not report what they compared; they only\n * leave attachments behind. `toHaveScreenshot()` / `toMatchSnapshot()` attach\n * `<stem>-expected`, `<stem>-actual`, `<stem>-diff` and, on a retried failure,\n * `<stem>-previous`. Those four share a stem, and the stem is the snapshot's\n * identity within the test.\n *\n * This module is pure — it groups names and reads a message, and touches no\n * filesystem. Copying the images out is `artifact-manager.copyVisualArtifacts`.\n */\n\nimport type { VisualSource, VisualStatus } from '@testrelic/core';\nimport { parseDiffMessage, isSizeMismatchFailure } from './visual-metrics.js';\nimport { VISUAL_DOM_ATTACHMENT, type VisualDomRecord } from './visual-expect.js';\n\ninterface Attachment {\n name: string;\n contentType: string;\n path?: string;\n body?: Buffer;\n}\n\n/**\n * A comparison found in the attachments, still pointing at Playwright's temp\n * files. `artifact-manager` turns this into the report-facing\n * `VisualComparison` once the images have been copied somewhere durable.\n */\nexport interface VisualCandidate {\n readonly name: string;\n readonly status: VisualStatus;\n readonly source: VisualSource;\n readonly expectedPath?: string;\n readonly actualPath?: string;\n readonly diffPath?: string;\n readonly previousPath?: string;\n readonly diffPixels?: number;\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The DOM comparison for this snapshot, when one was made. */\n readonly dom?: VisualDomRecord;\n}\n\n/** Attachment name carrying the explicit matcher's JSON side-channel. */\nexport const VISUAL_META_ATTACHMENT = 'testrelic-visual';\n\n/** The four roles a snapshot image can play, as Playwright suffixes them. */\ntype VisualRole = 'expected' | 'actual' | 'diff' | 'previous';\n\nconst VISUAL_SUFFIX_RE = /^(.+)-(expected|actual|diff|previous)(\\.[A-Za-z0-9]+)$/;\n\n/** One stem's worth of images, before a status is decided. */\ninterface StemGroup {\n expected?: string;\n actual?: string;\n diff?: string;\n previous?: string;\n}\n\n/**\n * Metadata the `toMatchVisualBaseline` matcher attaches alongside its images.\n *\n * It exists because a passing comparison is otherwise invisible: Playwright\n * attaches nothing at all when the images match, so without this the report\n * could only ever show the failures.\n */\nexport interface VisualMetaRecord {\n readonly stem: string;\n readonly name: string;\n readonly status: VisualStatus;\n}\n\n/** Read and validate the explicit matcher's metadata attachment. */\nexport function parseVisualMeta(attachments: readonly Attachment[]): Map<string, VisualMetaRecord> {\n const byStem = new Map<string, VisualMetaRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_META_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n const record = toMetaRecord(entry);\n if (record) byStem.set(record.stem, record);\n }\n } catch {\n // A malformed side-channel costs us the pass-case detail, nothing more.\n }\n }\n\n return byStem;\n}\n\nconst VALID_STATUSES: readonly string[] = ['passed', 'failed', 'new', 'updated'];\n\n// SAFETY: parsed from an attachment body written by the worker process; every\n// field is checked before use.\nfunction toMetaRecord(entry: unknown): VisualMetaRecord | null {\n if (typeof entry !== 'object' || entry === null) return null;\n const e = entry as Record<string, unknown>;\n if (typeof e.stem !== 'string' || !e.stem) return null;\n if (typeof e.name !== 'string' || !e.name) return null;\n if (typeof e.status !== 'string' || !VALID_STATUSES.includes(e.status)) return null;\n return { stem: e.stem, name: e.name, status: e.status as VisualStatus };\n}\n\n/**\n * Read the DOM comparison side-channel, keyed by the same stem the images use.\n *\n * Written by `visual-expect`; absent whenever the assertion used Playwright's\n * own `expect`, or the page had no DOM baseline yet.\n */\nexport function parseVisualDom(\n attachments: readonly Attachment[],\n): Map<string, VisualDomRecord> {\n const byStem = new Map<string, VisualDomRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_DOM_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n // SAFETY: written by the worker process; the two fields read before\n // anything else uses it are checked here.\n if (typeof entry !== 'object' || entry === null) continue;\n const record = entry as Record<string, unknown>;\n if (typeof record.stem !== 'string' || !Array.isArray(record.changes)) continue;\n byStem.set(record.stem, entry as VisualDomRecord);\n }\n } catch {\n // A malformed side-channel costs the element list, nothing more.\n }\n }\n\n return byStem;\n}\n\n/** Group every `<stem>-<role>.<ext>` attachment that has a file behind it. */\nfunction groupBySnapshotStem(attachments: readonly Attachment[]): Map<string, StemGroup> {\n const groups = new Map<string, StemGroup>();\n\n for (const attachment of attachments) {\n if (!attachment.path) continue;\n const match = VISUAL_SUFFIX_RE.exec(attachment.name);\n if (!match) continue;\n\n const [, stem, role] = match;\n const group = groups.get(stem as string) ?? {};\n group[role as VisualRole] = attachment.path;\n groups.set(stem as string, group);\n }\n\n return groups;\n}\n\n/**\n * Decide whether a stem is really a snapshot comparison.\n *\n * An ordinary user attachment named `report-actual.json` would otherwise be\n * mistaken for one. A genuine comparison always produces an `actual` next to\n * either the baseline it was compared against or the diff it produced, so\n * requiring that pair costs nothing real and rejects the lookalikes. Stems the\n * explicit matcher has vouched for skip this check — it attaches a lone\n * baseline on a pass, which is the one legitimate single-image case.\n */\nfunction isCredibleComparison(group: StemGroup): boolean {\n return Boolean(group.actual && (group.expected || group.diff));\n}\n\n/** Derive a status from the images alone, for natively-produced comparisons. */\nfunction deriveStatus(\n group: StemGroup,\n updatingSnapshots: boolean,\n failureMessage: string | null | undefined,\n): VisualStatus {\n if (group.diff) return 'failed';\n // A size mismatch also produces no diff — the comparator cannot draw one\n // when the two images have different dimensions, so it attaches expected +\n // actual and throws. Absence of a diff therefore does NOT imply \"no baseline\n // existed\": read the message before concluding that. Without this a genuine\n // failure was reported as a freshly written baseline, it was missing from\n // visualFailures / visualFailedCount / visualDiffPath, and because\n // applyMetrics only targets failed stems the parsed dimensions were dropped\n // too (making SIZE_MISMATCH_RE dead in practice).\n if (isSizeMismatchFailure(failureMessage)) return 'failed';\n // Playwright attached a baseline and an actual but no diff: it had no\n // baseline and wrote one. That is not a pass — nothing was compared.\n return updatingSnapshots ? 'updated' : 'new';\n}\n\n/**\n * Attach the pixel count to the comparison it actually describes.\n *\n * The failure message belongs to whichever assertion threw. When it echoes a\n * snapshot name, that name is matched against the stems; otherwise the numbers\n * are only trustworthy if exactly one comparison failed, and are dropped when\n * several did rather than being pinned on an arbitrary one.\n */\nfunction selectMetricsTarget(\n failed: readonly string[],\n snapshotName: string | undefined,\n): string | null {\n if (failed.length === 0) return null;\n if (failed.length === 1) return failed[0] as string;\n if (!snapshotName) return null;\n\n const base = snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n const matches = failed.filter((stem) => stem === base || stem.endsWith(`/${base}`));\n return matches.length === 1 ? (matches[0] as string) : null;\n}\n\nexport interface ParseVisualOptions {\n /** True when the run was invoked with `--update-snapshots`. */\n readonly updatingSnapshots?: boolean;\n /** Concatenated failure messages for the test attempt. */\n readonly failureMessage?: string | null;\n}\n\n/**\n * Extract every visual comparison recorded against one test attempt.\n *\n * Returns an empty array — never throws — for a test that made no visual\n * assertions, which is the overwhelming majority of them.\n */\nexport function parseVisualAttachments(\n attachments: readonly Attachment[],\n options: ParseVisualOptions = {},\n): VisualCandidate[] {\n if (attachments.length === 0) return [];\n\n const meta = parseVisualMeta(attachments);\n const dom = parseVisualDom(attachments);\n const groups = groupBySnapshotStem(attachments);\n if (groups.size === 0) return [];\n\n const updating = options.updatingSnapshots === true;\n const candidates: VisualCandidate[] = [];\n\n for (const [stem, group] of groups) {\n const vouched = meta.get(stem);\n if (!vouched && !isCredibleComparison(group)) continue;\n\n candidates.push({\n name: vouched?.name ?? stem,\n status: vouched?.status ?? deriveStatus(group, updating, options.failureMessage),\n source: vouched ? 'toMatchVisualBaseline' : 'toHaveScreenshot',\n ...(group.expected ? { expectedPath: group.expected } : {}),\n ...(group.actual ? { actualPath: group.actual } : {}),\n ...(group.diff ? { diffPath: group.diff } : {}),\n ...(group.previous ? { previousPath: group.previous } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n return applyMetrics(candidates, groups, options.failureMessage);\n}\n\n/** Fold the parsed failure numbers into the one comparison they belong to. */\nfunction applyMetrics(\n candidates: readonly VisualCandidate[],\n groups: ReadonlyMap<string, StemGroup>,\n failureMessage: string | null | undefined,\n): VisualCandidate[] {\n const parsed = parseDiffMessage(failureMessage);\n if (parsed.diffPixels === undefined && parsed.actualWidth === undefined) {\n return [...candidates];\n }\n\n const failedStems = candidates.filter((c) => c.status === 'failed').map((c) => stemOf(c, groups));\n const target = selectMetricsTarget(failedStems, parsed.snapshotName);\n if (target === null) return [...candidates];\n\n return candidates.map((candidate) =>\n stemOf(candidate, groups) === target\n ? {\n ...candidate,\n ...(parsed.diffPixels !== undefined ? { diffPixels: parsed.diffPixels } : {}),\n ...(parsed.actualWidth !== undefined ? { actualWidth: parsed.actualWidth } : {}),\n ...(parsed.actualHeight !== undefined ? { actualHeight: parsed.actualHeight } : {}),\n }\n : candidate,\n );\n}\n\n/**\n * Recover the stem a candidate came from.\n *\n * The explicit matcher renames a candidate to the author's chosen name, so the\n * name cannot be used to look it back up among the groups.\n */\nfunction stemOf(candidate: VisualCandidate, groups: ReadonlyMap<string, StemGroup>): string {\n if (groups.has(candidate.name)) return candidate.name;\n for (const [stem, group] of groups) {\n if (\n group.actual === candidate.actualPath &&\n group.expected === candidate.expectedPath &&\n group.diff === candidate.diffPath\n ) {\n return stem;\n }\n }\n return candidate.name;\n}\n","/**\n * `toMatchVisualBaseline` — TestRelic's visual assertion.\n *\n * It does not compare images itself. Playwright already ships a comparator\n * (pixelmatch, or SSIM-CIE94 when asked) and bundles the decoders it needs, so\n * this delegates to `toHaveScreenshot` and adds the two things Playwright does\n * not give a reporter:\n *\n * 1. **A name that survives into the report.** Playwright's attachments are\n * named after the resolved snapshot path, which carries project and\n * platform suffixes. The author's own name is recorded alongside.\n * 2. **Evidence for a passing comparison.** Playwright attaches nothing at all\n * when the images match, so a green visual check is invisible to any\n * reporter. The baseline is attached here so the report can show what was\n * actually asserted against, not just what broke.\n *\n * Both ride the same `-expected` / `-actual` / `-diff` attachment convention\n * the reporter already groups by, so nothing downstream needs a second path.\n */\n\nimport { expect as playwrightExpect, test as playwrightTest } from '@playwright/test';\nimport { basename, extname } from 'node:path';\nimport { VISUAL_META_ATTACHMENT, type VisualMetaRecord } from './visual-capture.js';\nimport { isSizeMismatchFailure } from './visual-metrics.js';\nimport { supportsSnapshotPathOptions, warnSnapshotPathUnsupported } from './snapshot-path-support.js';\n\n/** Options accepted on top of Playwright's own screenshot options. */\nexport interface VisualBaselineOptions {\n /**\n * Labels recorded with the comparison. Reserved for the cloud baseline store,\n * where they will select which baselines a branch is allowed to promote.\n */\n readonly tags?: readonly string[];\n /** Anything else is forwarded to `toHaveScreenshot` untouched. */\n readonly [key: string]: unknown;\n}\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n name: string;\n}\n\n// SAFETY: Playwright's TestInfo is reached through `test.info()`, which throws\n// outside a running test. Only the three members used here are described.\ninterface MinimalTestInfo {\n attachments: Array<{ name: string; contentType: string; path?: string; body?: Buffer }>;\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n attach(\n name: string,\n options: { path?: string; body?: Buffer; contentType?: string },\n ): Promise<void>;\n}\n\n/** Image extensions Playwright will accept for a screenshot baseline. */\nconst IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];\n\n/**\n * Give the snapshot name a file extension if the author left it off.\n *\n * `toHaveScreenshot` rejects a bare name outright (\"must have '.png'\n * extension\"). Requiring the suffix here would be the sort of papercut that\n * makes a wrapper worse than the thing it wraps, so `'home'` and `'home.png'`\n * both work and resolve to the same baseline.\n */\nexport function normalizeSnapshotName(name: string): string {\n const lower = name.toLowerCase();\n return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : `${name}.png`;\n}\n\n/** `test.info()` throws when no test is running; a matcher must not. */\nfunction currentTestInfo(): MinimalTestInfo | null {\n try {\n return playwrightTest.info() as unknown as MinimalTestInfo;\n } catch {\n return null;\n }\n}\n\n/**\n * The stem Playwright used for the attachments this assertion just produced.\n *\n * Reading it back off `testInfo.attachments` is the only reliable way to learn\n * it: the stem comes from the resolved output path, which the snapshot path\n * template controls, and reversing that template would be guesswork.\n */\nfunction stemOfNewAttachments(\n attachments: readonly { name: string }[],\n addedAfter: number,\n): string | null {\n for (let i = addedAfter; i < attachments.length; i++) {\n const match = /^(.+)-(expected|actual|diff|previous)\\.[A-Za-z0-9]+$/.exec(\n attachments[i]?.name ?? '',\n );\n if (match) return match[1] as string;\n }\n return null;\n}\n\n/** Attach the baseline for a comparison that passed and left no evidence. */\nasync function attachPassingBaseline(\n testInfo: MinimalTestInfo,\n snapshotName: string,\n): Promise<string | null> {\n if (!supportsSnapshotPathOptions(testInfo)) {\n warnSnapshotPathUnsupported('Attaching the baseline of a passing comparison');\n return null;\n }\n try {\n const path = testInfo.snapshotPath(snapshotName, { kind: 'screenshot' });\n const ext = extname(path) || '.png';\n const stem = basename(path, ext);\n await testInfo.attach(`${stem}-expected${ext}`, { path });\n return stem;\n } catch {\n // No baseline on disk, or an unwritable output dir. The comparison still\n // passed; it simply has no picture to show for it.\n return null;\n }\n}\n\n/** Record the author's name and outcome against the stem the reporter will see. */\nasync function attachMeta(testInfo: MinimalTestInfo, record: VisualMetaRecord): Promise<void> {\n try {\n await testInfo.attach(VISUAL_META_ATTACHMENT, {\n body: Buffer.from(JSON.stringify([record])),\n contentType: 'application/json',\n });\n } catch {\n // Without this the comparison still appears, just under Playwright's own\n // name and inferred status.\n }\n}\n\n/**\n * Assert a page or locator against its committed visual baseline.\n *\n * Registered onto `expect` by `./visual`; see that module for the type\n * declaration that makes it visible to TypeScript.\n */\nexport async function toMatchVisualBaseline(\n this: { isNot?: boolean },\n target: unknown,\n name: string,\n options: VisualBaselineOptions = {},\n): Promise<MatcherResult> {\n // `tags` is ours; everything else belongs to Playwright and is forwarded\n // verbatim, so that every screenshot option keeps working here.\n const screenshotOptions: Record<string, unknown> = { ...options };\n delete screenshotOptions.tags;\n const snapshotName = normalizeSnapshotName(name);\n const testInfo = currentTestInfo();\n const attachedBefore = testInfo?.attachments.length ?? 0;\n\n let pass = true;\n let message = '';\n try {\n await (\n playwrightExpect(target) as unknown as {\n toHaveScreenshot(n: string, o: Record<string, unknown>): Promise<void>;\n }\n ).toHaveScreenshot(snapshotName, screenshotOptions);\n } catch (error) {\n pass = false;\n message = error instanceof Error ? error.message : String(error);\n }\n\n if (testInfo) {\n const stem = pass\n ? await attachPassingBaseline(testInfo, snapshotName)\n : stemOfNewAttachments(testInfo.attachments, attachedBefore);\n\n if (stem) {\n // A failure that wrote no diff wrote a first baseline instead — the\n // distinction matters, because nothing was compared in that case.\n //\n // But a size mismatch also writes no diff: the comparator cannot draw\n // one across different dimensions. Absence of a diff alone therefore\n // misreported a real failure as a new baseline, so the message decides.\n const wroteNewBaseline =\n !pass &&\n !isSizeMismatchFailure(message) &&\n !testInfo.attachments.some(\n (a, i) => i >= attachedBefore && a.name.startsWith(`${stem}-diff.`),\n );\n await attachMeta(testInfo, {\n stem,\n name,\n status: pass ? 'passed' : wroteNewBaseline ? 'new' : 'failed',\n });\n }\n }\n\n return {\n pass,\n name: 'toMatchVisualBaseline',\n message: () =>\n pass ? `Expected \"${name}\" to differ from its visual baseline, but it matched.` : message,\n };\n}\n","/**\n * @testrelic/playwright-analytics/visual\n *\n * Registers `toMatchVisualBaseline` onto Playwright's `expect` and declares it\n * to TypeScript.\n *\n * Importing this module is only necessary when using Playwright's own `expect`.\n * The SDK fixture (`@testrelic/playwright-analytics/fixture`) already imports\n * it, so `expect` from there has the matcher without a second import.\n *\n * Native `toHaveScreenshot()` needs none of this — the reporter recovers those\n * comparisons from the attachments on its own.\n */\n\nimport { expect as playwrightExpect } from '@playwright/test';\nimport { toMatchVisualBaseline } from './visual-matcher.js';\n\nexport type { VisualBaselineOptions } from './visual-matcher.js';\nexport { toMatchVisualBaseline } from './visual-matcher.js';\n\ndeclare global {\n // eslint-disable-next-line @typescript-eslint/no-namespace\n namespace PlaywrightTest {\n // `T` is unused here but structurally required: declaration merging only\n // works against Playwright's `Matchers<R, T = unknown>` if the parameter\n // list matches, and dropping it silently stops the merge.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n interface Matchers<R, T = unknown> {\n /**\n * Compare a page or locator against its committed visual baseline.\n *\n * Uses Playwright's own comparator, so every `toHaveScreenshot` option\n * (`threshold`, `maxDiffPixels`, `maxDiffPixelRatio`, `mask`, `clip`,\n * `fullPage`, `animations`, …) applies unchanged. Baselines live where\n * Playwright puts them and are updated with `--update-snapshots`.\n *\n * ```ts\n * await expect(page).toMatchVisualBaseline('home', {\n * maxDiffPixelRatio: 0.01,\n * mask: [page.locator('.live-ticker')],\n * });\n * ```\n */\n toMatchVisualBaseline(\n name: string,\n options?: import('./visual-matcher.js').VisualBaselineOptions,\n ): Promise<R>;\n }\n }\n}\n\n// Registered at module scope so a bare import is enough to install it.\n// `expect.extend` is additive and idempotent here: the fixture and a direct\n// import of this module both land on the same registration.\nplaywrightExpect.extend({ toMatchVisualBaseline });\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/visual-metrics.ts","../src/snapshot-path-support.ts","../src/visual-capture.ts","../src/visual-matcher.ts","../src/visual.ts"],"names":["PROBE_NAME","WARNED_KEY","warnSlot","g","slot","supportsSnapshotPathOptions","testInfo","resolved","warnSnapshotPathUnsupported","feature","VISUAL_META_ATTACHMENT","IMAGE_EXTENSIONS","MISSING_SNAPSHOT_RE","decideStatus","o","recordsWithoutImages","markOf","path","st","statSync","expectedPathOf","snapshotName","normalizeSnapshotName","name","lower","ext","currentTestInfo","playwrightTest","stemOfNewAttachments","attachments","addedAfter","expectedPath","stemOf","added","a","stem","unattachedStem","basename","extname","attachBaselineCopy","attachMeta","record","toMatchVisualBaseline","target","options","screenshotOptions","attachedBefore","errorsBefore","before","pass","message","playwrightExpect","error","after","wrote","e","status","recordStem"],"mappings":"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","file":"visual.js","sourcesContent":["/**\n * Numbers for a visual comparison, recovered without decoding an image.\n *\n * Playwright compares images internally and keeps the result to itself: the\n * differing-pixel count exists only as prose inside the failure message, and\n * the image dimensions only inside the PNG. Rather than decode a PNG (which\n * would mean a dependency, and Principle V says no), both are read back — the\n * count from the message Playwright already formats, the dimensions from the\n * 8 bytes of header every PNG carries.\n */\n\n/** PNG magic number: \\x89 P N G \\r \\n \\x1a \\n */\nconst PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\n\n/** Byte offsets of width and height inside the IHDR chunk of a PNG. */\nconst IHDR_WIDTH_OFFSET = 16;\nconst IHDR_HEIGHT_OFFSET = 20;\n\n/** Enough bytes to cover the signature, the chunk header and both dimensions. */\nexport const PNG_HEADER_BYTES = 24;\n\nexport interface ImageDimensions {\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Read a PNG's pixel dimensions from its IHDR chunk.\n *\n * Only the first 24 bytes are consulted, so the caller can hand over a partial\n * read of a large file. Returns `null` for anything that is not a PNG — JPEG\n * baselines are legal in Playwright and simply go un-measured.\n */\nexport function readPngDimensions(header: Buffer): ImageDimensions | null {\n if (header.length < PNG_HEADER_BYTES) return null;\n if (!header.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return null;\n\n const width = header.readUInt32BE(IHDR_WIDTH_OFFSET);\n const height = header.readUInt32BE(IHDR_HEIGHT_OFFSET);\n\n // A zero dimension is not a valid PNG, and would make a ratio divide by zero.\n if (width === 0 || height === 0) return null;\n\n return { width, height };\n}\n\nexport interface ParsedDiffMessage {\n /** Differing pixel count, from `\"N pixels (ratio …) are different.\"` */\n readonly diffPixels?: number;\n /** Baseline dimensions, present only when the two images differ in size. */\n readonly expectedWidth?: number;\n readonly expectedHeight?: number;\n /** Actual dimensions, present only when the two images differ in size. */\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The snapshot name Playwright echoed, when the assertion supplied one. */\n readonly snapshotName?: string;\n}\n\n/**\n * `123 pixels (ratio 0.02 of all image pixels) are different.`\n *\n * The ratio in that sentence is rounded up to two decimals, so it is read past\n * deliberately — the caller recomputes it from the count and the dimensions.\n */\nconst PIXELS_DIFFERENT_RE = /(\\d+) pixels \\(ratio [\\d.]+ of all image pixels\\) are different/;\n\n/** `Expected an image 800px by 600px, received 800px by 640px.` */\nconst SIZE_MISMATCH_RE = /Expected an image (\\d+)px by (\\d+)px, received (\\d+)px by (\\d+)px/;\n\n/**\n * Whether a failure message is a size mismatch.\n *\n * The comparator cannot draw a diff across differing dimensions, so it\n * attaches expected + actual and throws instead. Callers that would otherwise\n * infer \"no baseline existed\" from a missing diff need this to tell the two\n * apart — without it a real failure was reported as a freshly written\n * baseline.\n */\nexport function isSizeMismatchFailure(message: string | null | undefined): boolean {\n if (!message) return false;\n return SIZE_MISMATCH_RE.test(message);\n}\n\n/** ` Snapshot: home.png` — only emitted when the assertion named the snapshot. */\nconst SNAPSHOT_NAME_RE = /^\\s*Snapshot:\\s*(.+?)\\s*$/m;\n\n/**\n * Pull the comparison numbers out of a Playwright snapshot failure message.\n *\n * Every field is optional and a malformed message yields an empty object: a\n * report that is missing a pixel count is a far better outcome than a reporter\n * that throws while assembling one.\n */\nexport function parseDiffMessage(message: string | null | undefined): ParsedDiffMessage {\n if (!message) return {};\n\n const result: {\n diffPixels?: number;\n expectedWidth?: number;\n expectedHeight?: number;\n actualWidth?: number;\n actualHeight?: number;\n snapshotName?: string;\n } = {};\n\n const pixels = PIXELS_DIFFERENT_RE.exec(message);\n if (pixels) {\n const count = Number.parseInt(pixels[1] as string, 10);\n if (Number.isFinite(count)) result.diffPixels = count;\n }\n\n const sizes = SIZE_MISMATCH_RE.exec(message);\n if (sizes) {\n const [, ew, eh, aw, ah] = sizes;\n const parsed = [ew, eh, aw, ah].map((v) => Number.parseInt(v as string, 10));\n if (parsed.every((v) => Number.isFinite(v))) {\n result.expectedWidth = parsed[0];\n result.expectedHeight = parsed[1];\n result.actualWidth = parsed[2];\n result.actualHeight = parsed[3];\n }\n }\n\n const name = SNAPSHOT_NAME_RE.exec(message);\n if (name) result.snapshotName = name[1];\n\n return result;\n}\n\n/**\n * Differing pixels as a fraction of the image, at full precision.\n *\n * Returns `undefined` rather than a wrong number when either input is missing,\n * so a consumer can tell \"no measurement\" apart from \"measured zero\".\n */\nexport function computeDiffRatio(\n diffPixels: number | undefined,\n dimensions: ImageDimensions | null | undefined,\n): number | undefined {\n if (diffPixels === undefined || !dimensions) return undefined;\n const total = dimensions.width * dimensions.height;\n if (total <= 0) return undefined;\n return diffPixels / total;\n}\n","/**\n * Whether this Playwright supports `snapshotPath(name, { kind })`.\n *\n * The options form arrived in Playwright 1.51. Before that `snapshotPath` took\n * variadic path segments, so an options object is consumed as a segment and\n * the resolved path is wrong — and because both call sites sit inside a\n * `try`/`catch` that swallows everything, DOM comparison and the\n * passing-baseline attachment simply never worked and said nothing about it.\n *\n * The package's peer range is `>=1.35.0` and stays that way deliberately:\n * everything except these two visual extras works on the older versions, and\n * raising the floor would lock those users out of the whole reporter over one\n * feature. So the loss is detected and *announced* once, instead of being\n * silent.\n *\n * The check is a feature probe rather than a version comparison. Reading\n * `@playwright/test/package.json` meant resolving a module at runtime from a\n * file that has to stay importable from both the CJS and the ESM build, and\n * the only spelling that worked in both reached the module loader through a\n * dynamically evaluated string. The repo's payload scanner rejects that shape\n * on sight, and rightly — this package is published to npm, and the scanner\n * does not exempt code that means well. Asking the API what it does needs no\n * module resolution, no dynamic execution and no version table, and it tests\n * the behaviour actually depended on rather than a number that correlates\n * with it.\n */\n\n/**\n * The one method probed. Both call sites already hold a `testInfo`; this is the\n * narrowest shape that covers the two call forms.\n */\nexport interface SnapshotPathCapable {\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n}\n\n/**\n * Passed to the probe. Never written to disk — `snapshotPath` only computes a\n * path — and named so it is unmistakable in a stack trace or a log line.\n */\nconst PROBE_NAME = '__testrelic_snapshot_path_probe__.png';\n\n/** Printed at most once per process, however many tests hit the path. */\nconst WARNED_KEY = Symbol.for('testrelic.snapshotPathWarned');\n\ninterface WarnSlot {\n warned: boolean;\n}\n\n/**\n * The latch lives on `globalThis`, not in a module variable.\n *\n * tsup builds with `splitting: false`, so every entry point bundles its own\n * copy of this module — and this module is reached from five of them. A\n * module-level `let` would therefore be five separate latches, and the\n * \"at most once per process\" promise above would print up to five times. This\n * is the same fix, and the same reason, as the active context in\n * `assertion-tracker.ts`.\n */\nfunction warnSlot(): WarnSlot {\n const g = globalThis as unknown as Record<symbol, WarnSlot | undefined>;\n let slot = g[WARNED_KEY];\n if (!slot) {\n slot = { warned: false };\n g[WARNED_KEY] = slot;\n }\n return slot;\n}\n\n/**\n * True when `snapshotPath(name, { kind })` resolves the way this code needs.\n *\n * The probe asks what the OLD form would have done wrong, not what the new one\n * returns verbatim. Before 1.51 the options object is consumed as another path\n * segment: `path.join` throws on a non-string, and a version that coerced it\n * instead would stringify it into the path as `[object Object]`. Either shape\n * answers `false`.\n *\n * It deliberately does NOT check that the result ends with the name it was\n * given. It did, and that was wrong: `snapshotPathTemplate` legitimately\n * inserts the project and platform before the extension, so\n * `toHaveScreenshot('dashboard.png')` resolves to `dashboard-win32.png` on\n * every default Playwright project. The probe therefore answered `false`\n * everywhere and silently disabled BOTH of its callers — passing-baseline\n * capture and DOM comparison — which shipped in 2.16.0 and is why a green\n * visual run recorded no comparisons at all and no element changes ever\n * appeared. Never assert on the shape of a path a template owns.\n */\nexport function supportsSnapshotPathOptions(testInfo: SnapshotPathCapable): boolean {\n try {\n const resolved = testInfo.snapshotPath(PROBE_NAME, { kind: 'screenshot' });\n return typeof resolved === 'string' && !resolved.includes('[object Object]');\n } catch {\n return false;\n }\n}\n\n/**\n * Say once that a visual extra is unavailable on this Playwright, and why.\n *\n * `feature` names what the user loses, so the line is actionable rather than\n * an abstract version complaint.\n */\nexport function warnSnapshotPathUnsupported(feature: string): void {\n const slot = warnSlot();\n if (slot.warned) return;\n slot.warned = true;\n process.stderr.write(\n `[testrelic] ${feature} needs Playwright >= 1.51 for ` +\n `testInfo.snapshotPath(name, { kind }); this project's Playwright ` +\n `resolves that call the older way. Everything else in the reporter is ` +\n `unaffected — upgrade Playwright to enable it.\\n`,\n );\n}\n\n/** Reset the once-per-process latch. Test-only. */\nexport function resetSnapshotPathWarning(): void {\n warnSlot().warned = false;\n}\n","/**\n * Recover visual baseline comparisons from a Playwright test result.\n *\n * Playwright's snapshot matchers do not report what they compared; they only\n * leave attachments behind. `toHaveScreenshot()` / `toMatchSnapshot()` attach\n * `<stem>-expected`, `<stem>-actual`, `<stem>-diff` and, on a retried failure,\n * `<stem>-previous`. Those four share a stem, and the stem is the snapshot's\n * identity within the test.\n *\n * This module is pure — it groups names and reads a message, and touches no\n * filesystem. Copying the images out is `artifact-manager.copyVisualArtifacts`.\n */\n\nimport type { VisualSource, VisualStatus } from '@testrelic/core';\nimport { parseDiffMessage, isSizeMismatchFailure } from './visual-metrics.js';\nimport { VISUAL_DOM_ATTACHMENT, type VisualDomRecord } from './visual-expect.js';\n\ninterface Attachment {\n name: string;\n contentType: string;\n path?: string;\n body?: Buffer;\n}\n\n/**\n * A comparison found in the attachments, still pointing at Playwright's temp\n * files. `artifact-manager` turns this into the report-facing\n * `VisualComparison` once the images have been copied somewhere durable.\n */\nexport interface VisualCandidate {\n readonly name: string;\n readonly status: VisualStatus;\n readonly source: VisualSource;\n readonly expectedPath?: string;\n /** The committed baseline itself, when the matcher could name it. */\n readonly committedPath?: string;\n readonly actualPath?: string;\n readonly diffPath?: string;\n readonly previousPath?: string;\n readonly diffPixels?: number;\n readonly actualWidth?: number;\n readonly actualHeight?: number;\n /** The DOM comparison for this snapshot, when one was made. */\n readonly dom?: VisualDomRecord;\n}\n\n/** Attachment name carrying the explicit matcher's JSON side-channel. */\nexport const VISUAL_META_ATTACHMENT = 'testrelic-visual';\n\n/** The four roles a snapshot image can play, as Playwright suffixes them. */\ntype VisualRole = 'expected' | 'actual' | 'diff' | 'previous';\n\nconst VISUAL_SUFFIX_RE = /^(.+)-(expected|actual|diff|previous)(\\.[A-Za-z0-9]+)$/;\n\n/** One stem's worth of images, before a status is decided. */\ninterface StemGroup {\n expected?: string;\n actual?: string;\n diff?: string;\n previous?: string;\n}\n\n/**\n * Metadata the `toMatchVisualBaseline` matcher attaches alongside its images.\n *\n * It exists because a passing comparison is otherwise invisible: Playwright\n * attaches nothing at all when the images match, so without this the report\n * could only ever show the failures.\n */\nexport interface VisualMetaRecord {\n readonly stem: string;\n readonly name: string;\n readonly status: VisualStatus;\n /**\n * The committed baseline, absolute. `testInfo.attach({ path })` COPIES the\n * file into the test's attachments dir, so on a pass the `-expected`\n * attachment's path is a per-run copy — this is where the real one is.\n */\n readonly committedPath?: string;\n}\n\n/** Read and validate the explicit matcher's metadata attachment. */\nexport function parseVisualMeta(attachments: readonly Attachment[]): Map<string, VisualMetaRecord> {\n const byStem = new Map<string, VisualMetaRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_META_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n const record = toMetaRecord(entry);\n if (record) byStem.set(record.stem, record);\n }\n } catch {\n // A malformed side-channel costs us the pass-case detail, nothing more.\n }\n }\n\n return byStem;\n}\n\nconst VALID_STATUSES: readonly string[] = ['passed', 'failed', 'new', 'updated'];\n\n// SAFETY: parsed from an attachment body written by the worker process; every\n// field is checked before use.\nfunction toMetaRecord(entry: unknown): VisualMetaRecord | null {\n if (typeof entry !== 'object' || entry === null) return null;\n const e = entry as Record<string, unknown>;\n if (typeof e.stem !== 'string' || !e.stem) return null;\n if (typeof e.name !== 'string' || !e.name) return null;\n if (typeof e.status !== 'string' || !VALID_STATUSES.includes(e.status)) return null;\n return {\n stem: e.stem,\n name: e.name,\n status: e.status as VisualStatus,\n ...(typeof e.committedPath === 'string' && e.committedPath ? { committedPath: e.committedPath } : {}),\n };\n}\n\n/**\n * Read the DOM comparison side-channel, keyed by the same stem the images use.\n *\n * Written by `visual-expect`; absent whenever the assertion used Playwright's\n * own `expect`, or the page had no DOM baseline yet.\n */\nexport function parseVisualDom(\n attachments: readonly Attachment[],\n): Map<string, VisualDomRecord> {\n const byStem = new Map<string, VisualDomRecord>();\n\n for (const attachment of attachments) {\n if (attachment.name !== VISUAL_DOM_ATTACHMENT || !attachment.body) continue;\n try {\n const parsed: unknown = JSON.parse(attachment.body.toString('utf-8'));\n if (!Array.isArray(parsed)) continue;\n for (const entry of parsed) {\n // SAFETY: written by the worker process; the two fields read before\n // anything else uses it are checked here.\n if (typeof entry !== 'object' || entry === null) continue;\n const record = entry as Record<string, unknown>;\n if (typeof record.stem !== 'string' || !Array.isArray(record.changes)) continue;\n byStem.set(record.stem, entry as VisualDomRecord);\n }\n } catch {\n // A malformed side-channel costs the element list, nothing more.\n }\n }\n\n return byStem;\n}\n\n/** Group every `<stem>-<role>.<ext>` attachment that has a file behind it. */\nfunction groupBySnapshotStem(attachments: readonly Attachment[]): Map<string, StemGroup> {\n const groups = new Map<string, StemGroup>();\n\n for (const attachment of attachments) {\n if (!attachment.path) continue;\n const match = VISUAL_SUFFIX_RE.exec(attachment.name);\n if (!match) continue;\n\n const [, stem, role] = match;\n const group = groups.get(stem as string) ?? {};\n group[role as VisualRole] = attachment.path;\n groups.set(stem as string, group);\n }\n\n return groups;\n}\n\n/**\n * Decide whether a stem is really a snapshot comparison.\n *\n * An ordinary user attachment named `report-actual.json` would otherwise be\n * mistaken for one. A genuine comparison always produces an `actual` next to\n * either the baseline it was compared against or the diff it produced, so\n * requiring that pair costs nothing real and rejects the lookalikes. Stems the\n * explicit matcher has vouched for skip this check — it attaches a lone\n * baseline on a pass, which is the one legitimate single-image case.\n */\nfunction isCredibleComparison(group: StemGroup): boolean {\n return Boolean(group.actual && (group.expected || group.diff));\n}\n\n/** Derive a status from the images alone, for natively-produced comparisons. */\nfunction deriveStatus(\n group: StemGroup,\n updatingSnapshots: boolean,\n failureMessage: string | null | undefined,\n): VisualStatus {\n if (group.diff) return 'failed';\n // A size mismatch also produces no diff — the comparator cannot draw one\n // when the two images have different dimensions, so it attaches expected +\n // actual and throws. Absence of a diff therefore does NOT imply \"no baseline\n // existed\": read the message before concluding that. Without this a genuine\n // failure was reported as a freshly written baseline, it was missing from\n // visualFailures / visualFailedCount / visualDiffPath, and because\n // applyMetrics only targets failed stems the parsed dimensions were dropped\n // too (making SIZE_MISMATCH_RE dead in practice).\n if (isSizeMismatchFailure(failureMessage)) return 'failed';\n // Playwright attached a baseline and an actual but no diff: it had no\n // baseline and wrote one. That is not a pass — nothing was compared.\n return updatingSnapshots ? 'updated' : 'new';\n}\n\n/**\n * Attach the pixel count to the comparison it actually describes.\n *\n * The failure message belongs to whichever assertion threw. When it echoes a\n * snapshot name, that name is matched against the stems; otherwise the numbers\n * are only trustworthy if exactly one comparison failed, and are dropped when\n * several did rather than being pinned on an arbitrary one.\n */\nfunction selectMetricsTarget(\n failed: readonly string[],\n snapshotName: string | undefined,\n): string | null {\n if (failed.length === 0) return null;\n if (failed.length === 1) return failed[0] as string;\n if (!snapshotName) return null;\n\n const base = snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n const matches = failed.filter((stem) => stem === base || stem.endsWith(`/${base}`));\n return matches.length === 1 ? (matches[0] as string) : null;\n}\n\nexport interface ParseVisualOptions {\n /** True when the run was invoked with `--update-snapshots`. */\n readonly updatingSnapshots?: boolean;\n /** Concatenated failure messages for the test attempt. */\n readonly failureMessage?: string | null;\n}\n\n/**\n * Extract every visual comparison recorded against one test attempt.\n *\n * Returns an empty array — never throws — for a test that made no visual\n * assertions, which is the overwhelming majority of them.\n */\nexport function parseVisualAttachments(\n attachments: readonly Attachment[],\n options: ParseVisualOptions = {},\n): VisualCandidate[] {\n if (attachments.length === 0) return [];\n\n const meta = parseVisualMeta(attachments);\n const dom = parseVisualDom(attachments);\n const groups = groupBySnapshotStem(attachments);\n if (groups.size === 0 && meta.size === 0) return [];\n\n const updating = options.updatingSnapshots === true;\n const candidates: VisualCandidate[] = [];\n\n for (const [stem, group] of groups) {\n const vouched = meta.get(stem);\n if (!vouched && !isCredibleComparison(group)) continue;\n\n candidates.push({\n name: vouched?.name ?? stem,\n status: vouched?.status ?? deriveStatus(group, updating, options.failureMessage),\n source: vouched ? 'toMatchVisualBaseline' : 'toHaveScreenshot',\n ...(group.expected ? { expectedPath: group.expected } : {}),\n ...(vouched?.committedPath ? { committedPath: vouched.committedPath } : {}),\n ...(group.actual ? { actualPath: group.actual } : {}),\n ...(group.diff ? { diffPath: group.diff } : {}),\n ...(group.previous ? { previousPath: group.previous } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n // A vouched comparison the loop above can never reach, because it produced\n // no images to group. Under `updateSnapshots: 'none'` a missing baseline\n // throws before anything is rendered and Playwright attaches nothing, so the\n // snapshot that broke the run was the one the report did not mention.\n for (const [stem, vouched] of meta) {\n if (groups.has(stem)) continue;\n candidates.push({\n name: vouched.name,\n status: vouched.status,\n source: 'toMatchVisualBaseline',\n ...(vouched.committedPath ? { committedPath: vouched.committedPath } : {}),\n ...(dom.has(stem) ? { dom: dom.get(stem) as VisualDomRecord } : {}),\n });\n }\n\n return applyMetrics(candidates, groups, options.failureMessage);\n}\n\n/** Fold the parsed failure numbers into the one comparison they belong to. */\nfunction applyMetrics(\n candidates: readonly VisualCandidate[],\n groups: ReadonlyMap<string, StemGroup>,\n failureMessage: string | null | undefined,\n): VisualCandidate[] {\n const parsed = parseDiffMessage(failureMessage);\n if (parsed.diffPixels === undefined && parsed.actualWidth === undefined) {\n return [...candidates];\n }\n\n // Only a comparison with images can be what a pixel count describes; an\n // imageless record neither claims the numbers nor makes the choice ambiguous.\n const failedStems = candidates\n .filter((c) => c.status === 'failed' && hasImages(c))\n .map((c) => stemOf(c, groups));\n const target = selectMetricsTarget(failedStems, parsed.snapshotName);\n if (target === null) return [...candidates];\n\n return candidates.map((candidate) =>\n hasImages(candidate) && stemOf(candidate, groups) === target\n ? {\n ...candidate,\n ...(parsed.diffPixels !== undefined ? { diffPixels: parsed.diffPixels } : {}),\n ...(parsed.actualWidth !== undefined ? { actualWidth: parsed.actualWidth } : {}),\n ...(parsed.actualHeight !== undefined ? { actualHeight: parsed.actualHeight } : {}),\n }\n : candidate,\n );\n}\n\n/** Whether any image was recorded for a candidate. */\nfunction hasImages(candidate: VisualCandidate): boolean {\n return Boolean(\n candidate.expectedPath || candidate.actualPath || candidate.diffPath || candidate.previousPath,\n );\n}\n\n/**\n * Recover the stem a candidate came from.\n *\n * The explicit matcher renames a candidate to the author's chosen name, so the\n * name cannot be used to look it back up among the groups.\n */\nfunction stemOf(candidate: VisualCandidate, groups: ReadonlyMap<string, StemGroup>): string {\n if (groups.has(candidate.name)) return candidate.name;\n for (const [stem, group] of groups) {\n if (\n group.actual === candidate.actualPath &&\n group.expected === candidate.expectedPath &&\n group.diff === candidate.diffPath\n ) {\n return stem;\n }\n }\n return candidate.name;\n}\n","/**\n * `toMatchVisualBaseline` — TestRelic's visual assertion.\n *\n * It does not compare images itself. Playwright already ships a comparator\n * (pixelmatch, or SSIM-CIE94 when asked) and bundles the decoders it needs, so\n * this delegates to `toHaveScreenshot` and adds the two things Playwright does\n * not give a reporter:\n *\n * 1. **A name that survives into the report.** Playwright's attachments are\n * named after the resolved snapshot path, which carries project and\n * platform suffixes. The author's own name is recorded alongside.\n * 2. **Evidence for a passing comparison.** Playwright attaches nothing at all\n * when the images match, so a green visual check is invisible to any\n * reporter. The baseline is attached here so the report can show what was\n * actually asserted against, not just what broke.\n *\n * Both ride the same `-expected` / `-actual` / `-diff` attachment convention\n * the reporter already groups by, so nothing downstream needs a second path.\n */\n\nimport { expect as playwrightExpect, test as playwrightTest } from '@playwright/test';\nimport { statSync } from 'node:fs';\nimport { basename, extname } from 'node:path';\nimport type { VisualStatus } from '@testrelic/core';\nimport { VISUAL_META_ATTACHMENT, type VisualMetaRecord } from './visual-capture.js';\nimport { supportsSnapshotPathOptions, warnSnapshotPathUnsupported } from './snapshot-path-support.js';\n\n/** Options accepted on top of Playwright's own screenshot options. */\nexport interface VisualBaselineOptions {\n /**\n * Labels recorded with the comparison. Reserved for the cloud baseline store,\n * where they will select which baselines a branch is allowed to promote.\n */\n readonly tags?: readonly string[];\n /** Anything else is forwarded to `toHaveScreenshot` untouched. */\n readonly [key: string]: unknown;\n}\n\ninterface MatcherResult {\n pass: boolean;\n message: () => string;\n name: string;\n}\n\n// SAFETY: Playwright's TestInfo is reached through `test.info()`, which throws\n// outside a running test. Only the three members used here are described.\ninterface MinimalTestInfo {\n attachments: Array<{ name: string; contentType: string; path?: string; body?: Buffer }>;\n /**\n * Playwright records a first-run write here and then RESOLVES the matcher.\n * Read only as a fallback, on a Playwright too old to name the committed\n * file (see `expectedPathOf`).\n */\n errors: ReadonlyArray<{ message?: string }>;\n snapshotPath(name: string, options: { kind: 'screenshot' }): string;\n attach(\n name: string,\n options: { path?: string; body?: Buffer; contentType?: string },\n ): Promise<void>;\n}\n\n/** Image extensions Playwright will accept for a screenshot baseline. */\nconst IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg'];\n\n/**\n * What Playwright records when `toHaveScreenshot` finds no baseline under the\n * default `updateSnapshots: 'missing'`: it writes the actual AS the baseline,\n * pushes this error onto `testInfo.errors`, and then resolves the matcher with\n * `pass: true`. The test fails at the end; the matcher call does not.\n *\n * Only the fallback signal — `testInfo.errors` is shared by every assertion in\n * the test, so two comparisons resolving at once could read each other's.\n */\nconst MISSING_SNAPSHOT_RE = /A snapshot doesn't exist at /;\n\n/**\n * The verdict for one `toMatchVisualBaseline` call.\n *\n * A written baseline is not a pass, whatever the matcher resolved: nothing was\n * compared. Playwright writes one on a first run (any mode but `'none'`) and\n * rewrites one under `--update-snapshots`, and resolves as a pass both times.\n * The first run of a suite once recorded every one of these as `passed`.\n */\nexport function decideStatus(o: {\n pass: boolean;\n /** The committed baseline was written or rewritten during this call. */\n wrote: boolean;\n /** A committed baseline existed before this call. */\n existedBefore: boolean;\n}): VisualStatus {\n if (o.wrote) return o.existedBefore ? 'updated' : 'new';\n return o.pass ? 'passed' : 'failed';\n}\n\n/**\n * Whether a comparison that left NO attachment behind should still be recorded.\n *\n * Only one situation deserves a record with no images: the baseline was missing\n * and the run was not allowed to write one, so Playwright rendered nothing,\n * attached nothing, and failed. Without this the snapshot that broke the run is\n * the one the report never mentions.\n *\n * The `hadBaseline` half is the guard, and it is load-bearing. A `failed`\n * verdict means only that `toHaveScreenshot` threw, and several of its throws\n * happen before a pixel is taken — a negative `maxDiffPixels`, a\n * `maxDiffPixelRatio` outside 0..1, a receiver that is not a Page or Locator,\n * an unreadable `stylePath`, or the page closing mid-call. Recording those\n * would invent a visual regression against a baseline that is perfectly fine,\n * and put a config typo in the run's visual-failure count.\n */\nexport function recordsWithoutImages(o: {\n status: VisualStatus;\n hadBaseline: boolean;\n}): boolean {\n return o.status === 'failed' && !o.hadBaseline;\n}\n\n/** Enough of a file to notice that it was rewritten. */\ninterface FileMark {\n size: number;\n mtimeMs: number;\n}\n\nfunction markOf(path: string | null): FileMark | null {\n if (!path) return null;\n try {\n const st = statSync(path);\n return { size: st.size, mtimeMs: st.mtimeMs };\n } catch {\n return null;\n }\n}\n\n/**\n * The committed baseline this call resolves to — the same file Playwright\n * will read, write, or rewrite — or null on a Playwright too old to say.\n */\nfunction expectedPathOf(testInfo: MinimalTestInfo, snapshotName: string): string | null {\n if (!supportsSnapshotPathOptions(testInfo)) return null;\n try {\n return testInfo.snapshotPath(snapshotName, { kind: 'screenshot' });\n } catch {\n return null;\n }\n}\n\n/**\n * Give the snapshot name a file extension if the author left it off.\n *\n * `toHaveScreenshot` rejects a bare name outright (\"must have '.png'\n * extension\"). Requiring the suffix here would be the sort of papercut that\n * makes a wrapper worse than the thing it wraps, so `'home'` and `'home.png'`\n * both work and resolve to the same baseline.\n */\nexport function normalizeSnapshotName(name: string): string {\n const lower = name.toLowerCase();\n return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : `${name}.png`;\n}\n\n/** `test.info()` throws when no test is running; a matcher must not. */\nfunction currentTestInfo(): MinimalTestInfo | null {\n try {\n return playwrightTest.info() as unknown as MinimalTestInfo;\n } catch {\n return null;\n }\n}\n\n/**\n * The stem Playwright used for the attachments this assertion just produced.\n *\n * Reading it back off `testInfo.attachments` is the only reliable way to learn\n * it: the stem comes from the resolved output path, which the snapshot path\n * template controls, and reversing that template would be guesswork. When the\n * committed file can be named, the `-expected` attachment that points at it is\n * the one that belongs to this call — so two assertions resolving at once\n * cannot borrow each other's stem. Otherwise the first new comparison\n * attachment is taken.\n */\nfunction stemOfNewAttachments(\n attachments: readonly { name: string; path?: string }[],\n addedAfter: number,\n expectedPath: string | null,\n): string | null {\n const stemOf = (name: string): string | null =>\n /^(.+)-(expected|actual|diff|previous)\\.[A-Za-z0-9]+$/.exec(name)?.[1] ?? null;\n const added = attachments.slice(addedAfter);\n if (expectedPath) {\n for (const a of added) {\n if (a.path === expectedPath && /-expected\\.[A-Za-z0-9]+$/.test(a.name)) return stemOf(a.name);\n }\n }\n for (const a of added) {\n const stem = stemOf(a.name);\n if (stem) return stem;\n }\n return null;\n}\n\n/**\n * The identity to record for a failed comparison Playwright attached nothing\n * for — a missing baseline under `updateSnapshots: 'none'`, where it throws\n * before anything is rendered.\n *\n * Nothing points at this stem, so it only has to name the comparison: the\n * resolved baseline's filename, which is the stem Playwright itself would have\n * used, or the author's own name on a Playwright too old to resolve it.\n */\nexport function unattachedStem(snapshotName: string, expectedPath: string | null): string {\n if (expectedPath) return basename(expectedPath, extname(expectedPath) || '.png');\n return snapshotName.replace(/\\.[A-Za-z0-9]+$/, '');\n}\n\n/**\n * Attach the committed baseline for a comparison that left no evidence — a\n * pass, or a rewrite under `--update-snapshots`, where Playwright writes the\n * file and attaches nothing.\n */\nasync function attachBaselineCopy(\n testInfo: MinimalTestInfo,\n expectedPath: string | null,\n): Promise<string | null> {\n if (expectedPath === null) {\n warnSnapshotPathUnsupported('Attaching the baseline of a passing comparison');\n return null;\n }\n try {\n const ext = extname(expectedPath) || '.png';\n const stem = basename(expectedPath, ext);\n await testInfo.attach(`${stem}-expected${ext}`, { path: expectedPath });\n return stem;\n } catch {\n // No baseline on disk, or an unwritable output dir. The comparison still\n // passed; it simply has no picture to show for it.\n return null;\n }\n}\n\n/** Record the author's name and outcome against the stem the reporter will see. */\nasync function attachMeta(testInfo: MinimalTestInfo, record: VisualMetaRecord): Promise<void> {\n try {\n await testInfo.attach(VISUAL_META_ATTACHMENT, {\n body: Buffer.from(JSON.stringify([record])),\n contentType: 'application/json',\n });\n } catch {\n // Without this the comparison still appears, just under Playwright's own\n // name and inferred status.\n }\n}\n\n/**\n * Assert a page or locator against its committed visual baseline.\n *\n * Registered onto `expect` by `./visual`; see that module for the type\n * declaration that makes it visible to TypeScript.\n */\nexport async function toMatchVisualBaseline(\n this: { isNot?: boolean },\n target: unknown,\n name: string,\n options: VisualBaselineOptions = {},\n): Promise<MatcherResult> {\n // `tags` is ours; everything else belongs to Playwright and is forwarded\n // verbatim, so that every screenshot option keeps working here.\n const screenshotOptions: Record<string, unknown> = { ...options };\n delete screenshotOptions.tags;\n const snapshotName = normalizeSnapshotName(name);\n const testInfo = currentTestInfo();\n const attachedBefore = testInfo?.attachments.length ?? 0;\n const errorsBefore = testInfo?.errors?.length ?? 0;\n const expectedPath = testInfo ? expectedPathOf(testInfo, snapshotName) : null;\n const before = markOf(expectedPath);\n\n let pass = true;\n let message = '';\n try {\n await (\n playwrightExpect(target) as unknown as {\n toHaveScreenshot(n: string, o: Record<string, unknown>): Promise<void>;\n }\n ).toHaveScreenshot(snapshotName, screenshotOptions);\n } catch (error) {\n pass = false;\n message = error instanceof Error ? error.message : String(error);\n }\n\n if (testInfo) {\n // `pass` is true whenever Playwright wrote the committed file itself — a\n // first run, or `--update-snapshots` — and nothing was compared then. The\n // file is the witness: it is there now and was not, or it is no longer\n // the file it was. Under `updateSnapshots: 'none'` a missing baseline is\n // a plain failure, and nothing is written.\n const after = markOf(expectedPath);\n const wrote =\n expectedPath !== null\n ? after !== null &&\n (before === null || before.size !== after.size || before.mtimeMs !== after.mtimeMs)\n : (testInfo.errors ?? [])\n .slice(errorsBefore)\n .some((e) => MISSING_SNAPSHOT_RE.test(e.message ?? ''));\n const status = decideStatus({ pass, wrote, existedBefore: before !== null });\n\n // A pass and a rewrite leave no attachment behind, so the committed file\n // is attached here; a first write and a failure are attached by\n // Playwright under its own stem, which is read back rather than guessed.\n const stem =\n status === 'passed' || status === 'updated'\n ? await attachBaselineCopy(testInfo, expectedPath)\n : stemOfNewAttachments(testInfo.attachments, attachedBefore, expectedPath);\n\n // A failure can leave nothing behind at all: with no baseline to compare\n // against and no mode that would write one, Playwright renders nothing and\n // attaches nothing, and the snapshot that broke the run was the one the\n // report never mentioned. The record carries the verdict without images.\n //\n // `before === null` is the whole gate, and it has to be. `status` is\n // 'failed' for ANY throw out of `toHaveScreenshot`, and several of those\n // happen before a pixel is taken — a negative `maxDiffPixels`, a\n // `maxDiffPixelRatio` outside 0..1, a receiver that is not a Page or\n // Locator, an unreadable `stylePath`, or the page closing mid-call. With a\n // perfectly good committed baseline on disk, synthesising a record there\n // would report a visual regression that never happened and put a config\n // typo in the run's visual-failure count. A missing baseline is the only\n // case this exists for, and Playwright's own missing-snapshot path runs\n // only when there was no file — so `before` is necessarily null there.\n const recordStem =\n stem ??\n (recordsWithoutImages({ status, hadBaseline: before !== null })\n ? unattachedStem(snapshotName, expectedPath)\n : null);\n\n if (recordStem) {\n await attachMeta(testInfo, {\n stem: recordStem,\n name,\n status,\n ...(expectedPath ? { committedPath: expectedPath } : {}),\n });\n }\n }\n\n return {\n pass,\n name: 'toMatchVisualBaseline',\n message: () =>\n pass ? `Expected \"${name}\" to differ from its visual baseline, but it matched.` : message,\n };\n}\n","/**\n * @testrelic/playwright-analytics/visual\n *\n * Registers `toMatchVisualBaseline` onto Playwright's `expect` and declares it\n * to TypeScript.\n *\n * Importing this module is only necessary when using Playwright's own `expect`.\n * The SDK fixture (`@testrelic/playwright-analytics/fixture`) already imports\n * it, so `expect` from there has the matcher without a second import.\n *\n * Native `toHaveScreenshot()` needs none of this — the reporter recovers those\n * comparisons from the attachments on its own.\n */\n\nimport { expect as playwrightExpect } from '@playwright/test';\nimport { toMatchVisualBaseline } from './visual-matcher.js';\n\nexport type { VisualBaselineOptions } from './visual-matcher.js';\nexport { toMatchVisualBaseline } from './visual-matcher.js';\n\ndeclare global {\n // eslint-disable-next-line @typescript-eslint/no-namespace\n namespace PlaywrightTest {\n // `T` is unused here but structurally required: declaration merging only\n // works against Playwright's `Matchers<R, T = unknown>` if the parameter\n // list matches, and dropping it silently stops the merge.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n interface Matchers<R, T = unknown> {\n /**\n * Compare a page or locator against its committed visual baseline.\n *\n * Uses Playwright's own comparator, so every `toHaveScreenshot` option\n * (`threshold`, `maxDiffPixels`, `maxDiffPixelRatio`, `mask`, `clip`,\n * `fullPage`, `animations`, …) applies unchanged. Baselines live where\n * Playwright puts them and are updated with `--update-snapshots`.\n *\n * ```ts\n * await expect(page).toMatchVisualBaseline('home', {\n * maxDiffPixelRatio: 0.01,\n * mask: [page.locator('.live-ticker')],\n * });\n * ```\n */\n toMatchVisualBaseline(\n name: string,\n options?: import('./visual-matcher.js').VisualBaselineOptions,\n ): Promise<R>;\n }\n }\n}\n\n// Registered at module scope so a bare import is enough to install it.\n// `expect.extend` is additive and idempotent here: the fixture and a direct\n// import of this module both land on the same registration.\nplaywrightExpect.extend({ toMatchVisualBaseline });\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testrelic/playwright-analytics",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.2-next.152",
|
|
4
4
|
"description": "Playwright test analytics reporter with E2E navigation tracking, API call capture, network stats, failure diagnostics, and interactive HTML reports",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"@playwright/test": ">=1.35.0"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@testrelic/core": "2.
|
|
117
|
+
"@testrelic/core": "2.16.2-next.152"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@playwright/test": "^1.35.0",
|