blob: b201153b169063ca7d607bfb70b205f7a92bc037 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Asserts two strings are equal, and logs the first differing line if not equal.
*/
function assertSnapshotContent(actual: string, expected: string): void {
if (actual !== expected) {
const actualLines = actual.split('\n');
const expectedLines = expected.split('\n');
for (let i = 0; i < Math.max(actualLines.length, expectedLines.length); i++) {
const actualLine = actualLines.at(i);
const expectedLine = expectedLines.at(i);
if (actualLine !== expectedLine) {
const firstDifference = `First differing line:\nexpected: ${expectedLine}\nactual: ${actualLine}`;
throw new Error(
`snapshot assertion failed! to update snapshot, run \`npm run test -- --on-diff=update ...\`\n\n${
firstDifference}`);
}
}
}
}
/**
* Provides snapshot testing for karma unit tests.
* See README.md for more.
*
* Note: karma.conf.ts implements the server logic (see snapshotTesterFactory).
*/
class BaseSnapshotTester {
static #updateMode: boolean|null = null;
protected snapshotPath: string;
#expected = new Map<string, string>();
#actual = new Map<string, string>();
#anyFailures = false;
#newTests = false;
constructor(context: Mocha.Suite, meta: ImportMeta) {
if (context.timeout() > 0) {
// The first usage of SnapshotTester in a test seems to take an extraordinary
// amount of time, so let's bump the timeout for all snapshot tests.
context.timeout(Math.max(context.timeout(), 45000));
}
context.beforeAll(async () => {
await this.load();
});
context.afterAll(async () => {
await this.finish();
});
// out/Default/gen/third_party/devtools-frontend/src/front_end/testing/SnapshotTester.test.js?8ee4f2b123e221040a4aa075a28d0e5b41d3d3ed
// ->
// front_end/testing/SnapshotTester.snapshot.txt
this.snapshotPath =
meta.url.substring(meta.url.lastIndexOf('front_end')).replace('.test.js', '.snapshot.txt').split('?')[0];
}
async load() {
if (BaseSnapshotTester.#updateMode === null) {
BaseSnapshotTester.#updateMode = await this.checkIfUpdateMode();
}
const content = await this.loadSnapshot(this.snapshotPath);
if (content) {
this.#parseSnapshotFileContent(content);
}
}
assert(context: Mocha.Context, actual: string) {
const title = context.test?.fullTitle() ?? '';
if (this.#actual.has(title)) {
throw new Error('sorry, currently only support 1 snapshot assertion per test');
}
if (actual.includes('=== end content')) {
throw new Error('invalid content');
}
actual = actual.trim();
this.#actual.set(title, actual);
const expected = this.#expected.get(title);
if (expected === undefined) {
this.#newTests = true;
if (BaseSnapshotTester.#updateMode) {
return;
}
this.#anyFailures = true;
throw new Error(`snapshot assertion failed! new snapshot found (${
title}), must run \`npm run test -- --on-diff=update ...\` to accept it.`);
}
const isDifferent = actual !== expected;
if (isDifferent) {
this.#anyFailures = true;
if (!BaseSnapshotTester.#updateMode) {
assertSnapshotContent(actual, expected);
}
}
}
async finish() {
let didAnyTestNotRun = false;
for (const title of this.#expected.keys()) {
if (!this.#actual.has(title)) {
didAnyTestNotRun = true;
break;
}
}
const hasChanges = this.#anyFailures || didAnyTestNotRun || this.#newTests;
if (!hasChanges) {
return;
}
// If the update flag is on, post any and all changes (failures, new tests, removals).
if (BaseSnapshotTester.#updateMode) {
await this.postUpdate();
return;
}
// Note: this does not handle test filtering (.only, --grep). Need a reliable way
// to distinguish a deleted test from a test that was filtered out.
if (didAnyTestNotRun) {
throw new Error(
'Snapshots are out of sync (a test was likely deleted or renamed). Run with `--on-diff=update` to fix.');
}
}
#parseSnapshotFileContent(content: string): void {
const sections = content.split('=== end content').map(s => s.trim()).filter(Boolean);
for (const section of sections) {
const [titleField, contentField, ...contentLines] = section.split('\n');
const title = titleField.replace('Title:', '').trim();
if (contentField !== 'Content:') {
throw new Error('unexpected snapshot file');
}
const content = contentLines.join('\n').trim();
this.#expected.set(title, content);
}
}
protected serializeSnapshotFileContent(): string {
if (!this.#actual.size) {
return '';
}
const lines = [];
for (const [title, result] of this.#actual) {
lines.push(`Title: ${title}`);
lines.push(`Content:\n${result}`);
lines.push('=== end content\n');
}
lines.push('');
return lines.join('\n').trim() + '\n';
}
protected async checkIfUpdateMode(): Promise<boolean> {
return false;
}
protected async postUpdate(): Promise<void> {
throw new Error(`Not implemented`);
}
protected async loadSnapshot(_snapshotPath: string): Promise<string|undefined> {
throw new Error('not implemented');
}
}
class WebSnapshotTester extends BaseSnapshotTester {
protected override async checkIfUpdateMode(): Promise<boolean> {
const response = await fetch('/snapshot-update-mode');
const data = await response.json();
return data.updateMode === true;
}
protected override async postUpdate(): Promise<void> {
const url = new URL('/update-snapshot', import.meta.url);
url.searchParams.set('snapshotPath', this.snapshotPath);
const content = this.serializeSnapshotFileContent();
const response = await fetch(url, {method: 'POST', body: content});
if (response.status !== 200) {
throw new Error(`Unable to update snapshot ${url}`);
}
}
protected override async loadSnapshot(snapshotPath: string): Promise<string|undefined> {
const url = new URL('/snapshot', import.meta.url);
url.searchParams.set('snapshotPath', snapshotPath);
const response = await fetch(url);
if (response.status === 404) {
console.warn(`Snapshot file not found: ${snapshotPath}. Will create it for you.`);
return;
}
if (response.status !== 200) {
throw new Error('failed to load snapshot');
}
return await response.text();
}
}
class NodeSnapshotTester extends BaseSnapshotTester {
protected override async checkIfUpdateMode(): Promise<boolean> {
// cannot update in node mode yet.
return false;
}
protected override async postUpdate(): Promise<void> {
const content = this.serializeSnapshotFileContent();
// @ts-expect-error no node types here.
const fs = await import('node:fs/promises');
await fs.writeFile(await this.#getSnapshotPath(this.snapshotPath), content);
}
protected override async loadSnapshot(snapshotPath: string): Promise<string|undefined> {
// @ts-expect-error no node types here.
const fs = await import('node:fs/promises');
return await fs.readFile(await this.#getSnapshotPath(snapshotPath), 'utf-8');
}
async #getSnapshotPath(snapshotPath: string): Promise<string> {
// @ts-expect-error no node types here.
const path = await import('node:path');
// @ts-expect-error no ESM types here.
const SOURCE_ROOT = path.join(import.meta.dirname, '..', '..', '..', '..', '..');
return path.join(SOURCE_ROOT, snapshotPath);
}
}
export type SnapshotTester = NodeSnapshotTester|WebSnapshotTester;
const SnapshotTesterValue = (typeof window === 'undefined') ? NodeSnapshotTester : WebSnapshotTester;
export {SnapshotTesterValue as SnapshotTester};