diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index ffaa0c96d9..3f444682bf 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -126,6 +126,7 @@ module.exports = { 'chai-expect/missing-assertion': 'error', 'chai-expect/terminating-properties': 'error', '@typescript-eslint/no-unused-expressions': 'off', + '@overleaf/require-vi-doMock-valid-path': 'error', }, }, { diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index d75a853c84..f34c654bb4 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -47,7 +47,7 @@ describe('LinkedFilesController', function () { ctx.settings = { enabledLinkedFileTypes: [] } vi.doMock( - '.../../../../app/src/Features/Authentication/SessionManager', + '../../../../app/src/Features/Authentication/SessionManager', () => ({ default: ctx.SessionManager, }) diff --git a/services/web/transform/cjs-to-esm/cjs-to-esm.mjs b/services/web/transform/cjs-to-esm/cjs-to-esm.mjs index c3b55003ea..1384cb0594 100644 --- a/services/web/transform/cjs-to-esm/cjs-to-esm.mjs +++ b/services/web/transform/cjs-to-esm/cjs-to-esm.mjs @@ -5,30 +5,34 @@ import Runner from 'jscodeshift/src/Runner.js' import { fileURLToPath } from 'node:url' import path from 'node:path' -const __dirname = fileURLToPath(new URL('.', import.meta.url)) // use minimist to get a list of files from the argv -const argv = minimist(process.argv.slice(2), { - boolean: ['usage'], +const { + dryRun, + verbose, + usage, + _: files, +} = minimist(process.argv.slice(2), { + boolean: ['dryRun', 'usage', 'verbose'], }) function printUsage() { console.log( - 'node scripts/esm-migration/cjs-to-esm.mjs [files] [--format] [--lint] [--usage]' + 'node scripts/esm-migration/cjs-to-esm.mjs [files] [--dryRun] [--format] [--lint] [--usage] [--verbose]' ) console.log( 'WARNING: this will only work in local development as important dependencies will be missing in production' ) console.log('Options:') console.log(' files: a list of files to convert') + console.log('--dryRun: do not actually run the commands, just print them') console.log('--format: run prettier to fix formatting') console.log(' --lint: run eslint to fix linting') console.log(' --usage: show this help message') + console.log('--verbose: enable verbose output') process.exit(0) } -const files = argv._ - -if (argv.usage) { +if (usage) { printUsage() } @@ -40,35 +44,46 @@ if (!Array.isArray(files) || files.length === 0) { const promisifiedExec = promisify(exec) -const cjsTransform = fileURLToPath( - import.meta.resolve('5to6-codemod/transforms/cjs.js') -) -const exportsTransform = fileURLToPath( - import.meta.resolve('5to6-codemod/transforms/exports.js') -) -const overleafTransform = fileURLToPath( - import.meta.resolve('./overleaf-es-codemod.js') -) +const transforms = [ + '5to6-codemod/transforms/cjs.js', + '5to6-codemod/transforms/exports.js', + './codemods/esmoduleDirname.js', + './codemods/addExtensions.js', + './codemods/fixMongodbImport.js', +] const config = { - output: __dirname, - silent: true, + output: import.meta.dirname, + silent: verbose, print: false, - verbose: 0, + verbose: verbose ? 1 : 0, hoist: true, + dry: dryRun, + runInBand: true, } -await Runner.run(cjsTransform, files, config) -await Runner.run(exportsTransform, files, config) -await Runner.run(overleafTransform, files, config) +if (dryRun) { + console.log('Dry run mode enabled. No changes will be made.') +} + +for (const transformPath of transforms) { + if (verbose) { + console.log(`Running transform: ${transformPath}`) + } + const transform = fileURLToPath(await import.meta.resolve(transformPath)) + await Runner.run(transform, files, config) +} const webRoot = fileURLToPath(new URL('../../', import.meta.url)) -for (const file of files) { - // move files with git mv - await promisifiedExec(`git mv ${file} ${file.replace('.js', '.mjs')}`) - const relativePath = path.relative(webRoot, file) - console.log( - `transformed ${relativePath} and renamed it to have a .mjs extension` - ) +if (!dryRun) { + for (const file of files) { + // move files with git mv + const newFileName = file.replace('.js', '.mjs') + await promisifiedExec(`git mv ${file} ${newFileName}`) + const relativePath = path.relative(webRoot, file) + console.log( + `transformed ${relativePath} and renamed it to have a .mjs extension` + ) + } } diff --git a/services/web/transform/cjs-to-esm/codemods/addExtensions.js b/services/web/transform/cjs-to-esm/codemods/addExtensions.js new file mode 100644 index 0000000000..dddd18bb5f --- /dev/null +++ b/services/web/transform/cjs-to-esm/codemods/addExtensions.js @@ -0,0 +1,33 @@ +const fs = require('node:fs') +const Path = require('node:path') + +module.exports = function (fileInfo, api) { + const j = api.jscodeshift + const root = j(fileInfo.source) + + // Add extension to relative path imports + root + .find(j.ImportDeclaration) + .filter(path => path.node.source.value.startsWith('.')) + .forEach(path => { + const importPath = path.node.source.value + const fullPathJs = Path.resolve( + Path.dirname(fileInfo.path), + `${importPath}.js` + ) + const fullPathMjs = Path.resolve( + Path.dirname(fileInfo.path), + `${importPath}.mjs` + ) + + if (fs.existsSync(fullPathJs)) { + path.node.source.value = `${importPath}.js` + } else if (fs.existsSync(fullPathMjs)) { + path.node.source.value = `${importPath}.mjs` + } + }) + + return root.toSource({ + quote: 'single', + }) +} diff --git a/services/web/transform/cjs-to-esm/codemods/esmoduleDirname.js b/services/web/transform/cjs-to-esm/codemods/esmoduleDirname.js new file mode 100644 index 0000000000..c9b995a121 --- /dev/null +++ b/services/web/transform/cjs-to-esm/codemods/esmoduleDirname.js @@ -0,0 +1,14 @@ +module.exports = function transformer(file, api) { + const j = api.jscodeshift + + return j(file.source) + .find(j.Identifier, { name: '__dirname' }) + .replaceWith( + j.memberExpression( + j.metaProperty(j.identifier('import'), j.identifier('meta')), + j.identifier('dirname'), + false + ) + ) + .toSource() +} diff --git a/services/web/transform/cjs-to-esm/codemods/fixMissingJsImports.mjs b/services/web/transform/cjs-to-esm/codemods/fixMissingJsImports.mjs new file mode 100644 index 0000000000..95aa355ff1 --- /dev/null +++ b/services/web/transform/cjs-to-esm/codemods/fixMissingJsImports.mjs @@ -0,0 +1,54 @@ +import path from 'node:path' +import fs from 'node:fs' + +/** + * @param {import('jscodeshift').FileInfo} file + * @param {import('jscodeshift').API} api + */ +module.exports = function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + let hasChanges = false + + const considerExtensionReplacement = nodePath => { + const source = nodePath.value.source + + if ( + !source || + typeof source.value !== 'string' || + !source.value.endsWith('.js') + ) { + return + } + + const importPath = source.value + const currentDirectory = path.dirname(file.path) + + const jsPath = path.resolve(currentDirectory, importPath) + if (fs.existsSync(jsPath)) { + return + } + + const mjsImportPath = importPath.replace(/\.js$/, '.mjs') + const mjsPath = path.resolve(currentDirectory, mjsImportPath) + + if (fs.existsSync(mjsPath)) { + j(nodePath).get('source').replace(j.literal(mjsImportPath)) + hasChanges = true + } + } + + const declarationTypes = [ + j.ImportDeclaration, + j.ExportNamedDeclaration, + j.ExportAllDeclaration, + ] + + declarationTypes.forEach(type => { + root + .find(type, { source: s => s !== null }) + .forEach(considerExtensionReplacement) + }) + + return hasChanges ? root.toSource({ quote: 'single' }) : null +} diff --git a/services/web/transform/cjs-to-esm/codemods/fixMongodbImport.js b/services/web/transform/cjs-to-esm/codemods/fixMongodbImport.js new file mode 100644 index 0000000000..30d3c52ab8 --- /dev/null +++ b/services/web/transform/cjs-to-esm/codemods/fixMongodbImport.js @@ -0,0 +1,46 @@ +const { getLastImport } = require('./utils') + +module.exports = function (fileInfo, api) { + const j = api.jscodeshift + const root = j(fileInfo.source) + const body = root.get().value.program.body + + // Fix mongodb-legacy import + root + .find(j.ImportDeclaration, { + source: { value: 'mongodb-legacy' }, + specifiers: [{ imported: { name: 'ObjectId' } }], + }) + .forEach(path => { + // Create new import declaration + const newImport = j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('mongodb'))], + j.literal('mongodb-legacy') + ) + + // Create new constant declaration + const newConst = j.variableDeclaration('const', [ + j.variableDeclarator( + j.objectPattern([ + j.property( + 'init', + j.identifier('ObjectId'), + j.identifier('ObjectId') + ), + ]), + j.identifier('mongodb') + ), + ]) + + // Replace the old import with the new import + j(path).replaceWith(newImport) + + // Insert the new constant declaration after the last import + const lastImportIndex = getLastImport(body) + body.splice(lastImportIndex + 1, 0, newConst) + }) + + return root.toSource({ + quote: 'single', + }) +} diff --git a/services/web/transform/cjs-to-esm/codemods/utils.js b/services/web/transform/cjs-to-esm/codemods/utils.js new file mode 100644 index 0000000000..3d702b0755 --- /dev/null +++ b/services/web/transform/cjs-to-esm/codemods/utils.js @@ -0,0 +1,13 @@ +/** + * + * @return {Node} + */ +function getLastImport(body) { + return body.reduce((lastIndex, node, index) => { + return node.type === 'ImportDeclaration' ? index : lastIndex + }, -1) +} + +module.exports = { + getLastImport, +} diff --git a/services/web/transform/cjs-to-esm/fixMissingJSImports.sh b/services/web/transform/cjs-to-esm/fixMissingJSImports.sh new file mode 100755 index 0000000000..d2edc49477 --- /dev/null +++ b/services/web/transform/cjs-to-esm/fixMissingJSImports.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname "$0") +FILES_TO_FIX=$(eslint . --format compact --no-color \ + | grep 'import/no-unresolved' \ + | cut -d':' -f1 \ + | sort -u) + +if [ -z "$FILES_TO_FIX" ]; then + echo "No files with 'import/no-unresolved' errors found. Nothing to do!" + exit 0 +fi + +echo "$FILES_TO_FIX" | xargs jscodeshift --parser=babel -t "$script_dir/codemods/fixMissingJsImports.mjs" + diff --git a/services/web/transform/cjs-to-esm/overleaf-es-codemod.js b/services/web/transform/cjs-to-esm/overleaf-es-codemod.js deleted file mode 100755 index 4b90f7691c..0000000000 --- a/services/web/transform/cjs-to-esm/overleaf-es-codemod.js +++ /dev/null @@ -1,230 +0,0 @@ -// Performs a few useful codemod transformations for Overleaf's esm migration. -// The transformations mostly address specific issues faced commonly in Overleaf's `web` service. -// * Replaces `sandboxed-module` imports with `esmock` imports. -// * Replaces `sandboxed-module` invocation with `esmock` invocation (Assumes `SandboxedModule.require` is used for the invocation). -// * Fixes `mongodb-legacy` import to use `mongodb` import and extract `ObjectId` from the import. -// * Replaces `require('path').join` with `path.join` (importing the path module if not already imported). -// * Adds `const __dirname = fileURLToPath(new URL('.', import.meta.url))` if `__dirname` is used in the file. -// * Adds `.js` or `.mjs` extension (as appropriate) to relative path imports. -// call this with `jscodeshift -t overleaf-es-codemod.js ` or using the `cjs-to-esm.js` script (which does this as the final step before formatting). - -const fs = require('node:fs') -const Path = require('node:path') - -module.exports = function (fileInfo, api) { - const j = api.jscodeshift - const root = j(fileInfo.source) - const body = root.get().value.program.body - - /** - * Conditionally adds an import statement to the top of the file if it doesn't already exist. - * @param moduleName A plain text name for the module to import (e.g. 'node:path'). - * @param specifier A jscodeshift specifier for the import statement (provides e.g. `{ promises }` from `import { promises } from 'fs'`. - * @param existingImportCheck A function that checks if a specific import statement is the one we're looking for. - */ - function addImport(moduleName, specifier, existingImportCheck) { - // Add import path from 'path' at the top if not already present - const importDeclaration = j.importDeclaration( - specifier, - j.literal(moduleName) - ) - - if (!existingImportCheck) { - existingImportCheck = node => node.source.value === moduleName - } - - const existingImport = body.find( - node => node.type === 'ImportDeclaration' && existingImportCheck(node) - ) - - if (!existingImport) { - const lastImportIndex = body.reduce((lastIndex, node, index) => { - return node.type === 'ImportDeclaration' ? index : lastIndex - }, -1) - body.splice(lastImportIndex, 0, importDeclaration) - } - } - - // Replace sandboxed-module imports - root - .find(j.ImportDeclaration, { - source: { value: 'sandboxed-module' }, - }) - .forEach(path => { - path.node.source.value = 'esmock' - if (path.node.specifiers.length > 0 && path.node.specifiers[0].local) { - path.node.specifiers[0].local.name = 'esmock' - } - }) - - // Replace sandboxedModule.require calls with awaited esmock calls - root - .find(j.CallExpression, { - callee: { - object: { name: 'SandboxedModule' }, - property: { name: 'require' }, - }, - }) - .forEach(path => { - const args = path.node.arguments - if (args.length > 0) { - const firstArg = args[0] - const esmockArgs = [firstArg] - - // Check if there's a second argument with a 'requires' property - if (args.length > 1 && args[1].type === 'ObjectExpression') { - const requiresProp = args[1].properties.find( - prop => - prop.key.name === 'requires' || prop.key.value === 'requires' - ) - - if (requiresProp) { - // Move contents of 'requires' to top level - esmockArgs.push(requiresProp.value) - } - } - - // Create the await expression with restructured arguments - const awaitExpression = j.awaitExpression( - j.callExpression( - j.memberExpression(j.identifier('esmock'), j.identifier('strict')), - esmockArgs - ) - ) - - // Replace the original call with the await expression - j(path).replaceWith(awaitExpression) - - // Find the closest function and make it async - let functionPath = path - while ((functionPath = functionPath.parent)) { - if ( - functionPath.node.type === 'FunctionDeclaration' || - functionPath.node.type === 'FunctionExpression' || - functionPath.node.type === 'ArrowFunctionExpression' - ) { - functionPath.node.async = true - break - } - } - } - }) - - // Fix mongodb-legacy import - root - .find(j.ImportDeclaration, { - source: { value: 'mongodb-legacy' }, - specifiers: [{ imported: { name: 'ObjectId' } }], - }) - .forEach(path => { - // Create new import declaration - const newImport = j.importDeclaration( - [j.importDefaultSpecifier(j.identifier('mongodb'))], - j.literal('mongodb-legacy') - ) - - // Create new constant declaration - const newConst = j.variableDeclaration('const', [ - j.variableDeclarator( - j.objectPattern([ - j.property( - 'init', - j.identifier('ObjectId'), - j.identifier('ObjectId') - ), - ]), - j.identifier('mongodb') - ), - ]) - - // Replace the old import with the new import and constant declaration - j(path).replaceWith(newImport) - path.insertAfter(newConst) - }) - - root - .find(j.CallExpression, { - callee: { - object: { callee: { name: 'require' }, arguments: [{ value: 'path' }] }, - property: { name: 'join' }, - }, - }) - .forEach(path => { - // Replace with path.join - j(path).replaceWith( - j.callExpression( - j.memberExpression(j.identifier('path'), j.identifier('join')), - path.node.arguments - ) - ) - - // Add import path from 'path' at the top if not already presen - addImport( - 'node:path', - [j.importDefaultSpecifier(j.identifier('path'))], - node => - node.source.value === 'path' || node.source.value === 'node:path' - ) - }) - - // Add const __dirname = fileURLToPath(new URL('.', import.meta.url)) if there is a usage of __dirname - const dirnameDeclaration = j.variableDeclaration('const', [ - j.variableDeclarator( - j.identifier('__dirname'), - j.callExpression(j.identifier('fileURLToPath'), [ - j.newExpression(j.identifier('URL'), [ - j.literal('.'), - j.memberExpression(j.identifier('import'), j.identifier('meta.url')), - ]), - ]) - ), - ]) - - const existingDirnameDeclaration = body.find( - node => - node.type === 'VariableDeclaration' && - node.declarations[0].id.name === '__dirname' - ) - const firstDirnameUsage = root.find(j.Identifier, { name: '__dirname' }).at(0) - - if (firstDirnameUsage.size() > 0 && !existingDirnameDeclaration) { - // Add import path from 'path' at the top if not already present - addImport( - 'node:url', - [j.importSpecifier(j.identifier('fileURLToPath'))], - node => node.source.value === 'url' || node.source.value === 'node:url' - ) - - const lastImportIndex = body.reduce((lastIndex, node, index) => { - return node.type === 'ImportDeclaration' ? index : lastIndex - }, -1) - - body.splice(lastImportIndex + 1, 0, dirnameDeclaration) - } - - // Add extension to relative path imports - root - .find(j.ImportDeclaration) - .filter(path => path.node.source.value.startsWith('.')) - .forEach(path => { - const importPath = path.node.source.value - const fullPathJs = Path.resolve( - Path.dirname(fileInfo.path), - `${importPath}.js` - ) - const fullPathMjs = Path.resolve( - Path.dirname(fileInfo.path), - `${importPath}.mjs` - ) - - if (fs.existsSync(fullPathJs)) { - path.node.source.value = `${importPath}.js` - } else if (fs.existsSync(fullPathMjs)) { - path.node.source.value = `${importPath}.mjs` - } - }) - - return root.toSource({ - quote: 'single', - }) -} diff --git a/services/web/transform/cjs-to-esm/transform-dir.sh b/services/web/transform/cjs-to-esm/transform-dir.sh index af8be580d0..d652e290fa 100755 --- a/services/web/transform/cjs-to-esm/transform-dir.sh +++ b/services/web/transform/cjs-to-esm/transform-dir.sh @@ -12,7 +12,9 @@ while true; do if [ -z "$FILES" ]; then break fi - node transform/cjs-to-esm/cjs-to-esm.mjs "$FILES" + # We want word splitting here + # shellcheck disable=SC2086 + node transform/cjs-to-esm/cjs-to-esm.mjs $FILES done make format_fix > /dev/null diff --git a/services/web/transform/vitest/codemods/convertThisToCtx.js b/services/web/transform/vitest/codemods/convertThisToCtx.js new file mode 100644 index 0000000000..cf733510ea --- /dev/null +++ b/services/web/transform/vitest/codemods/convertThisToCtx.js @@ -0,0 +1,88 @@ +/** + * @typedef {import('jscodeshift').ASTPath} ASTPath + * @typedef {import('jscodeshift').JSCodeshift} JSCodeshift + */ + +const TARGET_CALLER_NAMES = new Set([ + 'describe', + 'it', + 'before', + 'beforeEach', + 'after', + 'afterEach', +]) + +/** + * Helper function to check if a 'this' expression belongs directly to a given function scope, + * and not to a nested traditional function defined within that scope. + * @param {ASTPath} thisPath - The path to the 'this' expression. + * @param {ASTPath} targetFunctionPath - The path to the target function scope. + * @param {JSCodeshift} j - The jscodeshift instance. + * @returns {boolean} - True if 'this' belongs to the target function scope. + */ +function isThisFromScope(thisPath, targetFunctionPath, j) { + let current = thisPath.parentPath + while (current && current.node !== targetFunctionPath.node) { + if ( + (j.FunctionExpression.check(current.node) || + j.FunctionDeclaration.check(current.node)) && + current.node !== targetFunctionPath.node + ) { + return false + } + current = current.parentPath + } + return !!current && current.node === targetFunctionPath.node +} + +module.exports = function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + + const functionsToModify = new Set() + + root.find(j.CallExpression).forEach(callPath => { + const callNode = callPath.node + + if ( + j.Identifier.check(callNode.callee) && + TARGET_CALLER_NAMES.has(callNode.callee.name) + ) { + callNode.arguments.forEach((arg, index) => { + if ( + j.FunctionExpression.check(arg) || + j.FunctionDeclaration.check(arg) + ) { + const functionArgumentPath = callPath.get('arguments', index) + const containsRelevantThis = j(functionArgumentPath) + .find(j.ThisExpression) + .some(thisPath => + isThisFromScope(thisPath, functionArgumentPath, j) + ) + + if (containsRelevantThis) { + functionsToModify.add(functionArgumentPath) + } + } + }) + } + }) + + functionsToModify.forEach((functionPath /*: ASTPath */) => { + const functionNode = functionPath.node + + const hasCtxParam = functionNode.params.some( + param => j.Identifier.check(param) && param.name === 'ctx' + ) + if (!hasCtxParam) { + functionNode.params.push(j.identifier('ctx')) + } + + j(functionPath) + .find(j.ThisExpression) + .filter(thisPath => isThisFromScope(thisPath, functionPath, j)) + .replaceWith(j.identifier('ctx')) + }) + + return root.toSource({ quote: 'single' }) +} diff --git a/services/web/transform/vitest/codemods/replaceDirectChaiUsage.js b/services/web/transform/vitest/codemods/replaceDirectChaiUsage.js new file mode 100644 index 0000000000..350c76e70d --- /dev/null +++ b/services/web/transform/vitest/codemods/replaceDirectChaiUsage.js @@ -0,0 +1,55 @@ +// @ts-check + +/** + * @param {import('jscodeshift').FileInfo} file + * @param {import('jscodeshift').API} api + */ +module.exports = function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + + const chaiImportCollection = root.find(j.ImportDeclaration, { + source: { + value: 'chai', + }, + }) + + if (chaiImportCollection.length === 0) { + return root.toSource() + } + + const chaiImport = chaiImportCollection.get(0).node + const chaiSpecifiers = chaiImport.specifiers + + if (!chaiSpecifiers || chaiSpecifiers.length === 0) { + return root.toSource() + } + + const vitestImportCollection = root.find(j.ImportDeclaration, { + source: { + value: 'vitest', + }, + }) + + if (vitestImportCollection.length > 0) { + const vitestImport = vitestImportCollection.get(0).node + + const existingVitestSpecifierNames = new Set( + vitestImport.specifiers.map(specifier => specifier.imported.name) + ) + + const newSpecifiers = chaiSpecifiers.filter( + specifier => !existingVitestSpecifierNames.has(specifier.imported.name) + ) + + if (newSpecifiers.length > 0) { + vitestImport.specifiers.push(...newSpecifiers) + } + + chaiImportCollection.remove() + } else { + chaiImport.source.value = 'vitest' + } + + return root.toSource({ quote: 'single' }) +} diff --git a/services/web/transform/vitest/codemods/replaceDoneWithPromise.js b/services/web/transform/vitest/codemods/replaceDoneWithPromise.js new file mode 100644 index 0000000000..282cba7e0a --- /dev/null +++ b/services/web/transform/vitest/codemods/replaceDoneWithPromise.js @@ -0,0 +1,137 @@ +/** + * @typedef {import('jscodeshift').FileInfo} FileInfo + * @typedef {import('jscodeshift').API} API + * @typedef {import('jscodeshift').Collection} Collection + */ + +module.exports = function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + + const mochaFunctionNames = new Set([ + 'it', + 'specify', + 'before', + 'after', + 'beforeEach', + 'afterEach', + ]) + + root + .find(j.CallExpression, { + callee: { + type: 'Identifier', + name: name => mochaFunctionNames.has(name), + }, + }) + .forEach(path => { + let callbackFunctionArg = null + let funcArgIndex = -1 + + for (let i = path.node.arguments.length - 1; i >= 0; i--) { + const arg = path.node.arguments[i] + if ( + arg && + (arg.type === 'FunctionExpression' || + arg.type === 'ArrowFunctionExpression') + ) { + callbackFunctionArg = arg + funcArgIndex = i + break + } + } + + if (!callbackFunctionArg) { + return + } + + if (callbackFunctionArg.async) { + return + } + + const params = callbackFunctionArg.params + if (!params || params.length === 0) { + return + } + + const lastParam = params[params.length - 1] + if ( + !lastParam || + lastParam.type !== 'Identifier' || + lastParam.name !== 'done' + ) { + return + } + + const doneParamName = lastParam.name + + callbackFunctionArg.params.pop() + + const originalBody = callbackFunctionArg.body + + const bodyCollection = j(originalBody) + bodyCollection + .find(j.Identifier, { name: doneParamName }) + .forEach(identifierPath => { + const parentNode = identifierPath.parentPath.node + if ( + parentNode.type === 'MemberExpression' && + parentNode.property === identifierPath.node && + !parentNode.computed + ) { + return + } + + if ( + parentNode.type === 'Property' && + parentNode.key === identifierPath.node && + !parentNode.shorthand + ) { + return + } + + if ( + parentNode.type === 'LabeledStatement' && + parentNode.label === identifierPath.node + ) { + return + } + + identifierPath.node.name = 'resolve' + }) + + const resolveIdentifier = j.identifier('resolve') + let newBodyBlock + + if (originalBody.type === 'BlockStatement') { + newBodyBlock = originalBody + } else { + newBodyBlock = j.blockStatement([j.expressionStatement(originalBody)]) + } + + const promiseCallback = j.arrowFunctionExpression( + [resolveIdentifier], + newBodyBlock, + false + ) + promiseCallback.async = false + + const newPromiseExpression = j.newExpression(j.identifier('Promise'), [ + promiseCallback, + ]) + + const newFunctionBody = j.expressionStatement( + j.awaitExpression(newPromiseExpression) + ) + + callbackFunctionArg.body = j.blockStatement([newFunctionBody]) + + callbackFunctionArg.async = true + + console.log( + `Transformed function in ${file.path} (argument ${funcArgIndex} of ${path.node.callee.name})` + ) + }) + + return root.toSource({ quote: 'single' }) +} diff --git a/services/web/transform/vitest/codemods/replaceSandboxedModuleWithDoMock.js b/services/web/transform/vitest/codemods/replaceSandboxedModuleWithDoMock.js new file mode 100644 index 0000000000..172ab934a2 --- /dev/null +++ b/services/web/transform/vitest/codemods/replaceSandboxedModuleWithDoMock.js @@ -0,0 +1,105 @@ +module.exports = function (fileInfo, api) { + const j = api.jscodeshift + const root = j(fileInfo.source) + let shouldAddViImport = false + let shouldRemoveSandboxedModule = false + + root + .find(j.CallExpression, { + callee: { + object: { name: 'SandboxedModule' }, + property: { name: 'require' }, + }, + }) + .forEach(path => { + shouldRemoveSandboxedModule = true + const args = path.node.arguments + if (args.length > 0) { + const assignmentStatement = path.parentPath.parentPath + const firstArg = args[0] + + // Check if there's a second argument with a 'requires' property + if (args.length > 1 && args[1].type === 'ObjectExpression') { + const requiresProp = args[1].properties.find( + prop => + prop.key.name === 'requires' || prop.key.value === 'requires' + ) + + if (requiresProp) { + shouldAddViImport = true + + const mocks = requiresProp.value.properties.map(mock => { + const depPath = + mock.key.type === 'Literal' ? mock.key.value : mock.key.name + return j.expressionStatement( + j.callExpression(j.identifier('vi.doMock'), [ + j.literal(depPath), + j.arrowFunctionExpression( + [], + j.objectExpression([ + j.objectProperty(j.identifier('default'), mock.value), + ]) + ), + ]) + ) + }) + + j(assignmentStatement).insertBefore(mocks) + } + } + + // Create an expression to await the import of the module under test + const awaitExpression = j.memberExpression( + j.awaitExpression( + j.callExpression(j.identifier('import'), [firstArg]) + ), + j.identifier('default') + ) + + j(path).replaceWith(awaitExpression) + + // Find the closest function and make it async + let functionPath = path + while ((functionPath = functionPath.parent)) { + if ( + functionPath.node.type === 'FunctionDeclaration' || + functionPath.node.type === 'FunctionExpression' || + functionPath.node.type === 'ArrowFunctionExpression' + ) { + functionPath.node.async = true + break + } + } + } + }) + + const alreadyHasViImport = + root + .find(j.ImportDeclaration, { + source: { value: 'vitest' }, + }) + .size() > 0 + + if (shouldAddViImport && !alreadyHasViImport) { + root + .get() + .node.program.body.unshift( + j.importDeclaration( + [j.importSpecifier(j.identifier('vi'))], + j.literal('vitest') + ) + ) + } + + if (shouldRemoveSandboxedModule) { + root + .find(j.ImportDeclaration, { + source: { value: 'sandboxed-module' }, + }) + .remove() + } + + return root.toSource({ + quote: 'single', + }) +} diff --git a/services/web/transform/vitest/mocha-to-vitest.mjs b/services/web/transform/vitest/mocha-to-vitest.mjs new file mode 100644 index 0000000000..6b846c964e --- /dev/null +++ b/services/web/transform/vitest/mocha-to-vitest.mjs @@ -0,0 +1,91 @@ +import minimist from 'minimist' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import Runner from 'jscodeshift/src/Runner.js' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +// use minimist to get a list of files from the argv +const { + dryRun, + verbose, + usage, + _: files, +} = minimist(process.argv.slice(2), { + boolean: ['dryRun', 'usage', 'verbose'], +}) + +function printUsage() { + console.log( + 'node scripts/vitest/mocha-to-vitest.mjs [files] [--dryRun] [--format] [--lint] [--usage] [--verbose]' + ) + console.log( + 'WARNING: this will only work in local development as important dependencies will be missing in production' + ) + console.log('Options:') + console.log(' files: a list of files to convert') + console.log('--dryRun: do not actually run the commands, just print them') + console.log('--format: run prettier to fix formatting') + console.log(' --lint: run eslint to fix linting') + console.log(' --usage: show this help message') + console.log('--verbose: enable verbose output') + process.exit(0) +} + +if (usage) { + printUsage() +} + +if (!Array.isArray(files) || files.length === 0) { + console.error('You must provide a list of files to convert') + printUsage() + process.exit(1) +} + +const promisifiedExec = promisify(exec) + +const transforms = [ + './codemods/replaceDoneWithPromise.js', + './codemods/convertThisToCtx.js', + './codemods/replaceSandboxedModuleWithDoMock.js', + './codemods/replaceDirectChaiUsage.js', +] + +const config = { + output: import.meta.dirname, + silent: verbose, + print: false, + verbose: verbose ? 1 : 0, + hoist: true, + dry: dryRun, + runInBand: true, +} + +if (dryRun) { + console.log('Dry run mode enabled. No changes will be made.') +} + +for (const transformPath of transforms) { + if (verbose) { + console.log(`Running transform: ${transformPath}`) + } + const transform = fileURLToPath(await import.meta.resolve(transformPath)) + await Runner.run(transform, files, config) +} + +const webRoot = fileURLToPath(new URL('../../', import.meta.url)) + +if (!dryRun) { + for (const file of files) { + // move files with git mv + const newFileName = file + .replace('Tests.mjs', '.test.mjs') + .replace('Tests.js', '.test.js') + .replace('Test.js', '.test.js') + .replace('Test.mjs', '.test.mjs') + + await promisifiedExec(`git mv ${file} ${newFileName}`) + const relativePath = path.relative(webRoot, file) + console.log(`transformed ${relativePath} and renamed it for vitest`) + } +}