diff --git a/.gitignore b/.gitignore index bcff3aa..854c8d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules npm-debug.log* reports Thumbs.db + +# Hack to keep the fake node_modules dirs for tests +!__fixtures__/**/node_modules diff --git a/README.md b/README.md index 4566f48..545e0fc 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ the following when preparing for release: [`@vercel/ncc`](https://www.npmjs.com/package/@vercel/ncc) **This tool supports non-transpiled action code only.** This is because it uses -[`proxyquire`](https://github.com/thlorenz/proxyquire) to override GitHub -Actions Toolkit dependencies (e.g +[`quibble`](https://github.com/testdouble/quibble) to override GitHub Actions +Toolkit dependencies (e.g [`@actions/core`](https://www.npmjs.com/package/@actions/core)). In transpiled code, this simply doesn't work. diff --git a/__fixtures__/javascript-esm/no-import/node_modules/.gitkeep b/__fixtures__/javascript-esm/no-import/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/javascript-esm/success/node_modules/.gitkeep b/__fixtures__/javascript-esm/success/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/javascript/no-import/node_modules/.gitkeep b/__fixtures__/javascript/no-import/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/javascript/success/node_modules/.gitkeep b/__fixtures__/javascript/success/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/typescript-esm/no-import/node_modules/.gitkeep b/__fixtures__/typescript-esm/no-import/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/typescript-esm/success/node_modules/.gitkeep b/__fixtures__/typescript-esm/success/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/typescript/no-import/node_modules/.gitkeep b/__fixtures__/typescript/no-import/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__fixtures__/typescript/success/node_modules/.gitkeep b/__fixtures__/typescript/success/node_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/__tests__/commands/run.test.ts b/__tests__/commands/run.test.ts index b1d2893..ef0e85e 100644 --- a/__tests__/commands/run.test.ts +++ b/__tests__/commands/run.test.ts @@ -20,7 +20,7 @@ jest.unstable_mockModule('../../src/utils/output.js', () => { const { action } = await import('../../src/commands/run.js') // Prevent output during tests -jest.spyOn(console, 'log').mockImplementation(() => {}) +// jest.spyOn(console, 'log').mockImplementation(() => {}) jest.spyOn(console, 'table').mockImplementation(() => {}) describe('Command: run', () => { @@ -35,28 +35,8 @@ describe('Command: run', () => { jest.resetAllMocks() }) - describe('TypeScript', () => { - it('Action: success', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript/success/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript/success` - EnvMeta.dotenvFile = `./__fixtures__/typescript/success/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript/success/src/main.ts` - - await expect(action()).resolves.toBeUndefined() - }) - - it('Action: no-import', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript/no-import/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript/no-import` - EnvMeta.dotenvFile = `./__fixtures__/typescript/no-import/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript/no-import/src/main.ts` - - await expect(action()).resolves.toBeUndefined() - }) - }) - describe('TypeScript ESM', () => { - it('Action: success', async () => { + it('TypeScript ESM Action: success', async () => { EnvMeta.actionFile = `./__fixtures__/typescript-esm/success/action.yml` EnvMeta.actionPath = `./__fixtures__/typescript-esm/success` EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/success/.env.fixture` @@ -68,7 +48,7 @@ describe('Command: run', () => { expect(quibbleEsm).toHaveBeenCalled() }) - it('Action: no-import', async () => { + it('TypeScript ESM Action: no-import', async () => { EnvMeta.actionFile = `./__fixtures__/typescript-esm/no-import/action.yml` EnvMeta.actionPath = `./__fixtures__/typescript-esm/no-import` EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/no-import/.env.fixture` @@ -82,7 +62,7 @@ describe('Command: run', () => { }) describe('JavaScript', () => { - it('Action: success', async () => { + it('JavaScript Action: success', async () => { EnvMeta.actionFile = `./__fixtures__/javascript/success/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/success` EnvMeta.dotenvFile = `./__fixtures__/javascript/success/.env.fixture` @@ -91,7 +71,7 @@ describe('Command: run', () => { await expect(action()).resolves.toBeUndefined() }) - it('Action: no-import', async () => { + it('JavaScript Action: no-import', async () => { EnvMeta.actionFile = `./__fixtures__/javascript/no-import/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/no-import` EnvMeta.dotenvFile = `./__fixtures__/javascript/no-import/.env.fixture` @@ -102,7 +82,7 @@ describe('Command: run', () => { }) describe('JavaScript (ESM)', () => { - it('Action: success', async () => { + it('JavaScript ESM Action: success', async () => { EnvMeta.actionFile = `./__fixtures__/javascript/success/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/success` EnvMeta.dotenvFile = `./__fixtures__/javascript/success/.env.fixture` @@ -113,7 +93,7 @@ describe('Command: run', () => { expect(quibbleDefault).toHaveBeenCalled() }) - it('Action: no-import', async () => { + it('JavaScript ESM Action: no-import', async () => { EnvMeta.actionFile = `./__fixtures__/javascript/no-import/action.yml` EnvMeta.actionPath = `./__fixtures__/javascript/no-import` EnvMeta.dotenvFile = `./__fixtures__/javascript/no-import/.env.fixture` diff --git a/package-lock.json b/package-lock.json index 7a4f91d..dd3bca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/local-action", - "version": "2.1.1", + "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/local-action", - "version": "2.1.1", + "version": "2.1.2", "license": "MIT", "dependencies": { "@actions/core": "^1.10.1", @@ -30,7 +30,6 @@ "@types/figlet": "^1.5.8", "@types/jest": "^29.5.12", "@types/node": "^22.0.2", - "@types/proxyquire": "^1.3.31", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "eslint": "^8.57.0", @@ -2158,12 +2157,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/proxyquire": { - "version": "1.3.31", - "resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.31.tgz", - "integrity": "sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==", - "dev": true - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 7db0327..f51e499 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@github/local-action", "description": "Local Debugging for GitHub Actions", - "version": "2.1.1", + "version": "2.1.2", "type": "module", "author": "Nick Alteen ", "private": false, @@ -60,7 +60,6 @@ "@types/figlet": "^1.5.8", "@types/jest": "^29.5.12", "@types/node": "^22.0.2", - "@types/proxyquire": "^1.3.31", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "eslint": "^8.57.0", diff --git a/src/commands/run.ts b/src/commands/run.ts index 01b99f4..e66104f 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,4 +1,5 @@ import { config } from 'dotenv' +import { createRequire } from 'module' import quibble from 'quibble' import { CORE_STUBS, CoreMeta } from '../stubs/core-stubs.js' import { EnvMeta } from '../stubs/env-stubs.js' @@ -6,6 +7,8 @@ import type { Action } from '../types.js' import { printTitle } from '../utils/output.js' import { isESM } from '../utils/package.js' +const require = createRequire(import.meta.url) + export async function action(): Promise { const { Chalk } = await import('chalk') const chalk = new Chalk() @@ -90,24 +93,63 @@ export async function action(): Promise { printTitle(CoreMeta.colors.green, 'Running Action') - // Stub the `@actions/toolkit` libraries and run the action. Quibble requires - // a different approach depending on if this is an ESM action. + // Get the node_modules path, starting with the entrypoint. + /* istanbul ignore next */ + const dirs = + path.dirname(EnvMeta.entrypoint).split('/') || // Unix + path.dirname(EnvMeta.entrypoint).split('\\') || // Windows + [] + while (dirs.length > 0) { + // Check if the current directory has a node_modules directory. + if (fs.existsSync(path.join(...dirs, 'node_modules'))) break + + // Move up the directory tree. + dirs.pop() + } + /* istanbul ignore if */ + if (dirs.length === 0) + throw new Error('Could not find node_modules directory') + + // Stub the `@actions/toolkit` libraries and run the action. Quibble and + // local-action require a different approach depending on if the called action + // is written in ESM. if (isESM()) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call await quibble.esm( - `${path.resolve(EnvMeta.actionPath)}/node_modules/@actions/core/lib/core.js`, + path.resolve( + ...dirs, + 'node_modules', + '@actions', + 'core', + 'lib', + 'core.js' + ), CORE_STUBS ) + + // ESM actions need to be imported, not required. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { run } = await import(path.resolve(EnvMeta.entrypoint)) + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await run() } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-call quibble( - `${path.resolve(EnvMeta.actionPath)}/node_modules/@actions/core`, + path.resolve( + ...dirs, + 'node_modules', + '@actions', + 'core', + 'lib', + 'core.js' + ), CORE_STUBS ) - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { run } = await import(path.resolve(EnvMeta.entrypoint)) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await run() + // CJS actions need to be required, not imported. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, import/no-dynamic-require + const { run } = require(path.resolve(EnvMeta.entrypoint)) + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await run() + } } diff --git a/src/stubs/summary-stubs.ts b/src/stubs/summary-stubs.ts index 621ee40..662ba18 100644 --- a/src/stubs/summary-stubs.ts +++ b/src/stubs/summary-stubs.ts @@ -59,8 +59,7 @@ export class Summary { CoreMeta.stepSummaryPath, fs.constants.R_OK | fs.constants.W_OK ) - } catch (error) { - console.error(error) + } catch { throw new Error( `Unable to access summary file: '${CoreMeta.stepSummaryPath}'. Check if the file has correct read/write permissions.` ) diff --git a/src/utils/package.ts b/src/utils/package.ts index b2d10a0..3b40776 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { dirname, join } from 'path' import { EnvMeta } from '../stubs/env-stubs.js' /** @@ -7,12 +8,29 @@ import { EnvMeta } from '../stubs/env-stubs.js' * @returns True if the project is an ESM module, false otherwise. */ export function isESM(): boolean { - const packageJson = JSON.parse( - fs.readFileSync(`${EnvMeta.actionPath}/package.json`, { - encoding: 'utf8', - flag: 'r' - }) - ) as { type: string } + // Starting at this directory, walk up the directory tree until we find a + // package.json file. + /* istanbul ignore next */ + const dirs = + dirname(EnvMeta.entrypoint).split('/') || // Unix + dirname(EnvMeta.entrypoint).split('\\') || // Windows + [] + while (dirs.length > 0) { + // Check if the current directory has a packge.json. + if (fs.existsSync(join(...dirs, 'package.json'))) { + const packageJson = JSON.parse( + fs.readFileSync(join(...dirs, 'package.json'), 'utf8') + ) as { [key: string]: any } - return packageJson.type === 'module' + return packageJson.type === 'module' + } + + // Move up the directory tree. + dirs.pop() + } + + // If we reach the root directory and still haven't found a package.json + // file, assume that the project is not an ESM module. + /* istanbul ignore next */ + return false }