Эх сурвалжийг харах

Merge pull request #10938 from overleaf/em-esm-spelling

Migrate spelling to ES modules

GitOrigin-RevId: 4a200c8d1c28be44027cc8a3097e42575ab6593f
Eric Mc Sween 3 жил өмнө
parent
commit
f6c1e2738d

+ 2 - 2
package-lock.json

@@ -37903,8 +37903,8 @@
       "devDependencies": {
         "chai": "^4.3.6",
         "chai-as-promised": "^7.1.1",
+        "esmock": "^2.1.0",
         "mocha": "^8.4.0",
-        "sandboxed-module": "2.0.4",
         "sinon": "^9.2.4"
       }
     },
@@ -48268,11 +48268,11 @@
         "bunyan": "^1.8.15",
         "chai": "^4.3.6",
         "chai-as-promised": "^7.1.1",
+        "esmock": "^2.1.0",
         "express": "^4.17.1",
         "lru-cache": "^5.1.1",
         "mocha": "^8.4.0",
         "request": "^2.88.2",
-        "sandboxed-module": "2.0.4",
         "sinon": "^9.2.4",
         "underscore": "1.13.1"
       },

+ 21 - 50
services/spelling/app.js

@@ -1,51 +1,22 @@
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * DS103: Rewrite code to no longer use __guard__
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const metrics = require('@overleaf/metrics')
-metrics.initialize('spelling')
-
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-logger.initialize('spelling')
-if ((Settings.sentry != null ? Settings.sentry.dsn : undefined) != null) {
-  logger.initializeErrorReporting(Settings.sentry.dsn)
-}
-metrics.memory.monitor(logger)
-
-const SpellingAPIController = require('./app/js/SpellingAPIController')
-const express = require('express')
-const app = express()
-metrics.injectMetricsRoute(app)
-const bodyParser = require('body-parser')
-const HealthCheckController = require('./app/js/HealthCheckController')
-
-app.use(bodyParser.json({ limit: '2mb' }))
-app.use(metrics.http.monitor(logger))
-
-app.post('/user/:user_id/check', SpellingAPIController.check)
-app.get('/status', (req, res) => res.send({ status: 'spelling api is up' }))
-
-app.get('/health_check', HealthCheckController.healthCheck)
-
-const settings =
-  Settings.internal && Settings.internal.spelling
-    ? Settings.internal.spelling
-    : undefined
-const host = settings && settings.host ? settings.host : 'localhost'
-const port = settings && settings.port ? settings.port : 3005
-
-if (!module.parent) {
-  // application entry point, called directly
-  app.listen(port, host, function (error) {
-    if (error != null) {
-      throw error
-    }
-    return logger.debug(`spelling starting up, listening on ${host}:${port}`)
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import { app } from './app/js/server.js'
+import * as ASpell from './app/js/ASpell.js'
+
+const { host = 'localhost', port = 3005 } = Settings.internal?.spelling ?? {}
+
+ASpell.startCacheDump()
+
+const server = app.listen(port, host, function (error) {
+  if (error) {
+    throw error
+  }
+  logger.info({ host, port }, 'spelling HTTP server starting up')
+})
+
+process.on('SIGTERM', () => {
+  ASpell.stopCacheDump()
+  server.close(() => {
+    logger.info({ host, port }, 'spelling HTTP server closed')
   })
-}
-
-module.exports = app
+})

+ 52 - 46
services/spelling/app/js/ASpell.js

@@ -7,14 +7,16 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const ASpellWorkerPool = require('./ASpellWorkerPool')
-const LRU = require('lru-cache')
-const logger = require('@overleaf/logger')
-const fs = require('fs')
-const settings = require('@overleaf/settings')
-const Path = require('path')
-const { promisify } = require('util')
-const OError = require('@overleaf/o-error')
+import fs from 'node:fs'
+import Path from 'node:path'
+import { promisify } from 'node:util'
+import LRU from 'lru-cache'
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import OError from '@overleaf/o-error'
+import { ASpellWorkerPool } from './ASpellWorkerPool.js'
+
+let ASPELL_TIMEOUT = 10000
 
 const OneMinute = 60 * 1000
 const opts = { max: 10000, maxAge: OneMinute * 60 * 10 }
@@ -23,6 +25,8 @@ const cache = new LRU(opts)
 const cacheFsPath = Path.resolve(settings.cacheDir, 'spell.cache')
 const cacheFsPathTmp = cacheFsPath + '.tmp'
 
+const WorkerPool = new ASpellWorkerPool()
+
 // load any existing cache
 try {
   const oldCache = fs.readFileSync(cacheFsPath)
@@ -33,24 +37,31 @@ try {
   )
 }
 
-// write the cache every 30 minutes
-const cacheDump = setInterval(function () {
-  const dump = JSON.stringify(cache.dump())
-  return fs.writeFile(cacheFsPathTmp, dump, function (err) {
-    if (err != null) {
-      logger.debug(OError.tag(err, 'error writing cache file'))
-      fs.unlink(cacheFsPathTmp, () => {})
-    } else {
-      fs.rename(cacheFsPathTmp, cacheFsPath, err => {
-        if (err) {
-          logger.error(OError.tag(err, 'error renaming cache file'))
-        } else {
-          logger.debug({ len: dump.length, cacheFsPath }, 'wrote cache file')
-        }
-      })
-    }
-  })
-}, 30 * OneMinute)
+let cacheDumpInterval
+export function startCacheDump() {
+  // write the cache every 30 minutes
+  cacheDumpInterval = setInterval(function () {
+    const dump = JSON.stringify(cache.dump())
+    return fs.writeFile(cacheFsPathTmp, dump, function (err) {
+      if (err != null) {
+        logger.debug(OError.tag(err, 'error writing cache file'))
+        fs.unlink(cacheFsPathTmp, () => {})
+      } else {
+        fs.rename(cacheFsPathTmp, cacheFsPath, err => {
+          if (err) {
+            logger.error(OError.tag(err, 'error renaming cache file'))
+          } else {
+            logger.debug({ len: dump.length, cacheFsPath }, 'wrote cache file')
+          }
+        })
+      }
+    })
+  }, 30 * OneMinute)
+}
+
+export function stopCacheDump() {
+  clearInterval(cacheDumpInterval)
+}
 
 class ASpellRunner {
   checkWords(language, words, callback) {
@@ -159,33 +170,28 @@ class ASpellRunner {
     words = Object.keys(newWord)
 
     if (words.length) {
-      return WorkerPool.check(language, words, ASpell.ASPELL_TIMEOUT, callback)
+      return WorkerPool.check(language, words, ASPELL_TIMEOUT, callback)
     } else {
       return callback(null, '')
     }
   }
 }
 
-const ASpell = {
-  // The description of how to call aspell from another program can be found here:
-  // http://aspell.net/man-html/Through-A-Pipe.html
-  checkWords(language, words, callback) {
-    if (callback == null) {
-      callback = () => {}
-    }
-    const runner = new ASpellRunner()
-    return runner.checkWords(language, words, callback)
-  },
-  ASPELL_TIMEOUT: 10000,
+// The description of how to call aspell from another program can be found here:
+// http://aspell.net/man-html/Through-A-Pipe.html
+export function checkWords(language, words, callback) {
+  if (callback == null) {
+    callback = () => {}
+  }
+  const runner = new ASpellRunner()
+  return runner.checkWords(language, words, callback)
 }
 
-const promises = {
-  checkWords: promisify(ASpell.checkWords),
+export const promises = {
+  checkWords: promisify(checkWords),
 }
 
-ASpell.promises = promises
-
-module.exports = ASpell
-
-const WorkerPool = new ASpellWorkerPool()
-module.exports.cacheDump = cacheDump
+// for tests
+export function setTimeout(timeout) {
+  ASPELL_TIMEOUT = timeout
+}

+ 6 - 8
services/spelling/app/js/ASpellWorker.js

@@ -7,15 +7,15 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const childProcess = require('child_process')
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const _ = require('underscore')
-const OError = require('@overleaf/o-error')
+import childProcess from 'node:child_process'
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import _ from 'underscore'
+import OError from '@overleaf/o-error'
 
 const BATCH_SIZE = 100
 
-class ASpellWorker {
+export class ASpellWorker {
   constructor(language) {
     this.language = language
     this.count = 0
@@ -238,5 +238,3 @@ class ASpellWorker {
     return this.pipe.stdin.write(command + '\n')
   }
 }
-
-module.exports = ASpellWorker

+ 6 - 8
services/spelling/app/js/ASpellWorkerPool.js

@@ -7,13 +7,13 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const ASpellWorker = require('./ASpellWorker')
-const _ = require('underscore')
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const OError = require('@overleaf/o-error')
+import _ from 'underscore'
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import OError from '@overleaf/o-error'
+import { ASpellWorker } from './ASpellWorker.js'
 
-class ASpellWorkerPool {
+export class ASpellWorkerPool {
   static initClass() {
     this.prototype.MAX_REQUESTS = 100 * 1024
     this.prototype.MAX_WORKERS = 32
@@ -112,5 +112,3 @@ class ASpellWorkerPool {
   }
 }
 ASpellWorkerPool.initClass()
-
-module.exports = ASpellWorkerPool

+ 32 - 34
services/spelling/app/js/HealthCheckController.js

@@ -1,39 +1,37 @@
-const request = require('request')
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const OError = require('@overleaf/o-error')
+import request from 'request'
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import OError from '@overleaf/o-error'
 
-module.exports = {
-  healthCheck(req, res) {
-    const opts = {
-      url: `http://localhost:3005/user/${settings.healthCheckUserId}/check`,
-      json: {
-        words: ['helllo'],
-        language: 'en',
-      },
-      timeout: 1000 * 20,
+export function healthCheck(req, res) {
+  const opts = {
+    url: `http://localhost:3005/user/${settings.healthCheckUserId}/check`,
+    json: {
+      words: ['helllo'],
+      language: 'en',
+    },
+    timeout: 1000 * 20,
+  }
+  return request.post(opts, function (err, response, body) {
+    if (err != null) {
+      return res.sendStatus(500)
     }
-    return request.post(opts, function (err, response, body) {
-      if (err != null) {
-        return res.sendStatus(500)
-      }
 
-      const misspellings =
-        body && body.misspellings ? body.misspellings[0] : undefined
-      const numberOfSuggestions =
-        misspellings && misspellings.suggestions
-          ? misspellings.suggestions.length
-          : 0
+    const misspellings =
+      body && body.misspellings ? body.misspellings[0] : undefined
+    const numberOfSuggestions =
+      misspellings && misspellings.suggestions
+        ? misspellings.suggestions.length
+        : 0
 
-      if (numberOfSuggestions > 10) {
-        logger.debug('health check passed')
-        res.sendStatus(200)
-      } else {
-        logger.err(
-          new OError('health check failed', { body, numberOfSuggestions })
-        )
-        res.sendStatus(500)
-      }
-    })
-  },
+    if (numberOfSuggestions > 10) {
+      logger.debug('health check passed')
+      res.sendStatus(200)
+    } else {
+      logger.err(
+        new OError('health check failed', { body, numberOfSuggestions })
+      )
+      res.sendStatus(500)
+    }
+  })
 }

+ 22 - 25
services/spelling/app/js/SpellingAPIController.js

@@ -1,31 +1,28 @@
-const SpellingAPIManager = require('./SpellingAPIManager')
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const OError = require('@overleaf/o-error')
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import OError from '@overleaf/o-error'
+import * as SpellingAPIManager from './SpellingAPIManager.js'
 
 function extractCheckRequestData(req) {
-  const token = req.params ? req.params.user_id : undefined
-  const wordCount =
-    req.body && req.body.words ? req.body.words.length : undefined
+  const token = req.params?.user_id
+  const wordCount = req.body?.words?.length
   return { token, wordCount }
 }
 
-module.exports = {
-  check(req, res) {
-    metrics.inc('spelling-check', 0.1)
-    const { token, wordCount } = extractCheckRequestData(req)
-    logger.debug({ token, wordCount }, 'running check')
-    SpellingAPIManager.runRequest(token, req.body, function (error, result) {
-      if (error != null) {
-        logger.error(
-          OError.tag(error, 'error processing spelling request', {
-            user_id: token,
-            wordCount,
-          })
-        )
-        return res.sendStatus(500)
-      }
-      res.send(result)
-    })
-  },
+export function check(req, res) {
+  metrics.inc('spelling-check', 0.1)
+  const { token, wordCount } = extractCheckRequestData(req)
+  logger.debug({ token, wordCount }, 'running check')
+  SpellingAPIManager.runRequest(token, req.body, (error, result) => {
+    if (error != null) {
+      logger.error(
+        OError.tag(error, 'error processing spelling request', {
+          user_id: token,
+          wordCount,
+        })
+      )
+      return res.sendStatus(500)
+    }
+    res.send(result)
+  })
 }

+ 14 - 19
services/spelling/app/js/SpellingAPIManager.js

@@ -6,31 +6,26 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const ASpell = require('./ASpell')
-const { callbackify } = require('util')
-const OError = require('@overleaf/o-error')
+import { callbackify } from 'node:util'
+import OError from '@overleaf/o-error'
+import * as ASpell from './ASpell.js'
 
 // The max number of words checked in a single request
 const REQUEST_LIMIT = 10000
 
-const SpellingAPIManager = {}
+export const promises = {}
 
-const promises = {
-  async runRequest(token, request) {
-    if (!request.words) {
-      throw new OError('malformed JSON')
-    }
-    const lang = request.language || 'en'
+promises.runRequest = async (token, request) => {
+  if (!request.words) {
+    throw new OError('malformed JSON')
+  }
+  const lang = request.language || 'en'
 
-    // only the first 10K words are checked
-    const wordSlice = request.words.slice(0, REQUEST_LIMIT)
+  // only the first 10K words are checked
+  const wordSlice = request.words.slice(0, REQUEST_LIMIT)
 
-    const misspellings = await ASpell.promises.checkWords(lang, wordSlice)
-    return { misspellings }
-  },
+  const misspellings = await ASpell.promises.checkWords(lang, wordSlice)
+  return { misspellings }
 }
 
-SpellingAPIManager.runRequest = callbackify(promises.runRequest)
-SpellingAPIManager.promises = promises
-
-module.exports = SpellingAPIManager
+export const runRequest = callbackify(promises.runRequest)

+ 26 - 0
services/spelling/app/js/server.js

@@ -0,0 +1,26 @@
+import metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import express from 'express'
+import bodyParser from 'body-parser'
+import * as SpellingAPIController from './SpellingAPIController.js'
+import * as HealthCheckController from './HealthCheckController.js'
+
+metrics.initialize('spelling')
+logger.initialize('spelling')
+if (Settings.sentry?.dsn != null) {
+  logger.initializeErrorReporting(Settings.sentry.dsn)
+}
+metrics.memory.monitor(logger)
+
+export const app = express()
+
+metrics.injectMetricsRoute(app)
+
+app.use(bodyParser.json({ limit: '2mb' }))
+app.use(metrics.http.monitor(logger))
+
+app.post('/user/:user_id/check', SpellingAPIController.check)
+app.get('/status', (req, res) => res.send({ status: 'spelling api is up' }))
+
+app.get('/health_check', HealthCheckController.healthCheck)

+ 0 - 0
services/spelling/config/settings.defaults.js → services/spelling/config/settings.defaults.cjs


+ 4 - 3
services/spelling/package.json

@@ -3,12 +3,13 @@
   "description": "A JSON API wrapper around aspell",
   "private": true,
   "main": "app.js",
+  "type": "module",
   "scripts": {
     "compile:app": "([ -e app/coffee ] && coffee -m $COFFEE_OPTIONS -o app/js -c app/coffee || echo 'No CoffeeScript folder to compile') && ( [ -e app.coffee ] && coffee -m $COFFEE_OPTIONS -c app.coffee || echo 'No CoffeeScript app to compile')",
     "start": "node $NODE_APP_OPTIONS app.js",
-    "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
+    "test:acceptance:_run": "LOG_LEVEL=fatal mocha --loader=esmock --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js",
     "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP",
-    "test:unit:_run": "mocha --recursive --reporter spec $@ test/unit/js",
+    "test:unit:_run": "LOG_LEVEL=fatal mocha --loader=esmock --recursive --reporter spec $@ test/unit/js",
     "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
     "compile:unit_tests": "[ ! -e test/unit/coffee ] &&  echo 'No unit tests to compile' || coffee -o test/unit/js -c test/unit/coffee",
     "compile:acceptance_tests": "[ ! -e test/acceptance/coffee ] && echo 'No acceptance tests to compile' || coffee -o test/acceptance/js -c test/acceptance/coffee",
@@ -37,8 +38,8 @@
   "devDependencies": {
     "chai": "^4.3.6",
     "chai-as-promised": "^7.1.1",
+    "esmock": "^2.1.0",
     "mocha": "^8.4.0",
-    "sandboxed-module": "2.0.4",
     "sinon": "^9.2.4"
   }
 }

+ 2 - 2
services/spelling/test/acceptance/js/CheckTest.js

@@ -1,5 +1,5 @@
-const { expect } = require('chai')
-const request = require('./helpers/request')
+import { expect } from 'chai'
+import * as request from './helpers/request.js'
 
 const USER_ID = 101
 

+ 2 - 2
services/spelling/test/acceptance/js/HealthCheckTest.js

@@ -1,5 +1,5 @@
-const { expect } = require('chai')
-const request = require('./helpers/request')
+import { expect } from 'chai'
+import * as request from './helpers/request.js'
 
 describe('/health_check', function () {
   it('should return 200', async function () {

+ 4 - 3
services/spelling/test/acceptance/js/Init.js

@@ -1,5 +1,6 @@
-const App = require('../../../app.js')
-const { PORT } = require('./helpers/request')
+import { app } from '../../../app/js/server.js'
+import { PORT } from './helpers/request.js'
+
 before(function (done) {
-  return App.listen(PORT, 'localhost', done)
+  return app.listen(PORT, 'localhost', done)
 })

+ 2 - 2
services/spelling/test/acceptance/js/StatusTest.js

@@ -1,5 +1,5 @@
-const { expect } = require('chai')
-const request = require('./helpers/request')
+import { expect } from 'chai'
+import * as request from './helpers/request.js'
 
 describe('/status', function () {
   it('should return 200', async function () {

+ 7 - 9
services/spelling/test/acceptance/js/helpers/request.js

@@ -1,10 +1,11 @@
-const { promisify } = require('util')
+import { promisify } from 'util'
+import Request from 'request'
 
-const PORT = 3005
+export const PORT = 3005
 
 const BASE_URL = `http://${process.env.HTTP_TEST_HOST || 'localhost'}:${PORT}`
 
-const request = require('request').defaults({
+const request = Request.defaults({
   baseUrl: BASE_URL,
   headers: {
     'Content-Type': 'application/json',
@@ -12,9 +13,6 @@ const request = require('request').defaults({
   followRedirect: false,
 })
 
-module.exports = {
-  PORT,
-  get: promisify(request.get),
-  post: promisify(request.post),
-  del: promisify(request.del),
-}
+export const get = promisify(request.get)
+export const post = promisify(request.post)
+export const del = promisify(request.del)

+ 1 - 18
services/spelling/test/setup.js

@@ -1,21 +1,4 @@
-const chai = require('chai')
-const SandboxedModule = require('sandboxed-module')
+import chai from 'chai'
 
 // Chai configuration
 chai.should()
-
-// SandboxedModule configuration
-SandboxedModule.configure({
-  requires: {
-    '@overleaf/logger': {
-      debug() {},
-      log() {},
-      info() {},
-      warn() {},
-      err() {},
-      error() {},
-      fatal() {},
-    },
-  },
-  globals: { Buffer, JSON, console, process },
-})

+ 3 - 3
services/spelling/test/stress/js/stressTest.js

@@ -13,9 +13,9 @@
 // send P correct words and Q incorrect words
 // generate incorrect words by qq+random
 
-const async = require('async')
-const request = require('request')
-const fs = require('fs')
+import fs from 'node:fs'
+import async from 'async'
+import request from 'request'
 
 // created with
 // aspell -d en dump master | aspell -l en expand | shuf -n 150000 > words.txt

+ 10 - 15
services/spelling/test/unit/js/ASpellTests.js

@@ -8,22 +8,17 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect, assert } = require('chai')
-const SandboxedModule = require('sandboxed-module')
+import { expect, assert } from 'chai'
+import esmock from 'esmock'
 
 describe('ASpell', function () {
-  beforeEach(function () {
-    return (this.ASpell = SandboxedModule.require('../../../app/js/ASpell', {
-      requires: {
-        '@overleaf/metrics': {
-          gauge() {},
-          inc() {},
-        },
+  beforeEach(async function () {
+    this.ASpell = await esmock('../../../app/js/ASpell', {
+      '@overleaf/metrics': {
+        gauge() {},
+        inc() {},
       },
-    }))
-  })
-  afterEach(function () {
-    clearInterval(this.ASpell.cacheDump)
+    })
   })
 
   describe('a correctly spelled word', function () {
@@ -114,7 +109,7 @@ describe('ASpell', function () {
   return describe('when the request times out', function () {
     beforeEach(function (done) {
       const words = __range__(0, 1000, true).map(i => 'abcdefg')
-      this.ASpell.ASPELL_TIMEOUT = 1
+      this.ASpell.setTimeout(1)
       this.start = Date.now()
       return this.ASpell.checkWords('en', words, (error, result) => {
         expect(error).to.exist
@@ -128,7 +123,7 @@ describe('ASpell', function () {
     // or the CI server.
     return it('should return in reasonable time', function () {
       const delta = Date.now() - this.start
-      return delta.should.be.below(this.ASpell.ASPELL_TIMEOUT + 1000)
+      return delta.should.be.below(1000)
     })
   })
 })

+ 29 - 35
services/spelling/test/unit/js/ASpellWorkerTests.js

@@ -1,45 +1,39 @@
-/* eslint-disable
-    no-undef
-*/
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const EventEmitter = require('events')
+import sinon from 'sinon'
+import { expect } from 'chai'
+import esmock from 'esmock'
+import EventEmitter from 'events'
 
 describe('ASpellWorker', function () {
-  beforeEach(function () {
-    this.child_process = {}
-    return (this.ASpellWorker = SandboxedModule.require(
-      '../../../app/js/ASpellWorker',
-      {
-        requires: {
-          '@overleaf/metrics': {
-            gauge() {},
-            inc() {},
-          },
-          child_process: this.child_process,
-        },
-      }
-    ))
+  beforeEach(async function () {
+    this.pipe = {
+      stdout: new EventEmitter(),
+      stderr: { on: sinon.stub() },
+      stdin: { on: sinon.stub() },
+      on: sinon.stub(),
+      pid: 12345,
+    }
+    this.pipe.stdout.setEncoding = sinon.stub()
+    this.child_process = {
+      spawn: sinon.stub().returns(this.pipe),
+    }
+    const { ASpellWorker } = await esmock('../../../app/js/ASpellWorker', {
+      '@overleaf/metrics': {
+        gauge() {},
+        inc() {},
+      },
+      child_process: this.child_process,
+    })
+    this.ASpellWorker = ASpellWorker
   })
 
   describe('creating a worker', function () {
     beforeEach(function () {
-      this.pipe = {
-        stdout: new EventEmitter(),
-        stderr: { on: sinon.stub() },
-        stdin: { on: sinon.stub() },
-        on: sinon.stub(),
-        pid: 12345,
-      }
-      this.child_process.spawn = sinon.stub().returns(this.pipe)
-      this.pipe.stdout.setEncoding = sinon.stub()
-      worker = new this.ASpellWorker('en')
+      this.worker = new this.ASpellWorker('en')
     })
 
     describe('with normal aspell output', function () {
       beforeEach(function () {
-        this.callback = worker.callback = sinon.stub()
+        this.callback = this.worker.callback = sinon.stub()
         this.pipe.stdout.emit('data', '& hello\n')
         this.pipe.stdout.emit('data', '& world\n')
         this.pipe.stdout.emit('data', 'en\n')
@@ -56,7 +50,7 @@ describe('ASpellWorker', function () {
 
     describe('with the aspell end marker split across chunks', function () {
       beforeEach(function () {
-        this.callback = worker.callback = sinon.stub()
+        this.callback = this.worker.callback = sinon.stub()
         this.pipe.stdout.emit('data', '& hello\n')
         this.pipe.stdout.emit('data', '& world\ne')
         this.pipe.stdout.emit('data', 'n\n')
@@ -73,7 +67,7 @@ describe('ASpellWorker', function () {
 
     describe('with the aspell end marker newline split across chunks', function () {
       beforeEach(function () {
-        this.callback = worker.callback = sinon.stub()
+        this.callback = this.worker.callback = sinon.stub()
         this.pipe.stdout.emit('data', '& hello\n')
         this.pipe.stdout.emit('data', '& world\n')
         this.pipe.stdout.emit('data', 'en')
@@ -90,7 +84,7 @@ describe('ASpellWorker', function () {
 
     describe('with everything split across chunks', function () {
       beforeEach(function () {
-        this.callback = worker.callback = sinon.stub()
+        this.callback = this.worker.callback = sinon.stub()
         '& hello\n& world\nen\n& goodbye'.split('').forEach(x => {
           this.pipe.stdout.emit('data', x)
         })

+ 22 - 32
services/spelling/test/unit/js/SpellingAPIManagerTests.js

@@ -1,48 +1,38 @@
 /* eslint-disable
     handle-callback-err
 */
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../app/js/SpellingAPIManager'
-)
+import sinon from 'sinon'
+import { expect } from 'chai'
+import esmock from 'esmock'
+
+const MODULE_PATH = '../../../app/js/SpellingAPIManager'
 
 const promiseStub = val => new Promise(resolve => resolve(val))
 
 describe('SpellingAPIManager', function () {
-  beforeEach(function () {
+  beforeEach(async function () {
     this.token = 'user-id-123'
-    this.ASpell = {}
-    this.SpellingAPIManager = SandboxedModule.require(modulePath, {
-      requires: {
-        './ASpell': this.ASpell,
-        '@overleaf/settings': { ignoredMisspellings: ['ShareLaTeX'] },
+    this.nonLearnedWords = ['some', 'words', 'htat', 'are', 'speled', 'rong']
+    this.allWords = this.nonLearnedWords
+    this.misspellings = [
+      { index: 2, suggestions: ['that'] },
+      { index: 4, suggestions: ['spelled'] },
+      { index: 5, suggestions: ['wrong', 'ring'] },
+    ]
+    this.misspellingsWithoutLearnedWords = this.misspellings.slice(0, 3)
+    this.ASpell = {
+      checkWords: sinon.stub().yields(null, this.misspellings),
+      promises: {
+        checkWords: sinon.stub().returns(promiseStub(this.misspellings)),
       },
+    }
+    this.SpellingAPIManager = await esmock(MODULE_PATH, {
+      '../../../app/js/ASpell.js': this.ASpell,
+      '@overleaf/settings': { ignoredMisspellings: ['ShareLaTeX'] },
     })
   })
 
   describe('runRequest', function () {
-    beforeEach(function () {
-      this.nonLearnedWords = ['some', 'words', 'htat', 'are', 'speled', 'rong']
-      this.allWords = this.nonLearnedWords
-      this.misspellings = [
-        { index: 2, suggestions: ['that'] },
-        { index: 4, suggestions: ['spelled'] },
-        { index: 5, suggestions: ['wrong', 'ring'] },
-      ]
-      this.misspellingsWithoutLearnedWords = this.misspellings.slice(0, 3)
-
-      this.ASpell.checkWords = (lang, word, callback) => {
-        callback(null, this.misspellings)
-      }
-      this.ASpell.promises = {
-        checkWords: sinon.stub().returns(promiseStub(this.misspellings)),
-      }
-      sinon.spy(this.ASpell, 'checkWords')
-    })
-
     describe('with sensible JSON', function () {
       beforeEach(function (done) {
         this.SpellingAPIManager.runRequest(