Repository: avajs/ava
Pull request #2758 (2021-05-27 11:47:57)

Have the global timeout take into account t.timeout() durations
This PR attempts to allow tests to run beyond the global limit (which is 10s default).  
See #2384 for details.

Tests cases and documentation pending until the `debounce()` wait period can be overridden.

OhYash: I've updated the `api.js` to skip global timeout from getting fired in case an individual test timeout is defined.  
+ Updated some bits for existing tests to still pass.  

If this approach looks good, I'll proceed with adding fresh tests specific to the new changes.

This still leaves `debounce()` limitations to be fixed later. So parallel running tests are more likely to succeed even beyond their timeout limits. 

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..ae375a27 100644
--- a/lib/runner.js
+++ b/lib/runner.js
@@ -66,6 +66,13 @@ 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({
@@ -290,7 +297,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 +349,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);
 		}
 	}
 


Test patch:
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..afcf36b5 100644
--- a/test-tap/api.js
+++ b/test-tap/api.js
@@ -291,7 +291,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/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/runner.js b/test-tap/runner.js
index 91a85f82..f249158b 100644
--- a/test-tap/runner.js
+++ b/test-tap/runner.js
@@ -63,6 +63,21 @@ 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 => {
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',
+      },
+    ]