Skip to content
Open
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
11 changes: 11 additions & 0 deletions docs/03-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,17 @@ Assert that `contents` does not match `regex`.

Compares the `expected` value with a previously recorded snapshot. Snapshots are stored for each test, so ensure you give your tests unique titles.

### `.snapshot(expected, {formatAsCodeBlock}, message?)`

Only accepted when `expected` is a string. The string is stored verbatim and rendered as a fenced code block in the resulting `.snap.md` file. Set `formatAsCodeBlock` to `true` for a generic code block, or a non-empty language identifier string for syntax highlighting:

```js
test('generates code', t => {
const code = generate();
t.snapshot(code, {formatAsCodeBlock: 'js'});
});
```

### `.try(title?, implementation | macro, ...args?)`

`.try()` allows you to *try* assertions without causing the test to fail.
Expand Down
51 changes: 49 additions & 2 deletions lib/assert.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {isNativeError} from 'node:util/types';

import concordance from 'concordance';
import {isPlainObject} from 'is-plain-object';
import isPromise from 'is-promise';

import concordanceOptions from './concordance-options.js';
Expand Down Expand Up @@ -631,13 +632,59 @@ export class Assertions {
return handlePromise(retval, true);
});

this.snapshot = withSkip((expected, message) => {
this.snapshot = withSkip((expected, ...options) => {
if (disableSnapshots) {
throw fail(new AssertionError('`t.snapshot()` can only be used in tests', {
assertion: 't.snapshot()',
}));
}

let message;
let formatAsCodeBlock;
if (options.length > 0) {
if (typeof options[0] === 'object') {
if (!isPlainObject(options[0])) {
throw fail(new AssertionError('The options argument must be a plain object', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', options[0])],
}));
}

if (typeof expected !== 'string') {
throw fail(new AssertionError('The first argument must be a string when options are provided', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', expected)],
}));
}

[{formatAsCodeBlock}, message] = options;
if (formatAsCodeBlock === undefined) {
throw fail(new AssertionError('The options object must contain the `formatAsCodeBlock` property', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', options[0])],
}));
}

if (formatAsCodeBlock !== true && typeof formatAsCodeBlock !== 'string') {
throw fail(new AssertionError('The `formatAsCodeBlock` option must be `true` or a string', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', formatAsCodeBlock)],
}));
}

if (formatAsCodeBlock === '') {
throw fail(new AssertionError('The `formatAsCodeBlock` option must be a non-empty string when it is not `true`', {
assertion: 't.snapshot()',
formattedDetails: [formatWithLabel('Called with:', formatAsCodeBlock)],
}));
}
} else {
[message] = options;
}
}

formatAsCodeBlock ??= false;

assertMessage(message, 't.snapshot()');

if (message === '') {
Expand All @@ -649,7 +696,7 @@ export class Assertions {

let result;
try {
result = compareWithSnapshot({expected, message});
result = compareWithSnapshot({expected, formatAsCodeBlock, message});
} catch (error) {
if (!(error instanceof SnapshotError)) {
throw error;
Expand Down
88 changes: 69 additions & 19 deletions lib/snapshot-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,17 @@ const VERSION = 3;
const VERSION_HEADER = Buffer.alloc(2);
VERSION_HEADER.writeUInt16LE(VERSION);

// Version 4 adds support for rawValue snapshots (the formatAsCodeBlock option). Version 3 snapshots can still be read
// by AVA 8.1+. This version is only used when a snapshot file contains at least one rawValue entry.
const RAW_VALUE_VERSION = 4;

const RAW_VALUE_VERSION_HEADER = Buffer.alloc(2);
RAW_VALUE_VERSION_HEADER.writeUInt16LE(RAW_VALUE_VERSION);

// The decoder matches on the trailing newline byte (0x0A).
const READABLE_PREFIX = Buffer.from(`AVA Snapshot v${VERSION}\n`, 'ascii');
const RAW_VALUE_VERSION_READABLE_PREFIX = Buffer.from(`AVA Snapshot v${RAW_VALUE_VERSION}\n`, 'ascii');

const REPORT_SEPARATOR = Buffer.from('\n\n', 'ascii');
const REPORT_TRAILING_NEWLINE = Buffer.from('\n', 'ascii');

Expand Down Expand Up @@ -88,16 +97,30 @@ function tryRead(file) {
function formatEntry(snapshot, index) {
const {
data,
rawValue,
formatAsCodeBlock,
label = `Snapshot ${index + 1}`, // Human-readable labels start counting at 1.
} = snapshot;

const description = data
? concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions)
: '<No Data>';
let description;
if (rawValue !== undefined) {
const language = formatAsCodeBlock === true ? '' : formatAsCodeBlock;
let longestBacktickRun = 0;
for (const run of rawValue.match(/`+/g) ?? []) {
longestBacktickRun = Math.max(longestBacktickRun, run.length);
}

const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1));
description = `${fence}${language}\n${rawValue}\n${fence}`;
} else if (data) {
description = indentString(concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions), 4);
} else {
description = indentString('<No Data>', 4);
}

const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n');

return `${blockquote}\n\n${indentString(description, 4)}`;
return `${blockquote}\n\n${description}`;
}

function combineEntries({blocks}) {
Expand Down Expand Up @@ -180,7 +203,10 @@ function sortBlocks(blocksByTitle, blockIndices) {
});
}

async function encodeSnapshots(snapshotData) {
async function encodeSnapshots(snapshotData, version) {
const readablePrefix = version === RAW_VALUE_VERSION ? RAW_VALUE_VERSION_READABLE_PREFIX : READABLE_PREFIX;
const versionHeader = version === RAW_VALUE_VERSION ? RAW_VALUE_VERSION_HEADER : VERSION_HEADER;

const encoded = await cbor.encodeAsync(snapshotData, {
omitUndefinedProperties: true,
canonical: true,
Expand All @@ -189,11 +215,11 @@ async function encodeSnapshots(snapshotData) {
compressed[9] = 0x03; // Override the GZip header containing the OS to always be Linux
const sha256sum = crypto.createHash('sha256').update(compressed).digest();
return Buffer.concat([
READABLE_PREFIX,
VERSION_HEADER,
readablePrefix,
versionHeader,
sha256sum,
compressed,
], READABLE_PREFIX.byteLength + VERSION_HEADER.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength);
], readablePrefix.byteLength + versionHeader.byteLength + SHA_256_HASH_LENGTH + compressed.byteLength);
}

export function extractCompressedSnapshot(buffer, snapPath) {
Expand All @@ -210,7 +236,7 @@ export function extractCompressedSnapshot(buffer, snapPath) {

const versionOffset = newline + 1;
const version = buffer.readUInt16LE(versionOffset);
if (version !== VERSION) {
if (version !== VERSION && version !== RAW_VALUE_VERSION) {
throw new VersionMismatchError(snapPath, version);
}

Expand Down Expand Up @@ -267,8 +293,9 @@ class Manager {

const snapshot = block?.snapshots[options.index];
const data = snapshot?.data;
const rawValue = snapshot?.rawValue;

if (!data) {
if (data === undefined && rawValue === undefined) {
if (!this.recordNewSnapshots) {
return {pass: false};
}
Expand All @@ -282,41 +309,63 @@ class Manager {
return {pass: true};
}

if (rawValue !== undefined) {
const pass = rawValue === options.expected;
if (pass) {
return {pass};
}

const actual = concordance.describe(rawValue, concordanceOptions);
const expected = concordance.describe(options.expected, concordanceOptions);
return {actual, expected, pass};
}

const actual = concordance.deserialize(data, concordanceOptions);
const expected = concordance.describe(options.expected, concordanceOptions);
const pass = concordance.compareDescriptors(actual, expected);

return {actual, expected, pass};
}

recordSerialized({data, label, belongsTo, index}) {
recordSerialized({data, rawValue, formatAsCodeBlock, label, belongsTo, index}) {
const block = this.newBlocksByTitle.get(belongsTo) ?? {snapshots: []};
const {snapshots} = block;

if (index > snapshots.length) {
throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, exceeds expected index of ${snapshots.length}`);
} else if (index < snapshots.length) {
if (snapshots[index].data) {
if (snapshots[index].data || snapshots[index].rawValue !== undefined) {
throw new RangeError(`Cannot record snapshot ${index} for ${JSON.stringify(belongsTo)}, already exists`);
}

snapshots[index] = {data, label};
snapshots[index] = {
data, rawValue, formatAsCodeBlock, label,
};
} else {
snapshots.push({data, label});
snapshots.push({
data, rawValue, formatAsCodeBlock, label,
});
}

this.newBlocksByTitle.set(belongsTo, block);
}

deferRecord(options) {
const {expected, belongsTo, label, index} = options;
const descriptor = concordance.describe(expected, concordanceOptions);
const data = concordance.serialize(descriptor);
const {expected, formatAsCodeBlock, belongsTo, label, index} = options;

let data;
let rawValue;
if (formatAsCodeBlock) {
rawValue = expected;
} else {
const descriptor = concordance.describe(expected, concordanceOptions);
data = concordance.serialize(descriptor);
}

return () => { // Must be called in order!
this.hasChanges = true;
this.recordSerialized({
data, label, belongsTo, index,
data, rawValue, formatAsCodeBlock: formatAsCodeBlock || undefined, label, belongsTo, index,
});
};
}
Expand Down Expand Up @@ -369,7 +418,8 @@ class Manager {
blocks: sortBlocks(this.newBlocksByTitle, this.blockIndices).map(([title, block]) => ({title, ...block})),
};

const buffer = await encodeSnapshots(snapshots);
const hasRawValues = snapshots.blocks.some(({snapshots: blockSnapshots}) => blockSnapshots.some(snapshot => snapshot.rawValue !== undefined));
const buffer = await encodeSnapshots(snapshots, hasRawValues ? RAW_VALUE_VERSION : VERSION);
const reportBuffer = generateReport(relFile, snapFile, snapshots);

await fs.promises.mkdir(dir, {recursive: true});
Expand Down
3 changes: 2 additions & 1 deletion lib/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export default class Test {

const deferRecording = this.metadata.inline;
this.deferredSnapshotRecordings = [];
this.compareWithSnapshot = ({expected, message}) => {
this.compareWithSnapshot = ({expected, formatAsCodeBlock, message}) => {
this.snapshotCount++;

const belongsTo = snapshotBelongsTo;
Expand All @@ -229,6 +229,7 @@ export default class Test {
belongsTo,
deferRecording,
expected,
formatAsCodeBlock,
index,
label,
taskIndex: this.metadata.taskIndex,
Expand Down
Loading
Loading