diff --git a/boilerplate/skeleton/extension/js/package.json b/boilerplate/skeleton/extension/js/package.json index bf34aeb..1de7ee7 100644 --- a/boilerplate/skeleton/extension/js/package.json +++ b/boilerplate/skeleton/extension/js/package.json @@ -7,12 +7,12 @@ "dependencies": { }, "devDependencies": { - "@flarum/jest-config": "^2.0.0", + "@flarum/jest-config": "^2.1.0", "@flarum/prettier-config": "^1.0.0", "flarum-tsconfig": "^2.0.0", - "flarum-webpack-config": "^3.0.7", + "flarum-webpack-config": "^3.1.0", "prettier": "^2.5.1", - "typescript": "^4.9.5", + "typescript": "^5.9.3", "typescript-coverage-report": "^0.6.1", "webpack": "^5.104.1", "webpack-cli": "^4.9.1" diff --git a/boilerplate/skeleton/extension/js/tsconfig.json b/boilerplate/skeleton/extension/js/tsconfig.json index 8804296..7a5173d 100644 --- a/boilerplate/skeleton/extension/js/tsconfig.json +++ b/boilerplate/skeleton/extension/js/tsconfig.json @@ -12,8 +12,8 @@ "@types/**/*" ], "compilerOptions": { - // Only load the type packages the code imports. Others, such as the - // @types/node webpack brings along, use syntax TypeScript 4.9 can't read. + // Only load the type packages the code imports, not every one in + // node_modules, such as the @types/node webpack brings along. "types": [], // This will output typings to `dist-typings` "declarationDir": "./dist-typings", diff --git a/src/steps/upgrade/twopointoh/dependencies.ts b/src/steps/upgrade/twopointoh/dependencies.ts index fa8d99d..e81e59c 100644 --- a/src/steps/upgrade/twopointoh/dependencies.ts +++ b/src/steps/upgrade/twopointoh/dependencies.ts @@ -1,8 +1,8 @@ -import { AdvancedContent, BaseUpgradeStep, GitCommit, Replacement } from './base'; +import { BaseUpgradeStep, GitCommit, Replacement } from './base'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; -import { compilerOptions, libs } from './typescript-4-9'; +import { compilerOptions, libs } from './typescript-5-9'; // What `fl init` gave 1.x extensions. `sed -i` with no suffix is GNU sed's // form: BSD sed, as on macOS, takes the next argument as the suffix and fails. @@ -11,14 +11,6 @@ const GNU_POST_BUILD_TYPINGS = "find dist-typings -type f -name '*.d.ts' -print0 const PORTABLE_POST_BUILD_TYPINGS = "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i.bak 's,../src/@types,@types,g' && find dist-typings -type f -name '*.d.ts.bak' -delete"; -// Syntax TypeScript 4.9 can't compile, each checked against typescript@4.9.5. -const NEWER_SYNTAX: [RegExp, string][] = [ - [/(?:\bfrom|^\s*import)\s+(["'])[^\n"']*\1\s+with\s*{/, 'import attributes (TypeScript 5.3); use `assert` instead of `with`'], - [/^\s*export\s+type\s+\*/, '`export type *` (TypeScript 5.0)'], - [/[,<]\s*const\s+[$A-Z_a-z][\w$]*\s*(?:extends\b|[,=>])/, '`const` type parameters (TypeScript 5.0)'], - [/^\s*(?:await\s+)?using\s+[$A-Z_a-z][\w$]*\s*[:=]/, '`using` declarations (TypeScript 5.2)'], -]; - // Packages an extension only needs to build, check and test its frontend. const BUILD_TOOLING = new Set([ '@flarum/jest-config', @@ -48,8 +40,8 @@ function scaffoldedPackageJson(packageManager: 'yarn' | 'npm'): Record) => Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))); -const isTypeScript = (file: string) => /\.tsx?$/.test(file); - -/** Whether a version constraint stays within TypeScript 4: `4.9.5`, `^4.5.4`, `~4.7`, `4.x`. */ -const isFourX = (constraint: string) => { - const version = constraint.trim().replace(/^\^|^~/, ''); +/** + * Whether a TypeScript constraint could resolve to TypeScript 6 or newer: + * `^6.0.3`, `>=4.2`, `latest`, `*`. A caret, tilde or exact constraint on 4 or + * 5 can't. + */ +const allowsSixOrNewer = (constraint: string) => { + const match = constraint.trim().match(/^[=^~]?\s*v?(\d+)(?:\.|$)/); - return version === '4' || version.startsWith('4.'); + return !match || Number(match[1]) >= 6; }; /** @@ -110,14 +104,12 @@ const asksForNewer = (constraint: string, than: string): boolean => { export default class Dependencies extends BaseUpgradeStep { type = 'Update the extension dependencies to the latest versions.'; - /** The TypeScript the extension asked for, when it was not a 4.x. */ + /** The TypeScript the extension asked for, when it allowed TypeScript 6 or newer. */ protected typeScriptWas: string | null = null; - /** What was removed from tsconfig.json, as TypeScript 4.9 doesn't have it. */ + /** What was removed from tsconfig.json, as TypeScript 5.9 refuses it. */ protected removedCompilerOptions: string[] = []; - protected newerSyntax = false; - /** Constraints left as they were, as they already ask for newer versions. */ protected keptNewer: string[] = []; @@ -143,28 +135,13 @@ export default class Dependencies extends BaseUpgradeStep { case 'js/tsconfig.test.json': replacements.push(this.testTypes()); break; - default: - if (isTypeScript(file)) { - replacements.push(this.flagNewerSyntax()); - } } return replacements; } targets(): string[] { - return ['composer.json', 'js/package.json', 'js/tsconfig.json', 'js/tsconfig.test.json', 'js/src/**/*.ts', 'js/src/**/*.tsx']; - } - - // TypeScript sources are only searched, line by line: parsing them would - // fail on the very syntax being looked for, and reformatting would change - // more than the lines flagged. - advancedContent(file: string, code: string): AdvancedContent { - return isTypeScript(file) ? null : super.advancedContent(file, code); - } - - async updateCode(file: string, code: string, updated: string | AdvancedContent): Promise { - return isTypeScript(file) && typeof updated === 'string' ? updated : super.updateCode(file, code, updated); + return ['composer.json', 'js/package.json', 'js/tsconfig.json', 'js/tsconfig.test.json']; } gitCommit(): GitCommit { @@ -182,10 +159,9 @@ export default class Dependencies extends BaseUpgradeStep { const notes = [ this.typeScriptWas && - `TypeScript ${this.typeScriptWas} was brought down to ^4.9.5: Flarum 2.0 builds and checks its typings with TypeScript 4.9.`, + `TypeScript ${this.typeScriptWas} was brought down to ^5.9.3: flarum-tsconfig resolves modules the way TypeScript 6 refuses without ignoreDeprecations.`, this.removedCompilerOptions.length > 0 && - `Removed from js/tsconfig.json, as TypeScript 4.9 does not have them: ${this.removedCompilerOptions.join(', ')}.`, - this.newerSyntax && 'Some TypeScript uses syntax 4.9 cannot compile; look for the @TODO comments.', + `Removed from js/tsconfig.json, as TypeScript 5.9 refuses them: ${this.removedCompilerOptions.join(', ')}.`, this.addedTypings.length > 0 && `Added what the reusable frontend workflow runs to build and check typings: ${this.addedTypings.join(', ')}.`, this.movedToDev.length > 0 && `Moved to devDependencies, as the extension only builds with them: ${this.movedToDev.join(', ')}.`, this.keptNewer.length > 0 && `Kept, as they already ask for newer versions than Flarum 2.0 needs: ${this.keptNewer.join(', ')}.`, @@ -273,30 +249,34 @@ export default class Dependencies extends BaseUpgradeStep { return (_file, _code, advanced) => { advanced = advanced as Record; - // The versions a new extension is scaffolded with. 3.0.6 is the first - // flarum-webpack-config that loads an extension's code-split chunks from - // its own build, and 3.0.7 the first that exports a default export as its - // value, not under a name taken from its code. + // The versions a new extension is scaffolded with. 3.1.0 is the first + // flarum-webpack-config that supplies TypeScript 5.9 and only asks for + // webpack as a peer, and @flarum/jest-config 2.1.0 tests with the mithril + // Flarum ships. const map: Record = { - 'flarum-webpack-config': '^3.0.7', + 'flarum-webpack-config': '^3.1.0', 'flarum-tsconfig': '^2.0.0', - '@flarum/jest-config': '^2.0.0', + '@flarum/jest-config': '^2.1.0', webpack: '^5.104.1', 'webpack-cli': '^4.9.1', - // What Flarum 2.0 builds and checks its typings with. - typescript: '^4.9.5', + // What Flarum builds and checks its typings with. + typescript: '^5.9.3', }; // Extensions list the tooling under either key, often split between both. for (const dependencies of [advanced.dependencies, advanced.devDependencies]) { for (const key of Object.keys(dependencies || {})) { - if (key === 'typescript' && !isFourX(String(dependencies[key]))) { - this.typeScriptWas = dependencies[key]; - } + // TypeScript is the exception: it moves to what Flarum checks typings + // with, whatever the extension asked for. flarum-tsconfig resolves + // modules the way TypeScript 6 refuses, so a newer one comes down, + // unless the extension's tsconfig already resolves them another way. + if (key === 'typescript' && allowsSixOrNewer(String(dependencies[key])) && this.tsConfigReadsWithSixOrNewer()) { + this.keptNewer.push(`${key} ${dependencies[key]}`); + } else if (key === 'typescript') { + if (allowsSixOrNewer(String(dependencies[key]))) { + this.typeScriptWas = dependencies[key]; + } - // TypeScript is the exception: it comes down to what Flarum checks - // typings with, whatever the extension asked for. - if (key === 'typescript') { dependencies[key] = map[key]; } else if (map[key]) { this.raise(dependencies, key, map[key]); @@ -401,8 +381,8 @@ export default class Dependencies extends BaseUpgradeStep { } /** - * Drop the compiler options, and option values, TypeScript 4.9 doesn't have: - * it refuses a tsconfig.json with any. A tsconfig.json it can read is left + * Drop the compiler options, and option values, TypeScript 5.9 refuses: it + * won't read a tsconfig.json with any. A tsconfig.json it can read is left * exactly as it was. */ updateTsConfig(): Replacement { @@ -435,10 +415,6 @@ export default class Dependencies extends BaseUpgradeStep { } } - if (Array.isArray(tsconfig.extends)) { - this.leaveTodo('js/tsconfig.json', 'It extends a list of configs, which TypeScript 4.9 cannot read. Extend one, and move the rest into it.'); - } - this.removedCompilerOptions.push(...removed); return removed.length > 0 ? { updated: tsconfig } : null; @@ -447,9 +423,8 @@ export default class Dependencies extends BaseUpgradeStep { /** * TypeScript loads every type package in node_modules unless told which, - * and 4.9 can't read the @types/node that webpack's minimizer brings along. - * Load only the ones the code imports. The file is edited as text so its - * comments stay. + * including the @types/node that webpack's minimizer brings along. Load only + * the ones the code imports. The file is edited as text so its comments stay. */ limitTypes(): Replacement { return (_file, code, advanced) => { @@ -492,6 +467,22 @@ export default class Dependencies extends BaseUpgradeStep { }; } + /** + * Whether TypeScript 6 and newer accept the extension's tsconfig.json: it + * replaces the moduleResolution flarum-tsconfig sets, which they refuse, and + * sets no baseUrl, which they refuse too. + */ + protected tsConfigReadsWithSixOrNewer(): boolean { + try { + const code = fs.readFileSync(path.join(this.root, 'js/tsconfig.json'), 'utf8'); + const options = (this.advancedContent('js/tsconfig.json', code) as Record)?.compilerOptions ?? {}; + + return ['bundler', 'node16', 'nodenext'].includes(String(options.moduleResolution).toLowerCase()) && options.baseUrl === undefined; + } catch { + return false; + } + } + /** The extension's js/package.json, or nothing when it has none. */ protected packageJson(): Record { try { @@ -500,34 +491,4 @@ export default class Dependencies extends BaseUpgradeStep { return {}; } } - - /** - * Put a @TODO above each line using syntax TypeScript 4.9 can't compile. - */ - flagNewerSyntax(): Replacement { - return (_file, code) => { - const flagged: string[] = []; - - for (const line of code.split('\n')) { - for (const [pattern, what] of NEWER_SYNTAX) { - if (!pattern.test(line)) continue; - - const indent = line.slice(0, line.length - line.trimStart().length); - const todo = `${indent}// @TODO: Flarum 2.0 builds with TypeScript 4.9, which has no ${what}.`; - - // An earlier run already flagged it. - if (flagged.slice(-NEWER_SYNTAX.length).some((previous) => previous.trim() === todo.trim())) continue; - - flagged.push(line.endsWith('\r') ? `${todo}\r` : todo); - this.newerSyntax = true; - } - - flagged.push(line); - } - - const updated = flagged.join('\n'); - - return updated === code ? null : { updated }; - }; - } } diff --git a/src/steps/upgrade/twopointoh/typescript-4-9.ts b/src/steps/upgrade/twopointoh/typescript-5-9.ts similarity index 69% rename from src/steps/upgrade/twopointoh/typescript-4-9.ts rename to src/steps/upgrade/twopointoh/typescript-5-9.ts index 2144b28..c7fe295 100644 --- a/src/steps/upgrade/twopointoh/typescript-4-9.ts +++ b/src/steps/upgrade/twopointoh/typescript-5-9.ts @@ -1,15 +1,20 @@ /** - * The compiler options TypeScript 4.9 knows, which Flarum 2.0 builds with, and + * The compiler options TypeScript 5.9 accepts, which Flarum builds with, and * the values it accepts for those that take one of a set. A tsconfig.json has * to spell an option exactly so; its values may be in any case, so they are * listed lowercase. * - * Generated from typescript@4.9.5: `ts.optionDeclarations`, each name mapped + * Generated from typescript@5.9.3: `ts.optionDeclarations`, each name mapped * to true, or to the keys of its type when that is a Map; the libs are the - * keys of the `lib` option's element type. + * keys of the `lib` option's element type. Left out are the options and values + * 5.9 still declares but refuses, as TypeScript 5.5 removed them: + * charset, importsNotUsedAsValues, keyofStringsOnly, noImplicitUseStrict, noStrictGenericChecks, out, preserveValueImports, suppressExcessPropertyErrors, suppressImplicitAnyIndexErrors, + * and target ES3. */ export const compilerOptions: Record = { all: true, + allowArbitraryExtensions: true, + allowImportingTsExtensions: true, allowJs: true, allowSyntheticDefaultImports: true, allowUmdGlobalAccess: true, @@ -18,10 +23,9 @@ export const compilerOptions: Record = { alwaysStrict: true, assumeChangesOnlyAffectDirectDependencies: true, baseUrl: true, - build: true, - charset: true, checkJs: true, composite: true, + customConditions: true, declaration: true, declarationDir: true, declarationMap: true, @@ -34,6 +38,7 @@ export const compilerOptions: Record = { emitBOM: true, emitDeclarationOnly: true, emitDecoratorMetadata: true, + erasableSyntaxOnly: true, esModuleInterop: true, exactOptionalPropertyTypes: true, experimentalDecorators: true, @@ -45,29 +50,46 @@ export const compilerOptions: Record = { help: true, ignoreDeprecations: true, importHelpers: true, - importsNotUsedAsValues: ['error', 'preserve', 'remove'], incremental: true, init: true, inlineSourceMap: true, inlineSources: true, + isolatedDeclarations: true, isolatedModules: true, jsx: ['preserve', 'react', 'react-jsx', 'react-jsxdev', 'react-native'], jsxFactory: true, jsxFragmentFactory: true, jsxImportSource: true, - keyofStringsOnly: true, lib: true, + libReplacement: true, listEmittedFiles: true, listFiles: true, listFilesOnly: true, locale: true, mapRoot: true, maxNodeModuleJsDepth: true, - module: ['amd', 'commonjs', 'es2015', 'es2020', 'es2022', 'es6', 'esnext', 'node16', 'nodenext', 'none', 'system', 'umd'], + module: [ + 'amd', + 'commonjs', + 'es2015', + 'es2020', + 'es2022', + 'es6', + 'esnext', + 'node16', + 'node18', + 'node20', + 'nodenext', + 'none', + 'preserve', + 'system', + 'umd', + ], moduleDetection: ['auto', 'force', 'legacy'], - moduleResolution: ['classic', 'node', 'node16', 'nodenext'], + moduleResolution: ['bundler', 'classic', 'node', 'node10', 'node16', 'nodenext'], moduleSuffixes: true, newLine: ['crlf', 'lf'], + noCheck: true, noEmit: true, noEmitHelpers: true, noEmitOnError: true, @@ -77,28 +99,28 @@ export const compilerOptions: Record = { noImplicitOverride: true, noImplicitReturns: true, noImplicitThis: true, - noImplicitUseStrict: true, noLib: true, noPropertyAccessFromIndexSignature: true, noResolve: true, - noStrictGenericChecks: true, noUncheckedIndexedAccess: true, + noUncheckedSideEffectImports: true, noUnusedLocals: true, noUnusedParameters: true, - out: true, outDir: true, outFile: true, paths: true, plugins: true, preserveConstEnums: true, preserveSymlinks: true, - preserveValueImports: true, preserveWatchOutput: true, pretty: true, project: true, reactNamespace: true, removeComments: true, resolveJsonModule: true, + resolvePackageJsonExports: true, + resolvePackageJsonImports: true, + rewriteRelativeImportExtensions: true, rootDir: true, rootDirs: true, showConfig: true, @@ -108,25 +130,28 @@ export const compilerOptions: Record = { sourceRoot: true, strict: true, strictBindCallApply: true, + strictBuiltinIteratorReturn: true, strictFunctionTypes: true, strictNullChecks: true, strictPropertyInitialization: true, stripInternal: true, - suppressExcessPropertyErrors: true, - suppressImplicitAnyIndexErrors: true, - target: ['es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'es2022', 'es3', 'es5', 'es6', 'esnext'], + target: ['es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'es2022', 'es2023', 'es2024', 'es5', 'es6', 'esnext'], traceResolution: true, tsBuildInfoFile: true, typeRoots: true, types: true, useDefineForClassFields: true, useUnknownInCatchVariables: true, + verbatimModuleSyntax: true, version: true, watch: true, }; export const libs: string[] = [ + 'decorators', + 'decorators.legacy', 'dom', + 'dom.asynciterable', 'dom.iterable', 'es2015', 'es2015.collection', @@ -140,7 +165,10 @@ export const libs: string[] = [ 'es2015.symbol.wellknown', 'es2016', 'es2016.array.include', + 'es2016.intl', 'es2017', + 'es2017.arraybuffer', + 'es2017.date', 'es2017.intl', 'es2017.object', 'es2017.sharedmemory', @@ -177,8 +205,20 @@ export const libs: string[] = [ 'es2022.error', 'es2022.intl', 'es2022.object', - 'es2022.sharedmemory', + 'es2022.regexp', 'es2022.string', + 'es2023', + 'es2023.array', + 'es2023.collection', + 'es2023.intl', + 'es2024', + 'es2024.arraybuffer', + 'es2024.collection', + 'es2024.object', + 'es2024.promise', + 'es2024.regexp', + 'es2024.sharedmemory', + 'es2024.string', 'es5', 'es6', 'es7', @@ -186,13 +226,23 @@ export const libs: string[] = [ 'esnext.array', 'esnext.asynciterable', 'esnext.bigint', + 'esnext.collection', + 'esnext.decorators', + 'esnext.disposable', + 'esnext.error', + 'esnext.float16', 'esnext.intl', + 'esnext.iterator', + 'esnext.object', 'esnext.promise', + 'esnext.regexp', + 'esnext.sharedmemory', 'esnext.string', 'esnext.symbol', 'esnext.weakref', 'scripthost', 'webworker', + 'webworker.asynciterable', 'webworker.importscripts', 'webworker.iterable', ]; diff --git a/test/steps/upgrade/dependencies.test.ts b/test/steps/upgrade/dependencies.test.ts index 00e4637..d8b6cb8 100644 --- a/test/steps/upgrade/dependencies.test.ts +++ b/test/steps/upgrade/dependencies.test.ts @@ -120,20 +120,26 @@ describe('the dependencies step', () => { it('updates the build tooling in both dependencies and devDependencies', async () => { const pkg = await upgrade('js/package.json', onePackageJson); - expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.0.7'); + expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.1.0'); expect(pkg.devDependencies['flarum-tsconfig']).toBe('^2.0.0'); }); - it('requires flarum-webpack-config 3.0.7, which loads code-split chunks from the right build and exports a default export as its value', async () => { + it('requires flarum-webpack-config 3.1.0, which supplies TypeScript 5.9 and only asks for webpack as a peer', async () => { const pkg = await upgrade('js/package.json', { devDependencies: { 'flarum-webpack-config': '^3.0.0' } }); - expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.0.7'); + expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.1.0'); }); - it('moves an extension already on flarum-webpack-config 3.0.6 to 3.0.7', async () => { - const pkg = await upgrade('js/package.json', { devDependencies: { 'flarum-webpack-config': '^3.0.6' } }); + it('moves an extension already on flarum-webpack-config 3.0.7 to 3.1.0', async () => { + const pkg = await upgrade('js/package.json', { devDependencies: { 'flarum-webpack-config': '^3.0.7' } }); - expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.0.7'); + expect(pkg.devDependencies['flarum-webpack-config']).toBe('^3.1.0'); + }); + + it('requires @flarum/jest-config 2.1.0, which tests with the mithril Flarum ships', async () => { + const pkg = await upgrade('js/package.json', { devDependencies: { '@flarum/jest-config': '^2.0.0' } }); + + expect(pkg.devDependencies['@flarum/jest-config']).toBe('^2.1.0'); }); it('requires the webpack Flarum builds with', async () => { @@ -193,7 +199,7 @@ describe('the dependencies step', () => { }); expect(pkg.dependencies).toEqual({}); - expect(pkg.devDependencies).toEqual({ 'flarum-webpack-config': '^3.0.7', prettier: '^3.0.3' }); + expect(pkg.devDependencies).toEqual({ 'flarum-webpack-config': '^3.1.0', prettier: '^3.0.3' }); }); it('still drops a devDependency the extension also lists as a dependency, when it is not tooling', async () => { @@ -237,7 +243,7 @@ describe('the dependencies step', () => { expect([name, pkg.scripts[name]]).toEqual([name, scaffolded.scripts[name].replace(/<%= params\.jsPackageManager %>/g, 'yarn')]); } - expect(pkg.devDependencies.typescript).toBe('^4.9.5'); + expect(pkg.devDependencies.typescript).toBe('^5.9.3'); expect(pkg.devDependencies['typescript-coverage-report']).toBe(scaffolded.devDependencies['typescript-coverage-report']); expect(notes).toContain( 'build and check typings: clean-typings, build-typings, post-build-typings, check-typings, check-typings-coverage, typescript, typescript-coverage-report.' @@ -262,7 +268,7 @@ describe('the dependencies step', () => { it('keeps the TypeScript the extension lists, wherever it lists it', async () => { const { pkg } = await withTypings({ dependencies: { typescript: '^4.5.4' } }); - expect(pkg.devDependencies.typescript).toBe('^4.9.5'); + expect(pkg.devDependencies.typescript).toBe('^5.9.3'); expect(pkg.dependencies).not.toHaveProperty('typescript'); }); @@ -291,10 +297,11 @@ describe('the dependencies step', () => { }); describe('a new extension', () => { - it('is scaffolded with flarum-webpack-config 3.0.7 and the webpack Flarum builds with', () => { + it('is scaffolded with flarum-webpack-config 3.1.0, @flarum/jest-config 2.1.0 and the webpack Flarum builds with', () => { const scaffolded = scaffoldedPackageJson().devDependencies; - expect(scaffolded['flarum-webpack-config']).toBe('^3.0.7'); + expect(scaffolded['flarum-webpack-config']).toBe('^3.1.0'); + expect(scaffolded['@flarum/jest-config']).toBe('^2.1.0'); expect(scaffolded.webpack).toBe('^5.104.1'); }); }); diff --git a/test/steps/upgrade/typescript.test.ts b/test/steps/upgrade/typescript.test.ts index 93cbe83..748968c 100644 --- a/test/steps/upgrade/typescript.test.ts +++ b/test/steps/upgrade/typescript.test.ts @@ -5,9 +5,9 @@ import path from 'path'; import Dependencies from '../../../src/steps/upgrade/twopointoh/dependencies'; /** - * Flarum 2.0 builds and checks its typings with TypeScript 4.9 (core's - * `^4.5.4` resolves to 4.9.5). These tests pin how the upgrade brings an - * extension's TypeScript in line with that. + * Flarum builds and checks its typings with TypeScript 5.9, which + * flarum-webpack-config 3.1 supplies to extensions. These tests pin how the + * upgrade brings an extension's TypeScript in line with that. */ type Step = InstanceType; @@ -27,7 +27,7 @@ const upgradeJson = async (step: Step, file: string, content: Record - `${indent}// Only load the type packages the code imports. Others, such as the\n${indent}// @types/node webpack brings along, use syntax TypeScript 4.9 can't read.\n${indent}"types": [],\n`; + `${indent}// Only load the type packages the code imports, not every one in\n${indent}// node_modules, such as the @types/node webpack brings along.\n${indent}"types": [],\n`; /** An extension with the given js/package.json, for what the step reads from disk. */ function extensionWith(pkg: Record): string { @@ -39,6 +39,15 @@ function extensionWith(pkg: Record): string { return root; } +/** An extension with the given js/package.json and js/tsconfig.json. */ +function extensionWithTsConfig(pkg: Record, tsconfig: Record): string { + const root = extensionWith(pkg); + + fs.writeFileSync(path.join(root, 'js/tsconfig.json'), JSON.stringify(tsconfig)); + + return root; +} + const scaffolded = (file: string) => fs.readFileSync(path.resolve(__dirname, '../../../boilerplate/skeleton/extension', file), 'utf8'); // What `fl init` gave 1.x extensions, and core and every bundled extension @@ -46,43 +55,52 @@ const scaffolded = (file: string) => fs.readFileSync(path.resolve(__dirname, '.. // takes the next argument as the suffix and fails. const gnuPostBuildTypings = "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i 's,../src/@types,@types,g'"; -describe('TypeScript, which Flarum 2.0 builds with at 4.9', () => { +describe('TypeScript, which Flarum builds with at 5.9', () => { describe('the version', () => { - it('is the 4.9 Flarum builds with', async () => { - const pkg = await upgradeJson(newStep(), 'js/package.json', { devDependencies: { typescript: '^4.5.4' } }); + it.each(['^4.5.4', '4.9.5', '~4.7.4', '^5.4.5', '~5.9.2'])('raises %s to the 5.9 Flarum builds with, without a note', async (constraint) => { + const step = newStep(); + const pkg = await upgradeJson(step, 'js/package.json', { devDependencies: { typescript: constraint } }); - expect(pkg.devDependencies.typescript).toBe('^4.9.5'); + expect(pkg.devDependencies.typescript).toBe('^5.9.3'); + expect(step.pauseMessage()).not.toContain('brought down'); }); - it('comes down from TypeScript 5, saying why', async () => { - const step = newStep(); - const pkg = await upgradeJson(step, 'js/package.json', { devDependencies: { typescript: '^5.4.5' } }); + it('keeps the 5.9 a new extension is scaffolded with', async () => { + const pkg = await upgradeJson(newStep(), 'js/package.json', { devDependencies: { typescript: '^5.9.3' } }); - expect(pkg.devDependencies.typescript).toBe('^4.9.5'); - expect(step.pauseMessage()).toContain( - 'TypeScript ^5.4.5 was brought down to ^4.9.5: Flarum 2.0 builds and checks its typings with TypeScript 4.9.' - ); + expect(pkg.devDependencies.typescript).toBe('^5.9.3'); }); - it('says nothing about a 4.x it only moved to 4.9', async () => { + it.each(['^6.0.3', '^7.0.0', '>=4.2', 'latest', '*'])('brings %s, which allows TypeScript 6, down to 5.9, saying why', async (constraint) => { const step = newStep(); - await upgradeJson(step, 'js/package.json', { devDependencies: { typescript: '^4.5.4' } }); + const pkg = await upgradeJson(step, 'js/package.json', { devDependencies: { typescript: constraint } }); - expect(step.pauseMessage()).not.toContain('brought down'); + expect(pkg.devDependencies.typescript).toBe('^5.9.3'); + expect(step.pauseMessage()).toContain( + `TypeScript ${constraint} was brought down to ^5.9.3: flarum-tsconfig resolves modules the way TypeScript 6 refuses without ignoreDeprecations.` + ); }); - it.each([ - ['4.9.5', false], - ['~4.7.4', false], - ['4.x', false], - ['^5.0.0', true], - ['>=4.2', true], - ['latest', true], - ])('says why it changed %s only when that could have been a TypeScript 5', async (constraint, says) => { - const step = newStep(); - await upgradeJson(step, 'js/package.json', { devDependencies: { typescript: constraint } }); - - expect(step.pauseMessage().includes(`TypeScript ${constraint} was brought down`)).toBe(says); + it.each(['bundler', 'NodeNext', 'node16'])( + 'keeps a TypeScript 6 or newer the tsconfig already resolves modules for, with moduleResolution %s', + async (moduleResolution) => { + const pkg = { devDependencies: { typescript: '^7.0.2' } }; + const root = extensionWithTsConfig(pkg, { extends: 'flarum-tsconfig', compilerOptions: { moduleResolution } }); + const step = new Dependencies({} as any, root, 1, 1, false); + + expect((await upgradeJson(step, 'js/package.json', pkg)).devDependencies.typescript).toBe('^7.0.2'); + expect(step.pauseMessage()).toContain('Kept, as they already ask for newer versions than Flarum 2.0 needs: typescript ^7.0.2.'); + expect(step.pauseMessage()).not.toContain('brought down'); + } + ); + + it('brings a TypeScript 6 or newer down when the tsconfig still sets baseUrl, which TypeScript 6 refuses', async () => { + const pkg = { devDependencies: { typescript: '^7.0.2' } }; + const root = extensionWithTsConfig(pkg, { compilerOptions: { moduleResolution: 'bundler', baseUrl: '.' } }); + const step = new Dependencies({} as any, root, 1, 1, false); + + expect((await upgradeJson(step, 'js/package.json', pkg)).devDependencies.typescript).toBe('^5.9.3'); + expect(step.pauseMessage()).toContain('TypeScript ^7.0.2 was brought down to ^5.9.3'); }); it('is not added to an extension without it', async () => { @@ -92,21 +110,21 @@ describe('TypeScript, which Flarum 2.0 builds with at 4.9', () => { }); it('is the version a new extension is scaffolded with', () => { - expect(JSON.parse(scaffolded('js/package.json')).devDependencies.typescript).toBe('^4.9.5'); + expect(JSON.parse(scaffolded('js/package.json')).devDependencies.typescript).toBe('^5.9.3'); }); }); describe('in tsconfig.json', () => { - it('drops the compiler options TypeScript 4.9 does not have, and says so', async () => { + it('drops the compiler options, and values, TypeScript 5.9 refuses, and says so', async () => { const step = newStep(); const tsconfig = await upgradeJson(step, 'js/tsconfig.json', { extends: 'flarum-tsconfig', include: ['src/**/*'], compilerOptions: { - moduleResolution: 'bundler', - verbatimModuleSyntax: true, - allowImportingTsExtensions: true, - target: 'ES2023', + suppressImplicitAnyIndexErrors: true, + keyofStringsOnly: true, + importsNotUsedAsValues: 'error', + target: 'ES3', lib: ['DOM', 'es2023'], declarationDir: './dist-typings', baseUrl: '.', @@ -116,25 +134,31 @@ describe('TypeScript, which Flarum 2.0 builds with at 4.9', () => { expect(tsconfig.compilerOptions).toEqual({ types: [], - lib: ['DOM'], + lib: ['DOM', 'es2023'], declarationDir: './dist-typings', baseUrl: '.', paths: { 'flarum/*': ['../vendor/flarum/core/js/dist-typings/*'] }, }); expect(step.pauseMessage()).toContain( - 'Removed from js/tsconfig.json, as TypeScript 4.9 does not have them: moduleResolution "bundler", verbatimModuleSyntax, allowImportingTsExtensions, target "ES2023", lib "es2023".' + 'Removed from js/tsconfig.json, as TypeScript 5.9 refuses them: suppressImplicitAnyIndexErrors, keyofStringsOnly, importsNotUsedAsValues, target "ES3".' ); }); - it('keeps the values TypeScript 4.9 has, however they are written', async () => { - const tsconfig = await upgradeJson(newStep(), 'js/tsconfig.json', { - compilerOptions: { target: 'ESNext', moduleResolution: 'Node', module: 'ES2020', jsx: 'preserve' }, - }); - - expect(tsconfig.compilerOptions).toEqual({ types: [], target: 'ESNext', moduleResolution: 'Node', module: 'ES2020', jsx: 'preserve' }); + it('keeps the options and values TypeScript 5.9 has, however they are written', async () => { + const options = { + target: 'ES2023', + moduleResolution: 'Bundler', + module: 'ESNext', + jsx: 'preserve', + verbatimModuleSyntax: true, + allowImportingTsExtensions: true, + }; + const tsconfig = await upgradeJson(newStep(), 'js/tsconfig.json', { compilerOptions: options }); + + expect(tsconfig.compilerOptions).toEqual({ types: [], ...options }); }); - it('leaves a tsconfig TypeScript 4.9 can read as it was, but for the type packages it loads', async () => { + it('leaves a tsconfig TypeScript 5.9 can read as it was, but for the type packages it loads', async () => { const step = newStep(); const code = `{ // Use Flarum's shared TypeScript settings. @@ -221,80 +245,25 @@ describe('TypeScript, which Flarum 2.0 builds with at 4.9', () => { expect(test).toContain('"types": ["jest"]'); }); - it('flags a list of configs to extend, which TypeScript 4.9 cannot read', async () => { + it('keeps a list of configs to extend, which TypeScript 5 reads', async () => { const step = newStep(); const tsconfig = await upgradeJson(step, 'js/tsconfig.json', { extends: ['flarum-tsconfig', './tsconfig.paths.json'] }); expect(tsconfig.extends).toEqual(['flarum-tsconfig', './tsconfig.paths.json']); - expect((step as any).followUps).toEqual([ - { - file: 'js/tsconfig.json', - line: null, - text: 'It extends a list of configs, which TypeScript 4.9 cannot read. Extend one, and move the rest into it.', - }, - ]); + expect((step as any).followUps).toEqual([]); }); }); - describe('syntax TypeScript 4.9 cannot compile', () => { - const todo = (what: string) => `// @TODO: Flarum 2.0 builds with TypeScript 4.9, which has no ${what}.`; - - it('is flagged where it is used, and nothing else changes', async () => { - const step = newStep(); - const code = `import data from './data.json' with { type: 'json' }; -export type * from './types'; - -export function first(items: T) { - using handle = open(); - - return items[0]; -} -`; - - expect(await upgrade(step, 'js/src/forum/utils.ts', code)).toBe(`${todo('import attributes (TypeScript 5.3); use `assert` instead of `with`')} -import data from './data.json' with { type: 'json' }; -${todo('`export type *` (TypeScript 5.0)')} -export type * from './types'; - -${todo('`const` type parameters (TypeScript 5.0)')} -export function first(items: T) { - ${todo('`using` declarations (TypeScript 5.2)')} - using handle = open(); - - return items[0]; -} -`); - expect(step.pauseMessage()).toContain('Some TypeScript uses syntax 4.9 cannot compile; look for the @TODO comments.'); - }); - - it('leaves what TypeScript 4.9 has alone', async () => { - const step = newStep(); - const code = `import data from './data.json' assert { type: 'json' }; - -@decorate -export class Thing { - accessor count = 0; - settings = { a: 1 } satisfies Record; -} -`; - - expect(await upgrade(step, 'js/src/forum/Thing.tsx', code)).toBe(code); - expect(step.pauseMessage()).not.toContain('@TODO'); + describe('syntax TypeScript 5 added', () => { + it('is left alone, as Flarum builds with TypeScript 5.9', () => { + expect(newStep().replacements('js/src/forum/utils.ts')).toEqual([]); }); - it('is flagged once, however often the step runs', async () => { - const code = `export function first(item: T) { - return item; -} -`; - const once = await upgrade(newStep(), 'js/src/forum/utils.ts', code); - - expect(once.match(/@TODO/g)).toHaveLength(1); - expect(await upgrade(newStep(), 'js/src/forum/utils.ts', once)).toBe(once); - }); + it('is not looked for in the extension’s TypeScript', () => { + const targets = newStep().targets(); - it('is looked for in the extension’s TypeScript', () => { - expect(newStep().targets()).toEqual(expect.arrayContaining(['js/tsconfig.json', 'js/src/**/*.ts', 'js/src/**/*.tsx'])); + expect(targets).toEqual(expect.arrayContaining(['js/tsconfig.json'])); + expect(targets).not.toContain('js/src/**/*.ts'); }); });