ava-typescript/index.js
Mark Wubben b31b242831 Implement AVA 8 protocol
- Always import() when loading
- Don't include .cts in default extensions
2026-04-16 21:38:08 +02:00

279 lines
7.5 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import escapeStringRegexp from 'escape-string-regexp';
import {execa} from 'execa';
import pkg from './package.json' with {type: 'json'};
const help = `See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md`;
function isPlainObject(x) {
return x !== null && typeof x === 'object' && Reflect.getPrototypeOf(x) === Object.prototype;
}
function validate(target, properties) {
for (const key of Object.keys(properties)) {
const {required, isValid} = properties[key];
const missing = !Reflect.has(target, key);
if (missing) {
if (required) {
throw new Error(`Missing '${key}' property in TypeScript configuration for AVA. ${help}`);
}
continue;
}
if (!isValid(target[key])) {
throw new Error(`Invalid '${key}' property in TypeScript configuration for AVA. ${help}`);
}
}
for (const key of Object.keys(target)) {
if (!Reflect.has(properties, key)) {
throw new Error(`Unexpected '${key}' property in TypeScript configuration for AVA. ${help}`);
}
}
}
async function compileTypeScript(projectDirectory) {
return execa({preferLocal: true, cwd: projectDirectory})`tsc --incremental`;
}
const configProperties = {
compile: {
required: true,
isValid(compile) {
return compile === false || compile === 'tsc';
},
},
rewritePaths: {
required: true,
isValid(rewritePaths) {
if (!isPlainObject(rewritePaths)) {
return false;
}
return Object.entries(rewritePaths).every(([from, to]) => from.endsWith('/') && typeof to === 'string' && to.endsWith('/'));
},
},
extensions: {
required: false,
isValid(extensions) {
return Array.isArray(extensions)
&& extensions.length > 0
&& extensions.every(extension => typeof extension === 'string' && extension !== '')
&& new Set(extensions).size === extensions.length;
},
},
};
const changeInterpretations = Object.freeze(Object.assign(Object.create(null), {
unspecified: 0,
ignoreCompiled: 1,
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)) {
throw new Error(`Unexpected Typescript configuration for AVA. ${help}`);
}
validate(config, configProperties);
const {
extensions = isAva8 ? defaultExtensions : defaultAva6Extensions,
rewritePaths: relativeRewritePaths,
compile,
} = config;
const rewritePaths = Object.entries(relativeRewritePaths).map(([from, to]) => [
path.join(protocol.projectDir, from),
path.join(protocol.projectDir, to),
]);
const testFileExtension = new RegExp(String.raw`\.(${extensions.map(extension => escapeStringRegexp(extension)).join('|')})$`, 'v');
const watchMode = {
changeInterpretations,
interpretChange(filePath) {
if (config.compile === false) {
for (const [from] of rewritePaths) {
if (testFileExtension.test(filePath) && filePath.startsWith(from)) {
return changeInterpretations.waitForOutOfBandCompilation;
}
}
}
if (config.compile === 'tsc') {
for (const [, to] of rewritePaths) {
if (filePath.startsWith(to)) {
return changeInterpretations.ignoreCompiled;
}
}
}
return changeInterpretations.unspecified;
},
resolvePossibleOutOfBandCompilationSources(filePath) {
if (config.compile !== false) {
return null;
}
// Only recognize .cjs, .mjs and .js files.
if (!/\.(c|m)?js$/v.test(filePath)) {
return null;
}
for (const [from, to] of rewritePaths) {
if (!filePath.startsWith(to)) {
continue;
}
const rewritten = `${from}${filePath.slice(to.length)}`;
const possibleExtensions = [];
if (filePath.endsWith('.cjs')) {
if (extensions.includes('cjs')) {
possibleExtensions.push({replace: /\.cjs$/v, extension: 'cjs'});
}
if (extensions.includes('cts')) {
possibleExtensions.push({replace: /\.cjs$/v, extension: 'cts'});
}
if (possibleExtensions.length === 0) {
return null;
}
}
if (filePath.endsWith('.mjs')) {
if (extensions.includes('mjs')) {
possibleExtensions.push({replace: /\.mjs$/v, extension: 'mjs'});
}
if (extensions.includes('mts')) {
possibleExtensions.push({replace: /\.mjs$/v, extension: 'mts'});
}
if (possibleExtensions.length === 0) {
return null;
}
}
if (filePath.endsWith('.js')) {
if (extensions.includes('js')) {
possibleExtensions.push({replace: /\.js$/v, extension: 'js'});
}
if (extensions.includes('ts')) {
possibleExtensions.push({replace: /\.js$/v, extension: 'ts'});
}
if (extensions.includes('tsx')) {
possibleExtensions.push({replace: /\.js$/v, extension: 'tsx'});
}
if (possibleExtensions.length === 0) {
return null;
}
}
const possibleDeletedFiles = [];
for (const {replace, extension} of possibleExtensions) {
const possibleFilePath = rewritten.replace(replace, `.${extension}`);
// Pick the first file path that exists.
if (fs.existsSync(possibleFilePath)) {
return [possibleFilePath];
}
possibleDeletedFiles.push(possibleFilePath);
}
return possibleDeletedFiles;
}
return null;
},
};
return {
...watchMode,
async compile() {
if (compile === 'tsc') {
await compileTypeScript(protocol.projectDir);
}
return {
extensions: [...extensions],
rewritePaths: [...rewritePaths],
};
},
get extensions() {
return [...extensions];
},
updateGlobs({filePatterns, ignoredByWatcherPatterns}) {
return {
filePatterns: [
...filePatterns,
'!**/*.d.ts',
...Object.values(relativeRewritePaths).map(to => `!${to}**`),
],
ignoredByWatcherPatterns: [
...ignoredByWatcherPatterns,
...Object.values(relativeRewritePaths).flatMap(to => [
`${to}**/*.js.map`,
`${to}**/*.cjs.map`,
`${to}**/*.mjs.map`,
]),
],
};
},
};
},
worker({extensionsToLoadAsModules, state: {extensions, rewritePaths}}) {
const importJs = isAva8 || extensionsToLoadAsModules.includes('js');
const testFileExtension = new RegExp(String.raw`\.(${extensions.map(extension => escapeStringRegexp(extension)).join('|')})$`, 'v');
return {
canLoad(reference) {
return testFileExtension.test(reference) && rewritePaths.some(([from]) => reference.startsWith(from));
},
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 = isAva8;
} else if (reference.endsWith('.mts')) {
rewritten = rewritten.replace(/\.mts$/v, '.mjs');
} else {
rewritten = rewritten.replace(testFileExtension, '.js');
useImport = importJs;
}
return useImport ? import(pathToFileURL(rewritten)) : requireFn(rewritten);
},
};
},
};
}