diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eca3080c7..322156026 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,8 @@ This is for adding a module to be included in the default `citgm-all` runs. - Module source code must be on Github. - Published versions must include a tag on Github - The test process must be executable with only the commands - `npm install && npm test` or (`yarn install && yarn test`) using the tarball + `npm install && npm test` or (`yarn install && yarn test` + or `pnpm install && pnpm test`) using the tarball downloaded from the Github tag mentioned above - The tests pass on supported major release lines - The maintainers of the module remain responsive when there are problems diff --git a/README.md b/README.md index d2ac00b63..fcc09c518 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Options: --excludeTags tag1 tag2 Specify which tags to skip from the lookup (takes priority over includeTags) Module names are automatically added as tags. -y, --yarn Install and test the project using yarn instead of npm + --pnpm Install and test the project using pnpm instead of npm ``` When using a JSON config file, the properties need to be the same as the @@ -141,6 +142,7 @@ For syntax, see [lookup.json](./lib/lookup.json), the available attributes are: "useGitClone": true Use a shallow git clone instead of downloading the module "ignoreGitHead": Ignore the gitHead field if it exists and fallback to using github tags "yarn": Install and test the project using yarn instead of npm +"pnpm": Install and test the project using pnpm instead of npm "timeout": Number of milliseconds before timeout. Applies separately to `install` and `test` ``` diff --git a/lib/bin/citgm-all.js b/lib/bin/citgm-all.js index cbb61544d..821dbc6e7 100755 --- a/lib/bin/citgm-all.js +++ b/lib/bin/citgm-all.js @@ -69,6 +69,7 @@ const options = { tmpDir: app.tmpDir, customTest: app.customTest, yarn: app.yarn, + pnpm: app.pnpm, includeTags: app.includeTags || [], excludeTags: app.excludeTags || [] }; diff --git a/lib/bin/citgm.js b/lib/bin/citgm.js index 3bdc2763f..1cd47fa69 100755 --- a/lib/bin/citgm.js +++ b/lib/bin/citgm.js @@ -45,7 +45,8 @@ const options = { sha: app.sha, tmpDir: app.tmpDir, customTest: app.customTest, - yarn: app.yarn + yarn: app.yarn, + pnpm: app.pnpm }; if (!windows) { diff --git a/lib/citgm.js b/lib/citgm.js index d6b74981f..7388ed288 100644 --- a/lib/citgm.js +++ b/lib/citgm.js @@ -89,9 +89,10 @@ export class Tester extends EventEmitter { init(this); await findNode(this); - const { npm, yarn } = await getPackageManagers(); + const { npm, yarn, pnpm } = await getPackageManagers(); this.npmPath = npm; this.yarnPath = yarn; + this.pnpmPath = pnpm; await tempDirectory.create(this); await grabModuleData(this); diff --git a/lib/common-args.js b/lib/common-args.js index adcb00aec..695e127e9 100644 --- a/lib/common-args.js +++ b/lib/common-args.js @@ -82,6 +82,11 @@ export function commonArgs() { description: 'Install and test the project using yarn instead of npm', default: false }) + .option('pnpm', { + type: 'boolean', + description: 'Install and test the project using pnpm instead of npm', + default: false + }) .example( 'citgm-all --customTest /path/to/customTest.js', 'Runs a custom node test script instead of "npm test"' diff --git a/lib/grab-project.js b/lib/grab-project.js index 74ac28db9..535c17558 100644 --- a/lib/grab-project.js +++ b/lib/grab-project.js @@ -17,8 +17,12 @@ export async function grabProject(context) { } return new Promise((resolve, reject) => { - const packageManager = - context.options.yarn || context.module.useYarn ? 'yarn' : 'npm'; + let packageManager = 'npm'; + if (context.options.yarn || context.module.useYarn) { + packageManager = 'yarn'; + } else if (context.options.pnpm || context.module.usePnpm) { + packageManager = 'pnpm'; + } let packageName = context.module.raw; if (context.module.type === 'directory') { context.module.raw = context.module.name = path.basename(packageName); diff --git a/lib/lookup.js b/lib/lookup.js index 71453ec97..2d5ba17af 100644 --- a/lib/lookup.js +++ b/lib/lookup.js @@ -183,6 +183,9 @@ export function lookup(context) { if (rep.yarn) { context.module.useYarn = true; } + if (rep.pnpm) { + context.module.usePnpm = true; + } if (rep.timeout) { context.module.timeout = rep.timeout; } diff --git a/lib/package-manager/get-executable.js b/lib/package-manager/get-executable.js index 1d80b90d0..80f1d9c93 100644 --- a/lib/package-manager/get-executable.js +++ b/lib/package-manager/get-executable.js @@ -10,8 +10,8 @@ const npmWhich = promisify( ); export function getExecutable(binaryName) { - if (binaryName === 'yarn') { - // Use `npm-which` for yarn to get the local version + if (binaryName === 'yarn' || binaryName === 'pnpm') { + // Use `npm-which` for yarn or pnpm to get the local version return npmWhich(binaryName); } else { return which(binaryName); diff --git a/lib/package-manager/index.js b/lib/package-manager/index.js index 657dae4c5..8fc9b9ccc 100644 --- a/lib/package-manager/index.js +++ b/lib/package-manager/index.js @@ -5,6 +5,8 @@ import { getExecutable } from './get-executable.js'; export function pkgInstall(context) { if (context.options.yarn || context.module.useYarn) { return install('yarn', context); + } else if (context.options.pnpm || context.module.usePnpm) { + return install('pnpm', context); } else { return install('npm', context); } @@ -13,15 +15,18 @@ export function pkgInstall(context) { export function pkgTest(context) { if (context.options.yarn || context.module.useYarn) { return test('yarn', context); + } else if (context.options.pnpm || context.module.usePnpm) { + return test('pnpm', context); } else { return test('npm', context); } } export async function getPackageManagers() { - const [npm, yarn] = await Promise.all([ + const [npm, yarn, pnpm] = await Promise.all([ getExecutable('npm'), - getExecutable('yarn') + getExecutable('yarn'), + getExecutable('pnpm') ]); - return { npm, yarn }; + return { npm, yarn, pnpm }; } diff --git a/lib/package-manager/install.js b/lib/package-manager/install.js index 6ab463a75..179e56d2f 100644 --- a/lib/package-manager/install.js +++ b/lib/package-manager/install.js @@ -15,8 +15,12 @@ function getVersion(packageManager, context) { path.join(context.path, context.module.name), context ); - const packageManagerBin = - packageManager === 'npm' ? context.npmPath : context.yarnPath; + let packageManagerBin = context.npmPath; + if (packageManager === 'yarn') { + packageManagerBin = context.yarnPath; + } else if (packageManager === 'pnpm') { + packageManagerBin = context.pnpmPath; + } const binDirectory = path.dirname(packageManagerBin); options.env.PATH = `${binDirectory}${envSeparator}${process.env.PATH}`; @@ -74,14 +78,20 @@ export default function install(packageManager, context) { if (version && semverLt(version, '2.0.0', { includePrerelease: true })) { options.env['YARN_IGNORE_ENGINES'] = 'true'; } + } else if (packageManager === 'pnpm') { + // No pnpm-specific options yet } if (context.module.install) { args = context.module.install; } - const packageManagerBin = - packageManager === 'npm' ? context.npmPath : context.yarnPath; + let packageManagerBin = context.npmPath; + if (packageManager === 'yarn') { + packageManagerBin = context.yarnPath; + } else if (packageManager === 'pnpm') { + packageManagerBin = context.pnpmPath; + } const binDirectory = path.dirname(packageManagerBin); options.env.PATH = `${binDirectory}${envSeparator}${process.env.PATH}`; @@ -93,6 +103,10 @@ export default function install(packageManager, context) { proc, (err) => { if (err) { + if (context.testError.length === 0) { + // Because pnpm prints errors to stdout + context.testError = context.testOutput; + } return reject(err); } resolve(); diff --git a/lib/package-manager/test.js b/lib/package-manager/test.js index 8d30b26a1..e8d73a32c 100644 --- a/lib/package-manager/test.js +++ b/lib/package-manager/test.js @@ -59,8 +59,12 @@ export async function test(packageManager, context) { bin = await which('node', { path: options.env.PATH }); } - const packageManagerBin = - packageManager === 'npm' ? context.npmPath : context.yarnPath; + let packageManagerBin = context.npmPath; + if (packageManager === 'yarn') { + packageManagerBin = context.yarnPath; + } else if (packageManager === 'pnpm') { + packageManagerBin = context.pnpmPath; + } const binDirectory = dirname(packageManagerBin); options.env.PATH = `${binDirectory}${envSeparator}${ diff --git a/package.json b/package.json index b18e992ac..a28648253 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "normalize-git-url": "^3.0.2", "npm-package-arg": "^11.0.1", "npm-which": "^3.0.1", + "pnpm": "^8.10.0", "read-package-json": "^7.0.0", "root-check": "^2.0.0", "semver": "^7.5.4", diff --git a/test/bin/test-citgm-all.js b/test/bin/test-citgm-all.js index ea6d32a33..c97927860 100644 --- a/test/bin/test-citgm-all.js +++ b/test/bin/test-citgm-all.js @@ -269,6 +269,21 @@ test('citgm-all: install with yarn', (t) => { }); }); +test('citgm-all: install with pnpm', (t) => { + t.plan(1); + const proc = spawn(citgmAllPath, [ + '-l', + 'test/fixtures/custom-lookup.json', + '--pnpm' + ]); + proc.on('error', (err) => { + t.error(err); + }); + proc.on('close', (code) => { + t.equal(code, 0, 'citgm-all should only run omg-i-pass'); + }); +}); + test('bin: sigterm', (t) => { t.plan(1); diff --git a/test/helpers/make-context.js b/test/helpers/make-context.js index 98f1c7626..b20d741af 100644 --- a/test/helpers/make-context.js +++ b/test/helpers/make-context.js @@ -16,7 +16,8 @@ export function npmContext(mod, packageManagers, path, options) { meta: {}, options: options, npmPath: packageManagers.npm, - yarnPath: packageManagers.yarn + yarnPath: packageManagers.yarn, + pnpmPath: packageManagers.pnpm }; return context; } diff --git a/test/pnpm/test-pnpm-install.js b/test/pnpm/test-pnpm-install.js new file mode 100644 index 000000000..70d74c540 --- /dev/null +++ b/test/pnpm/test-pnpm-install.js @@ -0,0 +1,88 @@ +import { tmpdir } from 'os'; +import { promises as fs } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import tap from 'tap'; + +import { getPackageManagers } from '../../lib/package-manager/index.js'; +import packageManagerInstall from '../../lib/package-manager/install.js'; +import { npmContext } from '../helpers/make-context.js'; + +const { test } = tap; + +const sandbox = join(tmpdir(), `citgm-${Date.now()}-pnpm-install`); +const fixtures = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'fixtures' +); +const moduleFixtures = join(fixtures, 'omg-i-pass'); +const moduleTemp = join(sandbox, 'omg-i-pass'); +const extraParamFixtures = join(fixtures, 'omg-i-pass-with-install-param'); +const extraParamTemp = join(sandbox, 'omg-i-pass-with-install-param'); +const badFixtures = join(fixtures, 'omg-bad-tree'); +const badTemp = join(sandbox, 'omg-bad-tree'); + +let packageManagers; + +test('pnpm-install: setup', async () => { + packageManagers = await getPackageManagers(); + await fs.mkdir(sandbox, { recursive: true }); + await Promise.all([ + fs.cp(moduleFixtures, moduleTemp, { recursive: true }), + fs.cp(extraParamFixtures, extraParamTemp, { recursive: true }), + fs.cp(badFixtures, badTemp, { recursive: true }) + ]); +}); + +test('pnpm-install: basic module', async () => { + const context = npmContext('omg-i-pass', packageManagers, sandbox); + await packageManagerInstall('pnpm', context); +}); + +test('pnpm-install: no package.json', async (t) => { + t.plan(2); + const context = npmContext('omg-i-fail', packageManagers, sandbox); + try { + await packageManagerInstall('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'Install Failed'); + t.notOk(context.module.flaky, 'Module failed but is not flaky'); + } +}); + +test('pnpm-install: timeout', async (t) => { + t.plan(2); + const context = npmContext('omg-i-pass', packageManagers, sandbox, { + timeout: 10 + }); + try { + await packageManagerInstall('pnpm', context); + } catch (err) { + t.notOk(context.module.flaky, 'Time out should not mark module flaky'); + t.equal(err && err.message, 'Install Timed Out'); + } +}); + +test('pnpm-install: failed install', async (t) => { + t.plan(3); + const context = npmContext('omg-bad-tree', packageManagers, sandbox); + const expected = /\/THIS-WILL-FAIL: Not Found/; + try { + await packageManagerInstall('pnpm', context); + } catch (err) { + t.notOk(context.module.flaky, 'Module failed is not flaky'); + t.equal(err && err.message, 'Install Failed'); + t.match(context.testError.toString(), expected, 'Install error reported'); + } +}); + +tap.teardown(async () => { + await fs.rm(sandbox, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 10 + }); +}); diff --git a/test/pnpm/test-pnpm-test.js b/test/pnpm/test-pnpm-test.js new file mode 100644 index 000000000..b7860d9ca --- /dev/null +++ b/test/pnpm/test-pnpm-test.js @@ -0,0 +1,190 @@ +import { existsSync, promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import tap, { test } from 'tap'; + +import { npmContext } from '../helpers/make-context.js'; +import { getPackageManagers } from '../../lib/package-manager/index.js'; +import { test as packageManagerTest } from '../../lib/package-manager/test.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const sandbox = join(tmpdir(), `citgm-${Date.now()}-pnpm-test`); +const fixtures = join(__dirname, '..', 'fixtures'); + +const passFixtures = join(fixtures, 'omg-i-pass'); +const passTemp = join(sandbox, 'omg-i-pass'); + +const failFixtures = join(fixtures, 'omg-i-fail'); +const failTemp = join(sandbox, 'omg-i-fail'); + +const noTestScriptFixtures = join(fixtures, 'omg-i-have-no-test-script'); +const noTestScriptTemp = join(sandbox, 'omg-i-have-no-test-script'); + +const badFixtures = join(fixtures, 'omg-i-do-not-support-testing'); +const badTemp = join(sandbox, 'omg-i-do-not-support-testing'); + +const scriptsFixtures = join(fixtures, 'omg-i-pass-with-scripts'); +const scriptsTemp = join(sandbox, 'omg-i-pass-with-scripts'); + +const writeTmpdirFixtures = join(fixtures, 'omg-i-write-to-tmpdir'); +const writeTmpdirTemp = join(sandbox, 'omg-i-write-to-tmpdir'); + +let packageManagers; + +test('pnpm-test: setup', async () => { + packageManagers = await getPackageManagers(); + await fs.mkdir(sandbox, { recursive: true }); + await Promise.all([ + fs.cp(passFixtures, passTemp, { recursive: true }), + fs.cp(failFixtures, failTemp, { recursive: true }), + fs.cp(badFixtures, badTemp, { recursive: true }), + fs.cp(noTestScriptFixtures, noTestScriptTemp, { recursive: true }), + fs.cp(scriptsFixtures, scriptsTemp, { recursive: true }), + fs.cp(writeTmpdirFixtures, writeTmpdirTemp, { recursive: true }) + ]); +}); + +test('pnpm-test: basic module passing', async () => { + const context = npmContext('omg-i-pass', packageManagers, sandbox); + await packageManagerTest('pnpm', context); +}); + +test('pnpm-test: basic module failing', async (t) => { + t.plan(1); + const context = npmContext('omg-i-fail', packageManagers, sandbox); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'The canary is dead:'); + } +}); + +test('pnpm-test: basic module no test script', async (t) => { + t.plan(1); + const context = npmContext( + 'omg-i-do-not-support-testing', + packageManagers, + sandbox + ); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'Module does not support pnpm-test!'); + } +}); + +test('pnpm-test: no package.json', async (t) => { + t.plan(1); + const context = npmContext('omg-i-dont-exist', packageManagers, sandbox); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'Package.json Could not be found'); + } +}); + +test('pnpm-test: alternative test-path', async (t) => { + t.plan(1); + // Same test as 'basic module passing', except with alt node bin which fails. + const context = npmContext('omg-i-pass', packageManagers, sandbox, { + testPath: resolve(__dirname, '..', 'fixtures', 'fakenodebin') + }); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'The canary is dead:'); + } +}); + +test('pnpm-test: timeout', async (t) => { + t.plan(2); + const context = npmContext('omg-i-pass', packageManagers, sandbox, { + timeout: 10 + }); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.notOk(context.module.flaky, 'Time out should not mark module flaky'); + t.equal(err && err.message, 'Test Timed Out'); + } +}); + +test('pnpm-test: module with scripts passing', async () => { + const context = npmContext( + { + name: 'omg-i-pass-with-scripts', + scripts: ['test-build', 'test'] + }, + packageManagers, + sandbox, + { + npmLevel: 'silly' + } + ); + + await packageManagerTest('pnpm', context); +}); + +test('pnpm-test: module with no test script failing', async (t) => { + t.plan(1); + const context = npmContext( + { + name: 'omg-i-have-no-test-script' + }, + packageManagers, + sandbox, + { + npmLevel: 'silly' + } + ); + try { + await packageManagerTest('pnpm', context); + } catch (err) { + t.equal(err && err.message, 'Module does not support pnpm-test!'); + } +}); + +test('pnpm-test: module with no test script passing', async () => { + const context = npmContext( + { + name: 'omg-i-have-no-test-script', + scripts: ['test:node'] + }, + packageManagers, + sandbox, + { + npmLevel: 'silly' + } + ); + await packageManagerTest('pnpm', context); +}); + +test('pnpm-test: tmpdir is redirected', async (t) => { + t.plan(1); + const context = npmContext( + 'omg-i-write-to-tmpdir', + packageManagers, + sandbox, + { + npmLevel: 'silly' + } + ); + context.npmConfigTmp = writeTmpdirTemp; + await packageManagerTest('pnpm', context); + t.ok( + existsSync(join(writeTmpdirTemp, 'omg-i-write-to-tmpdir-testfile')), + 'Temporary file is written into the redirected temporary directory' + ); +}); + +tap.teardown(async () => { + await fs.rm(sandbox, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 10 + }); +});