Refactor t.snapshot to be async, load snapshots on demand
MODIFY
packages/@romejs/core/test-worker/SnapshotManager.ts
packages/@romejs/core/test-worker/SnapshotManager.ts
* LICENSE file in the root directory of this source tree.
*/
-import {AbsoluteFilePath, AbsoluteFilePathSet} from '@romejs/path';
-import {
- writeFile,
- readFileText,
- exists,
- unlink,
- readdir,
- lstat,
-} from '@romejs/fs';
+import {AbsoluteFilePath, AbsoluteFilePathMap} from '@romejs/path';
+import {writeFile, readFileText, exists, unlink} from '@romejs/fs';
import {TestRunnerOptions} from '../master/testing/types';
import TestWorkerRunner from './TestWorkerRunner';
import {descriptions, DiagnosticDescription} from '@romejs/diagnostics';
@@ -31,95 +24,91 @@ function cleanHeading(key: string): string {
return key.trim();
}
-type SnapshotEntry = Map<string, Map<string, {
+type SnapshotEntry = {
+ testName: string;
+ entryName: string;
language: undefined | string;
value: string;
-}>>;
+};
+
+type Snapshot = {
+ exists: boolean;
+ used: boolean;
+ raw: string;
+ entries: Map<string, SnapshotEntry>;
+};
+
+function buildEntriesKey(testName: string, entryName: string): string {
+ return `${testName}#${entryName}`;
+}
export default class SnapshotManager {
constructor(runner: TestWorkerRunner, testPath: AbsoluteFilePath) {
- this.path = testPath.getParent().append(
+ this.defaultSnapshotPath = testPath.getParent().append(
`${testPath.getExtensionlessBasename()}.test.md`,
);
this.testPath = testPath;
this.runner = runner;
this.options = runner.options;
-
- this.exists = false;
- this.raw = '';
-
- this.entries = new Map();
- this.outputFiles = new Map();
- this.outputFiles.set(this.path.join(), this.path);
+ this.snapshots = new AbsoluteFilePathMap();
+ this.loadingSnapshotCount = 0;
}
+ loadingSnapshotCount: number;
testPath: AbsoluteFilePath;
- path: AbsoluteFilePath;
- entries: Map<string, SnapshotEntry>;
- outputFiles: Map<string, AbsoluteFilePath>;
+ defaultSnapshotPath: AbsoluteFilePath;
+
+ snapshots: AbsoluteFilePathMap<Snapshot>;
runner: TestWorkerRunner;
options: TestRunnerOptions;
- raw: string;
- exists: boolean;
+ teardown() {
+ if (this.loadingSnapshotCount > 0) {
+ throw new Error();
+ }
+ }
+
+ async init() {
+ await this.loadSnapshot(this.defaultSnapshotPath);
+ }
async emitDiagnostic(metadata: DiagnosticDescription) {
await this.runner.emitDiagnostic({
description: metadata,
location: {
- filename: this.path.join(),
+ filename: this.defaultSnapshotPath.join(),
},
});
}
- async loadSnapshotFixtures(dir: AbsoluteFilePath) {
- const filenames: AbsoluteFilePathSet = await readdir(dir);
- for (const filename of filenames) {
- const stats = await lstat(filename);
- if (stats.isFile() && filename.join().endsWith('.test.md')) {
- this.loadSnapshot(filename);
- } else if (stats.isDirectory()) {
- this.loadSnapshotFixtures(filename);
- }
- }
- }
-
- async load() {
- const {path: snapshotFile} = this;
- if (await exists(snapshotFile)) {
- this.loadSnapshot(snapshotFile);
- } else {
- // checks if there are fixtures and load them
- const fixtureDir = snapshotFile.getParent().append('test-fixtures');
- if (await exists(fixtureDir)) {
- this.loadSnapshotFixtures(fixtureDir);
- }
- }
- }
-
- async loadSnapshot(snapshotFile: AbsoluteFilePath) {
- if (!(await exists(snapshotFile))) {
+ async loadSnapshot(path: AbsoluteFilePath): Promise<undefined | Snapshot> {
+ if (!(await exists(path))) {
return;
}
- this.exists = true;
-
// If we're force updating, pretend that no snapshots exist on disk
if (this.options.updateSnapshots) {
return;
}
- const file = await readFileText(snapshotFile);
+ const content = await readFileText(path);
const parser = createSnapshotParser({
- path: snapshotFile,
- input: file,
+ path,
+ input: content,
});
- this.raw = parser.input;
const nodes = parser.parse();
+ const snapshot: Snapshot = {
+ exists: true,
+ used: false,
+ raw: content,
+ entries: new Map(),
+ };
+ this.snapshots.set(path, snapshot);
+
while (nodes.length > 0) {
const node = nodes.shift();
if (node === undefined) {
@@ -140,7 +129,7 @@ export default class SnapshotManager {
if (node.type === 'Heading' && node.level === 3) {
nodes.shift();
- const snapshotName = cleanHeading(node.text);
+ const entryName = cleanHeading(node.text);
const codeBlock = nodes.shift();
if (codeBlock === undefined || codeBlock.type !== 'CodeBlock') {
@@ -152,25 +141,24 @@ export default class SnapshotManager {
);
}
- this.set({
+ snapshot.entries.set(buildEntriesKey(testName, entryName), {
testName,
- snapshotName,
+ entryName,
language: codeBlock.language,
value: codeBlock.text,
- snapshotFile,
});
+
continue;
}
if (node.type === 'CodeBlock') {
nodes.shift();
- this.set({
+ snapshot.entries.set(buildEntriesKey(testName, '0'), {
testName,
- snapshotName: '0',
+ entryName: '0',
language: node.language,
value: node.text,
- snapshotFile,
});
}
@@ -180,9 +168,11 @@ export default class SnapshotManager {
continue;
}
}
+
+ return snapshot;
}
- buildSnapShot(snapshotEntries: SnapshotEntry): Array<string> {
+ buildSnapshot(entries: Iterable<SnapshotEntry>): Array<string> {
// Build the snapshot
let lines: Array<string> = [];
@@ -200,11 +190,21 @@ export default class SnapshotManager {
);
pushNewline();
+ const testNameToEntries: Map<string, Map<string, SnapshotEntry>> = new Map();
+ for (const entry of entries) {
+ let entriesByTestName = testNameToEntries.get(entry.testName);
+ if (entriesByTestName === undefined) {
+ entriesByTestName = new Map();
+ testNameToEntries.set(entry.testName, entriesByTestName);
+ }
+ entriesByTestName.set(entry.entryName, entry);
+ }
+
// Get test names and sort them so they are in a predictable
- const testNames = Array.from(snapshotEntries.keys()).sort();
+ const testNames = Array.from(testNameToEntries.keys()).sort();
for (const testName of testNames) {
- const entries = snapshotEntries.get(testName);
+ const entries = testNameToEntries.get(testName);
if (entries === undefined) {
throw new Error('Impossible');
}
@@ -212,9 +212,9 @@ export default class SnapshotManager {
lines.push(`## \`${testName}\``);
pushNewline();
- const snapshotNames = Array.from(entries.keys()).sort();
+ const entryNames = Array.from(entries.keys()).sort();
- for (const snapshotName of snapshotNames) {
+ for (const snapshotName of entryNames) {
const entry = entries.get(snapshotName);
if (entry === undefined) {
throw new Error('Impossible');
@@ -224,7 +224,7 @@ export default class SnapshotManager {
const language = entry.language === undefined ? '' : entry.language;
// If the test only has one snapshot then omit the heading
- const skipHeading = snapshotName === '0' && snapshotNames.length === 1;
+ const skipHeading = snapshotName === '0' && entryNames.length === 1;
if (!skipHeading) {
lines.push(`### \`${snapshotName}\``);
}
@@ -246,100 +246,87 @@ export default class SnapshotManager {
return;
}
- // No point producing an empty snapshot file
- if (this.entries.size === 0) {
- if (this.exists) {
- if (this.options.freezeSnapshots) {
- await this.emitDiagnostic(descriptions.SNAPSHOTS.REDUNDANT);
- } else {
- // Remove the snapshot file as there were none ran
- await unlink(this.path);
- }
- }
- return;
- }
-
- for (const snapshotFilename of this.entries.keys()) {
- const entries = this.entries.get(snapshotFilename);
- if (entries === undefined) {
- continue;
- }
- const lines = this.buildSnapShot(entries);
+ for (const [path, {used, exists, raw, entries}] of this.snapshots) {
+ const lines = this.buildSnapshot(entries.values());
const formatted = lines.join('\n');
if (this.options.freezeSnapshots) {
- if (!this.exists) {
+ if (!used) {
+ await this.emitDiagnostic(descriptions.SNAPSHOTS.REDUNDANT);
+ } else if (exists) {
await this.emitDiagnostic(descriptions.SNAPSHOTS.MISSING);
- } else if (formatted !== this.raw) {
+ } else if (formatted !== raw) {
await this.emitDiagnostic(descriptions.SNAPSHOTS.INCORRECT(
- this.raw,
+ raw,
formatted,
));
}
- } else if (formatted !== this.raw) {
- // Save the file
- const snapshotFile = this.outputFiles.get(snapshotFilename);
- if (snapshotFile !== undefined) {
- await writeFile(snapshotFile, formatted);
+ } else {
+ if (exists && !used) {
+ // If a snapshot wasn't used or is empty then delete it!
+ await unlink(path);
+ } else if (used && formatted !== raw) {
+ // Fresh snapshot!
+ await writeFile(path, formatted);
}
}
}
}
- get(
+ async get(
testName: string,
- snapshotName: string,
- snapshotFile?: AbsoluteFilePath,
- ): undefined | string {
- if (snapshotFile === undefined) {
- snapshotFile = this.path;
- }
- const snapshotsForFile = this.entries.get(snapshotFile.join());
- if (snapshotsForFile !== undefined) {
- const entries = snapshotsForFile.get(testName);
- if (entries !== undefined) {
- const entry = entries.get(snapshotName);
- if (entry !== undefined) {
- return entry.value;
- }
+ entryName: string,
+ snapshotPath: AbsoluteFilePath = this.defaultSnapshotPath,
+ ): Promise<undefined | string> {
+ let snapshot = this.snapshots.get(snapshotPath);
+
+ if (snapshot === undefined) {
+ snapshot = await this.loadSnapshot(snapshotPath);
}
+
+ if (snapshot === undefined) {
+ return undefined;
}
+
+ snapshot.used = true;
+
+ const entry = snapshot.entries.get(buildEntriesKey(testName, entryName));
+ if (entry === undefined) {
return undefined;
+ } else {
+ return entry.value;
+ }
}
set({
+ testName,
+ entryName,
value,
language,
- testName,
- snapshotName,
- snapshotFile,
+ snapshotPath = this.defaultSnapshotPath,
}: {
+ testName: string;
+ entryName: string;
value: string;
language: undefined | string;
- testName: string;
- snapshotName: string;
- snapshotFile?: AbsoluteFilePath;
+ snapshotPath: undefined | AbsoluteFilePath;
}) {
- if (snapshotFile === undefined) {
- snapshotFile = this.path;
- }
- const snapshotFileName = snapshotFile.join();
- let snapshotsForFile = this.entries.get(snapshotFileName);
- if (snapshotsForFile === undefined) {
- snapshotsForFile = new Map();
- this.entries.set(snapshotFileName, snapshotsForFile);
- }
-
- let entries = snapshotsForFile.get(testName);
- if (entries === undefined) {
- entries = new Map();
- snapshotsForFile.set(testName, entries);
- }
-
- entries.set(snapshotName, {value, language});
-
- if (!this.outputFiles.has(snapshotFileName)) {
- this.outputFiles.set(snapshotFileName, snapshotFile);
- }
+ let snapshot = this.snapshots.get(snapshotPath);
+ if (snapshot === undefined) {
+ snapshot = {
+ raw: '',
+ exists: false,
+ used: true,
+ entries: new Map(),
+ };
+ this.snapshots.set(snapshotPath, snapshot);
+ }
+
+ snapshot.entries.set(buildEntriesKey(testName, entryName), {
+ testName,
+ entryName,
+ language,
+ value,
+ });
}
}

MODIFY
packages/@romejs/core/test-worker/TestAPI.ts
packages/@romejs/core/test-worker/TestAPI.ts
@@ -64,6 +64,13 @@ function matchExpectedError(error: Error, expected: ExpectedError): boolean {
export type OnTimeout = (time: number) => void;
+type SnapshotOptions = {
+ entryName: string;
+ expected: unknown;
+ message?: string;
+ optionalFilename?: string;
+};
+
export default class TestAPI implements TestHelper {
constructor({
testName,
@@ -82,9 +89,19 @@ export default class TestAPI implements TestHelper {
this.options = options;
this.snapshotManager = snapshotManager;
this.snapshotCounter = 0;
+ this.loadingSnapshots = 0;
this.file = file;
this.teardownEvent = new Event({name: 'TestAPI.teardown'});
+ this.onTeardown(
+ async () => {
+ if (this.loadingSnapshots) {
+ throw new Error(
+ `Test finished while we were still loading snapshots. Did you forget an await before t.snapshot?`,
+ );
+ }
+ },
+ );
this.startTime = Date.now();
this.onTimeout = onTimeout;
@@ -107,6 +124,7 @@ export default class TestAPI implements TestHelper {
advice: DiagnosticAdvice;
teardownEvent: Event<void, void>;
testName: string;
+ loadingSnapshots: number;
snapshotCounter: number;
snapshotManager: SnapshotManager;
@@ -429,25 +447,40 @@ export default class TestAPI implements TestHelper {
throw new Error('unimplemented');
}
- snapshot(expected: unknown, message?: string, filename?: string): string {
+ snapshot(
+ expected: unknown,
+ message?: string,
+ optionalFilename?: string,
+ ): Promise<string> {
const id = this.snapshotCounter++;
- return this._snapshotNamed(String(id), expected, message, 2, filename);
+ return this.catchNamedSnapshot({
+ entryName: String(id),
+ expected,
+ message,
+ optionalFilename,
+ });
}
snapshotNamed(
- name: string,
+ entryName: string,
expected: unknown,
message?: string,
- filename?: string,
- ): string {
- return this._snapshotNamed(name, expected, message, 1, filename);
+ optionalFilename?: string,
+ ): Promise<string> {
+ return this.catchNamedSnapshot({
+ entryName,
+ expected,
+ message,
+ optionalFilename,
+ });
}
- getSnapshot(snapshotName: string): unknown {
- return this.snapshotManager.get(this.testName, snapshotName);
+ async getSnapshot(entryName: string): Promise<unknown> {
+ // TODO add `filename`
+ return this.snapshotManager.get(this.testName, entryName);
}
- _normalizeFileName(filename: string): string {
+ _normalizeSnapshotFilename(filename: string): string {
if (!filename.endsWith('test.md')) {
const lastIndex = filename.lastIndexOf('.');
let baseName = undefined;
@@ -462,13 +495,19 @@ export default class TestAPI implements TestHelper {
return filename;
}
- _snapshotNamed(
- name: string,
- expected: unknown,
- message?: string,
- framesToPop?: number,
- filename?: string,
- ): string {
+ catchNamedSnapshot(opts: SnapshotOptions): Promise<string> {
+ this.loadingSnapshots++;
+ return this.compareNamedSnapshot(opts).finally(() => {
+ this.loadingSnapshots--;
+ });
+ }
+
+ async compareNamedSnapshot({
+ entryName,
+ message,
+ expected,
+ optionalFilename,
+ }: SnapshotOptions): Promise<string> {
let language: undefined | string;
let formatted = '';
@@ -479,28 +518,28 @@ export default class TestAPI implements TestHelper {
formatted = prettyFormat(expected);
}
- let snapshotFile = undefined;
- if (filename !== undefined) {
- filename = this._normalizeFileName(filename);
- snapshotFile = createAbsoluteFilePath(filename);
+ let snapshotPath = undefined;
+ if (optionalFilename !== undefined) {
+ optionalFilename = this._normalizeSnapshotFilename(optionalFilename);
+ snapshotPath = createAbsoluteFilePath(optionalFilename);
}
// Get the current snapshot
- const existingSnapshot = this.snapshotManager.get(
+ const existingSnapshot = await this.snapshotManager.get(
this.testName,
- name,
- snapshotFile,
+ entryName,
+ snapshotPath,
);
if (existingSnapshot === undefined) {
// No snapshot exists, let's save this one!
this.snapshotManager.set({
testName: this.testName,
- snapshotName: String(name),
+ entryName,
value: formatted,
language,
- snapshotFile,
+ snapshotPath,
});
- return name;
+ return entryName;
}
// Compare the snapshots
@@ -516,13 +555,13 @@ export default class TestAPI implements TestHelper {
if (message === undefined) {
message =
- markup`Snapshot ${name} at <filelink emphasis target="${this.snapshotManager.path.join()}" /> doesn't match`;
+ markup`Snapshot ${entryName} at <filelink emphasis target="${this.snapshotManager.defaultSnapshotPath.join()}" /> doesn't match`;
} else {
advice.push(
{
type: 'log',
category: 'info',
- message: markup`Snapshot can be found at <filelink emphasis target="${this.snapshotManager.path.join()}" />`,
+ message: markup`Snapshot can be found at <filelink emphasis target="${this.snapshotManager.defaultSnapshotPath.join()}" />`,
},
);
}
@@ -535,9 +574,10 @@ export default class TestAPI implements TestHelper {
},
);
- this.fail(message, advice, framesToPop);
+ // Ignore the original t.snapshot call and caughtNamedSnapshot
+ this.fail(message, advice, 2);
}
- return name;
+ return entryName;
}
}

Refactor t.snapshot to be async, load snapshots on demand