Repository: avajs/ava
Pull request #2762 (2021-06-06 15:48:08)

Change test.meta properties to be file URLs
- Change test.meta properties to be file URLs
- Run self-hosted tests first



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/reporters/improper-usage-messages.js b/lib/reporters/improper-usage-messages.js
index 8f6f3e13..56867fec 100644
--- a/lib/reporters/improper-usage-messages.js
+++ b/lib/reporters/improper-usage-messages.js
@@ -20,7 +20,7 @@ Visit the following URL for more details:
 	if (assertion === 'snapshot') {
 		const {name, snapPath} = error.improperUsage;
 
-		if (name === 'ChecksumError') {
+		if (name === 'ChecksumError' || name === 'InvalidSnapshotError') {
 			return `The snapshot file is corrupted.
 
 File path: ${chalk.yellow(snapPath)}
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 8093e38c..9181c142 100644
--- a/lib/snapshot-manager.js
+++ b/lib/snapshot-manager.js
@@ -52,6 +52,13 @@ export class VersionMismatchError extends SnapshotError {
 	}
 }
 
+export class InvalidSnapshotError extends SnapshotError {
+	constructor(snapPath) {
+		super('Invalid snapshot file', snapPath);
+		this.name = 'InvalidSnapshotError';
+	}
+}
+
 const LEGACY_SNAPSHOT_HEADER = Buffer.from('// Jest Snapshot v1');
 function isLegacySnapshot(buffer) {
 	return LEGACY_SNAPSHOT_HEADER.equals(buffer.slice(0, LEGACY_SNAPSHOT_HEADER.byteLength));
@@ -194,7 +201,12 @@ function decodeSnapshots(buffer, snapPath) {
 
 	// The version starts after the readable prefix, which is ended by a newline
 	// byte (0x0A).
-	const versionOffset = buffer.indexOf(0x0A) + 1;
+	const newline = buffer.indexOf(0x0A);
+	if (newline === -1) {
+		throw new InvalidSnapshotError(snapPath);
+	}
+
+	const versionOffset = newline + 1;
 	const version = buffer.readUInt16LE(versionOffset);
 	if (version !== VERSION) {
 		throw new VersionMismatchError(snapPath, version);
@@ -469,11 +481,9 @@ export function load({file, fixedLocation, projectDir, recordNewSnapshots, updat
 		blocksByTitle = new Map();
 
 		if (!updating) { // Discard all decoding errors when updating snapshots
-			if (error instanceof SnapshotError) {
-				snapshotError = error;
-			} else {
-				throw error;
-			}
+			snapshotError = error instanceof SnapshotError ?
+				error :
+				new InvalidSnapshotError(paths.snapPath);
 		}
 	}
 
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/corrupt.js b/test/snapshot-tests/corrupt.js
new file mode 100644
index 00000000..f92fcd47
--- /dev/null
+++ b/test/snapshot-tests/corrupt.js
@@ -0,0 +1,59 @@
+import {promises as 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';
+
+function countMatches(string, regex) {
+	return [...string.matchAll(regex)].length;
+}
+
+function countStringMatches(string, patternString) {
+	if (patternString.length === 0) {
+		throw new RangeError('Pattern must be non-empty');
+	}
+
+	let index = string.indexOf(patternString);
+	let matches = 0;
+	while (index !== -1 && index < string.length) {
+		matches++;
+		index = string.indexOf(patternString, index + patternString.length);
+	}
+
+	return matches;
+}
+
+test('snapshot corruption is reported to the console', async t => {
+	await withTemporaryFixture(cwd('corrupt'), async cwd => {
+		const snapPath = path.join(cwd, 'test.js.snap');
+		await fs.writeFile(snapPath, Uint8Array.of(0x00));
+		const result = await t.throwsAsync(fixture([], {cwd}));
+		t.snapshot(result.stats.failed, 'failed tests');
+		t.is(countMatches(result.stdout, /The snapshot file is corrupted./g), 2);
+		t.is(countMatches(result.stdout, /File path:/g), 2);
+		t.is(
+			countMatches(
+				result.stdout,
+				/Please run AVA again with the .*--update-snapshots.* flag to recreate it\./g
+			),
+			2
+		);
+		t.is(countStringMatches(result.stdout, snapPath), 2);
+	});
+});
+
+test('with --update-snapshots, corrupt snapshot files are overwritten', async t => {
+	await withTemporaryFixture(cwd('corrupt'), async cwd => {
+		const snapPath = path.join(cwd, 'test.js.snap');
+		const env = {
+			AVA_FORCE_CI: 'not-ci'
+		};
+		await fs.writeFile(snapPath, Uint8Array.of(0x00));
+		await fixture(['--update-snapshots'], {cwd, env});
+
+		const snapContents = await fs.readFile(snapPath);
+		t.not(snapContents.length, 1);
+	});
+});
diff --git a/test/snapshot-tests/fixtures/corrupt/package.json b/test/snapshot-tests/fixtures/corrupt/package.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/test/snapshot-tests/fixtures/corrupt/package.json
@@ -0,0 +1,1 @@
+{}
diff --git a/test/snapshot-tests/fixtures/corrupt/test.js b/test/snapshot-tests/fixtures/corrupt/test.js
new file mode 100644
index 00000000..924af777
--- /dev/null
+++ b/test/snapshot-tests/fixtures/corrupt/test.js
@@ -0,0 +1,9 @@
+const test = require(process.env.TEST_AVA_IMPORT_FROM);
+
+test('a snapshot', t => {
+	t.snapshot('foo');
+});
+
+test('a snapshot with a message', t => {
+	t.snapshot('foo', 'a message');
+});
diff --git a/test/snapshot-tests/snapshots/corrupt.js.md b/test/snapshot-tests/snapshots/corrupt.js.md
new file mode 100644
index 00000000..8f04802b
--- /dev/null
+++ b/test/snapshot-tests/snapshots/corrupt.js.md
@@ -0,0 +1,20 @@
+# Snapshot report for `test/snapshot-tests/corrupt.js`
+
+The actual snapshot is saved in `corrupt.js.snap`.
+
+Generated by [AVA](https://avajs.dev).
+
+## snapshot corruption is reported to the console
+
+> failed tests
+
+    [
+      {
+        file: 'test.js',
+        title: 'a snapshot',
+      },
+      {
+        file: 'test.js',
+        title: 'a snapshot with a message',
+      },
+    ]