Implement AVA 8 protocol

- Always import() when loading
- Don't include .cts in default extensions
This commit is contained in:
Mark Wubben 2026-04-12 22:27:37 +02:00
parent b4ae7ad7c1
commit b31b242831
11 changed files with 465 additions and 100 deletions

View file

@ -74,12 +74,17 @@ const changeInterpretations = Object.freeze(Object.assign(Object.create(null), {
waitForOutOfBandCompilation: 2,
}));
const defaultExtensions = ['ts', 'mts'];
const defaultAva6Extensions = ['ts', 'cts', 'mts'];
export default function typescriptProvider({negotiateProtocol}) {
const protocol = negotiateProtocol(['ava-6', 'ava-8'], {version: pkg.version});
if (protocol === null) {
return;
}
const isAva8 = protocol.identifier === 'ava-8';
return {
main({config}) {
if (!isPlainObject(config)) {
@ -89,7 +94,7 @@ export default function typescriptProvider({negotiateProtocol}) {
validate(config, configProperties);
const {
extensions = ['ts', 'cts', 'mts'],
extensions = isAva8 ? defaultExtensions : defaultAva6Extensions,
rewritePaths: relativeRewritePaths,
compile,
} = config;
@ -244,7 +249,7 @@ export default function typescriptProvider({negotiateProtocol}) {
},
worker({extensionsToLoadAsModules, state: {extensions, rewritePaths}}) {
const importJs = extensionsToLoadAsModules.includes('js');
const importJs = isAva8 || extensionsToLoadAsModules.includes('js');
const testFileExtension = new RegExp(String.raw`\.(${extensions.map(extension => escapeStringRegexp(extension)).join('|')})$`, 'v');
return {
@ -252,13 +257,13 @@ export default function typescriptProvider({negotiateProtocol}) {
return testFileExtension.test(reference) && rewritePaths.some(([from]) => reference.startsWith(from));
},
async load(reference, {requireFn}) {
async load(reference, {requireFn} = {}) {
const [from, to] = rewritePaths.find(([from]) => reference.startsWith(from));
let rewritten = `${to}${reference.slice(from.length)}`;
let useImport = true;
if (reference.endsWith('.cts')) {
rewritten = rewritten.replace(/\.cts$/v, '.cjs');
useImport = false;
useImport = isAva8;
} else if (reference.endsWith('.mts')) {
rewritten = rewritten.replace(/\.mts$/v, '.mjs');
} else {

View file

@ -4,18 +4,7 @@ import {deleteAsync} from 'del';
import {execaNode} from 'execa';
import createProviderMacro from './_with-provider.js';
const withProvider = createProviderMacro(
'ava-6',
'6.0.0',
path.join(import.meta.dirname, 'fixtures'),
);
const withAltProvider = createProviderMacro(
'ava-6',
'6.0.0',
path.join(import.meta.dirname, 'broken-fixtures'),
);
test.before('deleting compiled files', async t => {
test.beforeEach('deleting compiled files', async t => {
t.log(await deleteAsync('test/fixtures/typescript/compiled'));
t.log(await deleteAsync('test/broken-fixtures/typescript/compiled'));
});
@ -34,16 +23,64 @@ const compile = async provider => ({
.compile(),
});
test(
'worker(): load rewritten paths files',
withProvider,
async (t, provider) => {
for (const [identifier, withProvider, withAltProvider] of [
[
'ava-6',
createProviderMacro(
'ava-6',
'6.0.0',
path.join(import.meta.dirname, 'fixtures'),
),
createProviderMacro(
'ava-6',
'6.0.0',
path.join(import.meta.dirname, 'broken-fixtures'),
),
],
[
'ava-8',
createProviderMacro(
'ava-8',
'8.0.0',
path.join(import.meta.dirname, 'fixtures'),
),
createProviderMacro(
'ava-8',
'8.0.0',
path.join(import.meta.dirname, 'broken-fixtures'),
),
],
]) {
test(
'worker(): load rewritten paths files',
withProvider,
async (t, provider) => {
const {state} = await compile(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
identifier,
JSON.stringify({state}),
path.join(import.meta.dirname, 'fixtures/ts', 'file.ts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
},
);
test('worker(): runs compiled files', withProvider, async (t, provider) => {
const {state} = await compile(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
identifier,
JSON.stringify({state}),
path.join(import.meta.dirname, 'fixtures/ts', 'file.ts'),
path.join(import.meta.dirname, 'fixtures/compiled', 'index.ts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
@ -52,28 +89,11 @@ test(
}
t.snapshot(stdout);
},
);
});
test('worker(): runs compiled files', withProvider, async (t, provider) => {
const {state} = await compile(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
JSON.stringify({state}),
path.join(import.meta.dirname, 'fixtures/compiled', 'index.ts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
test('compile() error', withAltProvider, async (t, provider) => {
const {message} = await t.throwsAsync(compile(provider));
t.snapshot(stdout);
});
test('compile() error', withAltProvider, async (t, provider) => {
const {message} = await t.throwsAsync(compile(provider));
t.snapshot(message);
});
t.snapshot(message);
});
}

View file

@ -3,20 +3,26 @@ import path from 'node:path';
import process from 'node:process';
import makeProvider from '@ava/typescript';
const isAva8 = process.argv[2] === 'ava-8';
const provider = makeProvider({
negotiateProtocol() {
return {identifier: 'ava-6', ava: {version: '6.0.0'}, projectDir: import.meta.dirname};
return {identifier: isAva8 ? 'ava-8' : 'ava-6', ava: {version: isAva8 ? '8.0.0' : '6.0.0'}, projectDir: import.meta.dirname};
},
});
const worker = provider.worker({
extensionsToLoadAsModules: [],
...(!isAva8 && {extensionsToLoadAsModules: []}),
state: {},
...JSON.parse(process.argv[2]),
...JSON.parse(process.argv[3]),
});
const reference = path.resolve(process.argv[3]);
const reference = path.resolve(process.argv[4]);
if (worker.canLoad(reference)) {
worker.load(reference, {requireFn: createRequire(import.meta.url)});
if (isAva8) {
worker.load(reference);
} else {
worker.load(reference, {requireFn: createRequire(import.meta.url)});
}
}

View file

@ -3,57 +3,88 @@ import test from 'ava';
import {execaNode} from 'execa';
import createProviderMacro from './_with-provider.js';
const withProvider = createProviderMacro('ava-6', '6.0.0', path.join(import.meta.dirname, 'fixtures'));
const setup = async provider => ({
state: await provider.main({
config: {
rewritePaths: {
'load/': 'load/compiled/',
state: await provider
.main({
config: {
rewritePaths: {
'load/': 'load/compiled/',
},
compile: false,
},
compile: false,
},
}).compile(),
})
.compile(),
});
test('worker(): load .cts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[JSON.stringify({state}), path.join(import.meta.dirname, 'fixtures/load', 'index.cts')],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
for (const [identifier, withProvider] of [
[
'ava-6',
createProviderMacro(
'ava-6',
'6.0.0',
path.join(import.meta.dirname, 'fixtures'),
),
],
[
'ava-8',
createProviderMacro(
'ava-8',
'8.0.0',
path.join(import.meta.dirname, 'fixtures'),
),
],
]) {
test('worker(): load .cts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
identifier,
JSON.stringify({state}),
path.join(import.meta.dirname, 'fixtures/load', 'index.cts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
});
t.snapshot(stdout);
});
test('worker(): load .mts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[JSON.stringify({state}), path.join(import.meta.dirname, 'fixtures/load', 'index.mts')],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
test('worker(): load .mts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
identifier,
JSON.stringify({state}),
path.join(import.meta.dirname, 'fixtures/load', 'index.mts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
});
t.snapshot(stdout);
});
test('worker(): load .ts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[JSON.stringify({extensionsToLoadAsModules: ['js'], state}), path.join(import.meta.dirname, 'fixtures/load', 'index.ts')],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
test('worker(): load .ts', withProvider, async (t, provider) => {
const {state} = await setup(provider);
const {stdout, stderr} = await execaNode(
path.join(import.meta.dirname, 'fixtures/install-and-load'),
[
identifier,
JSON.stringify({extensionsToLoadAsModules: ['js'], state}),
path.join(import.meta.dirname, 'fixtures/load', 'index.ts'),
],
{cwd: path.join(import.meta.dirname, 'fixtures')},
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
});
t.snapshot(stdout);
});
}

148
test/protocol-ava-8.js Normal file
View file

@ -0,0 +1,148 @@
import path from 'node:path';
import test from 'ava';
import pkg from '../package.json' with {type: 'json'};
import createProviderMacro from './_with-provider.js';
const projectDirectory = import.meta.dirname;
const withProvider = createProviderMacro('ava-8', '8.0.0');
const validateConfig = (t, provider, config) => {
const error = t.throws(() => provider.main({config}));
error.message = error.message.replace(`v${pkg.version}`, 'v${pkg.version}'); // eslint-disable-line no-template-curly-in-string
t.snapshot(error);
};
test('negotiates ava-8 protocol', withProvider, t => t.plan(2));
test('main() config validation: throw when config is not a plain object', withProvider, (t, provider) => {
validateConfig(t, provider, false);
validateConfig(t, provider, true);
validateConfig(t, provider, null);
validateConfig(t, provider, []);
});
test('main() config validation: throw when config contains keys other than \'extensions\', \'rewritePaths\' or \'compile\'', withProvider, (t, provider) => {
validateConfig(t, provider, {compile: false, foo: 1, rewritePaths: {'src/': 'build/'}});
});
test('main() config validation: throw when config.extensions contains empty strings', withProvider, (t, provider) => {
validateConfig(t, provider, {extensions: ['']});
});
test('main() config validation: throw when config.extensions contains non-strings', withProvider, (t, provider) => {
validateConfig(t, provider, {extensions: [1]});
});
test('main() config validation: throw when config.extensions contains duplicates', withProvider, (t, provider) => {
validateConfig(t, provider, {extensions: ['ts', 'ts']});
});
test('main() config validation: config may not be an empty object', withProvider, (t, provider) => {
validateConfig(t, provider, {});
});
test('main() config validation: throw when config.compile is invalid', withProvider, (t, provider) => {
validateConfig(t, provider, {rewritePaths: {'src/': 'build/'}, compile: 1});
validateConfig(t, provider, {rewritePaths: {'src/': 'build/'}, compile: undefined});
});
test('main() config validation: rewrite paths must end in a /', withProvider, (t, provider) => {
validateConfig(t, provider, {rewritePaths: {src: 'build/', compile: false}});
validateConfig(t, provider, {rewritePaths: {'src/': 'build', compile: false}});
});
test('main() extensions: defaults to [\'ts\', \'mts\']', withProvider, (t, provider) => {
t.deepEqual(provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}}).extensions, ['ts', 'mts']);
});
test('main() extensions: returns configured extensions', withProvider, (t, provider) => {
const extensions = ['tsx'];
t.deepEqual(provider.main({config: {extensions, rewritePaths: {'src/': 'build/'}, compile: false}}).extensions, extensions);
});
test('main() extensions: always returns new arrays', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.not(main.extensions, main.extensions);
});
test('main() updateGlobs()', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.snapshot(main.updateGlobs({
filePatterns: ['src/test.ts'],
ignoredByWatcherPatterns: ['assets/**'],
}));
});
test('main() interpretChange() without compilation', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.interpretChange(path.join(projectDirectory, 'src/foo.ts')), main.changeInterpretations.waitForOutOfBandCompilation);
t.is(main.interpretChange(path.join(projectDirectory, 'build/foo.js')), main.changeInterpretations.unspecified);
t.is(main.interpretChange(path.join(projectDirectory, 'src/foo.txt')), main.changeInterpretations.unspecified);
});
test('main() interpretChange() with compilation', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: 'tsc'}});
t.is(main.interpretChange(path.join(projectDirectory, 'src/foo.ts')), main.changeInterpretations.unspecified);
t.is(main.interpretChange(path.join(projectDirectory, 'build/foo.js')), main.changeInterpretations.ignoreCompiled);
t.is(main.interpretChange(path.join(projectDirectory, 'src/foo.txt')), main.changeInterpretations.unspecified);
});
test('main() resolvePossibleOutOfBandCompilationSources() with compilation', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: 'tsc'}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.js')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() unknown extension', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.bar')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() not a build path', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'lib/foo.js')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() .cjs but .cts not configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['ts'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.cjs')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() .mjs but .mts not configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['ts'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.mjs')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() .js but .ts not configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['cts'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.js')), null);
});
test('main() resolvePossibleOutOfBandCompilationSources() .cjs and .cjs and .cts configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['cjs', 'cts'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.deepEqual(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.cjs')), [
path.join(projectDirectory, 'src/foo.cjs'),
path.join(projectDirectory, 'src/foo.cts'),
]);
});
test('main() resolvePossibleOutOfBandCompilationSources() .mjs and .mjs and .mts configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['mjs', 'mts'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.deepEqual(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.mjs')), [
path.join(projectDirectory, 'src/foo.mjs'),
path.join(projectDirectory, 'src/foo.mts'),
]);
});
test('main() resolvePossibleOutOfBandCompilationSources() .js and .js, .ts and .tsx configured', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['js', 'ts', 'tsx'], rewritePaths: {'src/': 'build/'}, compile: false}});
t.deepEqual(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'build/foo.js')), [
path.join(projectDirectory, 'src/foo.js'),
path.join(projectDirectory, 'src/foo.ts'),
path.join(projectDirectory, 'src/foo.tsx'),
]);
});
test('main() resolvePossibleOutOfBandCompilationSources() returns the first possible path that exists', withProvider, (t, provider) => {
const main = provider.main({config: {extensions: ['js', 'ts', 'tsx'], rewritePaths: {'fixtures/load/': 'fixtures/load/compiled/'}, compile: false}});
t.deepEqual(main.resolvePossibleOutOfBandCompilationSources(path.join(projectDirectory, 'fixtures/load/compiled/index.js')), [path.join(projectDirectory, 'fixtures/load/index.ts')]);
});

View file

@ -4,19 +4,39 @@ The actual snapshot is saved in `compilation.js.snap`.
Generated by [AVA](https://avajs.dev).
## worker(): load rewritten paths files
## worker(): load rewritten paths files (with ava-6 provider)
> Snapshot 1
'logged in file.js'
## worker(): runs compiled files
## worker(): runs compiled files (with ava-6 provider)
> Snapshot 1
'logged in fixtures/typescript/index.ts'
## compile() error
## compile() error (with ava-6 provider)
> Snapshot 1
`Command failed with exit code 2: tsc --incremental␊
␊
typescript/typescript.ts(1,1): error TS2304: Cannot find name 'a'.`
## worker(): load rewritten paths files (with ava-8 provider)
> Snapshot 1
'logged in file.js'
## worker(): runs compiled files (with ava-8 provider)
> Snapshot 1
'logged in fixtures/typescript/index.ts'
## compile() error (with ava-8 provider)
> Snapshot 1

Binary file not shown.

View file

@ -4,19 +4,37 @@ The actual snapshot is saved in `load.js.snap`.
Generated by [AVA](https://avajs.dev).
## worker(): load .cts
## worker(): load .cts (with ava-6 provider)
> Snapshot 1
'logged in fixtures/load/index.cts'
## worker(): load .mts
## worker(): load .mts (with ava-6 provider)
> Snapshot 1
'logged in fixtures/load/index.mts'
## worker(): load .ts
## worker(): load .ts (with ava-6 provider)
> Snapshot 1
'logged in fixtures/load/index.ts'
## worker(): load .cts (with ava-8 provider)
> Snapshot 1
''
## worker(): load .mts (with ava-8 provider)
> Snapshot 1
'logged in fixtures/load/index.mts'
## worker(): load .ts (with ava-8 provider)
> Snapshot 1

Binary file not shown.

View file

@ -0,0 +1,117 @@
# Snapshot report for `test/protocol-ava-8.js`
The actual snapshot is saved in `protocol-ava-8.js.snap`.
Generated by [AVA](https://avajs.dev).
## main() config validation: throw when config is not a plain object
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
> Snapshot 2
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
> Snapshot 3
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
> Snapshot 4
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: throw when config contains keys other than 'extensions', 'rewritePaths' or 'compile'
> Snapshot 1
Error {
message: 'Unexpected \'foo\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: throw when config.extensions contains empty strings
> Snapshot 1
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: throw when config.extensions contains non-strings
> Snapshot 1
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: throw when config.extensions contains duplicates
> Snapshot 1
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: config may not be an empty object
> Snapshot 1
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: throw when config.compile is invalid
> Snapshot 1
Error {
message: 'Invalid \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
> Snapshot 2
Error {
message: 'Invalid \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() config validation: rewrite paths must end in a /
> Snapshot 1
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
> Snapshot 2
Error {
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() updateGlobs()
> Snapshot 1
{
filePatterns: [
'src/test.ts',
'!**/*.d.ts',
'!build/**',
],
ignoredByWatcherPatterns: [
'assets/**',
'build/**/*.js.map',
'build/**/*.cjs.map',
'build/**/*.mjs.map',
],
}

Binary file not shown.