Add compile option

Support compilation using `tsc` before AVA runs tests.

This is a breaking change. To retain the old behavior you must configure `compile: false`.

Co-authored-by: Mark Wubben <mark@novemberborn.net>
This commit is contained in:
Szymon Marczak 2021-04-12 10:32:03 +02:00 committed by GitHub
parent de9c6f76dc
commit 869760a149
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 263 additions and 106 deletions

View file

@ -10,3 +10,6 @@ insert_final_newline = true
[*.yml]
indent_style = space
indent_size = 2
[package.json]
indent_style = space

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
/coverage
/node_modules
/test/fixtures/typescript/compiled
/test/broken-fixtures/typescript/compiled

View file

@ -1,10 +1,10 @@
# @ava/typescript
Adds rudimentary [TypeScript](https://www.typescriptlang.org/) support to [AVA](https://avajs.dev).
Adds [TypeScript](https://www.typescriptlang.org/) support to [AVA](https://avajs.dev).
This is designed to work for projects that precompile their TypeScript code, including tests. It allows AVA to load the resulting JavaScript, while configuring AVA to use the TypeScript paths.
This is designed to work for projects that precompile TypeScript. It allows AVA to load the compiled JavaScript, while configuring AVA to treat the TypeScript files as test files.
In other words, say you have a test file at `src/test.ts`. You've configured TypeScript to output to `build/`. Using `@ava/typescript` you can run the `build/test.js` file using `npx ava src/test.ts`. AVA won't pick up any of the JavaScript files present in the `build/` directory, unless they have a TypeScript counterpart in `src/`.
In other words, say you have a test file at `src/test.ts`. You've configured TypeScript to output to `build/`. Using `@ava/typescript` you can run the test using `npx ava src/test.ts`.
## Enabling TypeScript support
@ -24,7 +24,8 @@ Then, enable TypeScript support either in `package.json` or `ava.config.*`:
"typescript": {
"rewritePaths": {
"src/": "build/"
}
},
"compile": false
}
}
}
@ -32,6 +33,8 @@ Then, enable TypeScript support either in `package.json` or `ava.config.*`:
Both keys and values of the `rewritePaths` object must end with a `/`. Paths are relative to your project directory.
You can enable compilation via the `compile` property. If `false`, AVA will assume you have already compiled your project. If set to `'tsc'`, AVA will run the TypeScript compiler before running your tests. This can be inefficient when using AVA in watch mode.
Output files are expected to have the `.js` extension.
AVA searches your entire project for `*.js`, `*.cjs`, `*.mjs` and `*.ts` files (or other extensions you've configured). It will ignore such files found in the `rewritePaths` targets (e.g. `build/`). If you use more specific paths, for instance `build/main/`, you may need to change AVA's `files` configuration to ignore other directories.

View file

@ -1,31 +1,74 @@
'use strict';
const path = require('path');
const escapeStringRegexp = require('escape-string-regexp');
const execa = require('execa');
const pkg = require('./package.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 isValidExtensions(extensions) {
return Array.isArray(extensions) &&
extensions.length > 0 &&
extensions.every(ext => typeof ext === 'string' && ext !== '') &&
new Set(extensions).size === extensions.length;
}
function validate(target, properties) {
for (const key of Object.keys(properties)) {
const {required, isValid} = properties[key];
const missing = !Reflect.has(target, key);
function isValidRewritePaths(rewritePaths) {
if (!isPlainObject(rewritePaths)) {
return false;
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}`);
}
}
return Object.entries(rewritePaths).every(([from, to]) => {
return from.endsWith('/') && typeof to === 'string' && to.endsWith('/');
});
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(projectDir) {
return execa('tsc', ['--incremental'], {preferLocal: true, cwd: projectDir});
}
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]) => {
return from.endsWith('/') && typeof to === 'string' && to.endsWith('/');
});
}
},
extensions: {
required: false,
isValid(extensions) {
return Array.isArray(extensions) &&
extensions.length > 0 &&
extensions.every(ext => typeof ext === 'string' && ext !== '') &&
new Set(extensions).size === extensions.length;
}
}
};
module.exports = ({negotiateProtocol}) => {
const protocol = negotiateProtocol(['ava-3.2'], {version: pkg.version});
if (protocol === null) {
@ -34,23 +77,16 @@ module.exports = ({negotiateProtocol}) => {
return {
main({config}) {
let valid = false;
if (isPlainObject(config)) {
const keys = Object.keys(config);
if (keys.every(key => key === 'extensions' || key === 'rewritePaths')) {
valid =
(config.extensions === undefined || isValidExtensions(config.extensions)) &&
isValidRewritePaths(config.rewritePaths);
}
if (!isPlainObject(config)) {
throw new Error(`Unexpected Typescript configuration for AVA. ${help}`);
}
if (!valid) {
throw new Error(`Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.`);
}
validate(config, configProperties);
const {
extensions = ['ts'],
rewritePaths: relativeRewritePaths
rewritePaths: relativeRewritePaths,
compile
} = config;
const rewritePaths = Object.entries(relativeRewritePaths).map(([from, to]) => [
@ -61,6 +97,10 @@ module.exports = ({negotiateProtocol}) => {
return {
async compile() {
if (compile === 'tsc') {
await compileTypeScript(protocol.projectDir);
}
return {
extensions: extensions.slice(),
rewritePaths: rewritePaths.slice()

View file

@ -19,12 +19,14 @@
"test": "xo && c8 ava"
},
"dependencies": {
"escape-string-regexp": "^4.0.0"
"escape-string-regexp": "^4.0.0",
"execa": "^5.0.0"
},
"devDependencies": {
"ava": "^3.15.0",
"c8": "^7.7.1",
"execa": "^5.0.0",
"del": "^6.0.0",
"typescript": "^4.2.4",
"xo": "^0.38.2"
},
"c8": {
@ -34,7 +36,16 @@
"text"
]
},
"ava": {
"files": [
"!test/broken-fixtures/**"
],
"timeout": "60s"
},
"xo": {
"ignores": [
"test/broken-fixtures"
],
"rules": {
"import/order": "off"
}

23
test/_with-provider.js Normal file
View file

@ -0,0 +1,23 @@
const path = require('path');
const pkg = require('../package.json');
const makeProvider = require('..');
const createProviderMacro = (identifier, avaVersion, projectDir = __dirname) => {
return (t, run) => run(t, makeProvider({
negotiateProtocol(identifiers, {version}) {
t.true(identifiers.includes(identifier));
t.is(version, pkg.version);
return {
ava: {avaVersion},
identifier,
normalizeGlobPatterns: patterns => patterns,
async findFiles({patterns}) {
return patterns.map(file => path.join(projectDir, file));
},
projectDir
};
}
}));
};
module.exports = createProviderMacro;

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"outDir": "typescript/compiled"
},
"include": [
"typescript"
]
}

View file

@ -0,0 +1 @@
a

61
test/compilation.js Normal file
View file

@ -0,0 +1,61 @@
const path = require('path');
const test = require('ava');
const del = require('del');
const execa = require('execa');
const createProviderMacro = require('./_with-provider');
const withProvider = createProviderMacro('ava-3.2', '3.2.0', path.join(__dirname, 'fixtures'));
const withAltProvider = createProviderMacro('ava-3.2', '3.2.0', path.join(__dirname, 'broken-fixtures'));
test.before('deleting compiled files', async t => {
t.log(await del('test/fixtures/typescript/compiled'));
t.log(await del('test/broken-fixtures/typescript/compiled'));
});
const compile = async provider => {
return {
state: await provider.main({
config: {
rewritePaths: {
'ts/': 'typescript/',
'compiled/': 'typescript/compiled/'
},
compile: 'tsc'
}
}).compile()
};
};
test('worker(): load rewritten paths files', withProvider, async (t, provider) => {
const {state} = await compile(provider);
const {stdout, stderr} = await execa.node(
path.join(__dirname, 'fixtures/install-and-load'),
[JSON.stringify(state), path.join(__dirname, 'fixtures/ts', 'file.ts')],
{cwd: path.join(__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 execa.node(
path.join(__dirname, 'fixtures/install-and-load'),
[JSON.stringify(state), path.join(__dirname, 'fixtures/compiled', 'index.ts')],
{cwd: path.join(__dirname, 'fixtures')}
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
});
test('compile() error', withAltProvider, async (t, provider) => {
const {message} = await t.throwsAsync(compile(provider));
t.snapshot(message);
});

View file

@ -3,16 +3,16 @@ const makeProvider = require('../..');
const provider = makeProvider({
negotiateProtocol() {
return {identifier: process.argv[2], ava: {version: '3.0.0'}, projectDir: path.resolve(__dirname, '..')};
return {identifier: 'ava-3.2', ava: {version: '3.15.0'}, projectDir: __dirname};
}
});
const worker = provider.worker({
extensionsToLoadAsModules: [],
state: JSON.parse(process.argv[3])
state: JSON.parse(process.argv[2])
});
const ref = path.resolve(process.argv[4]);
const ref = path.resolve(process.argv[3]);
if (worker.canLoad(ref)) {
worker.load(ref, {requireFn: require});

8
test/fixtures/tsconfig.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"outDir": "typescript/compiled"
},
"include": [
"typescript"
]
}

1
test/fixtures/typescript/index.ts vendored Normal file
View file

@ -0,0 +1 @@
console.log('logged in fixtures/typescript/index.ts');

View file

@ -1,24 +1,9 @@
const path = require('path');
const test = require('ava');
const execa = require('execa');
const pkg = require('../package.json');
const makeProvider = require('..');
const createProviderMacro = require('./_with-provider');
const withProvider = (t, run) => run(t, makeProvider({
negotiateProtocol(identifiers, {version}) {
t.true(identifiers.includes('ava-3.2'));
t.is(version, pkg.version);
return {
ava: {version: '3.15.0'},
identifier: 'ava-3.2',
normalizeGlobPatterns: patterns => patterns,
async findFiles({patterns}) {
return patterns.map(file => path.join(__dirname, file));
},
projectDir: __dirname
};
}
}));
const withProvider = createProviderMacro('ava-3.2', '3.15.0');
const validateConfig = (t, provider, config) => {
const error = t.throws(() => provider.main({config}));
@ -35,8 +20,8 @@ test('main() config validation: throw when config is not a plain object', withPr
validateConfig(t, provider, []);
});
test('main() config validation: throw when config contains keys other than \'extensions\' or \'rewritePaths\'', withProvider, (t, provider) => {
validateConfig(t, provider, {foo: 1});
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) => {
@ -55,68 +40,47 @@ test('main() config validation: config may not be an empty object', withProvider
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/'}});
validateConfig(t, provider, {rewritePaths: {'src/': 'build'}});
validateConfig(t, provider, {rewritePaths: {src: 'build/', compile: false}});
validateConfig(t, provider, {rewritePaths: {'src/': 'build', compile: false}});
});
test('main() extensions: defaults to [\'ts\']', withProvider, (t, provider) => {
t.deepEqual(provider.main({config: {rewritePaths: {'src/': 'build/'}}}).extensions, ['ts']);
t.deepEqual(provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}}).extensions, ['ts']);
});
test('main() extensions: returns configured extensions', withProvider, (t, provider) => {
const extensions = ['tsx'];
t.deepEqual(provider.main({config: {extensions, rewritePaths: {'src/': 'build/'}}}).extensions, extensions);
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/'}}});
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.not(main.extensions, main.extensions);
});
test('main() ignoreChange()', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}}});
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.true(main.ignoreChange(path.join(__dirname, 'src/foo.ts')));
t.false(main.ignoreChange(path.join(__dirname, 'build/foo.js')));
});
test('main() resolveTestfile()', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}}});
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.is(main.resolveTestFile(path.join(__dirname, 'src/foo.ts')), path.join(__dirname, 'build/foo.js'));
t.is(main.resolveTestFile(path.join(__dirname, 'build/foo.js')), path.join(__dirname, 'build/foo.js'));
t.is(main.resolveTestFile(path.join(__dirname, 'foo/bar.ts')), path.join(__dirname, 'foo/bar.ts'));
});
test('main() updateGlobs()', withProvider, (t, provider) => {
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}}});
const main = provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}});
t.snapshot(main.updateGlobs({
filePatterns: ['src/test.ts'],
ignoredByWatcherPatterns: ['assets/**']
}));
});
const compile = async provider => {
return {
state: await provider.main({
config: {
rewritePaths: {
'typescript/': 'fixtures/'
}
}
}).compile()
};
};
test('worker(): load rewritten paths files', withProvider, async (t, provider) => {
const {state} = await compile(provider);
const {stdout, stderr} = await execa.node(
path.join(__dirname, 'fixtures/install-and-load'),
['ava-3', JSON.stringify(state), path.join(__dirname, 'typescript', 'file.ts')],
{cwd: path.join(__dirname, 'fixtures')}
);
if (stderr.length > 0) {
t.log(stderr);
}
t.snapshot(stdout);
});

View file

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

Binary file not shown.

View file

@ -9,33 +9,33 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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 for allowed values.',
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 for allowed values.',
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 for allowed values.',
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' or 'rewritePaths'
## main() config validation: throw when config contains keys other than 'extensions', 'rewritePaths' or 'compile'
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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
@ -43,7 +43,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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
@ -51,7 +51,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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
@ -59,7 +59,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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
@ -67,7 +67,21 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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 /
@ -75,13 +89,13 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
Error {
message: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
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: 'Unexpected Typescript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md for allowed values.',
message: 'Missing \'compile\' property in TypeScript configuration for AVA. See https://github.com/avajs/typescript/blob/v${pkg.version}/README.md',
}
## main() updateGlobs()
@ -99,9 +113,3 @@ Generated by [AVA](https://avajs.dev).
'build/**/*.js.map',
],
}
## worker(): load rewritten paths files
> Snapshot 1
'logged in file.js'