Skip to content

Commit

Permalink
feat: strengthened ModuleLoader & unit tests; now supports mixed ESM …
Browse files Browse the repository at this point in the history
…/ CJS plugins (#163)

* significantly strengthened ModuleLoader / Oclif can now load mixed CJS / ESM plugins.

* More complete ModuleLoader unit tests.

* Added complete ESM, mixed (CJS w/ ESM), and mixed (ESM w/ CJS) runtime tests.

* test description update

* removed unnecessary yarn.lock files

* removed to-do comments

* removed commented out old code.
  • Loading branch information
typhonrt authored May 26, 2021
1 parent 66a4867 commit 788bf17
Show file tree
Hide file tree
Showing 31 changed files with 416 additions and 48 deletions.
2 changes: 1 addition & 1 deletion src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class Plugin implements IPlugin {
}
this._debug(`loading IDs from ${this.commandsDir}`)
const patterns = [
'**/*.+(js|ts|tsx)',
'**/*.+(js|cjs|mjs|ts|tsx)',
'!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js)?(x)',
]
const ids = globby.sync(patterns, {cwd: this.commandsDir})
Expand Down
43 changes: 34 additions & 9 deletions src/module-loader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'path'
import * as url from 'url'
import * as fs from 'fs-extra'

import {ModuleLoadError} from './errors'
import {Config as IConfig} from './interfaces'
Expand All @@ -8,6 +9,11 @@ import * as Config from './config'

const getPackageType = require('get-package-type')

/**
* Defines file extension resolution when source files do not have an extension.
*/
const s_EXTENSIONS: string[] = ['.js', '.mjs', '.cjs']

/**
* Provides a mechanism to use dynamic import / import() with tsconfig -> module: commonJS as otherwise import() gets
* transpiled to require().
Expand Down Expand Up @@ -38,13 +44,15 @@ export default class ModuleLoader {
* @returns {Promise<*>} The entire ESM module from dynamic import or CJS module by require.
*/
static async load(config: IConfig|IPlugin, modulePath: string): Promise<any> {
const {isESM, filePath} = ModuleLoader.resolvePath(config, modulePath)
let filePath
let isESM
try {
({isESM, filePath} = ModuleLoader.resolvePath(config, modulePath))
// It is important to await on _importDynamic to catch the error code.
return isESM ? await _importDynamic(url.pathToFileURL(filePath)) : require(filePath)
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND') {
throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath}`)
throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath || modulePath}`)
}
throw error
}
Expand All @@ -68,13 +76,15 @@ export default class ModuleLoader {
* file path and whether the module is ESM.
*/
static async loadWithData(config: IConfig|IPlugin, modulePath: string): Promise<{isESM: boolean; module: any; filePath: string}> {
const {isESM, filePath} = ModuleLoader.resolvePath(config, modulePath)
let filePath
let isESM
try {
({isESM, filePath} = ModuleLoader.resolvePath(config, modulePath))
const module = isESM ? await _importDynamic(url.pathToFileURL(filePath)) : require(filePath)
return {isESM, module, filePath}
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND') {
throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath}`)
throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath || modulePath}`)
}
throw error
}
Expand Down Expand Up @@ -106,23 +116,38 @@ export default class ModuleLoader {

/**
* Resolves a modulePath first by `require.resolve` to allow Node to resolve an actual module. If this fails then
* the `modulePath` is resolved from the root of the provided config. `path.resolve` is used for ESM and `tsPath`
* for non-ESM paths.
* the `modulePath` is resolved from the root of the provided config. `Config.tsPath` is used for initial resolution.
* If this file path does not exist then several extensions are tried from `s_EXTENSIONS` in order: '.js', '.mjs',
* '.cjs'. After a file path has been selected `isPathModule` is used to determine if the file is an ES Module.
*
* @param {IConfig|IPlugin} config - Oclif config or plugin config.
* @param {string} modulePath - File path to load.
*
* @returns {{isESM: boolean, filePath: string}} An object including file path and whether the module is ESM.
*/
static resolvePath(config: IConfig|IPlugin, modulePath: string): {isESM: boolean; filePath: string} {
let isESM = config.pjson.type === 'module'
let filePath
let isESM: boolean
let filePath: string

try {
filePath = require.resolve(modulePath)
isESM = ModuleLoader.isPathModule(filePath)
} catch (error) {
filePath = isESM ? path.resolve(path.join(config.root, modulePath)) : Config.tsPath(config.root, modulePath)
filePath = Config.tsPath(config.root, modulePath)

// Try all supported extensions.
if (!fs.existsSync(filePath)) {
for (const extension of s_EXTENSIONS) {
const testPath = `${filePath}${extension}`

if (fs.existsSync(testPath)) {
filePath = testPath
break
}
}
}

isESM = ModuleLoader.isPathModule(filePath)
}

return {isESM, filePath}
Expand Down
52 changes: 52 additions & 0 deletions test/config/esm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as path from 'path'

import {Config} from '../../src/config'

import {expect, fancy} from './test'

const root = path.resolve(__dirname, 'fixtures/esm')
const p = (p: string) => path.join(root, p)

const withConfig = fancy
.add('config', () => Config.load(root))

describe('esm', () => {
withConfig
.it('has commandsDir', ({config}) => {
expect(config.plugins[0]).to.deep.include({
commandsDir: p('src/commands'),
})
})

withConfig
.stdout()
.it('runs esm command and prerun & postrun hooks', async ctx => {
await ctx.config.runCommand('foo:bar:baz')
expect(ctx.stdout).to.equal('running esm prerun hook\nit works!\nrunning esm postrun hook\n')
})

withConfig
.stdout()
.it('runs faulty command, only prerun hook triggers', async ctx => {
try {
await ctx.config.runCommand('foo:bar:fail')
} catch {
console.log('caught error')
}
expect(ctx.stdout).to.equal('running esm prerun hook\nit fails!\ncaught error\n')
})

withConfig
.stdout()
.it('runs esm command, postrun hook captures command result', async ctx => {
await ctx.config.runCommand('foo:bar:test-result')
expect(ctx.stdout).to.equal('running esm prerun hook\nit works!\nrunning esm postrun hook\nreturned success!\n')
})

withConfig
.stdout()
.it('runs init hook', async ctx => {
await (ctx.config.runHook as any)('init', {id: 'myid', argv: ['foo']})
expect(ctx.stdout).to.equal('running esm init hook\n')
})
})
22 changes: 22 additions & 0 deletions test/config/fixtures/esm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "esm-plugin",
"description": "Module type ESM; All ESM source",
"private": true,
"type": "module",
"files": [],
"oclif": {
"commands": "./src/commands",
"hooks": {
"init": "./src/hooks/init",
"prerun": [
"./src/hooks/prerun"
],
"postrun": [
"./src/hooks/postrun"
]
}
},
"devDependencies": {
"globby": "^8.0.1"
}
}
5 changes: 5 additions & 0 deletions test/config/fixtures/esm/src/commands/foo/bar/baz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class Command {
static run() {
console.log('it works!')
}
}
6 changes: 6 additions & 0 deletions test/config/fixtures/esm/src/commands/foo/bar/fail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class Command {
static run() {
console.log('it fails!')
throw new Error('random error')
}
}
6 changes: 6 additions & 0 deletions test/config/fixtures/esm/src/commands/foo/bar/test-result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class Command {
static run() {
console.log('it works!')
return 'returned success!'
}
}
3 changes: 3 additions & 0 deletions test/config/fixtures/esm/src/hooks/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function init() {
console.log('running esm init hook')
}
6 changes: 6 additions & 0 deletions test/config/fixtures/esm/src/hooks/postrun.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default function postrun(options) {
console.log('running esm postrun hook')
if (options.Command.id === 'foo:bar:test-result') {
console.log(options.result)
}
}
3 changes: 3 additions & 0 deletions test/config/fixtures/esm/src/hooks/prerun.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function prerun() {
console.log('running esm prerun hook')
}
21 changes: 21 additions & 0 deletions test/config/fixtures/mixed-cjs-esm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "mixed-cjs-esm-plugin",
"description": "CommonJS module, but mixed w/ ESM by .mjs extension",
"private": true,
"files": [],
"oclif": {
"commands": "./src/commands",
"hooks": {
"init": "./src/hooks/init",
"prerun": [
"./src/hooks/prerun"
],
"postrun": [
"./src/hooks/postrun"
]
}
},
"devDependencies": {
"globby": "^8.0.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class Command {
static run() {
console.log('it works!')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Command {
static run() {
console.log('it fails!')
throw new Error('random error')
}
}

module.exports = Command
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Command {
static run() {
console.log('it works!')
return 'returned success!'
}
}

module.exports = Command
5 changes: 5 additions & 0 deletions test/config/fixtures/mixed-cjs-esm/src/hooks/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function init() {
console.log('running mixed-cjs-esm init hook')
}

module.exports = init
6 changes: 6 additions & 0 deletions test/config/fixtures/mixed-cjs-esm/src/hooks/postrun.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default function postrun(options) {
console.log('running mixed-cjs-esm postrun hook')
if (options.Command.id === 'foo:bar:test-result') {
console.log(options.result)
}
}
5 changes: 5 additions & 0 deletions test/config/fixtures/mixed-cjs-esm/src/hooks/prerun.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function prerun() {
console.log('running mixed-cjs-esm prerun hook')
}

module.exports = prerun
22 changes: 22 additions & 0 deletions test/config/fixtures/mixed-esm-cjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "mixed-esm-cjs-plugin",
"description": "Module type ESM, but mixed w/ CommonJS by .cjs extension",
"private": true,
"type": "module",
"files": [],
"oclif": {
"commands": "./src/commands",
"hooks": {
"init": "./src/hooks/init",
"prerun": [
"./src/hooks/prerun"
],
"postrun": [
"./src/hooks/postrun"
]
}
},
"devDependencies": {
"globby": "^8.0.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class Command {
static run() {
console.log('it works!')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Command {
static run() {
console.log('it fails!')
throw new Error('random error')
}
}

module.exports = Command
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default class Command {
static run() {
console.log('it works!')
return 'returned success!'
}
}

3 changes: 3 additions & 0 deletions test/config/fixtures/mixed-esm-cjs/src/hooks/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function init() {
console.log('running mixed-esm-cjs init hook')
}
6 changes: 6 additions & 0 deletions test/config/fixtures/mixed-esm-cjs/src/hooks/postrun.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default function postrun(options) {
console.log('running mixed-esm-cjs postrun hook')
if (options.Command.id === 'foo:bar:test-result') {
console.log(options.result)
}
}
5 changes: 5 additions & 0 deletions test/config/fixtures/mixed-esm-cjs/src/hooks/prerun.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function prerun() {
console.log('running mixed-esm-cjs prerun hook')
}

module.exports = prerun
52 changes: 52 additions & 0 deletions test/config/mixed-cjs-esm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as path from 'path'

import {Config} from '../../src/config'

import {expect, fancy} from './test'

const root = path.resolve(__dirname, 'fixtures/mixed-cjs-esm')
const p = (p: string) => path.join(root, p)

const withConfig = fancy
.add('config', () => Config.load(root))

describe('mixed-cjs-esm', () => {
withConfig
.it('has commandsDir', ({config}) => {
expect(config.plugins[0]).to.deep.include({
commandsDir: p('src/commands'),
})
})

withConfig
.stdout()
.it('runs mixed-cjs-esm command and prerun & postrun hooks', async ctx => {
await ctx.config.runCommand('foo:bar:baz')
expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit works!\nrunning mixed-cjs-esm postrun hook\n')
})

withConfig
.stdout()
.it('runs faulty command, only prerun hook triggers', async ctx => {
try {
await ctx.config.runCommand('foo:bar:fail')
} catch {
console.log('caught error')
}
expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit fails!\ncaught error\n')
})

withConfig
.stdout()
.it('runs mixed-cjs-esm command, postrun hook captures command result', async ctx => {
await ctx.config.runCommand('foo:bar:test-result')
expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit works!\nrunning mixed-cjs-esm postrun hook\nreturned success!\n')
})

withConfig
.stdout()
.it('runs init hook', async ctx => {
await (ctx.config.runHook as any)('init', {id: 'myid', argv: ['foo']})
expect(ctx.stdout).to.equal('running mixed-cjs-esm init hook\n')
})
})
Loading

0 comments on commit 788bf17

Please sign in to comment.