Parcourir la source

Merge pull request #27958 from overleaf/ar-change-esm-codemod-to-use-vitest-and-general-refactor

[web] change esm codemod to use vitest and general refactor

GitOrigin-RevId: 7f8c699b160ee0b7ff991d6284cb126165694c4f
Andrew Rumble il y a 11 mois
Parent
commit
eda1bd697f

+ 1 - 0
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',
       },
     },
     {

+ 1 - 1
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,
       })

+ 44 - 29
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,
+}
+
+if (dryRun) {
+  console.log('Dry run mode enabled. No changes will be made.')
 }
 
-await Runner.run(cjsTransform, files, config)
-await Runner.run(exportsTransform, files, config)
-await Runner.run(overleafTransform, files, config)
+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`
+    )
+  }
 }

+ 33 - 0
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',
+  })
+}

+ 14 - 0
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()
+}

+ 54 - 0
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
+}

+ 46 - 0
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',
+  })
+}

+ 13 - 0
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,
+}

+ 17 - 0
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"
+

+ 0 - 230
services/web/transform/cjs-to-esm/overleaf-es-codemod.js

@@ -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 <file>` 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',
-  })
-}

+ 3 - 1
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

+ 88 - 0
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<ThisExpression>} thisPath - The path to the 'this' expression.
+ * @param {ASTPath<Function>} 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<Function> */) => {
+    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' })
+}

+ 55 - 0
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' })
+}

+ 137 - 0
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' })
+}

+ 105 - 0
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',
+  })
+}

+ 91 - 0
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`)
+  }
+}