Repository: avajs/ava
Pull request #2773 (2021-06-17 12:17:18)

Support multiline snapshot labels
Issue #2769

This PR only addresses the snapshot label formatting at the moment.
Changing the test title formatting has much bigger impact. I need more info there. Depending on how much bigger scope it gets it might be better to handle separately.


Patch:
diff --git a/lib/api.js b/lib/api.js
index d82d2937..03707eaf 100644
--- a/lib/api.js
+++ b/lib/api.js
@@ -73,10 +73,16 @@ export default class Api extends Emittery {
 		const pendingWorkers = new Set();
 		const timedOutWorkerFiles = new Set();
 		let restartTimer;
+		let ignoreTimeoutsUntil = 0;
 		if (apiOptions.timeout && !apiOptions.debug) {
 			const timeout = ms(apiOptions.timeout);
 
 			restartTimer = debounce(() => {
+				if (Date.now() < ignoreTimeoutsUntil) {
+					restartTimer();
+					return;
+				}
+
 				// If failFast is active, prevent new test files from running after
 				// the current ones are exited.
 				if (failFast) {
@@ -233,6 +239,11 @@ export default class Api extends Emittery {
 				}
 
 				const worker = fork(file, options, apiOptions.nodeArguments);
+				worker.onStateChange(data => {
+					if (data.type === 'test-timeout-configured' && !apiOptions.debug) {
+						ignoreTimeoutsUntil = Math.max(ignoreTimeoutsUntil, Date.now() + data.period);
+					}
+				});
 				runStatus.observeWorker(worker, file, {selectingLines: lineNumbers.length > 0});
 				deregisteredSharedWorkers.push(observeWorkerProcess(worker, runStatus));
 
diff --git a/lib/runner.js b/lib/runner.js
index 69af03f3..e017e6b6 100644
--- a/lib/runner.js
+++ b/lib/runner.js
@@ -1,3 +1,5 @@
+import {pathToFileURL} from 'url';
+
 import Emittery from 'emittery';
 import matcher from 'matcher';
 
@@ -9,6 +11,7 @@ import {load as loadSnapshots, determineSnapshotDir} from './snapshot-manager.js
 import Runnable from './test.js';
 import {waitForReady} from './worker/state.cjs';
 
+const makeFileURL = file => file.startsWith('file://') ? file : pathToFileURL(file).toString();
 export default class Runner extends Emittery {
 	constructor(options = {}) {
 		super();
@@ -66,13 +69,20 @@ export default class Runner extends Emittery {
 			return true;
 		};
 
+		this.notifyTimeoutUpdate = timeoutMs => {
+			this.emit('stateChange', {
+				type: 'test-timeout-configured',
+				period: timeoutMs
+			});
+		};
+
 		let hasStarted = false;
 		let scheduledStart = false;
 		const meta = Object.freeze({
-			file: options.file,
+			file: makeFileURL(options.file),
 			get snapshotDirectory() {
 				const {file, snapshotDir: fixedLocation, projectDir} = options;
-				return determineSnapshotDir({file, fixedLocation, projectDir});
+				return makeFileURL(determineSnapshotDir({file, fixedLocation, projectDir}));
 			}
 		});
 		this.chain = createChain((metadata, testArgs) => { // eslint-disable-line complexity
@@ -290,7 +300,8 @@ export default class Runner extends Emittery {
 			powerAssert: this.powerAssert,
 			title: `${task.title}${titleSuffix || ''}`,
 			isHook: true,
-			testPassed
+			testPassed,
+			notifyTimeoutUpdate: this.notifyTimeoutUpdate
 		}));
 		const outcome = await this.runMultiple(hooks, this.serial);
 		for (const result of outcome.storedResults) {
@@ -341,7 +352,8 @@ export default class Runner extends Emittery {
 				metadata: task.metadata,
 				powerAssert: this.powerAssert,
 				title: task.title,
-				registerUniqueTitle: this.registerUniqueTitle
+				registerUniqueTitle: this.registerUniqueTitle,
+				notifyTimeoutUpdate: this.notifyTimeoutUpdate
 			});
 
 			const result = await this.runSingle(test);
diff --git a/lib/snapshot-manager.js b/lib/snapshot-manager.js
index 9181c142..4973e5ed 100644
--- a/lib/snapshot-manager.js
+++ b/lib/snapshot-manager.js
@@ -93,7 +93,9 @@ function formatEntry(snapshot, index) {
 		concordance.formatDescriptor(concordance.deserialize(data), concordanceOptions) :
 		'<No Data>';
 
-	return `> ${label}\n\n${indentString(description, 4)}`;
+	const blockquote = label.split(/\n/).map(line => '> ' + line).join('\n');
+
+	return `${blockquote}\n\n${indentString(description, 4)}`;
 }
 
 function combineEntries({blocks}) {
diff --git a/package.json b/package.json
index 875573d2..9f7ce71f 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
 		"node": ">=12.22 <13 || >=14.15 <15 || >=16"
 	},
 	"scripts": {
-		"cover": "c8 --report=none tap && c8 --report=none --no-clean test-ava && c8 report",
+		"cover": "c8 --report=none test-ava && c8 --report=none --no-clean tap && c8 report",
 		"test": "xo && tsd && npm run -s cover"
 	},
 	"files": [


Test patch:
diff --git a/docs/01-writing-tests.md b/docs/01-writing-tests.md
index 8df67902..c6aae5fa 100644
--- a/docs/01-writing-tests.md
+++ b/docs/01-writing-tests.md
@@ -280,6 +280,8 @@ Available properties:
 * `file`: path to the test file
 * `snapshotDirectory`: directory where snapshots are stored
 
+In AVA 4 these are file URL strings.
+
 ```js
 const test = require('ava');
 
diff --git a/lib/test.js b/lib/test.js
index f7fae453..14d96475 100644
--- a/lib/test.js
+++ b/lib/test.js
@@ -208,6 +208,7 @@ export default class Test {
 		this.registerUniqueTitle = options.registerUniqueTitle;
 		this.logs = [];
 		this.teardowns = [];
+		this.notifyTimeoutUpdate = options.notifyTimeoutUpdate;
 
 		const {snapshotBelongsTo = this.title, nextSnapshotIndex = 0} = options;
 		this.snapshotBelongsTo = snapshotBelongsTo;
@@ -431,6 +432,8 @@ export default class Test {
 				this.finishDueToTimeout();
 			}
 		}, ms);
+
+		this.notifyTimeoutUpdate(this.timeoutMs);
 	}
 
 	refreshTimeout() {
diff --git a/test-tap/api.js b/test-tap/api.js
index 2d4137fa..780a8d77 100644
--- a/test-tap/api.js
+++ b/test-tap/api.js
@@ -37,17 +37,6 @@ const opts = [
 ];
 
 for (const opt of opts) {
-	test(`test.meta - workerThreads: ${opt.workerThreads}`, async t => {
-		const api = await apiCreator({
-			...opt,
-			snapshotDir: 'snapshot-fixture'
-		});
-		return api.run({files: [path.join(__dirname, 'fixture', 'meta.cjs')]})
-			.then(runStatus => {
-				t.equal(runStatus.stats.passedTests, 2);
-			});
-	});
-
 	test(`fail-fast mode - workerThreads: ${opt.workerThreads} - single file & serial`, async t => {
 		const api = await apiCreator({
 			...opt,
@@ -291,7 +280,8 @@ for (const opt of opts) {
 
 		return api.run({files: [
 			path.join(__dirname, 'fixture/fail-fast/timeout/fails.cjs'),
-			path.join(__dirname, 'fixture/fail-fast/timeout/passes.cjs')
+			path.join(__dirname, 'fixture/fail-fast/timeout/passes.cjs'),
+			path.join(__dirname, 'fixture/fail-fast/timeout/passes-slow.cjs')
 		]})
 			.then(runStatus => {
 				t.ok(api.options.failFast);
diff --git a/test-tap/fixture/fail-fast/timeout/passes-slow.cjs b/test-tap/fixture/fail-fast/timeout/passes-slow.cjs
new file mode 100644
index 00000000..e8eadb0c
--- /dev/null
+++ b/test-tap/fixture/fail-fast/timeout/passes-slow.cjs
@@ -0,0 +1,9 @@
+const delay = require('delay');
+
+const test = require('../../../../entrypoints/main.cjs');
+
+test('slow pass with timeout', async t => {
+	t.timeout(120);
+	await delay(110);
+	t.pass();
+});
diff --git a/test-tap/fixture/meta.cjs b/test-tap/fixture/meta.cjs
deleted file mode 100644
index a77d5afe..00000000
--- a/test-tap/fixture/meta.cjs
+++ /dev/null
@@ -1,10 +0,0 @@
-const test = require('../../entrypoints/main.cjs');
-
-test('meta.file', t => {
-	t.is(test.meta.file, __filename);
-});
-
-test('meta.snapshotDirectory', t => {
-	t.regex(test.meta.snapshotDirectory, /snapshot-fixture/);
-});
-
diff --git a/test-tap/helper/ava-test.js b/test-tap/helper/ava-test.js
index 3e3ef13c..dc4ac17d 100644
--- a/test-tap/helper/ava-test.js
+++ b/test-tap/helper/ava-test.js
@@ -20,7 +20,8 @@ export function withExperiments(experiments = {}) {
 			fn,
 			registerUniqueTitle,
 			metadata: {type: 'test'},
-			title
+			title,
+			notifyTimeoutUpdate() {}
 		});
 	}
 
@@ -32,7 +33,8 @@ export function withExperiments(experiments = {}) {
 			fn,
 			registerUniqueTitle,
 			metadata: {type: 'test', failing: true},
-			title: 'test.failing'
+			title: 'test.failing',
+			notifyTimeoutUpdate() {}
 		});
 	};
 
diff --git a/test-tap/hooks.js b/test-tap/hooks.js
index 4cb10718..5fc46604 100644
--- a/test-tap/hooks.js
+++ b/test-tap/hooks.js
@@ -16,7 +16,7 @@ test('before', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.before(() => {
 			array.push('a');
 		});
@@ -34,7 +34,7 @@ test('after', t => {
 	t.plan(2);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -58,7 +58,7 @@ test('after not run if test failed', t => {
 	t.plan(2);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-failed') {
 				t.pass();
@@ -81,7 +81,7 @@ test('after.always run even if test failed', t => {
 	t.plan(2);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-failed') {
 				t.pass();
@@ -104,7 +104,7 @@ test('after.always run even if before failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.before(() => {
 			throw new Error('something went wrong');
 		});
@@ -123,7 +123,7 @@ test('stop if before hooks failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.before(() => {
 			array.push('a');
 		});
@@ -146,7 +146,7 @@ test('before each with concurrent tests', t => {
 	t.plan(1);
 
 	const array = [[], []];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		let i = 0;
 		let k = 0;
 
@@ -176,7 +176,7 @@ test('before each with serial tests', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.beforeEach(() => {
 			array.push('a');
 		});
@@ -203,7 +203,7 @@ test('fail if beforeEach hook fails', t => {
 	t.plan(2);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'hook-failed') {
 				t.pass();
@@ -228,7 +228,7 @@ test('after each with concurrent tests', t => {
 	t.plan(1);
 
 	const array = [[], []];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		let i = 0;
 		let k = 0;
 
@@ -258,7 +258,7 @@ test('after each with serial tests', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach(() => {
 			array.push('a');
 		});
@@ -285,7 +285,7 @@ test('afterEach not run if concurrent tests failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach(() => {
 			array.push('a');
 		});
@@ -302,7 +302,7 @@ test('afterEach not run if serial tests failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach(() => {
 			array.push('a');
 		});
@@ -319,7 +319,7 @@ test('afterEach.always run even if concurrent tests failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach.always(() => {
 			array.push('a');
 		});
@@ -336,7 +336,7 @@ test('afterEach.always run even if serial tests failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach.always(() => {
 			array.push('a');
 		});
@@ -353,7 +353,7 @@ test('afterEach.always run even if beforeEach failed', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.beforeEach(() => {
 			throw new Error('something went wrong');
 		});
@@ -376,7 +376,7 @@ test('afterEach: property `passed` of execution-context is false when test faile
 
 	const passed = [];
 	let i;
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach(a => {
 			passed[i] = a.passed;
 		});
@@ -399,7 +399,7 @@ test('afterEach.always: property `passed` of execution-context is false when tes
 
 	const passed = [];
 	let i;
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach.always(a => {
 			passed[i] = a.passed;
 		});
@@ -421,7 +421,7 @@ test('afterEach.always: property `passed` of execution-context is false when bef
 	t.plan(1);
 
 	let passed;
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.before(() => {
 			throw new Error('something went wrong');
 		});
@@ -440,7 +440,7 @@ test('afterEach.always: property `passed` of execution-context is true when test
 	t.plan(1);
 
 	let passed;
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.afterEach(() => {
 			throw new Error('something went wrong');
 		});
@@ -459,7 +459,7 @@ test('ensure hooks run only around tests', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.beforeEach(() => {
 			array.push('beforeEach');
 		});
@@ -486,7 +486,7 @@ test('ensure hooks run only around tests', t => {
 });
 
 test('shared context', t => {
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'hook-failed' || evt.type === 'test-failed') {
 				t.fail();
@@ -529,7 +529,7 @@ test('shared context', t => {
 });
 
 test('shared context of any type', t => {
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'hook-failed' || evt.type === 'test-failed') {
 				t.fail();
@@ -549,7 +549,7 @@ test('shared context of any type', t => {
 
 test('teardowns cannot be used in hooks', async t => {
 	let hookFailure = null;
-	await promiseEnd(new Runner(), runner => {
+	await promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'hook-failed') {
 				hookFailure = evt;
diff --git a/test-tap/runner.js b/test-tap/runner.js
index 91a85f82..b79b04ba 100644
--- a/test-tap/runner.js
+++ b/test-tap/runner.js
@@ -18,7 +18,7 @@ const promiseEnd = (runner, next) => {
 test('nested tests and hooks aren’t allowed', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('test', a => {
 			t.throws(() => {
 				runner.chain(noop);
@@ -31,7 +31,7 @@ test('nested tests and hooks aren’t allowed', t => {
 test('tests must be declared synchronously', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('test', a => {
 			a.pass();
 			return Promise.resolve();
@@ -44,7 +44,7 @@ test('tests must be declared synchronously', t => {
 });
 
 test('runner emits "stateChange" events', t => {
-	const runner = new Runner();
+	const runner = new Runner({file: import.meta.url});
 
 	runner.on('stateChange', evt => {
 		if (evt.type === 'declared-test') {
@@ -63,9 +63,24 @@ test('runner emits "stateChange" events', t => {
 	});
 });
 
+test('notifyTimeoutUpdate emits "stateChange" event', t => {
+	const runner = new Runner();
+
+	runner.on('stateChange', evt => {
+		if (evt.type === 'test-timeout-configured') {
+			t.same(evt, {
+				type: 'test-timeout-configured',
+				period: 120
+			});
+			t.end();
+		}
+	});
+	runner.notifyTimeoutUpdate(120);
+});
+
 test('run serial tests before concurrent ones', t => {
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('test', a => {
 			array.push('c');
 			a.end();
@@ -94,7 +109,7 @@ test('anything can be skipped', t => {
 		};
 	}
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain.after(pusher('after'));
 		runner.chain.after.skip(pusher('after.skip'));
 
@@ -134,7 +149,7 @@ test('test types and titles', t => {
 	const pass = a => a.pass();
 
 	const check = (setup, expect) => {
-		const runner = new Runner();
+		const runner = new Runner({file: import.meta.url});
 		runner.on('stateChange', evt => {
 			if (evt.type === 'hook-failed' || evt.type === 'test-failed' || evt.type === 'test-passed') {
 				const expected = expect.shift();
@@ -192,7 +207,7 @@ test('skip test', t => {
 	t.plan(3);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'selected-test' && evt.skip) {
 				t.pass();
@@ -219,7 +234,7 @@ test('skip test', t => {
 test('tests must have a non-empty title)', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		t.throws(() => {
 			runner.chain('', t => t.pass());
 		}, new TypeError('Tests must have a title'));
@@ -229,7 +244,7 @@ test('tests must have a non-empty title)', t => {
 test('test titles must be unique', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('title', t => t.pass());
 
 		t.throws(() => {
@@ -241,7 +256,7 @@ test('test titles must be unique', t => {
 test('tests must have an implementation', t => {
 	t.plan(1);
 
-	const runner = new Runner();
+	const runner = new Runner({file: import.meta.url});
 
 	t.throws(() => {
 		runner.chain('title');
@@ -252,7 +267,7 @@ test('todo test', t => {
 	t.plan(3);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'selected-test' && evt.todo) {
 				t.pass();
@@ -277,7 +292,7 @@ test('todo test', t => {
 test('todo tests must not have an implementation', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		t.throws(() => {
 			runner.chain.todo('todo', () => {});
 		}, new TypeError('`todo` tests are not allowed to have an implementation. Use `test.skip()` for tests with an implementation.'));
@@ -287,7 +302,7 @@ test('todo tests must not have an implementation', t => {
 test('todo tests must have a title', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		t.throws(() => {
 			runner.chain.todo();
 		}, new TypeError('`todo` tests require a title'));
@@ -297,7 +312,7 @@ test('todo tests must have a title', t => {
 test('todo test titles must be unique', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('title', t => t.pass());
 
 		t.throws(() => {
@@ -310,7 +325,7 @@ test('only test', t => {
 	t.plan(2);
 
 	const array = [];
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'selected-test') {
 				t.pass();
@@ -334,7 +349,7 @@ test('only test', t => {
 test('options.runOnlyExclusive means only exclusive tests are run', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner({runOnlyExclusive: true}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, runOnlyExclusive: true}), runner => {
 		runner.chain('test', () => {
 			t.fail();
 		});
@@ -349,7 +364,7 @@ test('options.serial forces all tests to be serial', t => {
 	t.plan(1);
 
 	const array = [];
-	return promiseEnd(new Runner({serial: true}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, serial: true}), runner => {
 		runner.chain('async', async a => {
 			await delay(200);
 			array.push(1);
@@ -373,7 +388,7 @@ test('options.failFast does not stop concurrent tests from running', t => {
 	const expected = ['first', 'second'];
 	t.plan(expected.length);
 
-	promiseEnd(new Runner({failFast: true}), runner => {
+	promiseEnd(new Runner({file: import.meta.url, failFast: true}), runner => {
 		let block;
 		let resume;
 		runner.chain.beforeEach(() => {
@@ -407,7 +422,7 @@ test('options.failFast && options.serial stops subsequent tests from running ',
 	const expected = ['first'];
 	t.plan(expected.length);
 
-	promiseEnd(new Runner({failFast: true, serial: true}), runner => {
+	promiseEnd(new Runner({file: import.meta.url, failFast: true, serial: true}), runner => {
 		let block;
 		let resume;
 		runner.chain.beforeEach(() => {
@@ -441,7 +456,7 @@ test('options.failFast & failing serial test stops subsequent tests from running
 	const expected = ['first'];
 	t.plan(expected.length);
 
-	promiseEnd(new Runner({failFast: true, serial: true}), runner => {
+	promiseEnd(new Runner({file: import.meta.url, failFast: true, serial: true}), runner => {
 		let block;
 		let resume;
 		runner.chain.beforeEach(() => {
@@ -478,7 +493,7 @@ test('options.failFast & failing serial test stops subsequent tests from running
 test('options.match will not run tests with non-matching titles', t => {
 	t.plan(4);
 
-	return promiseEnd(new Runner({match: ['*oo', '!foo']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['*oo', '!foo']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -510,7 +525,7 @@ test('options.match will not run tests with non-matching titles', t => {
 test('options.match hold no effect on hooks with titles', t => {
 	t.plan(2);
 
-	return promiseEnd(new Runner({match: ['!before*']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['!before*']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -533,7 +548,7 @@ test('options.match hold no effect on hooks with titles', t => {
 test('options.match overrides .only', t => {
 	t.plan(4);
 
-	return promiseEnd(new Runner({match: ['*oo']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['*oo']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -555,7 +570,7 @@ test('options.match overrides .only', t => {
 test('options.match matches todo tests', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner({match: ['*oo']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['*oo']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'selected-test' && evt.todo) {
 				t.pass();
@@ -570,7 +585,7 @@ test('options.match matches todo tests', t => {
 test('macros: Additional args will be spread as additional args on implementation function', t => {
 	t.plan(3);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -611,7 +626,7 @@ test('macros: Customize test names attaching a `title` function', t => {
 
 	macroFn.title = (title = 'default', firstArg = undefined) => title + firstArg;
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'declared-test') {
 				t.equal(evt.title, expectedTitles.shift());
@@ -627,7 +642,7 @@ test('macros: Customize test names attaching a `title` function', t => {
 test('macros: test titles must be strings', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		t.throws(() => {
 			const macro = t => t.pass();
 			macro.title = () => [];
@@ -639,7 +654,7 @@ test('macros: test titles must be strings', t => {
 test('macros: hook titles must be strings', t => {
 	t.plan(1);
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		t.throws(() => {
 			const macro = t => t.pass();
 			macro.title = () => [];
@@ -657,7 +672,7 @@ test('match applies to macros', t => {
 
 	macroFn.title = (title, firstArg) => `${firstArg}bar`;
 
-	return promiseEnd(new Runner({match: ['foobar']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['foobar']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.equal(evt.title, 'foobar');
@@ -696,7 +711,7 @@ test('arrays of macros', t => {
 
 	macroFnB.title = prefix => `${prefix}.B`;
 
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -737,7 +752,7 @@ test('match applies to arrays of macros', t => {
 
 	bazMacro.title = (title, firstArg) => `${firstArg}baz`;
 
-	return promiseEnd(new Runner({match: ['foobar']}), runner => {
+	return promiseEnd(new Runner({file: import.meta.url, match: ['foobar']}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.equal(evt.title, 'foobar');
@@ -751,7 +766,7 @@ test('match applies to arrays of macros', t => {
 
 test('silently skips other tests when .only is used', t => {
 	t.plan(1);
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.on('stateChange', evt => {
 			if (evt.type === 'test-passed') {
 				t.pass();
@@ -766,7 +781,7 @@ test('silently skips other tests when .only is used', t => {
 
 test('subsequent always hooks are run even if earlier always hooks fail', t => {
 	t.plan(3);
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('test', a => a.pass());
 		runner.chain.serial.after.always(a => {
 			t.pass();
@@ -787,7 +802,7 @@ test('hooks run concurrently, but can be serialized', t => {
 	t.plan(7);
 
 	let activeCount = 0;
-	return promiseEnd(new Runner(), runner => {
+	return promiseEnd(new Runner({file: import.meta.url}), runner => {
 		runner.chain('test', a => a.pass());
 
 		runner.chain.before(() => {
diff --git a/test/meta/fixtures/meta.cjs b/test/meta/fixtures/meta.cjs
new file mode 100644
index 00000000..4569c0aa
--- /dev/null
+++ b/test/meta/fixtures/meta.cjs
@@ -0,0 +1,14 @@
+const url = require('url');
+
+const test = require('ava');
+
+test('meta.file', t => {
+	t.is(test.meta.file, url.pathToFileURL(__filename).toString());
+});
+
+test('meta.snapshotDirectory', t => {
+	const {meta} = test;
+	t.true(meta.snapshotDirectory.startsWith('file://'));
+	t.regex(meta.snapshotDirectory, /snapshot-fixture/);
+});
+
diff --git a/test/meta/fixtures/meta.mjs b/test/meta/fixtures/meta.mjs
new file mode 100644
index 00000000..8d019407
--- /dev/null
+++ b/test/meta/fixtures/meta.mjs
@@ -0,0 +1,12 @@
+import test from 'ava';
+
+test('meta.file', t => {
+	t.is(test.meta.file, import.meta.url);
+});
+
+test('meta.snapshotDirectory', t => {
+	const {meta} = test;
+	t.true(meta.snapshotDirectory.startsWith('file://'));
+	t.regex(meta.snapshotDirectory, /snapshot-fixture/);
+});
+
diff --git a/test/meta/fixtures/package.json b/test/meta/fixtures/package.json
new file mode 100644
index 00000000..71a90096
--- /dev/null
+++ b/test/meta/fixtures/package.json
@@ -0,0 +1,9 @@
+{
+	"type": "module",
+	"ava": {
+		"files": [
+			"*"
+		],
+		"snapshotDir": "snapshot-fixture"
+	}
+}
diff --git a/test/meta/test.js b/test/meta/test.js
new file mode 100644
index 00000000..56c0dae8
--- /dev/null
+++ b/test/meta/test.js
@@ -0,0 +1,13 @@
+import test from '@ava/test';
+
+import {fixture} from '../helpers/exec.js';
+
+test('with CJS files', async t => {
+	const result = await fixture(['meta.cjs']);
+	t.is(result.stats.passed.length, 2);
+});
+
+test('with MJS files', async t => {
+	const result = await fixture(['meta.mjs']);
+	t.is(result.stats.passed.length, 2);
+});
diff --git a/test/snapshot-tests/fixtures/multiline-snapshot-label/package.json b/test/snapshot-tests/fixtures/multiline-snapshot-label/package.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/test/snapshot-tests/fixtures/multiline-snapshot-label/package.json
@@ -0,0 +1,1 @@
+{}
diff --git a/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js b/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js
new file mode 100644
index 00000000..2c3e6b2c
--- /dev/null
+++ b/test/snapshot-tests/fixtures/multiline-snapshot-label/test.js
@@ -0,0 +1,14 @@
+const test = require(process.env.TEST_AVA_IMPORT_FROM);
+
+const f = () => {
+	return [
+		'Hello',
+		'World!'
+	].join(', ');
+};
+
+test('snapshot with a multiline label', t => {
+	const result = f();
+	const label = '```javascript\n' + f.toString() + '\n```';
+	t.snapshot(result, label);
+});
diff --git a/test/snapshot-tests/formatting.js b/test/snapshot-tests/formatting.js
new file mode 100644
index 00000000..3585f490
--- /dev/null
+++ b/test/snapshot-tests/formatting.js
@@ -0,0 +1,24 @@
+import fs from 'fs';
+import path from 'path';
+
+import test from '@ava/test';
+
+import {cwd, fixture} from '../helpers/exec.js';
+import {withTemporaryFixture} from '../helpers/with-temporary-fixture.js';
+
+test('multiline snapshot label should be formatted correctly in the report', async t => {
+	await withTemporaryFixture(cwd('multiline-snapshot-label'), async cwd => {
+		// Run test fixture
+		await fixture(['--update-snapshots'], {
+			cwd,
+			env: {
+				AVA_FORCE_CI: 'not-ci'
+			}
+		});
+
+		// Assert report is unchanged
+		const reportPath = path.join(cwd, 'test.js.md');
+		const report = fs.readFileSync(reportPath, {encoding: 'utf8'});
+		t.snapshot(report, 'resulting snapshot report');
+	});
+});
diff --git a/test/snapshot-tests/snapshots/formatting.js.md b/test/snapshot-tests/snapshots/formatting.js.md
new file mode 100644
index 00000000..aa2d483b
--- /dev/null
+++ b/test/snapshot-tests/snapshots/formatting.js.md
@@ -0,0 +1,29 @@
+# Snapshot report for `test/snapshot-tests/formatting.js`
+
+The actual snapshot is saved in `formatting.js.snap`.
+
+Generated by [AVA](https://avajs.dev).
+
+## multiline snapshot label should be formatted correctly in the report
+
+> resulting snapshot report
+
+    `# Snapshot report for \`test.js\`␊
+    ␊
+    The actual snapshot is saved in \`test.js.snap\`.␊
+    ␊
+    Generated by [AVA](https://avajs.dev).␊
+    ␊
+    ## snapshot with a multiline label␊
+    ␊
+    > \`\`\`javascript␊
+    > () => {␊
+    > 	return [␊
+    > 		'Hello',␊
+    > 		'World!'␊
+    > 	].join(', ');␊
+    > }␊
+    > \`\`\`␊
+    ␊
+        'Hello, World!'␊
+    `