diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2600df973..4721344d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - if: runner.os == 'Windows' run: yarn mocha --forbid-only "test/**/*.integration.ts" --exclude "test/integration/sf.integration.ts" --parallel --timeout 1200000 - if: runner.os == 'Linux' - run: yarn test:integration + run: yarn test:integration --retries 3 windows-sf-integration: # For whatever reason the windows-latest runner doesn't like it when you shell yarn commands in the sf repo # which is an integral part of the setup for the tests. Instead, we replicate the setup here. @@ -72,16 +72,33 @@ jobs: OCLIF_CORE_INTEGRATION_SKIP_SETUP: true OCLIF_CORE_INTEGRATION_TEST_DIR: D:\a\integration DEBUG: integration:* - esm-cjs-interop: + interoperability: needs: linux-unit-tests strategy: matrix: os: [ubuntu-latest, windows-latest] node_version: [lts/*, latest] test: [esm, cjs, precore, coreV1, coreV2] + dev_runtime: [default, bun, tsx] exclude: - os: windows-latest node_version: lts/* + - os: windows-latest + dev_runtime: tsx + - os: windows-latest + dev_runtime: bun + - test: precore + dev_runtime: tsx + - test: precore + dev_runtime: bun + - test: coreV1 + dev_runtime: tsx + - test: coreV1 + dev_runtime: bun + - test: coreV2 + dev_runtime: tsx + - test: coreV2 + dev_runtime: bun fail-fast: false runs-on: ${{ matrix.os }} timeout-minutes: 75 @@ -91,9 +108,13 @@ jobs: with: node-version: ${{ matrix.node_version }} cache: yarn + - if: matrix.dev_runtime == 'bun' + uses: oven-sh/setup-bun@v1 + - if: matrix.dev_runtime == 'tsx' + run: 'npm install -g tsx' - uses: salesforcecli/github-workflows/.github/actions/yarnInstallWithRetries@main - run: yarn build - - run: yarn test:esm-cjs --test=${{ matrix.test }} + - run: yarn test:interoperability --test=${{ matrix.test }} --dev-run-time=${{ matrix.dev_runtime }} nuts: needs: linux-unit-tests uses: salesforcecli/github-workflows/.github/workflows/externalNut.yml@main diff --git a/package.json b/package.json index 3e39d883e..f22fb3ce7 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "test:circular-deps": "madge lib/ -c", "test:debug": "nyc mocha --debug-brk --inspect \"test/**/*.test.ts\"", "test:integration": "mocha --forbid-only \"test/**/*.integration.ts\" --parallel --timeout 1200000", - "test:esm-cjs": "cross-env DEBUG=integration:* ts-node test/integration/esm-cjs.ts", + "test:interoperability": "cross-env DEBUG=integration:* ts-node test/integration/interop.ts", "test:perf": "ts-node test/perf/parser.perf.ts", "test:dev": "nyc mocha \"test/**/*.test.ts\"", "test": "nyc mocha --forbid-only \"test/**/*.test.ts\"" diff --git a/src/config/config.ts b/src/config/config.ts index fa005f84d..43988b92d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -21,7 +21,7 @@ import {requireJson, safeReadJson} from '../util/fs' import {getHomeDir, getPlatform} from '../util/os' import {compact, isProd} from '../util/util' import PluginLoader from './plugin-loader' -import {tsPath} from './ts-node' +import {tsPath} from './ts-path' import {Debug, collectUsableIds, getCommandIdPermutations} from './util' // eslint-disable-next-line new-cap diff --git a/src/config/index.ts b/src/config/index.ts index e3e23deca..1a97a888d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,3 +1,3 @@ export {Config} from './config' export {Plugin} from './plugin' -export {tsPath} from './ts-node' +export {tsPath} from './ts-path' diff --git a/src/config/plugin.ts b/src/config/plugin.ts index 681d18e97..5214f3a7c 100644 --- a/src/config/plugin.ts +++ b/src/config/plugin.ts @@ -14,7 +14,7 @@ import {cacheCommand} from '../util/cache-command' import {findRoot} from '../util/find-root' import {readJson, requireJson} from '../util/fs' import {castArray, compact} from '../util/util' -import {tsPath} from './ts-node' +import {tsPath} from './ts-path' import {Debug, getCommandIdPermutations} from './util' const _pjson = requireJson(__dirname, '..', '..', 'package.json') diff --git a/src/config/ts-node.ts b/src/config/ts-path.ts similarity index 75% rename from src/config/ts-node.ts rename to src/config/ts-path.ts index abbb3c904..029a0af4b 100644 --- a/src/config/ts-node.ts +++ b/src/config/ts-path.ts @@ -10,11 +10,50 @@ import {readTSConfig} from '../util/read-tsconfig' import {isProd} from '../util/util' import {Debug} from './util' // eslint-disable-next-line new-cap -const debug = Debug('ts-node') +const debug = Debug('ts-path') export const TS_CONFIGS: Record = {} const REGISTERED = new Set() +function determineRuntime(): 'node' | 'bun' | 'ts-node' | 'tsx' { + /** + * Examples: + * #!/usr/bin/env bun + * bun bin/run.js + * bun bin/dev.js + */ + if (process.execPath.split(sep).includes('bun')) return 'bun' + /** + * Examples: + * #!/usr/bin/env node + * #!/usr/bin/env node --loader ts-node/esm --experimental-specifier-resolution=node --no-warnings + * node bin/run.js + * node bin/dev.js + */ + if (process.execArgv.length === 0) return 'node' + /** + * Examples: + * #!/usr/bin/env ts-node + * #!/usr/bin/env node_modules/.bin/ts-node + * ts-node bin/run.js + * ts-node bin/dev.js + */ + if (process.execArgv[0] === '--require' && process.execArgv[1].split(sep).includes('ts-node')) return 'ts-node' + if (process.execArgv[0].split(sep).includes('ts-node')) return 'ts-node' + /** + * Examples: + * #!/usr/bin/env tsx + * #!/usr/bin/env node_modules/.bin/tsx + * tsx bin/run.js + * tsx bin/dev.js + */ + if (process.execArgv[0] === '--require' && process.execArgv[1].split(sep).includes('tsx')) return 'tsx' + + return 'node' +} + +const RUN_TIME = determineRuntime() + function isErrno(error: any): error is NodeJS.ErrnoException { return 'code' in error && error.code === 'ENOENT' } @@ -23,21 +62,21 @@ async function loadTSConfig(root: string): Promise { try { if (TS_CONFIGS[root]) return TS_CONFIGS[root] - TS_CONFIGS[root] = await readTSConfig(root) - + const tsconfig = await readTSConfig(root) + if (!tsconfig) return + debug('tsconfig: %O', tsconfig) + TS_CONFIGS[root] = tsconfig return TS_CONFIGS[root] } catch (error) { if (isErrno(error)) return - debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`) + debug(`Could not parse tsconfig.json. Skipping typescript path lookup for ${root}.`) memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`) } } -async function registerTSNode(root: string): Promise { - const tsconfig = await loadTSConfig(root) - if (!tsconfig) return - if (REGISTERED.has(root)) return tsconfig +async function registerTSNode(root: string, tsconfig: TSConfig): Promise { + if (REGISTERED.has(root)) return debug('registering ts-node at', root) const tsNodePath = require.resolve('ts-node', {paths: [root, __dirname]}) @@ -91,12 +130,9 @@ async function registerTSNode(root: string): Promise { transpileOnly: true, } + debug('ts-node options: %O', conf) tsNode.register(conf) REGISTERED.add(root) - debug('tsconfig: %O', tsconfig) - debug('ts-node options: %O', conf) - - return tsconfig } /** @@ -132,18 +168,24 @@ function cannotUseTsNode(root: string, plugin: Plugin | undefined, isProduction: if (plugin?.moduleType !== 'module' || isProduction) return false const nodeMajor = Number.parseInt(process.version.replace('v', '').split('.')[0], 10) - const tsNodeExecIsUsed = process.execArgv[0] === '--require' && process.execArgv[1].split(sep).includes(`ts-node`) - return tsNodeExecIsUsed && nodeMajor >= 20 + return RUN_TIME === 'ts-node' && nodeMajor >= 20 } /** * Determine the path to the source file from the compiled ./lib files */ async function determinePath(root: string, orig: string): Promise { - const tsconfig = await registerTSNode(root) + const tsconfig = await loadTSConfig(root) if (!tsconfig) return orig - debug(`determining path for ${orig}`) + debug(`Determining path for ${orig}`) + + if (RUN_TIME === 'tsx' || RUN_TIME === 'bun') { + debug(`Skipping ts-node registration for ${root} because the runtime is: ${RUN_TIME}`) + } else { + await registerTSNode(root, tsconfig) + } + const {baseUrl, outDir, rootDir, rootDirs} = tsconfig.compilerOptions const rootDirPath = rootDir ?? (rootDirs ?? [])[0] ?? baseUrl if (!rootDirPath) { @@ -197,22 +239,24 @@ export async function tsPath(root: string, orig: string | undefined, plugin?: Pl // NOTE: The order of these checks matter! - if (settings.tsnodeEnabled === false) { - debug(`Skipping ts-node registration for ${root} because tsNodeEnabled is explicitly set to false`) + const enableAutoTranspile = settings.enableAutoTranspile ?? settings.tsnodeEnabled + + if (enableAutoTranspile === false) { + debug(`Skipping typescript path lookup for ${root} because enableAutoTranspile is explicitly set to false`) return orig } const isProduction = isProd() // Do not skip ts-node registration if the plugin is linked - if (settings.tsnodeEnabled === undefined && isProduction && plugin?.type !== 'link') { - debug(`Skipping ts-node registration for ${root} because NODE_ENV is NOT "test" or "development"`) + if (enableAutoTranspile === undefined && isProduction && plugin?.type !== 'link') { + debug(`Skipping typescript path lookup for ${root} because NODE_ENV is NOT "test" or "development"`) return orig } if (cannotTranspileEsm(rootPlugin, plugin, isProduction)) { debug( - `Skipping ts-node registration for ${root} because it's an ESM module (NODE_ENV: ${process.env.NODE_ENV}, root plugin module type: ${rootPlugin?.moduleType})`, + `Skipping typescript path lookup for ${root} because it's an ESM module (NODE_ENV: ${process.env.NODE_ENV}, root plugin module type: ${rootPlugin?.moduleType})`, ) if (plugin?.type === 'link') memoizedWarn( @@ -222,7 +266,7 @@ export async function tsPath(root: string, orig: string | undefined, plugin?: Pl } if (cannotUseTsNode(root, plugin, isProduction)) { - debug(`Skipping ts-node registration for ${root} because ts-node is run in node version ${process.version}"`) + debug(`Skipping typescript path lookup for ${root} because ts-node is run in node version ${process.version}"`) memoizedWarn( `ts-node executable cannot transpile ESM in Node 20. Existing compiled source will be used instead. See https://github.com/oclif/core/issues/817.`, ) diff --git a/src/module-loader.ts b/src/module-loader.ts index 15a2981d5..ed3e8cc27 100644 --- a/src/module-loader.ts +++ b/src/module-loader.ts @@ -3,7 +3,7 @@ import {extname, join, sep} from 'node:path' import {pathToFileURL} from 'node:url' import {Command} from './command' -import {tsPath} from './config/ts-node' +import {tsPath} from './config/ts-path' import {ModuleLoadError} from './errors' import {Config as IConfig, Plugin as IPlugin} from './interfaces' import {existsSync} from './util/fs' diff --git a/src/settings.ts b/src/settings.ts index 398cb665c..6ee729cd3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,7 +9,7 @@ export type Settings = { /** * Show additional debug output without DEBUG. Mainly shows stackstraces. * - * Useful to set in the ./bin/dev script. + * Useful to set in the ./bin/dev.js script. * oclif.settings.debug = true; */ debug?: boolean @@ -25,16 +25,19 @@ export type Settings = { */ performanceEnabled?: boolean /** - * Try to use ts-node to load typescript source files instead of - * javascript files. + * Try to use ts-node to load typescript source files instead of javascript files. + * Defaults to true in development and test environments (e.g. using bin/dev.js or + * NODE_ENV=development or NODE_ENV=test). * - * NOTE: This requires registering ts-node first. - * require('ts-node').register(); - * - * Environment Variable: - * NODE_ENV=development + * @deprecated use enableAutoTranspile instead. */ tsnodeEnabled?: boolean + /** + * Enable automatic transpilation of TypeScript files to JavaScript. + * + * Defaults to true in development and test environments (e.g. using bin/dev.js or NODE_ENV=development or NODE_ENV=test). + */ + enableAutoTranspile?: boolean } // Set global.oclif to the new object if it wasn't set before diff --git a/test/config/ts-node.test.ts b/test/config/ts-path.test.ts similarity index 94% rename from test/config/ts-node.test.ts rename to test/config/ts-path.test.ts index 57c6f18af..c7c9e16ad 100644 --- a/test/config/ts-node.test.ts +++ b/test/config/ts-path.test.ts @@ -3,7 +3,7 @@ import {join, resolve} from 'node:path' import {SinonSandbox, createSandbox} from 'sinon' import * as tsNode from 'ts-node' -import * as configTsNode from '../../src/config/ts-node' +import * as configTsNode from '../../src/config/ts-path' import {Interfaces, settings} from '../../src/index' import * as util from '../../src/util/read-tsconfig' @@ -84,7 +84,7 @@ describe('tsPath', () => { it('should resolve to .ts file if enabled and prod', async () => { sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) - settings.tsnodeEnabled = true + settings.enableAutoTranspile = true const originalNodeEnv = process.env.NODE_ENV delete process.env.NODE_ENV @@ -92,15 +92,15 @@ describe('tsPath', () => { expect(result).to.equal(join(root, tsModule)) process.env.NODE_ENV = originalNodeEnv - delete settings.tsnodeEnabled + delete settings.enableAutoTranspile }) it('should resolve to js if disabled', async () => { sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) - settings.tsnodeEnabled = false + settings.enableAutoTranspile = false const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) - delete settings.tsnodeEnabled + delete settings.enableAutoTranspile }) }) diff --git a/test/integration/esm-cjs.ts b/test/integration/esm-cjs.ts deleted file mode 100644 index 7dc33f94d..000000000 --- a/test/integration/esm-cjs.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * These integration tests do not use mocha because we encountered an issue with - * spawning child processes for testing root ESM plugins with linked ESM plugins. - * This scenario works as expected when running outside of mocha. - * - * Instead of spending more time diagnosing the root cause, we are just going to - * run these integration tests using ts-node and a lightweight homemade test runner. - */ -import {expect} from 'chai' -import chalk from 'chalk' -import * as fs from 'node:fs/promises' -import * as path from 'node:path' - -import {Executor, setup} from './util' - -const FAILED: string[] = [] -const PASSED: string[] = [] - -async function test(name: string, fn: () => Promise) { - try { - await fn() - PASSED.push(name) - console.log(chalk.green('✓'), name) - } catch (error) { - FAILED.push(name) - console.log(chalk.red('𐄂'), name) - console.log(error) - } -} - -function exit(): never { - console.log() - console.log(chalk.bold('#### Summary ####')) - - for (const name of PASSED) { - console.log(chalk.green('✓'), name) - } - - for (const name of FAILED) { - console.log(chalk.red('𐄂'), name) - } - - console.log(`${chalk.green('Passed:')} ${PASSED.length}`) - console.log(`${chalk.red('Failed:')} ${FAILED.length}`) - - // eslint-disable-next-line no-process-exit, unicorn/no-process-exit - process.exit(FAILED.length) -} - -type Plugin = { - name: string - command: string - package: string - repo: string -} - -type Script = 'run' | 'dev' - -type InstallPluginOptions = { - executor: Executor - plugin: Plugin - script: Script -} - -type LinkPluginOptions = { - executor: Executor - plugin: Plugin - script: Script - noLinkCore?: boolean -} - -type RunCommandOptions = { - executor: Executor - plugin: Plugin - script: Script - expectStrings?: string[] - expectJson?: Record - env?: Record - args?: Array -} - -type ModifyCommandOptions = { - executor: Executor - plugin: Plugin - from: string - to: string -} - -type CleanUpOptions = { - executor: Executor - script: Script - plugin: Plugin -} - -type PluginConfig = { - name: string - command: string - package: string - repo: string - commandText: string - hookText: string - expectJson: { - whenProvided: { - args: Record - flags: Record - } - whenNotProvided: { - args: Record - flags: Record - } - } -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -;(async () => { - const commonProps = { - expectJson: { - whenProvided: { - args: { - optionalArg: 'arg1', - defaultArg: 'arg2', - defaultFnArg: 'arg3', - }, - flags: { - optionalString: 'flag1', - defaultString: 'flag2', - defaultFnString: 'flag3', - json: true, - }, - }, - whenNotProvided: { - args: { - defaultArg: 'simple string default', - defaultFnArg: 'async fn default', - }, - flags: { - defaultString: 'simple string default', - defaultFnString: 'async fn default', - json: true, - }, - }, - }, - } - - const PLUGINS: Record = { - esm1: { - name: 'plugin-test-esm-1', - command: 'esm1', - package: '@oclif/plugin-test-esm-1', - repo: 'https://github.com/oclif/plugin-test-esm-1', - commandText: 'hello I am an ESM plugin', - hookText: 'Greetings! from plugin-test-esm-1 init hook', - ...commonProps, - }, - esm2: { - name: 'plugin-test-esm-2', - command: 'esm2', - package: '@oclif/plugin-test-esm-2', - repo: 'https://github.com/oclif/plugin-test-esm-2', - commandText: 'hello I am an ESM plugin', - hookText: 'Greetings! from plugin-test-esm-2 init hook', - ...commonProps, - }, - cjs1: { - name: 'plugin-test-cjs-1', - command: 'cjs1', - package: '@oclif/plugin-test-cjs-1', - repo: 'https://github.com/oclif/plugin-test-cjs-1', - commandText: 'hello I am a CJS plugin', - hookText: 'Greetings! from plugin-test-cjs-1 init hook', - ...commonProps, - }, - cjs2: { - name: 'plugin-test-cjs-2', - command: 'cjs2', - package: '@oclif/plugin-test-cjs-2', - repo: 'https://github.com/oclif/plugin-test-cjs-2', - commandText: 'hello I am a CJS plugin', - hookText: 'Greetings! from plugin-test-cjs-2 init hook', - ...commonProps, - }, - precore: { - name: 'plugin-test-pre-core', - command: 'pre-core', - package: '@oclif/plugin-test-pre-core', - repo: 'https://github.com/oclif/plugin-test-pre-core', - commandText: 'hello I am a pre-core plugin', - hookText: 'Greetings! from plugin-test-pre-core init hook', - expectJson: { - whenProvided: commonProps.expectJson.whenProvided, - whenNotProvided: { - args: { - defaultArg: 'simple string default', - defaultFnArg: 'fn default', - }, - flags: { - defaultString: 'simple string default', - defaultFnString: 'fn default', - json: true, - }, - }, - }, - }, - coreV1: { - name: 'plugin-test-core-v1', - command: 'core-v1', - package: '@oclif/plugin-test-core-v1', - repo: 'https://github.com/oclif/plugin-test-core-v1', - commandText: 'hello I am an @oclif/core@v1 plugin', - hookText: 'Greetings! from plugin-test-core-v1 init hook', - ...commonProps, - }, - coreV2: { - name: 'plugin-test-core-v2', - command: 'core-v2', - package: '@oclif/plugin-test-core-v2', - repo: 'https://github.com/oclif/plugin-test-core-v2', - commandText: 'hello I am an @oclif/core@v2 plugin', - hookText: 'Greetings! from plugin-test-core-v2 init hook', - ...commonProps, - }, - } - - async function installPlugin(options: InstallPluginOptions): Promise { - const result = await options.executor.executeCommand(`plugins:install ${options.plugin.package}`, options.script) - expect(result.code).to.equal(0) - - const pluginsResult = await options.executor.executeCommand('plugins', options.script) - expect(pluginsResult.stdout).to.include(options.plugin.name) - } - - async function linkPlugin(options: LinkPluginOptions): Promise { - const pluginExecutor = await setup(__filename, { - repo: options.plugin.repo, - subDir: options.executor.parentDir, - noLinkCore: options.noLinkCore ?? false, - }) - - const result = await options.executor.executeCommand( - `plugins:link ${pluginExecutor.pluginDir} --no-install`, - options.script, - ) - expect(result.code).to.equal(0) - - const pluginsResult = await options.executor.executeCommand('plugins', options.script) - expect(pluginsResult.stdout).to.include(options.plugin.name) - - return pluginExecutor - } - - async function modifyCommand(options: ModifyCommandOptions): Promise { - const filePath = path.join(options.executor.pluginDir, 'src', 'commands', `${options.plugin.command}.ts`) - const content = await fs.readFile(filePath, 'utf8') - const modifiedContent = content.replace(options.from, options.to) - await fs.writeFile(filePath, modifiedContent) - } - - async function runCommand(options: RunCommandOptions): Promise { - const env = {...process.env, ...options.env} - const result = await options.executor.executeCommand( - `${options.plugin.command} ${options.args?.join(' ') ?? ''}`, - options.script, - {env}, - ) - expect(result.code).to.equal(0) - - if (options.expectStrings) { - for (const expectString of options.expectStrings) { - expect(result.stdout).to.include(expectString) - } - } - - if (options.expectJson && options.args?.includes('--json')) { - // clear any non-json output from hooks - const split = result.stdout?.split('\n') ?? [] - const idx = split.findIndex((i) => i.startsWith('{')) - const json = JSON.parse(split.slice(idx).join('\n')) - expect(json).to.deep.equal(options.expectJson) - } - } - - async function cleanUp(options: CleanUpOptions): Promise { - await options.executor.executeCommand(`plugins:uninstall ${options.plugin.package}`) - const {stdout} = await options.executor.executeCommand('plugins') - expect(stdout).to.not.include(options.plugin.package) - } - - const args = process.argv.slice(process.argv.indexOf(__filename) + 1) - const providedSkips = args.find((arg) => arg.startsWith('--skip=')) - const providedTests = args.find((arg) => arg.startsWith('--test=')) ?? '=cjs,esm,precore,coreV1,coreV2' - - const skips = providedSkips ? providedSkips.split('=')[1].split(',') : [] - const tests = providedTests ? providedTests.split('=')[1].split(',') : [] - - const runTests = { - esm: tests.includes('esm') && !skips.includes('esm'), - cjs: tests.includes('cjs') && !skips.includes('cjs'), - precore: tests.includes('precore') && !skips.includes('precore'), - coreV1: tests.includes('coreV1') && !skips.includes('coreV1'), - coreV2: tests.includes('coreV2') && !skips.includes('coreV2'), - } - - console.log('Node version:', process.version) - console.log('Running tests:', runTests) - - let cjsExecutor: Executor - let esmExecutor: Executor - - const cjsBefore = async () => { - cjsExecutor = await setup(__filename, {repo: PLUGINS.cjs1.repo, subDir: 'cjs'}) - } - - const esmBefore = async () => { - esmExecutor = await setup(__filename, {repo: PLUGINS.esm1.repo, subDir: 'esm'}) - } - - const precoreBefore = async () => { - if (!cjsExecutor) await cjsBefore() - if (!esmExecutor) await esmBefore() - } - - const coreV1Before = async () => { - if (!cjsExecutor) await cjsBefore() - if (!esmExecutor) await esmBefore() - } - - const coreV2Before = async () => { - if (!cjsExecutor) await cjsBefore() - if (!esmExecutor) await esmBefore() - } - - const installTest = async (plugin: PluginConfig, executor: Executor) => { - await installPlugin({executor, plugin, script: 'run'}) - - // test that the root plugin's bin/run can execute the installed plugin - await runCommand({ - executor, - plugin, - script: 'run', - expectStrings: [plugin.commandText], - }) - - // test that the root plugin's bin/run can execute the installed plugin - // and that args and flags work as expected when no values are provided - await runCommand({ - executor, - plugin, - script: 'run', - args: ['--json'], - expectJson: plugin.expectJson.whenNotProvided, - }) - - // test that the root plugin's bin/run can execute the installed plugin - // and that args and flags work as expected when values are provided - await runCommand({ - executor, - plugin, - script: 'run', - args: [ - ...Object.values(plugin.expectJson.whenProvided.args), - ...Object.entries(plugin.expectJson.whenProvided.flags).map(([flag, value]) => { - if (flag === 'json') return '--json' - return `--${flag} ${value}` - }), - ], - expectJson: plugin.expectJson.whenProvided, - }) - - // test that the root plugin's bin/dev can execute the installed plugin - await runCommand({ - executor, - plugin, - script: 'dev', - expectStrings: [plugin.commandText], - }) - - await cleanUp({executor, plugin, script: 'run'}) - } - - const linkTest = async (plugin: PluginConfig, executor: Executor, noLinkCore = false) => { - const linkedPlugin = await linkPlugin({executor, plugin, script: 'run', noLinkCore}) - - // test bin/run - await runCommand({ - executor, - plugin, - script: 'run', - expectStrings: [plugin.commandText, plugin.hookText], - }) - // test un-compiled changes with bin/run - await modifyCommand({executor: linkedPlugin, plugin, from: 'hello', to: 'howdy'}) - await runCommand({ - executor, - plugin, - script: 'run', - expectStrings: ['howdy', plugin.hookText], - }) - - // test un-compiled changes with bin/dev - await modifyCommand({executor: linkedPlugin, plugin, from: 'howdy', to: 'cheers'}) - await runCommand({ - executor, - plugin, - script: 'dev', - expectStrings: ['cheers', plugin.hookText], - }) - - await cleanUp({executor, plugin, script: 'run'}) - } - - const cjsTests = async () => { - await test('Install CJS plugin to CJS root plugin', async () => { - await installTest(PLUGINS.cjs2, cjsExecutor) - }) - - await test('Install ESM plugin to CJS root plugin', async () => { - await installTest(PLUGINS.esm1, cjsExecutor) - }) - - await test('Link CJS plugin to CJS root plugin', async () => { - await linkTest(PLUGINS.cjs2, cjsExecutor) - }) - - await test('Link ESM plugin to CJS root plugin', async () => { - // We don't use linkTest here because that would test that the - // ESM plugin is auto-transpiled which we're not supporting at the moment. - const plugin = PLUGINS.esm2 - - await linkPlugin({executor: cjsExecutor, plugin, script: 'run'}) - - // test bin/run - await runCommand({ - executor: cjsExecutor, - plugin, - script: 'run', - expectStrings: [plugin.commandText, plugin.hookText], - }) - - // test bin/dev - await runCommand({ - executor: cjsExecutor, - plugin, - script: 'dev', - expectStrings: [plugin.commandText, plugin.hookText], - }) - - await cleanUp({executor: cjsExecutor, plugin, script: 'run'}) - }) - } - - const esmTests = async () => { - await test('Install CJS plugin to ESM root plugin', async () => { - await installTest(PLUGINS.cjs1, esmExecutor) - }) - - await test('Install ESM plugin to ESM root plugin', async () => { - await installTest(PLUGINS.esm2, esmExecutor) - }) - - await test('Link CJS plugin to ESM root plugin', async () => { - await linkTest(PLUGINS.cjs1, esmExecutor) - }) - - await test('Link ESM plugin to ESM root plugin', async () => { - const plugin = PLUGINS.esm2 - - await linkPlugin({executor: esmExecutor, plugin, script: 'run'}) - // test bin/run - await runCommand({ - executor: esmExecutor, - plugin, - script: 'run', - expectStrings: [plugin.commandText, plugin.hookText], - }) - - // Skipping these because we decided to not support auto-transpiling ESM plugins at this time. - // // test un-compiled changes with bin/run - // await modifyCommand({executor: linkedPlugin, plugin, from: 'hello', to: 'howdy'}) - // await runCommand({ - // executor: esmExecutor, - // plugin, - // script: 'run', - // expectStrings: ['howdy', plugin.hookText], - // env: {NODE_OPTIONS: '--loader=ts-node/esm'}, - // }) - // // test un-compiled changes with bin/dev - // await modifyCommand({executor: linkedPlugin, plugin, from: 'howdy', to: 'cheers'}) - // await runCommand({ - // executor: esmExecutor, - // plugin, - // script: 'dev', - // expectStrings: ['cheers', plugin.hookText], - // env: {NODE_OPTIONS: '--loader=ts-node/esm'}, - // }) - - await cleanUp({executor: esmExecutor, plugin, script: 'run'}) - }) - } - - const preCoreTests = async () => { - await test('Install pre-core plugin to ESM root plugin', async () => { - await installTest(PLUGINS.precore, esmExecutor) - }) - - await test('Install pre-core plugin to CJS root plugin', async () => { - await installTest(PLUGINS.precore, cjsExecutor) - }) - - await test('Link pre-core plugin to CJS root plugin', async () => { - // Pass in true to skip linking the local version of @oclif/core - // to the test pre-core plugin since it doesn't use core. - await linkTest(PLUGINS.precore, cjsExecutor, true) - }) - - await test('Link pre-core plugin to ESM root plugin', async () => { - // Pass in true to skip linking the local version of @oclif/core - // to the test pre-core plugin since it doesn't use core. - await linkTest(PLUGINS.precore, esmExecutor, true) - }) - } - - const coreV1Tests = async () => { - await test('Install core v1 plugin to ESM root plugin', async () => { - await installTest(PLUGINS.coreV1, esmExecutor) - }) - - await test('Install core v1 plugin to CJS root plugin', async () => { - await installTest(PLUGINS.coreV1, cjsExecutor) - }) - - await test('Link core v1 plugin to CJS root plugin', async () => { - // Pass in true to skip linking the local version of @oclif/core - // to plugin-test-core-v1. There are breaking changes to how - // args are defined in a command so the plugin won't compile if - // we link the local version of core. - await linkTest(PLUGINS.coreV1, cjsExecutor, true) - }) - - await test('Link core v1 plugin to ESM root plugin', async () => { - // Pass in true to skip linking the local version of @oclif/core - // to plugin-test-core-v1. There are breaking changes to how - // args are defined in a command so the plugin won't compile if - // we link the local version of core. - await linkTest(PLUGINS.coreV1, esmExecutor, true) - }) - } - - const coreV2Tests = async () => { - await test('Install core v2 plugin to ESM root plugin', async () => { - await installTest(PLUGINS.coreV2, esmExecutor) - }) - - await test('Install core v2 plugin to CJS root plugin', async () => { - await installTest(PLUGINS.coreV2, cjsExecutor) - }) - - await test('Link core v2 plugin to CJS root plugin', async () => { - await linkTest(PLUGINS.coreV2, cjsExecutor) - }) - - await test('Link core v2 plugin to ESM root plugin', async () => { - await linkTest(PLUGINS.coreV2, esmExecutor) - }) - } - - if (runTests.cjs) await cjsBefore() - if (runTests.esm) await esmBefore() - if (runTests.precore) await precoreBefore() - if (runTests.coreV1) await coreV1Before() - if (runTests.coreV2) await coreV2Before() - - if (runTests.cjs) await cjsTests() - if (runTests.esm) await esmTests() - if (runTests.precore) await preCoreTests() - if (runTests.coreV1) await coreV1Tests() - if (runTests.coreV2) await coreV2Tests() - - exit() -})() diff --git a/test/integration/interop-plugins-matrix.ts b/test/integration/interop-plugins-matrix.ts new file mode 100644 index 000000000..79671502e --- /dev/null +++ b/test/integration/interop-plugins-matrix.ts @@ -0,0 +1,126 @@ +export type PluginConfig = { + name: string + command: string + package: string + repo: string + commandText: string + hookText: string + expectJson: { + whenProvided: { + args: Record + flags: Record + } + whenNotProvided: { + args: Record + flags: Record + } + } +} + +const commonProps = { + expectJson: { + whenProvided: { + args: { + optionalArg: 'arg1', + defaultArg: 'arg2', + defaultFnArg: 'arg3', + }, + flags: { + optionalString: 'flag1', + defaultString: 'flag2', + defaultFnString: 'flag3', + json: true, + }, + }, + whenNotProvided: { + args: { + defaultArg: 'simple string default', + defaultFnArg: 'async fn default', + }, + flags: { + defaultString: 'simple string default', + defaultFnString: 'async fn default', + json: true, + }, + }, + }, +} + +export const plugins: Record = { + esm1: { + name: 'plugin-test-esm-1', + command: 'esm1', + package: '@oclif/plugin-test-esm-1', + repo: 'https://github.com/oclif/plugin-test-esm-1', + commandText: 'hello I am an ESM plugin', + hookText: 'Greetings! from plugin-test-esm-1 init hook', + ...commonProps, + }, + esm2: { + name: 'plugin-test-esm-2', + command: 'esm2', + package: '@oclif/plugin-test-esm-2', + repo: 'https://github.com/oclif/plugin-test-esm-2', + commandText: 'hello I am an ESM plugin', + hookText: 'Greetings! from plugin-test-esm-2 init hook', + ...commonProps, + }, + cjs1: { + name: 'plugin-test-cjs-1', + command: 'cjs1', + package: '@oclif/plugin-test-cjs-1', + repo: 'https://github.com/oclif/plugin-test-cjs-1', + commandText: 'hello I am a CJS plugin', + hookText: 'Greetings! from plugin-test-cjs-1 init hook', + ...commonProps, + }, + cjs2: { + name: 'plugin-test-cjs-2', + command: 'cjs2', + package: '@oclif/plugin-test-cjs-2', + repo: 'https://github.com/oclif/plugin-test-cjs-2', + commandText: 'hello I am a CJS plugin', + hookText: 'Greetings! from plugin-test-cjs-2 init hook', + ...commonProps, + }, + precore: { + name: 'plugin-test-pre-core', + command: 'pre-core', + package: '@oclif/plugin-test-pre-core', + repo: 'https://github.com/oclif/plugin-test-pre-core', + commandText: 'hello I am a pre-core plugin', + hookText: 'Greetings! from plugin-test-pre-core init hook', + expectJson: { + whenProvided: commonProps.expectJson.whenProvided, + whenNotProvided: { + args: { + defaultArg: 'simple string default', + defaultFnArg: 'fn default', + }, + flags: { + defaultString: 'simple string default', + defaultFnString: 'fn default', + json: true, + }, + }, + }, + }, + coreV1: { + name: 'plugin-test-core-v1', + command: 'core-v1', + package: '@oclif/plugin-test-core-v1', + repo: 'https://github.com/oclif/plugin-test-core-v1', + commandText: 'hello I am an @oclif/core@v1 plugin', + hookText: 'Greetings! from plugin-test-core-v1 init hook', + ...commonProps, + }, + coreV2: { + name: 'plugin-test-core-v2', + command: 'core-v2', + package: '@oclif/plugin-test-core-v2', + repo: 'https://github.com/oclif/plugin-test-core-v2', + commandText: 'hello I am an @oclif/core@v2 plugin', + hookText: 'Greetings! from plugin-test-core-v2 init hook', + ...commonProps, + }, +} diff --git a/test/integration/interop.ts b/test/integration/interop.ts new file mode 100644 index 000000000..a66efc265 --- /dev/null +++ b/test/integration/interop.ts @@ -0,0 +1,476 @@ +/** + * These integration tests do not use mocha because we encountered an issue with + * spawning child processes for testing root ESM plugins with linked ESM plugins. + * This scenario works as expected when running outside of mocha. + * + * Instead of spending more time diagnosing the root cause, we are just going to + * run these integration tests using ts-node and a lightweight homemade test runner. + */ +import {expect} from 'chai' +import chalk from 'chalk' +import fs from 'node:fs/promises' +import path from 'node:path' + +import {Command, Flags, flush, handle} from '../../src' +import {PluginConfig, plugins} from './interop-plugins-matrix' +import {Executor, Script, setup} from './util' + +const TESTS = ['cjs', 'esm', 'precore', 'coreV1', 'coreV2'] as const +const DEV_RUN_TIMES = ['default', 'bun', 'tsx'] as const + +type Plugin = { + name: string + command: string + package: string + repo: string +} + +type InstallPluginOptions = { + executor: Executor + plugin: Plugin + script: Script +} + +type LinkPluginOptions = { + executor: Executor + plugin: Plugin + script: Script + noLinkCore?: boolean +} + +type RunCommandOptions = { + executor: Executor + plugin: Plugin + script: Script + expectStrings?: string[] + expectJson?: Record + env?: Record + args?: Array +} + +type ModifyCommandOptions = { + executor: Executor + plugin: Plugin + from: string + to: string +} + +type CleanUpOptions = { + executor: Executor + script: Script + plugin: Plugin +} + +async function installPlugin(options: InstallPluginOptions): Promise { + const result = await options.executor.executeCommand(`plugins:install ${options.plugin.package}`, options.script) + expect(result.code).to.equal(0) + + const pluginsResult = await options.executor.executeCommand('plugins', options.script) + expect(pluginsResult.stdout).to.include(options.plugin.name) +} + +async function linkPlugin(options: LinkPluginOptions): Promise { + const pluginExecutor = await setup(__filename, { + repo: options.plugin.repo, + subDir: options.executor.parentDir, + noLinkCore: options.noLinkCore ?? false, + }) + + const result = await options.executor.executeCommand( + `plugins:link ${pluginExecutor.pluginDir} --no-install`, + options.script, + ) + expect(result.code).to.equal(0) + + const pluginsResult = await options.executor.executeCommand('plugins', options.script) + expect(pluginsResult.stdout).to.include(options.plugin.name) + + return pluginExecutor +} + +async function modifyCommand(options: ModifyCommandOptions): Promise { + const filePath = path.join(options.executor.pluginDir, 'src', 'commands', `${options.plugin.command}.ts`) + const content = await fs.readFile(filePath, 'utf8') + const modifiedContent = content.replace(options.from, options.to) + await fs.writeFile(filePath, modifiedContent) +} + +async function runCommand(options: RunCommandOptions): Promise { + const env = {...process.env, ...options.env} + const result = await options.executor.executeCommand( + `${options.plugin.command} ${options.args?.join(' ') ?? ''}`, + options.script, + {env}, + ) + expect(result.code).to.equal(0) + + if (options.expectStrings) { + for (const expectString of options.expectStrings) { + expect(result.stdout).to.include(expectString) + } + } + + if (options.expectJson && options.args?.includes('--json')) { + // clear any non-json output from hooks + const split = result.stdout?.split('\n') ?? [] + const idx = split.findIndex((i) => i.startsWith('{')) + const json = JSON.parse(split.slice(idx).join('\n')) + expect(json).to.deep.equal(options.expectJson) + } +} + +async function cleanUp(options: CleanUpOptions): Promise { + await options.executor.executeCommand(`plugins:uninstall ${options.plugin.package}`) + const {stdout} = await options.executor.executeCommand('plugins') + expect(stdout).to.not.include(options.plugin.package) +} + +async function testRunner({ + tests, + devRunTime, +}: { + tests: Array<(typeof TESTS)[number]> + devRunTime: (typeof DEV_RUN_TIMES)[number] +}): Promise<{failed: string[]; passed: string[]}> { + const failed: string[] = [] + const passed: string[] = [] + + async function test(name: string, fn: () => Promise) { + try { + await fn() + passed.push(name) + console.log(chalk.green('✓'), name) + } catch (error) { + failed.push(name) + console.log(chalk.red('𐄂'), name) + console.log(error) + } + } + + const devExecutable = (devRunTime === 'default' ? 'dev' : `${devRunTime} dev`) as 'dev' | 'bun dev' | 'tsx dev' + + let cjsExecutor: Executor + let esmExecutor: Executor + + const cjsBefore = async () => { + cjsExecutor = await setup(__filename, {repo: plugins.cjs1.repo, subDir: 'cjs'}) + } + + const esmBefore = async () => { + esmExecutor = await setup(__filename, {repo: plugins.esm1.repo, subDir: 'esm'}) + } + + const precoreBefore = async () => { + if (!cjsExecutor) await cjsBefore() + if (!esmExecutor) await esmBefore() + } + + const coreV1Before = async () => { + if (!cjsExecutor) await cjsBefore() + if (!esmExecutor) await esmBefore() + } + + const coreV2Before = async () => { + if (!cjsExecutor) await cjsBefore() + if (!esmExecutor) await esmBefore() + } + + const installTest = async (plugin: PluginConfig, executor: Executor) => { + await installPlugin({executor, plugin, script: 'run'}) + + // test that the root plugin's bin/run can execute the installed plugin + await runCommand({ + executor, + plugin, + script: 'run', + expectStrings: [plugin.commandText], + }) + + // test that the root plugin's bin/run can execute the installed plugin + // and that args and flags work as expected when no values are provided + await runCommand({ + executor, + plugin, + script: 'run', + args: ['--json'], + expectJson: plugin.expectJson.whenNotProvided, + }) + + // test that the root plugin's bin/run can execute the installed plugin + // and that args and flags work as expected when values are provided + await runCommand({ + executor, + plugin, + script: 'run', + args: [ + ...Object.values(plugin.expectJson.whenProvided.args), + ...Object.entries(plugin.expectJson.whenProvided.flags).map(([flag, value]) => { + if (flag === 'json') return '--json' + return `--${flag} ${value}` + }), + ], + expectJson: plugin.expectJson.whenProvided, + }) + + // test that the root plugin's bin/dev can execute the installed plugin + await runCommand({ + executor, + plugin, + script: devExecutable, + expectStrings: [plugin.commandText], + }) + + await cleanUp({executor, plugin, script: 'run'}) + } + + const linkTest = async (plugin: PluginConfig, executor: Executor, noLinkCore = false) => { + const linkedPlugin = await linkPlugin({executor, plugin, script: 'run', noLinkCore}) + + // test bin/run + await runCommand({ + executor, + plugin, + script: 'run', + expectStrings: [plugin.commandText, plugin.hookText], + }) + // test un-compiled changes with bin/run + await modifyCommand({executor: linkedPlugin, plugin, from: 'hello', to: 'howdy'}) + await runCommand({ + executor, + plugin, + script: 'run', + expectStrings: ['howdy', plugin.hookText], + }) + + // test un-compiled changes with bin/dev + await modifyCommand({executor: linkedPlugin, plugin, from: 'howdy', to: 'cheers'}) + await runCommand({ + executor, + plugin, + script: devExecutable, + expectStrings: ['cheers', plugin.hookText], + }) + + await cleanUp({executor, plugin, script: 'run'}) + } + + const cjsTests = async () => { + await test('Install CJS plugin to CJS root plugin', async () => { + await installTest(plugins.cjs2, cjsExecutor) + }) + + await test('Install ESM plugin to CJS root plugin', async () => { + await installTest(plugins.esm1, cjsExecutor) + }) + + await test('Link CJS plugin to CJS root plugin', async () => { + await linkTest(plugins.cjs2, cjsExecutor) + }) + + await test('Link ESM plugin to CJS root plugin', async () => { + // We don't use linkTest here because that would test that the + // ESM plugin is auto-transpiled which we're not supporting at the moment. + const plugin = plugins.esm2 + + await linkPlugin({executor: cjsExecutor, plugin, script: 'run'}) + + // test bin/run + await runCommand({ + executor: cjsExecutor, + plugin, + script: 'run', + expectStrings: [plugin.commandText, plugin.hookText], + }) + + // test bin/dev + await runCommand({ + executor: cjsExecutor, + plugin, + script: devExecutable, + expectStrings: [plugin.commandText, plugin.hookText], + }) + + await cleanUp({executor: cjsExecutor, plugin, script: 'run'}) + }) + } + + const esmTests = async () => { + await test('Install CJS plugin to ESM root plugin', async () => { + await installTest(plugins.cjs1, esmExecutor) + }) + + await test('Install ESM plugin to ESM root plugin', async () => { + await installTest(plugins.esm2, esmExecutor) + }) + + await test('Link CJS plugin to ESM root plugin', async () => { + await linkTest(plugins.cjs1, esmExecutor) + }) + + await test('Link ESM plugin to ESM root plugin', async () => { + const plugin = plugins.esm2 + + await linkPlugin({executor: esmExecutor, plugin, script: 'run'}) + // test bin/run + await runCommand({ + executor: esmExecutor, + plugin, + script: 'run', + expectStrings: [plugin.commandText, plugin.hookText], + }) + + // Skipping these because we decided to not support auto-transpiling ESM plugins at this time. + // // test un-compiled changes with bin/run + // await modifyCommand({executor: linkedPlugin, plugin, from: 'hello', to: 'howdy'}) + // await runCommand({ + // executor: esmExecutor, + // plugin, + // script: 'run', + // expectStrings: ['howdy', plugin.hookText], + // env: {NODE_OPTIONS: '--loader=ts-node/esm'}, + // }) + // // test un-compiled changes with bin/dev + // await modifyCommand({executor: linkedPlugin, plugin, from: 'howdy', to: 'cheers'}) + // await runCommand({ + // executor: esmExecutor, + // plugin, + // script: 'dev', + // expectStrings: ['cheers', plugin.hookText], + // env: {NODE_OPTIONS: '--loader=ts-node/esm'}, + // }) + + await cleanUp({executor: esmExecutor, plugin, script: 'run'}) + }) + } + + const preCoreTests = async () => { + await test('Install pre-core plugin to ESM root plugin', async () => { + await installTest(plugins.precore, esmExecutor) + }) + + await test('Install pre-core plugin to CJS root plugin', async () => { + await installTest(plugins.precore, cjsExecutor) + }) + + await test('Link pre-core plugin to CJS root plugin', async () => { + // Pass in true to skip linking the local version of @oclif/core + // to the test pre-core plugin since it doesn't use core. + await linkTest(plugins.precore, cjsExecutor, true) + }) + + await test('Link pre-core plugin to ESM root plugin', async () => { + // Pass in true to skip linking the local version of @oclif/core + // to the test pre-core plugin since it doesn't use core. + await linkTest(plugins.precore, esmExecutor, true) + }) + } + + const coreV1Tests = async () => { + await test('Install core v1 plugin to ESM root plugin', async () => { + await installTest(plugins.coreV1, esmExecutor) + }) + + await test('Install core v1 plugin to CJS root plugin', async () => { + await installTest(plugins.coreV1, cjsExecutor) + }) + + await test('Link core v1 plugin to CJS root plugin', async () => { + // Pass in true to skip linking the local version of @oclif/core + // to plugin-test-core-v1. There are breaking changes to how + // args are defined in a command so the plugin won't compile if + // we link the local version of core. + await linkTest(plugins.coreV1, cjsExecutor, true) + }) + + await test('Link core v1 plugin to ESM root plugin', async () => { + // Pass in true to skip linking the local version of @oclif/core + // to plugin-test-core-v1. There are breaking changes to how + // args are defined in a command so the plugin won't compile if + // we link the local version of core. + await linkTest(plugins.coreV1, esmExecutor, true) + }) + } + + const coreV2Tests = async () => { + await test('Install core v2 plugin to ESM root plugin', async () => { + await installTest(plugins.coreV2, esmExecutor) + }) + + await test('Install core v2 plugin to CJS root plugin', async () => { + await installTest(plugins.coreV2, cjsExecutor) + }) + + await test('Link core v2 plugin to CJS root plugin', async () => { + await linkTest(plugins.coreV2, cjsExecutor) + }) + + await test('Link core v2 plugin to ESM root plugin', async () => { + await linkTest(plugins.coreV2, esmExecutor) + }) + } + + if (tests.includes('cjs')) await cjsBefore() + if (tests.includes('esm')) await esmBefore() + if (tests.includes('precore')) await precoreBefore() + if (tests.includes('coreV1')) await coreV1Before() + if (tests.includes('coreV2')) await coreV2Before() + + if (tests.includes('cjs')) await cjsTests() + if (tests.includes('esm')) await esmTests() + if (tests.includes('precore')) await preCoreTests() + if (tests.includes('coreV1')) await coreV1Tests() + if (tests.includes('coreV2')) await coreV2Tests() + + return {passed, failed} +} + +class InteropTest extends Command { + static description = 'Execute interoperability tests' + static flags = { + test: Flags.option({ + description: 'Run a specific test', + options: TESTS, + required: true, + multiple: true, + })(), + 'dev-run-time': Flags.option({ + description: 'Set the dev runtime to use when executing bin/dev.js', + options: DEV_RUN_TIMES, + default: 'default', + })(), + } + + public async run(): Promise { + const {flags} = await this.parse(InteropTest) + + this.log('Node version:', process.version) + this.log('Running tests:', flags.test.join(', ')) + this.log('Dev runtime:', flags['dev-run-time']) + + const results = await testRunner({tests: flags.test, devRunTime: flags['dev-run-time']}) + + this.processResults(results) + } + + private processResults({failed, passed}: {failed: string[]; passed: string[]}): never { + this.log() + this.log(chalk.bold('#### Summary ####')) + + for (const name of passed) this.log(chalk.green('✓'), name) + + for (const name of failed) this.log(chalk.red('𐄂'), name) + + this.log(`${chalk.green('Passed:')} ${passed.length}`) + this.log(`${chalk.red('Failed:')} ${failed.length}`) + + this.exit(failed.length) + } +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +;(async () => { + InteropTest.run().then( + async () => flush(), + async (error) => handle(error), + ) +})() diff --git a/test/integration/util.ts b/test/integration/util.ts index 03c01c745..a0ec14b01 100644 --- a/test/integration/util.ts +++ b/test/integration/util.ts @@ -33,6 +33,8 @@ export type ExecutorOptions = { export type ExecOptions = ExecSyncOptionsWithBufferEncoding & {silent?: boolean} +export type Script = 'run' | 'dev' | 'bun dev' | 'tsx dev' + function updatePkgJson(testDir: string, obj: Record): Interfaces.PJSON { const pkgJsonFile = join(testDir, 'package.json') const pkgJson = JSON.parse(readFileSync(pkgJsonFile, 'utf8')) @@ -100,7 +102,16 @@ export class Executor { }) } - public executeCommand(cmd: string, script: 'run' | 'dev' = 'run', options: ExecOptions = {}): Promise { + public executeCommand(cmd: string, script: Script = 'run', options: ExecOptions = {}): Promise { + if (script.includes(' ')) { + const [runtime, theScript] = script.split(' ') + const executable = + process.platform === 'win32' + ? join('bin', `${theScript}.cmd`) + : join('bin', `${theScript}${this.usesJsScript ? '.js' : ''}`) + return this.executeInTestDir(`${runtime} ${executable} ${cmd}`, options) + } + const executable = process.platform === 'win32' ? join('bin', `${script}.cmd`)