diff --git a/index.js b/index.js index ab9b35a..fb5b165 100644 --- a/index.js +++ b/index.js @@ -68,8 +68,14 @@ const configProperties = { }, }; +const changeInterpretations = Object.freeze(Object.assign(Object.create(null), { + unspecified: 0, + ignoreCompiled: 1, + waitForOutOfBandCompilation: 2, +})); + export default function typescriptProvider({negotiateProtocol}) { - const protocol = negotiateProtocol(['ava-3.2'], {version: pkg.version}); + const protocol = negotiateProtocol(['ava-6', 'ava-3.2'], {version: pkg.version}); if (protocol === null) { return; } @@ -94,7 +100,145 @@ export default function typescriptProvider({negotiateProtocol}) { ]); const testFileExtension = new RegExp(`\\.(${extensions.map(ext => escapeStringRegexp(ext)).join('|')})$`); + const watchMode = protocol.identifier === 'ava-3.2' + ? { + ignoreChange(filePath) { + if (!testFileExtension.test(filePath)) { + return false; + } + + return rewritePaths.some(([from]) => filePath.startsWith(from)); + }, + + resolveTestFile(testfile) { // Used under AVA 3.2 protocol by legacy watcher implementation. + if (!testFileExtension.test(testfile)) { + return testfile; + } + + const rewrite = rewritePaths.find(([from]) => testfile.startsWith(from)); + if (rewrite === undefined) { + return testfile; + } + + const [from, to] = rewrite; + let newExtension = '.js'; + if (testfile.endsWith('.cts')) { + newExtension = '.cjs'; + } else if (testfile.endsWith('.mts')) { + newExtension = '.mjs'; + } + + return `${to}${testfile.slice(from.length)}`.replace(testFileExtension, newExtension); + }, + } + : { + 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$/.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$/, extension: 'cjs'}); + } + + if (extensions.includes('cts')) { + possibleExtensions.push({replace: /\.cjs$/, extension: 'cts'}); + } + + if (possibleExtensions.length === 0) { + return null; + } + } + + if (filePath.endsWith('.mjs')) { + if (extensions.includes('mjs')) { + possibleExtensions.push({replace: /\.mjs$/, extension: 'mjs'}); + } + + if (extensions.includes('mts')) { + possibleExtensions.push({replace: /\.mjs$/, extension: 'mts'}); + } + + if (possibleExtensions.length === 0) { + return null; + } + } + + if (filePath.endsWith('.js')) { + if (extensions.includes('js')) { + possibleExtensions.push({replace: /\.js$/, extension: 'js'}); + } + + if (extensions.includes('ts')) { + possibleExtensions.push({replace: /\.js$/, extension: 'ts'}); + } + + if (extensions.includes('tsx')) { + possibleExtensions.push({replace: /\.js$/, 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); @@ -110,35 +254,6 @@ export default function typescriptProvider({negotiateProtocol}) { return [...extensions]; }, - ignoreChange(filePath) { - if (!testFileExtension.test(filePath)) { - return false; - } - - return rewritePaths.some(([from]) => filePath.startsWith(from)); - }, - - resolveTestFile(testfile) { // Used under AVA 3.2 protocol by legacy watcher implementation. - if (!testFileExtension.test(testfile)) { - return testfile; - } - - const rewrite = rewritePaths.find(([from]) => testfile.startsWith(from)); - if (rewrite === undefined) { - return testfile; - } - - const [from, to] = rewrite; - let newExtension = '.js'; - if (testfile.endsWith('.cts')) { - newExtension = '.cjs'; - } else if (testfile.endsWith('.mts')) { - newExtension = '.mjs'; - } - - return `${to}${testfile.slice(from.length)}`.replace(testFileExtension, newExtension); - }, - updateGlobs({filePatterns, ignoredByWatcherPatterns}) { return { filePatterns: [ diff --git a/test/protocol-ava-6.js b/test/protocol-ava-6.js new file mode 100644 index 0000000..55ed841 --- /dev/null +++ b/test/protocol-ava-6.js @@ -0,0 +1,140 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import test from 'ava'; +import createProviderMacro from './_with-provider.js'; + +const projectDir = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))); +const withProvider = createProviderMacro('ava-6', '5.3.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-6 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\', \'cts\', \'mts\']', withProvider, (t, provider) => { + t.deepEqual(provider.main({config: {rewritePaths: {'src/': 'build/'}, compile: false}}).extensions, ['ts', 'cts', '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(projectDir, 'src/foo.ts')), main.changeInterpretations.waitForOutOfBandCompilation); + t.is(main.interpretChange(path.join(projectDir, 'build/foo.js')), main.changeInterpretations.unspecified); + t.is(main.interpretChange(path.join(projectDir, '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(projectDir, 'src/foo.ts')), main.changeInterpretations.unspecified); + t.is(main.interpretChange(path.join(projectDir, 'build/foo.js')), main.changeInterpretations.ignoreCompiled); + t.is(main.interpretChange(path.join(projectDir, '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(projectDir, '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(projectDir, '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(projectDir, '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(projectDir, '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(projectDir, '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(projectDir, '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(projectDir, 'build/foo.cjs')), [path.join(projectDir, 'src/foo.cjs'), path.join(projectDir, '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(projectDir, 'build/foo.mjs')), [path.join(projectDir, 'src/foo.mjs'), path.join(projectDir, '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(projectDir, 'build/foo.js')), [path.join(projectDir, 'src/foo.js'), path.join(projectDir, 'src/foo.ts'), path.join(projectDir, '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(projectDir, 'fixtures/load/compiled/index.js')), [path.join(projectDir, 'fixtures/load/index.ts')]); +}); diff --git a/test/snapshots/protocol-ava-6.js.md b/test/snapshots/protocol-ava-6.js.md new file mode 100644 index 0000000..c566013 --- /dev/null +++ b/test/snapshots/protocol-ava-6.js.md @@ -0,0 +1,117 @@ +# Snapshot report for `test/protocol-ava-6.js` + +The actual snapshot is saved in `protocol-ava-6.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', + ], + } diff --git a/test/snapshots/protocol-ava-6.js.snap b/test/snapshots/protocol-ava-6.js.snap new file mode 100644 index 0000000..6f7ecc7 Binary files /dev/null and b/test/snapshots/protocol-ava-6.js.snap differ