Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test_runner: refactor snapshots to support multiple files in the same process #53853

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3204,6 +3204,16 @@ test('top level test', (t) => {
});
```

### `context.filePath`

<!-- YAML
added: REPLACEME
-->

The absolute path of the test file that created the current test. If a test file
imports additional modules that generate tests, the imported tests will return
the path of the root test file.

### `context.fullName`

<!-- YAML
Expand Down Expand Up @@ -3438,6 +3448,16 @@ An instance of `SuiteContext` is passed to each suite function in order to
interact with the test runner. However, the `SuiteContext` constructor is not
exposed as part of the API.

### `context.filePath`

<!-- YAML
added: REPLACEME
-->

The absolute path of the test file that created the current suite. If a test
file imports additional modules that generate suites, the imported suites will
return the path of the root test file.

### `context.name`

<!-- YAML
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ let globalRoot;
let reportersSetup;
function getGlobalRoot() {
if (!globalRoot) {
globalRoot = createTestTree();
globalRoot = createTestTree({ __proto__: null, entryFile: process.argv?.[1] });
globalRoot.reporter.on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
process.exitCode = kGenericUserError;
Expand Down
135 changes: 72 additions & 63 deletions lib/internal/test_runner/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,50 +58,12 @@ function setDefaultSnapshotSerializers(serializers) {
serializerFns = ArrayPrototypeSlice(serializers);
}

class SnapshotManager {
constructor(entryFile, updateSnapshots) {
this.entryFile = entryFile;
this.snapshotFile = undefined;
class SnapshotFile {
constructor(snapshotFile) {
this.snapshotFile = snapshotFile;
this.snapshots = { __proto__: null };
this.nameCounts = new SafeMap();
// A manager instance will only read or write snapshot files based on the
// updateSnapshots argument.
this.loaded = updateSnapshots;
this.updateSnapshots = updateSnapshots;
}

resolveSnapshotFile() {
if (this.snapshotFile === undefined) {
const resolved = resolveSnapshotPathFn(this.entryFile);

if (typeof resolved !== 'string') {
const err = new ERR_INVALID_STATE('Invalid snapshot filename.');
err.filename = resolved;
throw err;
}

this.snapshotFile = resolved;
}
}

serialize(input, serializers = serializerFns) {
try {
let value = input;

for (let i = 0; i < serializers.length; ++i) {
const fn = serializers[i];
value = fn(value);
}

return `\n${templateEscape(value)}\n`;
} catch (err) {
const error = new ERR_INVALID_STATE(
'The provided serializers did not generate a string.',
);
error.input = input;
error.cause = err;
throw error;
}
this.loaded = false;
}

getSnapshot(id) {
Expand All @@ -122,12 +84,11 @@ class SnapshotManager {

nextId(name) {
const count = this.nameCounts.get(name) ?? 1;

this.nameCounts.set(name, count + 1);
return `${name} ${count}`;
}

readSnapshotFile() {
readFile() {
if (this.loaded) {
debug('skipping read of snapshot file');
return;
Expand Down Expand Up @@ -164,12 +125,7 @@ class SnapshotManager {
}
}

writeSnapshotFile() {
if (!this.updateSnapshots) {
debug('skipping write of snapshot file');
return;
}

writeFile() {
try {
const keys = ArrayPrototypeSort(ObjectKeys(this.snapshots));
const snapshotStrings = ArrayPrototypeMap(keys, (key) => {
Expand All @@ -186,34 +142,87 @@ class SnapshotManager {
throw error;
}
}
}

class SnapshotManager {
constructor(updateSnapshots) {
// A manager instance will only read or write snapshot files based on the
// updateSnapshots argument.
this.updateSnapshots = updateSnapshots;
this.cache = new SafeMap();
}

resolveSnapshotFile(entryFile) {
let snapshotFile = this.cache.get(entryFile);

if (snapshotFile === undefined) {
const resolved = resolveSnapshotPathFn(entryFile);

if (typeof resolved !== 'string') {
const err = new ERR_INVALID_STATE('Invalid snapshot filename.');
err.filename = resolved;
throw err;
}

snapshotFile = new SnapshotFile(resolved);
snapshotFile.loaded = this.updateSnapshots;
this.cache.set(entryFile, snapshotFile);
}

return snapshotFile;
}

serialize(input, serializers = serializerFns) {
try {
let value = input;

for (let i = 0; i < serializers.length; ++i) {
const fn = serializers[i];
value = fn(value);
}

return `\n${templateEscape(value)}\n`;
} catch (err) {
const error = new ERR_INVALID_STATE(
'The provided serializers did not generate a string.',
);
error.input = input;
error.cause = err;
throw error;
}
}

writeSnapshotFiles() {
if (!this.updateSnapshots) {
debug('skipping write of snapshot files');
return;
}

this.cache.forEach((snapshotFile) => {
snapshotFile.writeFile();
});
}

createAssert() {
const manager = this;

return function snapshotAssertion(actual, options = kEmptyObject) {
emitExperimentalWarning(kExperimentalWarning);
// Resolve the snapshot file here so that any resolution errors are
// surfaced as early as possible.
manager.resolveSnapshotFile();

const { fullName } = this;
const id = manager.nextId(fullName);

validateObject(options, 'options');

const {
serializers = serializerFns,
} = options;

validateFunctionArray(serializers, 'options.serializers');

const { filePath, fullName } = this;
const snapshotFile = manager.resolveSnapshotFile(filePath);
const value = manager.serialize(actual, serializers);
const id = snapshotFile.nextId(fullName);

if (manager.updateSnapshots) {
manager.setSnapshot(id, value);
snapshotFile.setSnapshot(id, value);
} else {
manager.readSnapshotFile();
strictEqual(value, manager.getSnapshot(id));
snapshotFile.readFile();
strictEqual(value, snapshotFile.getSnapshot(id));
}
};
}
Expand Down
16 changes: 13 additions & 3 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function lazyAssertObject(harness) {
const { getOptionValue } = require('internal/options');
if (getOptionValue('--experimental-test-snapshots')) {
const { SnapshotManager } = require('internal/test_runner/snapshot');
harness.snapshotManager = new SnapshotManager(kFilename, updateSnapshots);
harness.snapshotManager = new SnapshotManager(updateSnapshots);
assertObj.set('snapshot', harness.snapshotManager.createAssert());
}
}
Expand Down Expand Up @@ -225,6 +225,10 @@ class TestContext {
return this.#test.name;
}

get filePath() {
return this.#test.entryFile;
}

get fullName() {
return getFullName(this.#test);
}
Expand Down Expand Up @@ -343,6 +347,10 @@ class SuiteContext {
return this.#suite.name;
}

get filePath() {
return this.#suite.entryFile;
}

get fullName() {
return getFullName(this.#suite);
}
Expand All @@ -357,7 +365,7 @@ class Test extends AsyncResource {
super('Test');

let { fn, name, parent } = options;
const { concurrency, loc, only, timeout, todo, skip, signal, plan } = options;
const { concurrency, entryFile, loc, only, timeout, todo, skip, signal, plan } = options;

if (typeof fn !== 'function') {
fn = noop;
Expand Down Expand Up @@ -386,6 +394,7 @@ class Test extends AsyncResource {
this.runOnlySubtests = this.only;
this.childNumber = 0;
this.timeout = kDefaultTimeout;
this.entryFile = entryFile;
this.root = this;
this.hooks = {
__proto__: null,
Expand All @@ -406,6 +415,7 @@ class Test extends AsyncResource {
this.runOnlySubtests = !this.only;
this.childNumber = parent.subtests.length + 1;
this.timeout = parent.timeout;
this.entryFile = parent.entryFile;
this.root = parent.root;
this.hooks = {
__proto__: null,
Expand Down Expand Up @@ -967,7 +977,7 @@ class Test extends AsyncResource {

// Call this harness.coverage() before collecting diagnostics, since failure to collect coverage is a diagnostic.
const coverage = harness.coverage();
harness.snapshotManager?.writeSnapshotFile();
harness.snapshotManager?.writeSnapshotFiles();
for (let i = 0; i < diagnostics.length; i++) {
reporter.diagnostic(nesting, loc, diagnostics[i]);
}
Expand Down
8 changes: 8 additions & 0 deletions test/fixtures/test-runner/snapshots/imported-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';
const { suite, test } = require('node:test');

suite('imported suite', () => {
test('imported test', (t) => {
t.assert.snapshot({ foo: 1, bar: 2 });
});
});
2 changes: 2 additions & 0 deletions test/fixtures/test-runner/snapshots/unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ test('`${foo}`', async (t) => {
test('escapes in `\\${foo}`\n', async (t) => {
t.assert.snapshot('`\\${foo}`\n');
});

require('./imported-tests');
Loading
Loading