Skip to content

Commit 7bcc9bf

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
Add SnapshotTester utility for karma unit tests
Provides textual snapshot testing, similar to jest snapshot tests. Uses the `--on-diff` CLI test parameter. Updated `PerformanceInsightFormatter.test.ts` to use this. Bug: 434252192 Change-Id: I2d3e90f78e73a75e78273518660f92f60dd820d0 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6789018 Auto-Submit: Connor Clark <[email protected]> Commit-Queue: Paul Irish <[email protected]> Reviewed-by: Paul Irish <[email protected]>
1 parent 50d1618 commit 7bcc9bf

File tree

10 files changed

+1034
-592
lines changed

10 files changed

+1034
-592
lines changed

front_end/models/ai_assistance/BUILD.gn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,7 @@ ts_library("unittests") {
9898
"../trace:bundle",
9999
]
100100
}
101+
102+
copy_to_gen("snapshots") {
103+
sources = [ "data_formatters/PerformanceInsightFormatter.snapshot.txt" ]
104+
}

front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.snapshot.txt

Lines changed: 719 additions & 0 deletions
Large diffs are not rendered by default.

front_end/models/ai_assistance/data_formatters/PerformanceInsightFormatter.test.ts

Lines changed: 31 additions & 591 deletions
Large diffs are not rendered by default.

front_end/testing/BUILD.gn

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Use of this source code is governed by a BSD-style license that can be
33
# found in the LICENSE file.
44

5+
import("../../scripts/build/ninja/copy.gni")
56
import("../../scripts/build/typescript/typescript.gni")
67

78
ts_library("testing") {
@@ -34,6 +35,8 @@ ts_library("testing") {
3435
"PersistenceHelpers.ts",
3536
"PropertyParser.ts",
3637
"ResourceTreeHelpers.ts",
38+
"SnapshotTester.test.ts",
39+
"SnapshotTester.ts",
3740
"SourceMapEncoder.test.ts",
3841
"SourceMapEncoder.ts",
3942
"SourceMapHelpers.ts",
@@ -82,3 +85,7 @@ ts_library("testing") {
8285
"../ui/visual_logging:testing",
8386
]
8487
}
88+
89+
copy_to_gen("snapshots") {
90+
sources = [ "SnapshotTester.snapshot.txt" ]
91+
}

front_end/testing/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,37 @@ it('does not mutate the DOM when we scroll the component', async () => {
8787
```
8888

8989
The above test will fail if any DOM mutations are detected.
90+
91+
## SnapshotTester
92+
93+
For Karma unit tests, you can add regression tests for string output (similar to Jest snapshots) via SnapshotTester.
94+
95+
> Note: Prefer unit tests that test behavior more directly when possible. Some good candidates for snapshot tests is code the formats long strings. A bad candidate would be a `JSON.stringify` of a complex object structure (prefer explicit asserts instead).
96+
97+
Results for all snapshots for a test file `MyFile.test.ts` are written to `MyFile.snapshot.txt`.
98+
99+
To add snapshot tests to a new test file, add a before and after hook to the describe test suite:
100+
101+
```
102+
let snapshotTester: SnapshotTester;
103+
before(async () => {
104+
snapshotTester = new SnapshotTester(import.meta);
105+
await snapshotTester.load();
106+
});
107+
108+
after(async () => {
109+
await snapshotTester.finish();
110+
});
111+
```
112+
113+
Then add the `.snapshot.txt` file to the appropriate BUILD.gn:
114+
115+
```
116+
copy_to_gen("snapshots") {
117+
sources = [ "myfile.snapshot.txt" ]
118+
}
119+
```
120+
121+
Then inside a test, call `snapshotTester.assert(this, output)`. For an example, see SnapshotTester.test.ts.
122+
123+
To update snapshots, run `npm run test -- --on-diff=update ...`
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Title: SnapshotTester example snapshot
2+
Content:
3+
hello world
4+
=== end content
5+
6+
Title: SnapshotTester only one snapshot assert allowed
7+
Content:
8+
hello world 1
9+
=== end content
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import {SnapshotTester} from './SnapshotTester.js';
6+
7+
describe('SnapshotTester', () => {
8+
let snapshotTester: SnapshotTester;
9+
before(async () => {
10+
snapshotTester = new SnapshotTester(import.meta);
11+
await snapshotTester.load();
12+
});
13+
14+
after(async () => {
15+
await snapshotTester.finish();
16+
});
17+
18+
it('example snapshot', function() {
19+
snapshotTester.assert(this, 'hello world');
20+
});
21+
22+
it('only one snapshot assert allowed', function() {
23+
snapshotTester.assert(this, 'hello world 1');
24+
25+
try {
26+
snapshotTester.assert(this, 'hello world 2');
27+
assert.fail('Expected `snapshotTester.assert` to throw');
28+
} catch (err) {
29+
assert.strictEqual(err.message, 'sorry, currently only support 1 snapshot assertion per test');
30+
}
31+
});
32+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
/**
6+
* Asserts two strings are equal, and logs the first differing line if not equal.
7+
*/
8+
function assertSnapshotContent(actual: string, expected: string): void {
9+
if (actual !== expected) {
10+
const actualLines = actual.split('\n');
11+
const expectedLines = expected.split('\n');
12+
for (let i = 0; i < Math.max(actualLines.length, expectedLines.length); i++) {
13+
const actualLine = actualLines.at(i);
14+
const expectedLine = expectedLines.at(i);
15+
if (actualLine !== expectedLine) {
16+
const firstDifference = `First differing line:\nexpected: ${expectedLine}\nactual: ${actualLine}`;
17+
throw new Error(
18+
`snapshot assertion failed! to update snapshot, run \`npm run test -- --on-diff=update ...\`\n\n${
19+
firstDifference}`);
20+
}
21+
}
22+
}
23+
}
24+
25+
/**
26+
* Provides snapshot testing for karma unit tests.
27+
* See README.md for more.
28+
*
29+
* Note: karma.conf.ts implements the server logic (see snapshotTesterFactory).
30+
*/
31+
export class SnapshotTester {
32+
static #updateMode: boolean|null = null;
33+
34+
#snapshotUrl: string;
35+
#expected = new Map<string, string>();
36+
#actual = new Map<string, string>();
37+
#anyFailures = false;
38+
#newTests = false;
39+
40+
constructor(meta: ImportMeta) {
41+
this.#snapshotUrl = meta.url.replace('.test.js', '.snapshot.txt').split('?')[0];
42+
}
43+
44+
async load() {
45+
if (SnapshotTester.#updateMode === null) {
46+
SnapshotTester.#updateMode = await this.#checkIfUpdateMode();
47+
}
48+
49+
const url = new URL(this.#snapshotUrl, import.meta.url);
50+
const response = await fetch(url);
51+
if (response.status === 404) {
52+
console.warn(`Snapshot file not found: ${url.href}. Will create it for you.`);
53+
return;
54+
}
55+
if (response.status !== 200) {
56+
throw new Error('failed to load snapshot');
57+
}
58+
59+
this.#parseSnapshotFileContent(await response.text());
60+
}
61+
62+
assert(context: Mocha.Context, actual: string) {
63+
const title = context.test?.fullTitle() ?? '';
64+
65+
if (this.#actual.has(title)) {
66+
throw new Error('sorry, currently only support 1 snapshot assertion per test');
67+
}
68+
69+
if (actual.includes('=== end content')) {
70+
throw new Error('invalid content');
71+
}
72+
73+
actual = actual.trim();
74+
this.#actual.set(title, actual);
75+
76+
const expected = this.#expected.get(title);
77+
if (!expected) {
78+
// New tests always pass on the first run.
79+
this.#newTests = true;
80+
return;
81+
}
82+
83+
if (!SnapshotTester.#updateMode && actual !== expected) {
84+
this.#anyFailures = true;
85+
assertSnapshotContent(actual, expected);
86+
}
87+
}
88+
89+
async finish() {
90+
let didAnyTestNotRun = false;
91+
for (const title of this.#expected.keys()) {
92+
if (!this.#actual.has(title)) {
93+
didAnyTestNotRun = true;
94+
break;
95+
}
96+
}
97+
98+
let shouldPostUpdate = SnapshotTester.#updateMode;
99+
if (!this.#anyFailures && (didAnyTestNotRun || this.#newTests)) {
100+
shouldPostUpdate = true;
101+
}
102+
103+
if (!shouldPostUpdate) {
104+
return;
105+
}
106+
107+
const url = new URL('/update-snapshot', import.meta.url);
108+
url.searchParams.set('snapshotUrl', this.#snapshotUrl);
109+
const content = this.#serializeSnapshotFileContent();
110+
const response = await fetch(url, {method: 'POST', body: content});
111+
if (response.status !== 200) {
112+
throw new Error(`Unable to update snapshot ${url}`);
113+
}
114+
}
115+
116+
#serializeSnapshotFileContent(): string {
117+
if (!this.#actual.size) {
118+
return '';
119+
}
120+
121+
const lines = [];
122+
for (const [title, result] of this.#actual) {
123+
lines.push(`Title: ${title}`);
124+
lines.push(`Content:\n${result}`);
125+
lines.push('=== end content\n');
126+
}
127+
lines.push('');
128+
129+
return lines.join('\n').trim() + '\n';
130+
}
131+
132+
#parseSnapshotFileContent(content: string): void {
133+
const sections = content.split('=== end content').map(s => s.trim()).filter(Boolean);
134+
for (const section of sections) {
135+
const [titleField, contentField, ...contentLines] = section.split('\n');
136+
const title = titleField.replace('Title:', '').trim();
137+
if (contentField !== 'Content:') {
138+
throw new Error('unexpected snapshot file');
139+
}
140+
const content = contentLines.join('\n').trim();
141+
this.#expected.set(title, content);
142+
}
143+
}
144+
145+
async #checkIfUpdateMode(): Promise<boolean> {
146+
const response = await fetch('/snapshot-update-mode');
147+
const data = await response.json();
148+
return data.updateMode === true;
149+
}
150+
}

front_end/testing/TraceLoader.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ export class TraceLoader {
7777
if (cached) {
7878
return cached;
7979
}
80-
// Required URLs differ across the component server and the unit tests, so try both.
8180
const urlForTest = new URL(`../panels/timeline/fixtures/traces/${name}`, import.meta.url);
8281

8382
const contents = await TraceLoader.loadTraceFileFromURL(urlForTest);

test/unit/karma.conf.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
/* eslint @typescript-eslint/no-explicit-any: 0 */
66

7+
import * as fs from 'fs';
78
import * as path from 'path';
89
import type {Page, ScreenshotOptions, Target} from 'puppeteer-core';
910
import puppeteer from 'puppeteer-core';
11+
import * as url from 'url';
1012

1113
import {formatAsPatch, resultAssertionsDiff, ResultsDBReporter} from '../../test/conductor/karma-resultsdb-reporter.js';
1214
import {CHECKOUT_ROOT, GEN_DIR, SOURCE_ROOT} from '../../test/conductor/paths.js';
@@ -195,6 +197,7 @@ module.exports = function(config: any) {
195197
{pattern: path.join(GEN_DIR, 'inspector_overlay/**/*.js'), served: true, included: false},
196198
{pattern: path.join(GEN_DIR, 'inspector_overlay/**/*.js.map'), served: true, included: false},
197199
{pattern: path.join(GEN_DIR, 'front_end/**/fixtures/**/*'), served: true, included: false},
200+
{pattern: path.join(GEN_DIR, 'front_end/**/*.snapshot.txt'), served: true, included: false},
198201
{pattern: path.join(GEN_DIR, 'front_end/ui/components/docs/**/*'), served: true, included: false},
199202
],
200203

@@ -230,6 +233,7 @@ module.exports = function(config: any) {
230233
require('karma-coverage'),
231234
{'reporter:resultsdb': ['type', ResultsDBReporter]},
232235
{'reporter:progress-diff': ['type', ProgressWithDiffReporter]},
236+
{'middleware:snapshotTester': ['factory', snapshotTesterFactory]},
233237
],
234238

235239
preprocessors: {
@@ -244,6 +248,8 @@ module.exports = function(config: any) {
244248
'/front_end': `/base/${targetDir}/front_end`,
245249
},
246250

251+
middleware: ['snapshotTester'],
252+
247253
coverageReporter: {
248254
dir: path.join(TestConfig.artifactsDir, COVERAGE_OUTPUT_DIRECTORY),
249255
subdir: '.',
@@ -266,3 +272,45 @@ module.exports = function(config: any) {
266272

267273
config.set(options);
268274
};
275+
276+
function snapshotTesterFactory() {
277+
return (req: any, res: any, next: any) => {
278+
if (req.url.startsWith('/snapshot-update-mode')) {
279+
res.writeHead(200, {'Content-Type': 'application/json'});
280+
const updateMode = TestConfig.onDiff.update === true;
281+
res.end(JSON.stringify({updateMode}));
282+
return res.end();
283+
}
284+
285+
if (req.url.startsWith('/update-snapshot')) {
286+
const parsedUrl = url.parse(req.url, true);
287+
if (typeof parsedUrl.query.snapshotUrl !== 'string') {
288+
throw new Error('invalid snapshotUrl');
289+
}
290+
291+
const snapshotUrl = parsedUrl.query.snapshotUrl;
292+
const snapshotPath = path.join(SOURCE_ROOT, url.parse(snapshotUrl, false).pathname?.split('gen')[1] ?? '');
293+
294+
let body = '';
295+
req.on('data', (chunk: any) => {
296+
body += chunk.toString();
297+
});
298+
req.on('end', () => {
299+
// eslint-disable-next-line no-console
300+
console.info(`updating snapshot: ${snapshotPath}`);
301+
if (body) {
302+
fs.writeFileSync(snapshotPath, body);
303+
} else {
304+
fs.rmSync(snapshotPath, {force: true});
305+
}
306+
307+
res.writeHead(200);
308+
res.end();
309+
});
310+
311+
return;
312+
}
313+
314+
next();
315+
};
316+
}

0 commit comments

Comments
 (0)