From 674b2904a85a094f3937965732aa148a0f7f535a Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Thu, 31 Mar 2022 17:18:35 -0500 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Move=20packages=20to=20ESM?= =?UTF-8?q?=20(#852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 Migrate repo scripts to ESM * ♻ Migrate @percy/env to ESM * ♻ Migrate @percy/logger to ESM * ✅ Fix test usage of logger mocking helper * ✅ Fix test usage of core test helper * ♻ Migrate @percy/client to ESM * ✅ Fix test usage of client test helper * ♻ Migrate @percy/config to ESM * ♻ Migrate @percy/core to ESM * ➖ Remove nock dependency * ♻ Migrate @percy/cli-command to ESM * ♻ Migrate @percy/cli to ESM * ♻ Migrate @percy/cli-build to ESM * ♻ Migrate @percy/cli-config to ESM * ♻ Migrate @percy/cli-upload to ESM * ♻ Migrate @percy/cli-exec to ESM * ♻ Migrate @percy/cli-snapshot to ESM * ➖ Remove babel-plugin-module-resolver dependency * ♻ Migrate @percy/sdk-utils to ESM * 💚 Update CI workflows * ✅ Fix flakey tests * ✅ Use different test port to work around Windows CI * 🐛 Specify extension and use commonjs for CLI bin * 🐛 Reference dist in package import aliases And subsequently rewrite import aliases from dist to src in development * 🔨 Fix clean script --- .github/workflows/lint.yml | 2 - .github/workflows/test.yml | 10 +- .github/workflows/typecheck.yml | 8 +- .github/workflows/windows.yml | 14 +- babel.config.cjs | 29 +++ babel.config.js | 46 ---- karma.config.cjs | 72 +++++++ karma.config.js | 70 ------ package.json | 5 +- packages/cli-build/package.json | 3 +- packages/cli-build/src/build.js | 4 +- packages/cli-build/src/index.js | 6 +- packages/cli-build/test/finalize.test.js | 6 +- packages/cli-build/test/wait.test.js | 6 +- packages/cli-command/README.md | 2 +- packages/cli-command/bin/readme | 43 ++-- packages/cli-command/package.json | 3 +- packages/cli-command/src/command.js | 6 +- packages/cli-command/src/index.js | 4 +- packages/cli-command/src/legacy.js | 2 +- packages/cli-command/src/parse.js | 2 +- packages/cli-command/test/command.test.js | 8 +- packages/cli-command/test/flags.test.js | 8 +- packages/cli-command/test/help.test.js | 8 +- packages/cli-command/test/legacy.test.js | 8 +- packages/cli-command/test/parse.test.js | 8 +- packages/cli-config/package.json | 7 +- packages/cli-config/src/config.js | 6 +- packages/cli-config/src/index.js | 8 +- packages/cli-config/test/create.test.js | 6 +- packages/cli-config/test/migrate.test.js | 10 +- packages/cli-config/test/validate.test.js | 6 +- packages/cli-exec/package.json | 7 +- packages/cli-exec/src/exec.js | 10 +- packages/cli-exec/src/index.js | 8 +- packages/cli-exec/src/ping.js | 2 +- packages/cli-exec/src/start.js | 2 +- packages/cli-exec/src/stop.js | 2 +- packages/cli-exec/test/.eslintrc | 2 + packages/cli-exec/test/exec.test.js | 28 ++- packages/cli-exec/test/ping.test.js | 10 +- packages/cli-exec/test/start.test.js | 9 +- packages/cli-exec/test/stop.test.js | 10 +- packages/cli-snapshot/package.json | 9 +- packages/cli-snapshot/src/index.js | 2 +- packages/cli-snapshot/src/snapshot.js | 13 +- packages/cli-snapshot/test/common.test.js | 8 +- packages/cli-snapshot/test/directory.test.js | 8 +- packages/cli-snapshot/test/file.test.js | 6 +- packages/cli-snapshot/test/sitemap.test.js | 6 +- .../cli-snapshot/test/unit/config.test.js | 2 +- packages/cli-upload/package.json | 7 +- packages/cli-upload/src/index.js | 2 +- packages/cli-upload/src/upload.js | 7 +- packages/cli-upload/test/unit/config.test.js | 2 +- packages/cli-upload/test/upload.test.js | 10 +- packages/cli/bin/run | 14 -- packages/cli/bin/run.cjs | 14 ++ packages/cli/package.json | 5 +- packages/cli/src/commands.js | 14 +- packages/cli/src/index.js | 4 +- packages/cli/src/percy.js | 6 +- packages/cli/src/update.js | 7 +- packages/cli/test/commands.test.js | 15 +- packages/cli/test/helpers.js | 70 +++--- packages/cli/test/update.test.js | 51 ++--- packages/client/package.json | 3 +- packages/client/src/client.js | 13 +- packages/client/src/index.js | 2 +- packages/client/src/utils.js | 29 ++- packages/client/test/client.test.js | 22 +- packages/client/test/helpers.js | 201 ++++++++++++------ packages/client/test/unit/request.test.js | 34 +-- packages/config/package.json | 3 +- packages/config/src/defaults.js | 4 +- packages/config/src/index.js | 12 +- packages/config/src/load.js | 8 +- packages/config/src/migrate.js | 2 +- packages/config/src/utils/index.js | 6 +- packages/config/src/utils/normalize.js | 4 +- packages/config/src/utils/stringify.js | 2 +- packages/config/src/validate.js | 4 +- packages/config/test/helpers.js | 28 ++- packages/config/test/index.test.js | 10 +- packages/core/package.json | 5 +- packages/core/post-install.js | 37 ++-- packages/core/src/api.js | 14 +- packages/core/src/browser.js | 11 +- packages/core/src/discovery.js | 2 +- packages/core/src/index.js | 4 +- packages/core/src/install.js | 107 +++++----- packages/core/src/network.js | 6 +- packages/core/src/page.js | 9 +- packages/core/src/percy.js | 14 +- packages/core/src/queue.js | 2 +- packages/core/src/server.js | 4 +- packages/core/src/snapshot.js | 4 +- packages/core/src/utils.js | 9 +- packages/core/test/api.test.js | 39 ++-- packages/core/test/discovery.test.js | 34 +-- packages/core/test/helpers/index.js | 19 +- packages/core/test/helpers/server.js | 10 +- packages/core/test/percy.test.js | 12 +- packages/core/test/snapshot-multiple.test.js | 12 +- packages/core/test/snapshot.test.js | 8 +- packages/core/test/unit/config.test.js | 6 +- packages/core/test/unit/install.test.js | 78 +++---- packages/core/test/unit/queue.test.js | 2 +- packages/core/test/unit/server.test.js | 6 +- packages/dom/test/serialize-frames.test.js | 2 +- packages/env/package.json | 3 +- packages/env/src/dotenv.js | 2 + packages/env/src/environment.js | 2 +- packages/env/src/index.js | 8 +- packages/env/src/utils.js | 2 +- packages/env/test/appveyor.test.js | 2 +- packages/env/test/azure.test.js | 2 +- packages/env/test/bitbucket.test.js | 2 +- packages/env/test/buildkite.test.js | 2 +- packages/env/test/circle.test.js | 2 +- packages/env/test/codeship.test.js | 2 +- packages/env/test/defaults.test.js | 4 +- packages/env/test/dotenv.test.js | 2 +- packages/env/test/drone.test.js | 2 +- packages/env/test/github.test.js | 16 +- packages/env/test/gitlab.test.js | 2 +- packages/env/test/heroku.test.js | 2 +- packages/env/test/jenkins.test.js | 4 +- packages/env/test/netlify.test.js | 2 +- packages/env/test/probo.test.js | 2 +- packages/env/test/semaphore.test.js | 2 +- packages/env/test/travis.test.js | 2 +- packages/logger/package.json | 11 +- packages/logger/src/browser.js | 4 +- packages/logger/src/index.js | 10 +- packages/logger/src/logger.js | 2 +- packages/logger/test/helpers.js | 9 +- packages/logger/test/logger.test.js | 6 +- packages/logger/test/remote.test.js | 4 +- packages/sdk-utils/README.md | 12 +- packages/sdk-utils/package.json | 5 +- packages/sdk-utils/src/index.js | 18 +- packages/sdk-utils/src/percy-dom.js | 4 +- packages/sdk-utils/src/percy-enabled.js | 4 +- packages/sdk-utils/src/percy-idle.js | 2 +- packages/sdk-utils/src/post-snapshot.js | 4 +- packages/sdk-utils/src/request.js | 6 +- packages/sdk-utils/test/helpers.js | 17 +- packages/sdk-utils/test/index.test.js | 4 +- packages/sdk-utils/test/server.js | 91 ++++---- rollup.config.js | 48 +++-- scripts/babel-register.js | 16 -- scripts/build.js | 28 +-- scripts/chromium-revision | 20 +- scripts/loader.js | 112 ++++++++++ scripts/test-helpers.js | 9 +- scripts/test.js | 52 +++-- scripts/watch.js | 18 +- yarn.lock | 58 +---- 159 files changed, 1221 insertions(+), 1063 deletions(-) create mode 100644 babel.config.cjs delete mode 100644 babel.config.js create mode 100644 karma.config.cjs delete mode 100644 karma.config.js delete mode 100755 packages/cli/bin/run create mode 100755 packages/cli/bin/run.cjs delete mode 100644 scripts/babel-register.js create mode 100644 scripts/loader.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6706bdda8..195dc740a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,6 +27,4 @@ jobs: ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ - run: yarn - env: - PERCY_POSTINSTALL_BROWSER: true - run: yarn lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9cf87d14a..0f991d115 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 14 - uses: actions/cache@v3 with: path: | @@ -20,15 +20,13 @@ jobs: packages/*/node_modules packages/core/.local-chromium key: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ ${{ hashFiles('**/yarn.lock') }} restore-keys: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ - run: yarn - env: - PERCY_POSTINSTALL_BROWSER: true - run: yarn build - uses: actions/upload-artifact@v2 with: @@ -41,7 +39,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node: [12] + node: [14] package: - '@percy/env' - '@percy/client' diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 42632968a..a12081948 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 14 - uses: actions/cache@v3 with: path: | @@ -20,13 +20,11 @@ jobs: packages/*/node_modules packages/core/.local-chromium key: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ ${{ hashFiles('**/yarn.lock') }} restore-keys: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ - run: yarn - env: - PERCY_POSTINSTALL_BROWSER: true - run: yarn test:types diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d6aa0d4f2..dad2cdfb4 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 14 - uses: actions/cache@v3 with: path: | @@ -20,15 +20,13 @@ jobs: packages/*/node_modules packages/core/.local-chromium key: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ ${{ hashFiles('**/yarn.lock') }} restore-keys: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ - run: yarn - env: - PERCY_POSTINSTALL_BROWSER: true - run: yarn build - uses: actions/upload-artifact@v2 with: @@ -61,7 +59,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 12 + node-version: 14 - uses: actions/cache@v3 with: path: | @@ -69,11 +67,11 @@ jobs: packages/*/node_modules packages/core/.local-chromium key: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ ${{ hashFiles('**/yarn.lock') }} restore-keys: > - ${{ runner.os }}/node-12/ + ${{ runner.os }}/node-14/ ${{ hashFiles('.github/.cache-key') }}/ - uses: actions/download-artifact@v2 with: diff --git a/babel.config.cjs b/babel.config.cjs new file mode 100644 index 000000000..3ede4b424 --- /dev/null +++ b/babel.config.cjs @@ -0,0 +1,29 @@ +const cwd = process.cwd(); +const path = require('path'); +const pkg = require(`${cwd}/package.json`); + +module.exports = { + overrides: [{ + exclude: pkg.files && ( + pkg.files.map(f => ( + path.join(cwd, f) + ))), + presets: [ + ['@babel/env', { + modules: false, + targets: { + node: '14' + } + }] + ] + }], + env: { + test: { + plugins: [ + ['istanbul', { + exclude: ['dist', 'test'] + }] + ] + } + } +}; diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index a745bd534..000000000 --- a/babel.config.js +++ /dev/null @@ -1,46 +0,0 @@ -const cwd = process.cwd(); -const path = require('path'); -const pkg = require(`${cwd}/package.json`); - -const base = { - overrides: [{ - exclude: pkg.files && ( - pkg.files.map(f => ( - path.join(cwd, f) - ))), - presets: [ - ['@babel/env', { - targets: { - node: '12' - } - }] - ] - }] -}; - -const development = { - plugins: [ - ['module-resolver', { - cwd: __dirname, - alias: { - '^@percy/((?!dom)[^/]+)$': './packages/\\1/src', - '^@percy/([^/]+)/((?!test).+)$': './packages/\\1/src/\\2' - } - }] - ] -}; - -const test = { - plugins: [ - ...development.plugins, - ['istanbul', { exclude: ['dist', 'test'] }] - ] -}; - -module.exports = { - ...base, - env: { - development, - test - } -}; diff --git a/karma.config.cjs b/karma.config.cjs new file mode 100644 index 000000000..7b9624684 --- /dev/null +++ b/karma.config.cjs @@ -0,0 +1,72 @@ +module.exports = async config => { + const rollup = await import('./rollup.config.js'); + + return config.set({ + basePath: process.cwd(), + frameworks: ['jasmine'], + reporters: ['mocha'], + singleRun: true, + concurrency: 1, + + browsers: [ + 'ChromeHeadless', + 'FirefoxHeadless' + ], + + files: [ + // common files + { pattern: require.resolve('regenerator-runtime/runtime'), watched: false }, + { pattern: require.resolve('./scripts/test-helpers'), type: 'module', watched: false }, + // local package files + { pattern: 'src/index.js', type: 'module', watched: false }, + { pattern: 'test/helpers.js', type: 'module', watched: false }, + { pattern: 'test/**/*.test.js', type: 'module', watched: false }, + { pattern: 'test/assets/**', watched: false, included: false } + ], + + proxies: { + // useful when the contents of a fake asset do not matter + '/_/': 'localhost/' + }, + + // create dedicated bundles for src, test helpers, and each test suite + preprocessors: { + 'src/index.js': ['rollup'], + 'test/helpers.js': ['rollupTestHelpers'], + 'test/**/*.test.js': ['rollupTestFiles'] + }, + + client: { + env: { + // used in the test helper to add failed test debug logs + DUMP_FAILED_TEST_LOGS: process.env.DUMP_FAILED_TEST_LOGS + }, + // reports look better when not randomized + jasmine: { + random: false + } + }, + + // (see rollup.config.js) + rollupPreprocessor: rollup.test, + + customPreprocessors: { + rollupTestHelpers: { + base: 'rollup', + options: rollup.testHelpers + }, + rollupTestFiles: { + base: 'rollup', + options: rollup.testFiles + } + }, + + plugins: [ + 'karma-chrome-launcher', + 'karma-firefox-launcher', + 'karma-jasmine', + 'karma-mocha-reporter', + 'karma-rollup-preprocessor' + ] + }); +}; diff --git a/karma.config.js b/karma.config.js deleted file mode 100644 index 73eace899..000000000 --- a/karma.config.js +++ /dev/null @@ -1,70 +0,0 @@ -const cwd = process.cwd(); -const rollup = require('./rollup.config'); - -module.exports = config => config.set({ - basePath: cwd, - singleRun: true, - frameworks: ['jasmine'], - reporters: ['mocha'], - - browsers: [ - 'ChromeHeadless', - 'FirefoxHeadless' - ], - - files: [ - // common files - { pattern: require.resolve('regenerator-runtime/runtime'), watched: false }, - { pattern: require.resolve('./scripts/test-helpers'), watched: false }, - // local package files - { pattern: 'src/index.js', watched: false }, - { pattern: 'test/helpers.js', watched: false }, - { pattern: 'test/**/*.test.js', watched: false }, - { pattern: 'test/assets/**', watched: false, included: false } - ], - - proxies: { - // useful when the contents of a fake asset do not matter - '/_/': 'localhost/' - }, - - // create dedicated bundles for src, test helpers, and each test suite - preprocessors: { - 'src/index.js': ['rollup'], - 'test/helpers.js': ['rollupTestHelpers'], - 'test/**/*.test.js': ['rollupTestFiles'] - }, - - client: { - env: { - // used in the test helper to add failed test debug logs - DUMP_FAILED_TEST_LOGS: process.env.DUMP_FAILED_TEST_LOGS - }, - // reports look better when not randomized - jasmine: { - random: false - } - }, - - // (see rollup.config.js) - rollupPreprocessor: rollup.test, - - customPreprocessors: { - rollupTestHelpers: { - base: 'rollup', - options: rollup.testHelpers - }, - rollupTestFiles: { - base: 'rollup', - options: rollup.testFiles - } - }, - - plugins: [ - 'karma-chrome-launcher', - 'karma-firefox-launcher', - 'karma-jasmine', - 'karma-mocha-reporter', - 'karma-rollup-preprocessor' - ] -}); diff --git a/package.json b/package.json index 1497be547..89c406921 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "workspaces": [ "packages/*" ], @@ -12,7 +13,7 @@ "build:watch": "lerna run build --stream -- --watch", "bump-version": "lerna version --exact --no-git-tag-version --no-push", "chromium-revision": "./scripts/chromium-revision", - "clean": "git clean -Xdf --exclude !node_modules", + "clean": "git clean -Xdf -e !node_modules -e !**/node_modules/**", "lint": "eslint --ignore-path .gitignore .", "readme": "lerna run --parallel readme", "postinstall": "lerna run --stream postinstall", @@ -31,7 +32,6 @@ "@rollup/plugin-commonjs": "^21.0.0", "@rollup/plugin-node-resolve": "^13.0.1", "babel-plugin-istanbul": "^6.0.0", - "babel-plugin-module-resolver": "^4.0.0", "cross-env": "^7.0.2", "eslint": "^7.30.0", "eslint-config-standard": "^16.0.2", @@ -50,7 +50,6 @@ "karma-rollup-preprocessor": "^7.0.5", "lerna": "^4.0.0", "memfs": "^3.4.0", - "nock": "^13.1.1", "nyc": "^15.1.0", "rollup": "^2.53.2", "tsd": "^0.19.0" diff --git a/packages/cli-build/package.json b/packages/cli-build/package.json index 7fd85338c..411fbf3c7 100644 --- a/packages/cli-build/package.json +++ b/packages/cli-build/package.json @@ -11,12 +11,13 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist" ], "main": "./dist/index.js", + "type": "module", "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", diff --git a/packages/cli-build/src/build.js b/packages/cli-build/src/build.js index 6b749174c..9e7806e2a 100644 --- a/packages/cli-build/src/build.js +++ b/packages/cli-build/src/build.js @@ -1,7 +1,7 @@ import command from '@percy/cli-command'; -import finalize from './finalize'; -import wait from './wait'; +import finalize from './finalize.js'; +import wait from './wait.js'; export const build = command('build', { description: 'Finalize and wait on Percy builds', diff --git a/packages/cli-build/src/index.js b/packages/cli-build/src/index.js index 73974a4d0..00de93e07 100644 --- a/packages/cli-build/src/index.js +++ b/packages/cli-build/src/index.js @@ -1,3 +1,3 @@ -export { default, build } from './build'; -export { finalize } from './finalize'; -export { wait } from './wait'; +export { default, build } from './build.js'; +export { finalize } from './finalize.js'; +export { wait } from './wait.js'; diff --git a/packages/cli-build/test/finalize.test.js b/packages/cli-build/test/finalize.test.js index c3d706cc4..31b6cfd0b 100644 --- a/packages/cli-build/test/finalize.test.js +++ b/packages/cli-build/test/finalize.test.js @@ -1,9 +1,9 @@ import { logger, setupTest } from '@percy/cli-command/test/helpers'; -import finalize from '../src/finalize'; +import finalize from '../src/finalize.js'; describe('percy build:finalize', () => { - beforeEach(() => { - setupTest(); + beforeEach(async () => { + await setupTest(); }); afterEach(() => { diff --git a/packages/cli-build/test/wait.test.js b/packages/cli-build/test/wait.test.js index 7001fd027..57c9ab0ab 100644 --- a/packages/cli-build/test/wait.test.js +++ b/packages/cli-build/test/wait.test.js @@ -1,5 +1,5 @@ import { logger, api, setupTest } from '@percy/cli-command/test/helpers'; -import wait from '../src/wait'; +import wait from '../src/wait.js'; describe('percy build:wait', () => { let build = attrs => ({ @@ -16,9 +16,9 @@ describe('percy build:wait', () => { } }); - beforeEach(() => { + beforeEach(async () => { process.env.PERCY_TOKEN = '<>'; - setupTest({ loggerTTY: true }); + await setupTest({ loggerTTY: true }); }); afterEach(() => { diff --git a/packages/cli-command/README.md b/packages/cli-command/README.md index cccd81548..f8a6ecd3a 100644 --- a/packages/cli-command/README.md +++ b/packages/cli-command/README.md @@ -14,7 +14,7 @@ The `command` function accepts a name, definition, and callback and returns a ru accepts an array of command-line arguments. ``` js -const { command } = require('@percy/cli-command'); +import command from '@percy/cli-command'; // example command runner const example = command('example', { diff --git a/packages/cli-command/bin/readme b/packages/cli-command/bin/readme index 8396cfcd4..e1046cf53 100755 --- a/packages/cli-command/bin/readme +++ b/packages/cli-command/bin/readme @@ -1,23 +1,21 @@ #!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const { formatHelp } = require('@percy/cli-command/dist/help'); - -const CWD = process.cwd(); -const README = path.join(CWD, 'README.md'); - -const HELP_USAGE = /^(.*)?(usage:.*)$/gis; -const CMDS_TMPL = /()(.*)()/is; - -async function generateReadmeCommands(pkg) { - if (!pkg['@percy/cli']?.commands) return ''; +const fs = await import('fs'); +const path = await import('path'); +const { formatHelp } = await import('../dist/help'); +async function updateReadmeCommands(cwd = process.cwd()) { + let readmePath = path.join(cwd, 'README.md'); + let pkgPath = path.join(cwd, 'package.json'); let sections = []; let toc = []; + let pkg = JSON.parse(fs.readFileSync(pkgPath)); + if (!pkg['@percy/cli']?.commands) return ''; + for (let cmdPath of pkg['@percy/cli'].commands) { - let { default: command } = require(path.join(CWD, cmdPath)); + let cmdURL = url.pathToFileURL(path.join(CWD, cmdPath)); + let { default: command } = await import(cmdURL.href); command = { ...command, parent: { name: 'percy' } }; for (let cmd of [command, ...(command.definition.commands || [])]) { @@ -30,22 +28,15 @@ async function generateReadmeCommands(pkg) { toc.push(`* [${title}](#${slug})`); let help = await formatHelp(cmd); - help = help.replace(HELP_USAGE, '$1```\n$2```'); + help = help.replace(/^(.*)?(usage:.*)$/gis, '$1```\n$2```'); sections.push(`### ${title}\n\n${help}\n`); } } - return [ - toc.join('\n'), - sections.join('\n') - ].join('\n\n'); -} - -async function updatePackageReadme() { - let pkg = require(path.join(CWD, 'package.json')); - let content = fs.readFileSync(README, 'utf8') - .replace(CMDS_TMPL, `$1\n${await generateReadmeCommands(pkg)}$3`) - fs.writeFileSync(README, content); + fs.writeFileSync(readmePath, fs.readFileSync(readmePath, 'utf-8').replace( + /()(.*)()/is, + `$1\n${[toc.join('\n'), sections.join('\n')].join('\n\n')}$3` + )); } -updatePackageReadme(); +updateReadmeCommands(); diff --git a/packages/cli-command/package.json b/packages/cli-command/package.json index 8365a364e..26472a196 100644 --- a/packages/cli-command/package.json +++ b/packages/cli-command/package.json @@ -14,12 +14,13 @@ "./dist" ], "engines": { - "node": ">=12" + "node": ">=14" }, "bin": { "percy-cli-readme": "./bin/readme" }, "main": "./dist/index.js", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils.js", diff --git a/packages/cli-command/src/command.js b/packages/cli-command/src/command.js index 22524fa95..81121aa63 100644 --- a/packages/cli-command/src/command.js +++ b/packages/cli-command/src/command.js @@ -2,9 +2,9 @@ import logger from '@percy/logger'; import PercyConfig from '@percy/config'; import { set, del } from '@percy/config/utils'; import * as CoreConfig from '@percy/core/config'; -import * as builtInFlags from './flags'; -import formatHelp from './help'; -import parse from './parse'; +import * as builtInFlags from './flags.js'; +import formatHelp from './help.js'; +import parse from './parse.js'; // Copies a command definition and adds built-in flags and config options. function withBuiltIns(definition) { diff --git a/packages/cli-command/src/index.js b/packages/cli-command/src/index.js index 0d4040524..312782ada 100644 --- a/packages/cli-command/src/index.js +++ b/packages/cli-command/src/index.js @@ -1,5 +1,5 @@ -export { default, command } from './command'; -export { legacyCommand, legacyFlags as flags } from './legacy'; +export { default, command } from './command.js'; +export { legacyCommand, legacyFlags as flags } from './legacy.js'; // export common packages to avoid dependency resolution issues export { default as PercyConfig } from '@percy/config'; export { default as logger } from '@percy/logger'; diff --git a/packages/cli-command/src/legacy.js b/packages/cli-command/src/legacy.js index 518623835..5fbb1c098 100644 --- a/packages/cli-command/src/legacy.js +++ b/packages/cli-command/src/legacy.js @@ -1,5 +1,5 @@ import { merge } from '@percy/config/utils'; -import { command } from './command'; +import { command } from './command.js'; // Legacy flags for older commands that inadvertently import a newer @percy/cli-command export const legacyFlags = { diff --git a/packages/cli-command/src/parse.js b/packages/cli-command/src/parse.js index 4a42d1ed7..2b9b7e650 100644 --- a/packages/cli-command/src/parse.js +++ b/packages/cli-command/src/parse.js @@ -1,6 +1,6 @@ import logger from '@percy/logger'; import { camelcase } from '@percy/config/utils'; -import { flagUsage } from './help'; +import { flagUsage } from './help.js'; // Make it possible to identify parse errors. export class ParseError extends Error { diff --git a/packages/cli-command/test/command.test.js b/packages/cli-command/test/command.test.js index 43a806f9f..ce3e0d6d4 100644 --- a/packages/cli-command/test/command.test.js +++ b/packages/cli-command/test/command.test.js @@ -1,9 +1,9 @@ -import { logger, dedent } from './helpers'; -import command from '../src'; +import { logger, dedent } from './helpers.js'; +import command from '@percy/cli-command'; describe('Command', () => { - beforeEach(() => { - logger.mock(); + beforeEach(async () => { + await logger.mock(); }); it('is a function that runs an action', async () => { diff --git a/packages/cli-command/test/flags.test.js b/packages/cli-command/test/flags.test.js index a5fa2cdae..c52233256 100644 --- a/packages/cli-command/test/flags.test.js +++ b/packages/cli-command/test/flags.test.js @@ -1,11 +1,11 @@ -import { logger, dedent } from './helpers'; -import command from '../src'; +import { logger, dedent } from './helpers.js'; +import command from '@percy/cli-command'; describe('Built-in flags:', () => { let test; - beforeEach(() => { - logger.mock(); + beforeEach(async () => { + await logger.mock(); test = command('foo', {}, ({ log }) => { log.info('information'); diff --git a/packages/cli-command/test/help.test.js b/packages/cli-command/test/help.test.js index 185e3bf2a..54a255ceb 100644 --- a/packages/cli-command/test/help.test.js +++ b/packages/cli-command/test/help.test.js @@ -1,9 +1,9 @@ -import { logger, dedent } from './helpers'; -import command from '../src'; +import { logger, dedent } from './helpers.js'; +import command from '@percy/cli-command'; describe('Help output', () => { - beforeEach(() => { - logger.mock(); + beforeEach(async () => { + await logger.mock(); }); it('is displayed by default when there is no action', async () => { diff --git a/packages/cli-command/test/legacy.test.js b/packages/cli-command/test/legacy.test.js index 6d1c1f146..411e02fbc 100644 --- a/packages/cli-command/test/legacy.test.js +++ b/packages/cli-command/test/legacy.test.js @@ -1,5 +1,5 @@ -import { logger, dedent } from './helpers'; -import { command, legacyCommand, flags } from '../src'; +import { logger, dedent } from './helpers.js'; +import { command, legacyCommand, flags } from '@percy/cli-command'; describe('Legacy support', () => { let test; @@ -59,9 +59,9 @@ describe('Legacy support', () => { } } - beforeEach(() => { + beforeEach(async () => { test = legacyCommand('test', LegacyClass); - logger.mock(); + await logger.mock(); }); it('shows expected usage help', async () => { diff --git a/packages/cli-command/test/parse.test.js b/packages/cli-command/test/parse.test.js index ef5cdd837..9ce467e74 100644 --- a/packages/cli-command/test/parse.test.js +++ b/packages/cli-command/test/parse.test.js @@ -1,5 +1,5 @@ -import { logger } from './helpers'; -import command from '../src'; +import { logger } from './helpers.js'; +import command from '@percy/cli-command'; describe('Option parsing', () => { let cmd = (name, def) => { @@ -12,8 +12,8 @@ describe('Option parsing', () => { return test; }; - beforeEach(() => { - logger.mock(); + beforeEach(async () => { + await logger.mock(); }); it('parses any provided command-line options', async () => { diff --git a/packages/cli-config/package.json b/packages/cli-config/package.json index 8004a6278..bd5b98d02 100644 --- a/packages/cli-config/package.json +++ b/packages/cli-config/package.json @@ -11,13 +11,14 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist" ], - "main": "dist/index.js", - "exports": "dist/index.js", + "main": "./dist/index.js", + "type": "module", + "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-config/src/config.js b/packages/cli-config/src/config.js index 5ebcd91b8..298883fe1 100644 --- a/packages/cli-config/src/config.js +++ b/packages/cli-config/src/config.js @@ -1,8 +1,8 @@ import command from '@percy/cli-command'; -import create from './create'; -import validate from './validate'; -import migrate from './migrate'; +import create from './create.js'; +import validate from './validate.js'; +import migrate from './migrate.js'; export const config = command('config', { description: 'Manage Percy config files', diff --git a/packages/cli-config/src/index.js b/packages/cli-config/src/index.js index db08333ce..7cb638d70 100644 --- a/packages/cli-config/src/index.js +++ b/packages/cli-config/src/index.js @@ -1,4 +1,4 @@ -export { default, config } from './config'; -export { create } from './create'; -export { validate } from './validate'; -export { migrate } from './migrate'; +export { default, config } from './config.js'; +export { create } from './create.js'; +export { validate } from './validate.js'; +export { migrate } from './migrate.js'; diff --git a/packages/cli-config/test/create.test.js b/packages/cli-config/test/create.test.js index fdd1704bf..c81d55017 100644 --- a/packages/cli-config/test/create.test.js +++ b/packages/cli-config/test/create.test.js @@ -1,11 +1,11 @@ import path from 'path'; import { PercyConfig } from '@percy/cli-command'; import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; -import create from '../src/create'; +import create from '../src/create.js'; describe('percy config:create', () => { - beforeEach(() => { - setupTest(); + beforeEach(async () => { + await setupTest(); }); it('creates a .percy.yml config file by default', async () => { diff --git a/packages/cli-config/test/migrate.test.js b/packages/cli-config/test/migrate.test.js index 80aacdb46..a32032acd 100644 --- a/packages/cli-config/test/migrate.test.js +++ b/packages/cli-config/test/migrate.test.js @@ -1,12 +1,14 @@ import path from 'path'; import { PercyConfig } from '@percy/cli-command'; import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; -import migrate from '../src/migrate'; +import migrate from '../src/migrate.js'; describe('percy config:migrate', () => { - beforeEach(() => { - let filesystem = { '.percy.yml': 'version: 1\n' }; - setupTest({ filesystem, resetConfig: true }); + beforeEach(async () => { + await setupTest({ + resetConfig: true, + filesystem: { '.percy.yml': 'version: 1\n' } + }); PercyConfig.addMigration((config, util) => { if (config.migrate) util.map('migrate', 'migrated', v => v.replace('old', 'new')); diff --git a/packages/cli-config/test/validate.test.js b/packages/cli-config/test/validate.test.js index 56bfb1817..1a8e579aa 100644 --- a/packages/cli-config/test/validate.test.js +++ b/packages/cli-config/test/validate.test.js @@ -1,11 +1,11 @@ import path from 'path'; import { PercyConfig } from '@percy/cli-command'; import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; -import validate from '../src/validate'; +import validate from '../src/validate.js'; describe('percy config:validate', () => { - beforeEach(() => { - setupTest({ resetConfig: true }); + beforeEach(async () => { + await setupTest({ resetConfig: true }); PercyConfig.addSchema({ test: { diff --git a/packages/cli-exec/package.json b/packages/cli-exec/package.json index 63703a581..ed060a2fe 100644 --- a/packages/cli-exec/package.json +++ b/packages/cli-exec/package.json @@ -11,13 +11,14 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist" ], - "main": "dist/index.js", - "exports": "dist/index.js", + "main": "./dist/index.js", + "type": "module", + "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-exec/src/exec.js b/packages/cli-exec/src/exec.js index 5abc7cf70..8f68d1e1b 100644 --- a/packages/cli-exec/src/exec.js +++ b/packages/cli-exec/src/exec.js @@ -1,9 +1,9 @@ import command from '@percy/cli-command'; -import * as common from './common'; +import * as common from './common.js'; -import start from './start'; -import stop from './stop'; -import ping from './ping'; +import start from './start.js'; +import stop from './stop.js'; +import ping from './ping.js'; export const exec = command('exec', { description: 'Start and stop Percy around a supplied command', @@ -45,7 +45,7 @@ export const exec = command('exec', { } // verify the provided command exists - let which = await import('which'); + let { default: which } = await import('which'); if (!which.sync(command, { nothrow: true })) { exit(127, `Command not found "${command}"`); diff --git a/packages/cli-exec/src/index.js b/packages/cli-exec/src/index.js index 916ed86b4..4af142480 100644 --- a/packages/cli-exec/src/index.js +++ b/packages/cli-exec/src/index.js @@ -1,4 +1,4 @@ -export { default, exec } from './exec'; -export { start } from './start'; -export { stop } from './stop'; -export { ping } from './ping'; +export { default, exec } from './exec.js'; +export { start } from './start.js'; +export { stop } from './stop.js'; +export { ping } from './ping.js'; diff --git a/packages/cli-exec/src/ping.js b/packages/cli-exec/src/ping.js index 0f3ad56b9..b126b5992 100644 --- a/packages/cli-exec/src/ping.js +++ b/packages/cli-exec/src/ping.js @@ -1,5 +1,5 @@ import command from '@percy/cli-command'; -import * as common from './common'; +import * as common from './common.js'; export const ping = command('ping', { description: 'Pings a local running Percy snapshot server', diff --git a/packages/cli-exec/src/start.js b/packages/cli-exec/src/start.js index 10199e938..19bc7fef6 100644 --- a/packages/cli-exec/src/start.js +++ b/packages/cli-exec/src/start.js @@ -1,5 +1,5 @@ import command from '@percy/cli-command'; -import * as common from './common'; +import * as common from './common.js'; export const start = command('start', { description: 'Starts a local Percy snapshot server', diff --git a/packages/cli-exec/src/stop.js b/packages/cli-exec/src/stop.js index 0a8af5689..7db93cb1d 100644 --- a/packages/cli-exec/src/stop.js +++ b/packages/cli-exec/src/stop.js @@ -1,5 +1,5 @@ import command from '@percy/cli-command'; -import * as common from './common'; +import * as common from './common.js'; export const stop = command('stop', { description: 'Stops a local running Percy snapshot server', diff --git a/packages/cli-exec/test/.eslintrc b/packages/cli-exec/test/.eslintrc index e9b386cb0..d4d123f4e 100644 --- a/packages/cli-exec/test/.eslintrc +++ b/packages/cli-exec/test/.eslintrc @@ -2,3 +2,5 @@ env: jasmine: true rules: import/no-extraneous-dependencies: off + no-return-assign: off + no-sequences: off diff --git a/packages/cli-exec/test/exec.test.js b/packages/cli-exec/test/exec.test.js index aa727be12..dfc1f8e2a 100644 --- a/packages/cli-exec/test/exec.test.js +++ b/packages/cli-exec/test/exec.test.js @@ -1,14 +1,13 @@ import { logger, api, setupTest } from '@percy/cli-command/test/helpers'; -import exec from '../src/exec'; +import exec from '@percy/cli-exec'; describe('percy exec', () => { beforeEach(async () => { process.env.PERCY_TOKEN = '<>'; - setupTest(); + await setupTest(); - delete require.cache[require.resolve('which')]; let { default: which } = await import('which'); - spyOn(which, 'sync').and.returnValue(true); + spyOn(which, 'sync').and.callFake(c => c); }); afterEach(() => { @@ -32,7 +31,7 @@ describe('percy exec', () => { it('logs an error when the command cannot be found', async () => { let { default: which } = await import('which'); - which.sync.and.returnValue(false); + which.sync.and.returnValue(null); await expectAsync(exec(['--', 'foobar'])).toBeRejected(); @@ -124,10 +123,15 @@ describe('percy exec', () => { }); it('throws when the command receives an error event and stops percy', async () => { + let { default: EventEmitter } = await import('events'); + let [e, err] = [new EventEmitter(), new Error('spawn error')]; + let crossSpawn = () => (setImmediate(() => e.emit('error', err)), e); + global.__MOCK_IMPORTS__.set('cross-spawn', { default: crossSpawn }); + await expectAsync(exec(['--', 'foobar'])).toBeRejected(); expect(logger.stderr).toEqual([ - '[percy] Error: spawn foobar ENOENT' + '[percy] Error: spawn error' ]); expect(logger.stdout).toEqual([ '[percy] Percy has started!', @@ -161,16 +165,18 @@ describe('percy exec', () => { }); it('provides the child process with a percy server address env var', async () => { - await exec(['--port=1234', '--', 'node', '--eval', [ - 'require("@percy/cli-command/utils")', - '.request(new URL("/percy/healthcheck", process.env.PERCY_SERVER_ADDRESS))', - '.catch(e => (console.error(e), process.exit(1)))' + let args = ['--no-warnings', '--input-type=module', '--loader=../../scripts/loader.js']; + + await exec(['--port=4567', '--', 'node', ...args, '--eval', [ + 'import { request } from "../cli-command/src/utils.js";', + 'let url = new URL("/percy/healthcheck", process.env.PERCY_SERVER_ADDRESS);', + 'await request(url).catch(e => (console.error(e), process.exit(2)));' ].join('')]); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([ '[percy] Percy has started!', - jasmine.stringMatching('\\[percy] Running "node --eval '), + jasmine.stringMatching('\\[percy] Running "node '), '[percy] Finalized build #1: https://percy.io/test/test/123' ]); }); diff --git a/packages/cli-exec/test/ping.test.js b/packages/cli-exec/test/ping.test.js index 4bdcc8f67..b1273a806 100644 --- a/packages/cli-exec/test/ping.test.js +++ b/packages/cli-exec/test/ping.test.js @@ -1,12 +1,12 @@ import { logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; -import ping from '../src/ping'; +import { ping } from '@percy/cli-exec'; describe('percy exec:ping', () => { let percyServer; - beforeEach(() => { + beforeEach(async () => { process.env.PERCY_TOKEN = '<>'; - setupTest(); + await setupTest(); }); afterEach(async () => { @@ -40,9 +40,9 @@ describe('percy exec:ping', () => { it('can ping /percy/healthcheck at an alternate port', async () => { percyServer = await createTestServer({ '/percy/healthcheck': () => [200, 'application/json', { success: true }] - }, 1234); + }, 4567); - await ping(['--port=1234']); + await ping(['--port=4567']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Percy is running']); diff --git a/packages/cli-exec/test/start.test.js b/packages/cli-exec/test/start.test.js index 90d36390a..dbde11e73 100644 --- a/packages/cli-exec/test/start.test.js +++ b/packages/cli-exec/test/start.test.js @@ -1,7 +1,6 @@ import { request } from '@percy/cli-command/utils'; import { logger, setupTest } from '@percy/cli-command/test/helpers'; -import start from '../src/start'; -import ping from '../src/ping'; +import { start, ping } from '@percy/cli-exec'; describe('percy exec:start', () => { let started; @@ -15,7 +14,7 @@ describe('percy exec:start', () => { beforeEach(async () => { process.env.PERCY_TOKEN = '<>'; - setupTest(); + await setupTest(); started = start(['--quiet']); started.then(() => (started = null)); @@ -39,8 +38,8 @@ describe('percy exec:start', () => { }); it('can start on an alternate port', async () => { - start(['--quiet', '--port=1234']); - let response = await request('http://localhost:1234/percy/healthcheck'); + start(['--quiet', '--port=4567']); + let response = await request('http://localhost:4567/percy/healthcheck'); expect(response).toHaveProperty('success', true); }); diff --git a/packages/cli-exec/test/stop.test.js b/packages/cli-exec/test/stop.test.js index 7f2d2917a..bc6db7f6d 100644 --- a/packages/cli-exec/test/stop.test.js +++ b/packages/cli-exec/test/stop.test.js @@ -1,12 +1,12 @@ import { logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; -import stop from '../src/stop'; +import { stop } from '@percy/cli-exec'; describe('percy exec:stop', () => { let percyServer; - beforeEach(() => { + beforeEach(async () => { process.env.PERCY_TOKEN = '<>'; - setupTest(); + await setupTest(); }); afterEach(async () => { @@ -50,9 +50,9 @@ describe('percy exec:stop', () => { it('can stop a server on another port', async () => { percyServer = await createTestServer({ '/percy/stop': () => [200, 'application/json', { success: true }] - }, 1234); + }, 4567); - await stop(['--port=1234']); + await stop(['--port=4567']); expect(percyServer.requests).toEqual([ ['/percy/stop'], diff --git a/packages/cli-snapshot/package.json b/packages/cli-snapshot/package.json index f9b4a6eb4..e3e3a25ce 100644 --- a/packages/cli-snapshot/package.json +++ b/packages/cli-snapshot/package.json @@ -11,13 +11,14 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ - "dist" + "./dist" ], - "main": "dist/index.js", - "exports": "dist/index.js", + "main": "./dist/index.js", + "type": "module", + "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-snapshot/src/index.js b/packages/cli-snapshot/src/index.js index b32d17794..cdbb91ffe 100644 --- a/packages/cli-snapshot/src/index.js +++ b/packages/cli-snapshot/src/index.js @@ -1 +1 @@ -export { default, snapshot } from './snapshot'; +export { default, snapshot } from './snapshot.js'; diff --git a/packages/cli-snapshot/src/snapshot.js b/packages/cli-snapshot/src/snapshot.js index 86442f615..3066b2c81 100644 --- a/packages/cli-snapshot/src/snapshot.js +++ b/packages/cli-snapshot/src/snapshot.js @@ -1,8 +1,7 @@ import fs from 'fs'; import path from 'path'; import command from '@percy/cli-command'; -import * as SnapshotConfig from './config'; -import pkg from '../package.json'; +import * as SnapshotConfig from './config.js'; export const snapshot = command('snapshot', { description: 'Snapshot a static directory, snapshots file, or sitemap URL', @@ -59,9 +58,7 @@ export const snapshot = command('snapshot', { ], percy: { - deferUploads: true, - clientInfo: `${pkg.name}/${pkg.version}`, - environmentInfo: `node/${process.version}` + deferUploads: true }, config: { @@ -137,10 +134,10 @@ async function loadSnapshotFile(file) { let { default: module } = await import(path.resolve(file)); return typeof module === 'function' ? await module() : module; } else if (ext === '.json') { - return JSON.parse(fs.readFileSync(file, { encoding: 'utf-8' })); + return JSON.parse(fs.readFileSync(file, 'utf-8')); } else if (ext.match(/\.ya?ml$/)) { - let { parse } = await import('yaml'); - return parse(fs.readFileSync(file, { encoding: 'utf-8' })); + let { default: YAML } = await import('yaml'); + return YAML.parse(fs.readFileSync(file, 'utf-8')); } else { throw new Error(`Unsupported filetype: ${file}`); } diff --git a/packages/cli-snapshot/test/common.test.js b/packages/cli-snapshot/test/common.test.js index 84f2d19cf..d938bfc16 100644 --- a/packages/cli-snapshot/test/common.test.js +++ b/packages/cli-snapshot/test/common.test.js @@ -1,13 +1,15 @@ import { logger, setupTest } from '@percy/cli-command/test/helpers'; -import snapshot from '../src/snapshot'; +import snapshot from '@percy/cli-snapshot'; describe('percy snapshot', () => { - beforeEach(() => { - setupTest(); + beforeEach(async () => { + snapshot.packageInformation = { name: '@percy/cli-snapshot' }; + await setupTest(); }); afterEach(() => { delete process.env.PERCY_ENABLE; + delete snapshot.packageInformation; }); it('skips snapshotting when Percy is disabled', async () => { diff --git a/packages/cli-snapshot/test/directory.test.js b/packages/cli-snapshot/test/directory.test.js index 5794bc052..9a88bec4c 100644 --- a/packages/cli-snapshot/test/directory.test.js +++ b/packages/cli-snapshot/test/directory.test.js @@ -1,11 +1,12 @@ import { logger, setupTest, fs } from '@percy/cli-command/test/helpers'; -import snapshot from '../src/snapshot'; +import snapshot from '@percy/cli-snapshot'; describe('percy snapshot ', () => { - beforeEach(() => { + beforeEach(async () => { + snapshot.packageInformation = { name: '@percy/cli-snapshot' }; process.env.PERCY_TOKEN = '<>'; - setupTest({ + await setupTest({ filesystem: { 'test-1.html': '

Test 1

', 'test-2.html': '

Test 2

', @@ -20,6 +21,7 @@ describe('percy snapshot ', () => { afterEach(() => { delete process.env.PERCY_TOKEN; delete process.env.PERCY_ENABLE; + delete snapshot.packageInformation; }); it('errors when the base-url is invalid', async () => { diff --git a/packages/cli-snapshot/test/file.test.js b/packages/cli-snapshot/test/file.test.js index 81cc73b53..20f681325 100644 --- a/packages/cli-snapshot/test/file.test.js +++ b/packages/cli-snapshot/test/file.test.js @@ -1,18 +1,19 @@ import { inspect } from 'util'; import { fs, logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; -import snapshot from '../src/snapshot'; +import snapshot from '@percy/cli-snapshot'; describe('percy snapshot ', () => { let server; beforeEach(async () => { + snapshot.packageInformation = { name: '@percy/cli-snapshot' }; process.env.PERCY_TOKEN = '<>'; server = await createTestServer({ default: () => [200, 'text/html', '

Test

'] }); - setupTest({ + await setupTest({ filesystem: { 'pages.yml': [ '- name: YAML Snapshot', @@ -42,6 +43,7 @@ describe('percy snapshot ', () => { afterEach(async () => { delete process.env.PERCY_TOKEN; + delete snapshot.packageInformation; await server.close(); }); diff --git a/packages/cli-snapshot/test/sitemap.test.js b/packages/cli-snapshot/test/sitemap.test.js index d9380e71b..8edfcb892 100644 --- a/packages/cli-snapshot/test/sitemap.test.js +++ b/packages/cli-snapshot/test/sitemap.test.js @@ -1,12 +1,13 @@ import { fs, logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; -import snapshot from '../src/snapshot'; +import snapshot from '@percy/cli-snapshot'; describe('percy snapshot ', () => { let server; beforeEach(async () => { + snapshot.packageInformation = { name: '@percy/cli-snapshot' }; process.env.PERCY_TOKEN = '<>'; - setupTest(); + await setupTest(); server = await createTestServer({ default: () => [200, 'text/html', '

Test

'], @@ -32,6 +33,7 @@ describe('percy snapshot ', () => { afterEach(async () => { delete process.env.PERCY_TOKEN; + delete snapshot.packageInformation; await server.close(); }); diff --git a/packages/cli-snapshot/test/unit/config.test.js b/packages/cli-snapshot/test/unit/config.test.js index 785947535..722fa507c 100644 --- a/packages/cli-snapshot/test/unit/config.test.js +++ b/packages/cli-snapshot/test/unit/config.test.js @@ -1,4 +1,4 @@ -import { configMigration } from '../../src/config'; +import { configMigration } from '../../src/config.js'; describe('Unit / Config Migration', () => { let mocked = { diff --git a/packages/cli-upload/package.json b/packages/cli-upload/package.json index 854d39093..bdc9119dd 100644 --- a/packages/cli-upload/package.json +++ b/packages/cli-upload/package.json @@ -11,13 +11,14 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist" ], - "main": "dist/index.js", - "exports": "dist/index.js", + "main": "./dist/index.js", + "type": "module", + "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-upload/src/index.js b/packages/cli-upload/src/index.js index 71d6da9d1..2abca25be 100644 --- a/packages/cli-upload/src/index.js +++ b/packages/cli-upload/src/index.js @@ -1 +1 @@ -export { default, upload } from './upload'; +export { default, upload } from './upload.js'; diff --git a/packages/cli-upload/src/upload.js b/packages/cli-upload/src/upload.js index bbf5d38b1..baed669d0 100644 --- a/packages/cli-upload/src/upload.js +++ b/packages/cli-upload/src/upload.js @@ -1,8 +1,7 @@ import fs from 'fs'; import path from 'path'; import command from '@percy/cli-command'; -import * as UploadConfig from './config'; -import pkg from '../package.json'; +import * as UploadConfig from './config.js'; const ALLOWED_FILE_TYPES = /\.(png|jpg|jpeg)$/i; @@ -49,8 +48,6 @@ export const upload = command('upload', { ], percy: { - clientInfo: `${pkg.name}/${pkg.version}`, - environmentInfo: `node/${process.version}`, discoveryFlags: false, deferUploads: true }, @@ -75,7 +72,7 @@ export const upload = command('upload', { } let { default: imageSize } = await import('image-size'); - let { createImageResources } = await import('./resources'); + let { createImageResources } = await import('./resources.js'); // the internal upload queue shares a concurrency with the snapshot queue percy.setConfig({ discovery: { concurrency: config.concurrency } }); diff --git a/packages/cli-upload/test/unit/config.test.js b/packages/cli-upload/test/unit/config.test.js index 1de0cd956..61467a302 100644 --- a/packages/cli-upload/test/unit/config.test.js +++ b/packages/cli-upload/test/unit/config.test.js @@ -1,4 +1,4 @@ -import { migration } from '../../src/config'; +import { migration } from '../../src/config.js'; describe('unit / config', () => { let mocked = { diff --git a/packages/cli-upload/test/upload.test.js b/packages/cli-upload/test/upload.test.js index ea610c2d2..d401a7da5 100644 --- a/packages/cli-upload/test/upload.test.js +++ b/packages/cli-upload/test/upload.test.js @@ -1,5 +1,5 @@ import { fs, logger, api, setupTest } from '@percy/cli-command/test/helpers'; -import upload from '../src/upload'; +import upload from '@percy/cli-upload'; // http://png-pixel.com/ const pixel = Buffer.from(( @@ -7,10 +7,11 @@ const pixel = Buffer.from(( ), 'base64').toString(); describe('percy upload', () => { - beforeEach(() => { + beforeEach(async () => { + upload.packageInformation = { name: '@percy/cli-upload' }; process.env.PERCY_TOKEN = '<>'; - setupTest({ + await setupTest({ filesystem: { 'images/test-1.png': pixel, 'images/test-2.jpg': pixel, @@ -24,6 +25,7 @@ describe('percy upload', () => { afterEach(() => { delete process.env.PERCY_TOKEN; delete process.env.PERCY_ENABLE; + delete upload.packageInformation; }); it('skips uploading when percy is disabled', async () => { @@ -165,7 +167,7 @@ describe('percy upload', () => { }); it('stops uploads on process termination', async () => { - api.mock({ delay: 100 }); + await api.mock({ delay: 100 }); // specify a low concurrency to interupt the queue later fs.writeFileSync('.percy.yml', [ diff --git a/packages/cli/bin/run b/packages/cli/bin/run deleted file mode 100755 index 2bef41e29..000000000 --- a/packages/cli/bin/run +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node - -if (parseInt(process.version.split('.')[0].substring(1), 10) < 12) { - console.error(`Node ${process.version} is not supported. Percy only ` + ( - 'supports the current LTS version of Node. Please upgrade to Node 12+')); - process.exit(1); -} - -let { checkForUpdate, percy } = require('../dist/index.js'); - -(async function() { - await checkForUpdate(); - await percy(process.argv.slice(2)); -})(); diff --git a/packages/cli/bin/run.cjs b/packages/cli/bin/run.cjs new file mode 100755 index 000000000..fa1a47b71 --- /dev/null +++ b/packages/cli/bin/run.cjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +// ensure that we're running within a supported node version +if (parseInt(process.version.split('.')[0].substring(1), 10) < 14) { + console.error(`Node ${process.version} is not supported. Percy only ` + ( + 'supports current LTS versions of Node. Please upgrade to Node 14+')); + process.exit(1); +} + +import('../dist/index.js') + .then(async ({ percy, checkForUpdate }) => { + await checkForUpdate(); + await percy(process.argv.slice(2)); + }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 952f7d0c4..c87bf1428 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,13 +15,14 @@ "./dist" ], "engines": { - "node": ">=12" + "node": ">=14" }, "bin": { - "percy": "./bin/run" + "percy": "./bin/run.cjs" }, "main": "./dist/index.js", "exports": "./dist/index.js", + "type": "module", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli/src/commands.js b/packages/cli/src/commands.js index fdf8e13e1..61e435354 100644 --- a/packages/cli/src/commands.js +++ b/packages/cli/src/commands.js @@ -1,5 +1,6 @@ import os from 'os'; import fs from 'fs'; +import url from 'url'; import path from 'path'; import Module from 'module'; import { command, legacyCommand, logger } from '@percy/cli-command'; @@ -84,7 +85,7 @@ function importLegacyCommands(commandsPath) { return cmds.concat(index); } else { // find and wrap the command exported by the module - let exports = Object.values(await import(filepath)); + let exports = Object.values(await import(url.pathToFileURL(filepath).href)); let cmd = exports.find(e => typeof e?.prototype?.run === 'function'); return cmd ? cmds.concat(legacyCommand(name, cmd)) : cmds; } @@ -93,12 +94,14 @@ function importLegacyCommands(commandsPath) { // Imports and returns compatibile CLI commands from various sources export async function importCommands() { + let root = path.resolve(url.fileURLToPath(import.meta.url), '../..'); + // start with a set to get built-in deduplication let cmdPkgs = await reduceAsync(new Set([ // find included dependencies - path.join(__dirname, '..'), + root, // find potential sibling packages - path.join(__dirname, '..', '..'), + path.join(root, '..'), // find any current project dependencies process.cwd() ]), async (roots, dir) => { @@ -118,7 +121,7 @@ export async function importCommands() { pkgs.set(pkg.name, async () => { if (pkg.oclif.hooks?.init) { let initPath = path.join(pkgPath, pkg.oclif.hooks.init); - let init = await import(initPath); + let init = await import(url.pathToFileURL(initPath).href); await init.default(); } @@ -135,7 +138,8 @@ export async function importCommands() { if (pkg['@percy/cli']?.commands) { pkgs.set(pkg.name, () => Promise.all( pkg['@percy/cli'].commands.map(async cmdPath => { - let module = await import(path.join(pkgPath, cmdPath)); + let modulePath = path.join(pkgPath, cmdPath); + let module = await import(url.pathToFileURL(modulePath).href); module.default.packageInformation ||= pkg; return module.default; }) diff --git a/packages/cli/src/index.js b/packages/cli/src/index.js index 27517d843..991956e44 100644 --- a/packages/cli/src/index.js +++ b/packages/cli/src/index.js @@ -1,2 +1,2 @@ -export { default, percy } from './percy'; -export { checkForUpdate } from './update'; +export { default, percy } from './percy.js'; +export { checkForUpdate } from './update.js'; diff --git a/packages/cli/src/percy.js b/packages/cli/src/percy.js index 9428441c7..aeb561eba 100644 --- a/packages/cli/src/percy.js +++ b/packages/cli/src/percy.js @@ -1,6 +1,8 @@ import command from '@percy/cli-command'; -import { importCommands } from './commands'; -import pkg from '../package.json'; +import { getPackageJSON } from '@percy/cli-command/utils'; +import { importCommands } from './commands.js'; + +const pkg = getPackageJSON(import.meta.url); export const percy = command('percy', { version: `${pkg.name} ${pkg.version}`, diff --git a/packages/cli/src/update.js b/packages/cli/src/update.js index 84f0d1f9c..d0e477625 100644 --- a/packages/cli/src/update.js +++ b/packages/cli/src/update.js @@ -1,11 +1,12 @@ import fs from 'fs'; +import url from 'url'; import path from 'path'; import logger from '@percy/logger'; import { colors } from '@percy/logger/utils'; +import { getPackageJSON } from '@percy/cli-command/utils'; -const PKG_FILE = path.join(__dirname, '..', 'package.json'); // filepath where the cache will be read and written to -const CACHE_FILE = path.join(__dirname, '..', '.releases'); +const CACHE_FILE = path.resolve(url.fileURLToPath(import.meta.url), '../../.releases'); // max age the cache should be used for (3 days) const CACHE_MAX_AGE = 3 * 24 * 60 * 60 * 1000; @@ -65,7 +66,7 @@ async function fetchReleases(pkg) { // is cached to speed up subsequent CLI usage. export async function checkForUpdate() { let { data: releases, error: cacheError } = readFromCache(); - let pkg = JSON.parse(fs.readFileSync(PKG_FILE)); + let pkg = getPackageJSON(import.meta.url); let log = logger('cli:update'); try { diff --git a/packages/cli/test/commands.test.js b/packages/cli/test/commands.test.js index 0a77da352..c994293de 100644 --- a/packages/cli/test/commands.test.js +++ b/packages/cli/test/commands.test.js @@ -1,11 +1,11 @@ import path from 'path'; import { logger, mockfs, fs } from '@percy/cli-command/test/helpers'; -import { mockModuleCommands, mockPnpCommands, mockLegacyCommands } from './helpers'; -import { importCommands } from '../src/commands'; +import { mockModuleCommands, mockPnpCommands, mockLegacyCommands } from './helpers.js'; +import { importCommands } from '../src/commands.js'; describe('CLI commands', () => { - beforeEach(() => { - logger.mock(); + beforeEach(async () => { + await logger.mock(); logger.loglevel('debug'); mockfs({ $modules: true }); }); @@ -30,14 +30,14 @@ describe('CLI commands', () => { ]; it('imports from dependencies', async () => { - mockModuleCommands(path.join(__dirname, '..'), mockCmds); + mockModuleCommands(path.resolve('.'), mockCmds); await expectAsync(importCommands()).toBeResolvedTo(expectedCmds); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); }); it('imports from a parent directory', async () => { - mockModuleCommands(path.join(__dirname, '..', '..', '..'), mockCmds); + mockModuleCommands(path.resolve('../..'), mockCmds); await expectAsync(importCommands()).toBeResolvedTo(expectedCmds); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); @@ -123,14 +123,13 @@ describe('CLI commands', () => { }); it('runs oclif init hooks', async () => { - let init = 'jasmine.createSpy("init")'; + let init = jasmine.createSpy('init'); mockLegacyCommands(process.cwd(), { 'percy-cli-legacy': { name: 'test', init } }); await expectAsync(importCommands()).toBeResolvedTo([]); - init = await import('percy-cli-legacy/init.js'); expect(init).toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/helpers.js b/packages/cli/test/helpers.js index c7fb77043..8f97b3c3b 100644 --- a/packages/cli/test/helpers.js +++ b/packages/cli/test/helpers.js @@ -1,5 +1,7 @@ +import fs from 'fs'; +import url from 'url'; import path from 'path'; -import { mockfs, fs } from '@percy/cli-command/test/helpers'; +import { mockfs } from '@percy/cli-command/test/helpers'; // Mocks the update cache file with the provided data and timestamp export function mockUpdateCache(data, createdAt = Date.now()) { @@ -10,42 +12,39 @@ export function mockUpdateCache(data, createdAt = Date.now()) { // Mocks the filesystem and require cache to simulate installed commands export function mockModuleCommands(atPath, cmdMocks) { let modulesPath = `${atPath}/node_modules`; - let mockModules = { $modules: true }; + let vol = mockfs({ $modules: true, [modulesPath]: null }); + let write = (rel, str) => vol.fromJSON({ [`${modulesPath}/${rel}`]: str }); + + // for coverage + write('.DS_Store', 'Not a directory'); + write('@percy/.DS_Store', 'Not a directory'); for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { - let pkgPath = `${modulesPath}/${pkgName}`; let mockPkg = { name: pkgName }; if (cmdMock) { mockPkg['@percy/cli'] = { commands: ['command.js'] }; - mockModules[`${pkgPath}/command.js`] = [ - `exports.name = "${cmdMock.name}"`, - (cmdMock.callback ? 'exports.callback = () => {}' : '') - ].join(''); + write(`${pkgName}/command.js`, `export default { + name: "${cmdMock.name}", + ${(cmdMock.callback ? 'callback() {}' : '')} + }`); if (cmdMock.multiple) { mockPkg['@percy/cli'].commands.push('other.js'); - - mockModules[`${pkgPath}/other.js`] = [ - `exports.name = "${cmdMock.name}-other"` - ].join(''); + write(`${pkgName}/other.js`, `export default { + name: "${cmdMock.name}-other" + }`); } } - mockModules[`${pkgPath}/package.json`] = JSON.stringify(mockPkg); + write(`${pkgName}/package.json`, JSON.stringify(mockPkg)); } - - // for coverage - mockModules[`${modulesPath}/@percy/.DS_Store`] = 'Not a directory'; - mockModules[`${modulesPath}/.DS_Store`] = 'Not a directory'; - - return mockfs(mockModules); } // Mocks Yarn's PnP APIs to work as expected for installed commands export async function mockPnpCommands(atPath, cmdMocks) { - let Module = await import('module'); + let { default: Module } = await import('module'); let findPnpApi = spyOn(Module, 'findPnpApi').and.callThrough(); let projectLoc = { name: 'project', ref: '' }; let projectInfo = { packageLocation: `${atPath}/`, packageDependencies: new Map() }; @@ -58,8 +57,9 @@ export async function mockPnpCommands(atPath, cmdMocks) { findPackageLocator.withArgs(projectInfo.packageLocation).and.returnValue(projectLoc); getPackageInformation.withArgs(projectLoc).and.returnValue(projectInfo); + let vol = mockfs({ $modules: true }); let pnpPath = path.join('/.yarn/berry/cache'); - let mockModules = { $modules: true }; + let write = (fp, str) => vol.fromJSON({ [`${pnpPath}/${fp}`]: str }); for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { let pkgLoc = { name: pkgName, ref: `<${pkgName}-pnpref>` }; @@ -72,52 +72,46 @@ export async function mockPnpCommands(atPath, cmdMocks) { if (cmdMock) { mockPkg['@percy/cli'] = { commands: ['command.js'] }; - mockModules[`${pnpPath}/${pkgName}/command.js`] = `exports.name = "${cmdMock.name}"`; + write(`${pkgName}/command.js`, `export default { name: "${cmdMock.name}" }`); } - mockModules[`${pnpPath}/${pkgName}/package.json`] = JSON.stringify(mockPkg); + write(`${pkgName}/package.json`, JSON.stringify(mockPkg)); } - - return mockfs(mockModules); } // Mocks the filesystem and require cache to simulate installed legacy commands export function mockLegacyCommands(atPath, cmdMocks) { let modulesPath = `${atPath}/node_modules`; - let mockModules = { $modules: true }; + let vol = mockfs({ $modules: true, [modulesPath]: null }); + let write = (fp, str) => vol.fromJSON({ [`${modulesPath}/${fp}`]: str }); for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { - let pkgPath = `${modulesPath}/${pkgName}`; let mockPkg = { name: pkgName }; if (cmdMock) { - let entryPath = `${pkgPath}/commands/${cmdMock.name}`; + let entryPath = `${pkgName}/commands/${cmdMock.name}`; mockPkg.oclif = { bin: 'percy' }; if (cmdMock.topic || cmdMock.index) { - mockModules[`${entryPath}/notcmd.js`] = 'module.exports = {}'; - mockModules[`${entryPath}/subcmd.js`] = 'exports.Command = ' + - 'class LegacySubCmd { run() {} }'; + write(`${entryPath}/notcmd.js`, 'module.exports = {}'); + write(`${entryPath}/subcmd.js`, 'export class LegacySubCmd { run() {} }'); if (cmdMock.index) { - mockModules[`${entryPath}/index.js`] = 'exports.Command = ' + - 'class LegacyIndex { run() {} }'; + write(`${entryPath}/index.js`, 'export class LegacyIndex { run() {} }'); } } else { - mockModules[`${entryPath}.js`] = 'exports.Command = ' + - 'class LegacyCommand { run() {} }'; + write(`${entryPath}.js`, 'export class LegacyCommand { run() {} }'); } if (cmdMock.init) { - mockModules[`${pkgPath}/init.js`] = `module.exports = ${cmdMock.init}`; + let initURL = url.pathToFileURL(`${modulesPath}/${pkgName}/init.js`).href; + global.__MOCK_IMPORTS__.set(initURL, { default: cmdMock.init }); mockPkg.oclif.hooks = { init: 'init.js' }; } else { mockPkg.oclif.commands = 'commands'; } } - mockModules[`${pkgPath}/package.json`] = JSON.stringify(mockPkg); + write(`${pkgName}/package.json`, JSON.stringify(mockPkg)); } - - return mockfs(mockModules); } diff --git a/packages/cli/test/update.test.js b/packages/cli/test/update.test.js index 2e9fdc117..032295813 100644 --- a/packages/cli/test/update.test.js +++ b/packages/cli/test/update.test.js @@ -1,39 +1,26 @@ -import nock from 'nock'; -import { logger, mockfs, fs } from '@percy/cli-command/test/helpers'; -import { mockUpdateCache } from './helpers'; -import { checkForUpdate } from '../src/update'; +import { logger, mockRequests, mockfs, fs } from '@percy/cli-command/test/helpers'; +import { mockUpdateCache } from './helpers.js'; +import { checkForUpdate } from '../src/update.js'; describe('CLI update check', () => { - let request; + let ghAPI; beforeEach(async () => { - logger.mock(); - - request = nock('https://api.github.com/repos/percy/cli', { - reqheaders: { 'User-Agent': ua => !!ua } - }); - - mockfs({ - './package.json': JSON.stringify({ - name: '@percy/cli', - version: '1.0.0' - }) - }); - }); - - afterEach(() => { - nock.cleanAll(); + let pkg = { name: '@percy/cli', version: '1.0.0' }; + mockfs({ './package.json': JSON.stringify(pkg) }); + ghAPI = await mockRequests('https://api.github.com'); + await logger.mock(); }); it('fetches and caches the latest release information', async () => { - request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]); + ghAPI.and.returnValue([200, [{ tag_name: 'v1.0.0' }]]); expect(fs.existsSync('.releases')).toBe(false); await checkForUpdate(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); - expect(request.isDone()).toBe(true); + expect(ghAPI).toHaveBeenCalled(); expect(fs.existsSync('.releases')).toBe(true); expect(JSON.parse(fs.readFileSync('.releases'))) @@ -41,17 +28,17 @@ describe('CLI update check', () => { }); it('does not fetch the latest release information if cached', async () => { - request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]); + ghAPI.and.returnValue([200, [{ tag_name: 'v1.0.0' }]]); mockUpdateCache([{ tag: 'v1.0.0' }]); await checkForUpdate(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); - expect(request.isDone()).toBe(false); + expect(ghAPI).not.toHaveBeenCalled(); }); it('fetchs the latest release information if the cache is outdated', async () => { - request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]); + ghAPI.and.returnValue([200, [{ tag_name: 'v1.0.0' }]]); let cacheCreatedAt = Date.now() - (30 * 24 * 60 * 60 * 1000); mockUpdateCache([{ tag: 'v0.2.0' }, { tag: 'v0.1.0' }], cacheCreatedAt); @@ -59,7 +46,7 @@ describe('CLI update check', () => { await checkForUpdate(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); - expect(request.isDone()).toBe(true); + expect(ghAPI).toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync('.releases'))) .toHaveProperty('data', [{ tag: 'v1.0.0' }]); @@ -89,7 +76,7 @@ describe('CLI update check', () => { it('handles errors reading from cache and logs debug info', async () => { let cachefile = mockUpdateCache([{ tag: 'v1.0.0' }]); fs.readFileSync.withArgs(cachefile).and.throwError(new Error('EACCES')); - request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]).persist(); + ghAPI.and.returnValue([200, [{ tag_name: 'v1.0.0' }]]); await checkForUpdate(); expect(logger.stdout).toEqual([]); @@ -104,12 +91,12 @@ describe('CLI update check', () => { jasmine.stringContaining('[percy:cli:update:cache] Error: EACCES') ]); - expect(request.isDone()).toEqual(true); + expect(ghAPI).toHaveBeenCalled(); }); it('handles errors writing to cache and logs debug info', async () => { fs.writeFileSync.and.throwError(new Error('EACCES')); - request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]).persist(); + ghAPI.and.returnValue([200, [{ tag_name: 'v1.0.0' }]]); await checkForUpdate(); expect(logger.stdout).toEqual([]); @@ -124,12 +111,12 @@ describe('CLI update check', () => { jasmine.stringContaining('[percy:cli:update:cache] Error: EACCES') ]); - expect(request.isDone()).toEqual(true); + expect(ghAPI).toHaveBeenCalled(); expect(fs.existsSync('.releases')).toBe(false); }); it('handles request errors and logs debug info', async () => { - request.get('/releases').reply(503).persist(); + ghAPI.and.returnValue([503]); await checkForUpdate(); expect(logger.stdout).toEqual([]); diff --git a/packages/client/package.json b/packages/client/package.json index 0dd8b45cb..b981075a0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,13 +11,14 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist", "./test/helpers.js" ], "main": "./dist/index.js", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils.js", diff --git a/packages/client/src/client.js b/packages/client/src/client.js index cbc6f01c7..9fdc919ee 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -1,17 +1,19 @@ +import fs from 'fs'; import PercyEnv from '@percy/env'; import { git } from '@percy/env/utils'; import logger from '@percy/logger'; -import pkg from '../package.json'; import { + pool, request, sha256hash, base64encode, - pool -} from './utils'; + getPackageJSON +} from './utils.js'; // Default client API URL can be set with an env var for API development const { PERCY_CLIENT_API_URL = 'https://percy.io/api/v1' } = process.env; +const pkg = getPackageJSON(import.meta.url); // Validate build ID arguments function validateBuildId(id) { @@ -254,10 +256,7 @@ export class PercyClient { validateBuildId(buildId); this.log.debug(`Uploading resource: ${url}...`); - - content = filepath - ? require('fs').readFileSync(filepath) - : content; + if (filepath) content = fs.readFileSync(filepath); return this.post(`builds/${buildId}/resources`, { data: { diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 8b5c78ffd..5ac24ab14 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -1 +1 @@ -export { default, PercyClient } from './client'; +export { default, PercyClient } from './client.js'; diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 72cb052d3..ce67d400c 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -1,3 +1,7 @@ +import os from 'os'; +import fs from 'fs'; +import url from 'url'; +import path from 'path'; import crypto from 'crypto'; // Returns a sha256 hash of a string. @@ -15,6 +19,19 @@ export function base64encode(content) { .toString('base64'); } +// Returns the package.json content at the package path. +export function getPackageJSON(rel) { + /* istanbul ignore else: sanity check */ + if (rel.startsWith('file:')) rel = url.fileURLToPath(rel); + + let pkg = path.join(rel, 'package.json'); + if (fs.existsSync(pkg)) return JSON.parse(fs.readFileSync(pkg)); + + let dir = path.dirname(rel); + /* istanbul ignore else: sanity check */ + if (dir !== rel && dir !== os.homedir()) return getPackageJSON(dir); +} + // Creates a concurrent pool of promises created by the given generator. // Resolves when the generator's final promise resolves and rejects when any // generated promise rejects. @@ -96,8 +113,10 @@ export async function request(url, options = {}, callback) { // gather request options let { body, headers, retries, retryNotFound, interval, noProxy, ...requestOptions } = options; let { protocol, hostname, port, pathname, search, hash } = new URL(url); - let { request } = await import(protocol === 'https:' ? 'https' : 'http'); - let { proxyAgentFor } = await import('./proxy'); + + // reference the default export so tests can mock it + let { default: http } = await import(protocol === 'https:' ? 'https' : 'http'); + let { proxyAgentFor } = await import('./proxy.js'); // automatically stringify body content if (body && typeof body !== 'string') { @@ -151,13 +170,13 @@ export async function request(url, options = {}, callback) { let handleResponse = res => { let body = ''; - res.setEncoding('utf8'); + res.setEncoding('utf-8'); res.on('data', chunk => (body += chunk)); res.on('end', () => handleFinished(body, res)); res.on('error', handleError); }; - let req = request(requestOptions); + let req = http.request(requestOptions); req.on('response', handleResponse); req.on('error', handleError); req.end(body); @@ -169,4 +188,4 @@ export { ProxyHttpAgent, ProxyHttpsAgent, proxyAgentFor -} from './proxy'; +} from './proxy.js'; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index f88d84865..96a2eadd9 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1,17 +1,17 @@ import fs from 'fs'; import logger from '@percy/logger/test/helpers'; import { mockgit } from '@percy/env/test/helpers'; -import api from './helpers'; - -import { sha256hash, base64encode } from '../src/utils'; -import PercyClient from '../src'; +import { sha256hash, base64encode } from '@percy/client/utils'; +import PercyClient from '@percy/client'; +import api from './helpers.js'; describe('PercyClient', () => { let client; - beforeEach(() => { - api.mock(); - logger.mock(); + beforeEach(async () => { + await logger.mock(); + await api.mock(); + client = new PercyClient({ token: 'PERCY_TOKEN' }); @@ -106,7 +106,7 @@ describe('PercyClient', () => { expect(api.requests['/foobar'][0].method).toBe('GET'); expect(api.requests['/foobar'][0].headers).toEqual( jasmine.objectContaining({ - authorization: 'Token token=PERCY_TOKEN' + Authorization: 'Token token=PERCY_TOKEN' }) ); }); @@ -124,8 +124,8 @@ describe('PercyClient', () => { expect(api.requests['/foobar'][0].method).toBe('POST'); expect(api.requests['/foobar'][0].headers).toEqual( jasmine.objectContaining({ - authorization: 'Token token=PERCY_TOKEN', - 'content-type': 'application/vnd.api+json' + Authorization: 'Token token=PERCY_TOKEN', + 'Content-Type': 'application/vnd.api+json' }) ); }); @@ -494,7 +494,7 @@ describe('PercyClient', () => { expect(api.requests['/builds/123/snapshots'][0].headers).toEqual( jasmine.objectContaining({ - 'user-agent': jasmine.stringMatching( + 'User-Agent': jasmine.stringMatching( /^Percy\/v1 @percy\/client\/\S+ sdk\/info \(sdk\/env; node\/v[\d.]+.*\)$/ ) }) diff --git a/packages/client/test/helpers.js b/packages/client/test/helpers.js index dfb7dc994..3df97746b 100644 --- a/packages/client/test/helpers.js +++ b/packages/client/test/helpers.js @@ -1,70 +1,145 @@ -const nock = require('nock'); - -const DEFAULT_REPLIES = { - '/builds': () => [201, { - data: { - id: '123', - attributes: { - 'build-number': 1, - 'web-url': 'https://percy.io/test/test/123' - } +import EventEmitter from 'events'; +import url from 'url'; + +// Mock response class used for mock requests +export class MockResponse extends EventEmitter { + constructor(options) { + Object.assign(super(), options); + } + + resume() {} + setEncoding() {} + pipe = stream => this + .on('data', d => stream.write(d)) + .on('end', () => stream.end()); +}; + +// Mock request class automates basic mocking necessities +export class MockRequest extends EventEmitter { + constructor(reply, url, opts, cb) { + // handle optional url string + if (url && typeof url === 'string') { + let { protocol, hostname, port, pathname, search, hash } = new URL(url); + opts = { ...opts, protocol, hostname, port, path: pathname + search + hash }; + } else if (typeof url !== 'string') { + opts = url; } - }], - - '/builds/123/snapshots': ({ body }) => [201, { - data: { - id: '4567', - attributes: body.attributes, - relationships: { - 'missing-resources': { - data: body.data.relationships.resources - .data.map(({ id }) => ({ id })) + + Object.assign(super(), opts, { reply }); + if (cb) this.on('response', cb); + } + + // useful for logs/tests + get url() { + return new URL(this.path, url.format(this)).href; + } + + // kick off a reply response on request end + end(body) { + // process async but return sync + (async () => { + try { this.body = JSON.parse(body); } catch {} + let [statusCode, data = '', headers = {}] = await this.reply?.(this) ?? []; + + if (data && typeof data !== 'string') { + // handle common json data + headers['content-type'] = headers['content-type'] || 'application/json'; + data = JSON.stringify(data); + } else if (!statusCode) { + // no status code was mocked + data = `Not mocked ${this.url}`; + statusCode = 404; + } + + // automate content-length header + if (data != null && !headers['content-length']) { + headers['content-length'] = Buffer.byteLength(data); + } + + // create and trigger a mock response + let res = new MockResponse({ statusCode, headers }); + this.emit('response', res); + + // maybe delay response data + setTimeout(() => { + res.emit('data', data); + res.emit('end'); + }, this.delay); + })(); + + return this; + } +} + +// Mock request responses using jasmine spies +export async function mockRequests(baseUrl, defaultReply = () => [200]) { + let { protocol, hostname, pathname } = new URL(baseUrl); + let { default: http } = await import(protocol === 'https:' ? 'https' : 'http'); + + if (!jasmine.isSpy(http.request)) { + spyOn(http, 'request').and.callFake((...a) => new MockRequest(null, ...a)); + spyOn(http, 'get').and.callFake((...a) => new MockRequest(null, ...a).end()); + } + + let any = jasmine.anything(); + let match = o => o.hostname === hostname && + (o.path ?? o.pathname).startsWith(pathname); + let reply = jasmine.createSpy('reply').and.callFake(defaultReply); + + http.request.withArgs({ asymmetricMatch: match }) + .and.callFake((...a) => new MockRequest(reply, ...a)); + http.get.withArgs({ asymmetricMatch: u => match(new URL(u)) }, any, any) + .and.callFake((...a) => new MockRequest(reply, ...a).end()); + + return reply; +} + +// Group of helpers to mock Percy API requests +export const api = { + DEFAULT_REPLIES: { + '/builds': () => [201, { + data: { + id: '123', + attributes: { + 'build-number': 1, + 'web-url': 'https://percy.io/test/test/123' } } - } - }] -}; + }], -const api = { - nock: null, - requests: null, - replies: null, - - mock(options) { - nock.cleanAll(); - nock.disableNetConnect(); - nock.enableNetConnect('storage.googleapis.com|localhost|127.0.0.1'); - - let n = this.nock = nock('https://percy.io/api/v1').persist(); - let requests = this.requests = {}; - let replies = this.replies = {}; - - function intercept(_, body) { - let { path, headers, method } = this.req; - - try { body = JSON.parse(body); } catch {} - path = path.replace('/api/v1', ''); - - let req = { body, headers, method }; - let reply = replies[path] && ( - replies[path].length > 1 - ? replies[path].shift() - : replies[path][0] - ); - - requests[path] = requests[path] || []; - requests[path].push(req); - - return reply ? reply(req) : ( - DEFAULT_REPLIES[path] - ? DEFAULT_REPLIES[path](req) - : [200] - ); - } + '/builds/123/snapshots': ({ body }) => [201, { + data: { + id: '4567', + attributes: body.attributes, + relationships: { + 'missing-resources': { + data: body.data.relationships.resources + .data.map(({ id }) => ({ id })) + } + } + } + }] + }, + + async mock({ delay = 10 } = {}) { + this.replies = {}; + this.requests = {}; + + await mockRequests('https://percy.io/api/v1', req => { + let path = req.path.replace('/api/v1', ''); + + let reply = (this.replies[path] && ( + this.replies[path].length > 1 + ? this.replies[path].shift() + : this.replies[path][0] + )) || this.DEFAULT_REPLIES[path]; + + this.requests[path] = this.requests[path] || []; + this.requests[path].push(req); - let { delay = 0 } = options || {}; - n.get(/.*/).delay(delay).reply(intercept); - n.post(/.*/).delay(delay).reply(intercept); + if (delay) req.delay = delay; + return reply?.(req) ?? [200]; + }); }, reply(path, handler) { @@ -74,4 +149,4 @@ const api = { } }; -module.exports = api; +export default api; diff --git a/packages/client/test/unit/request.test.js b/packages/client/test/unit/request.test.js index d88eab28e..c20656454 100644 --- a/packages/client/test/unit/request.test.js +++ b/packages/client/test/unit/request.test.js @@ -1,15 +1,17 @@ import fs from 'fs'; -import path from 'path'; -import { request, ProxyHttpAgent } from '../../src/utils'; -import { port, href, proxyAgentFor } from '../../src/proxy'; +import net from 'net'; +import http from 'http'; +import https from 'https'; +import { request, ProxyHttpAgent } from '@percy/client/utils'; +import { port, href, proxyAgentFor } from '../../src/proxy.js'; const ssl = { - cert: fs.readFileSync(path.resolve(__dirname, '../certs/test.crt')), - key: fs.readFileSync(path.resolve(__dirname, '../certs/test.key')) + cert: fs.readFileSync('./test/certs/test.crt'), + key: fs.readFileSync('./test/certs/test.key') }; function createTestServer({ type = 'http', ...options } = {}, handler) { - let { createServer } = require(type); + let { createServer } = type === 'http' ? http : https; let connections = new Set(); let received = []; @@ -80,11 +82,11 @@ function createProxyServer({ type, port, ...options }) { return res.writeHead(403).end(); } - require(proto).request(url.href, { + (proto === 'http' ? http : https).request(url.href, { method, headers, rejectUnauthorized: false }).on('response', remote => { let body = ''; - remote.setEncoding('utf8'); + remote.setEncoding('utf-8'); remote.on('data', chunk => (body += chunk)); remote.on('end', () => { let { statusCode, headers } = remote; @@ -108,7 +110,7 @@ function createProxyServer({ type, port, ...options }) { return client.end(); } - let socket = require('net').connect({ + let socket = net.connect({ rejectUnauthorized: false, host: 'localhost', port: mitm.port @@ -198,8 +200,6 @@ describe('Unit / Request', () => { }); describe('retries', () => { - let { OutgoingMessage } = require('http'); - it('automatically retries server 500 errors', async () => { let responses = [[502], [503], [520], [200]]; server.reply('/test', () => responses.splice(0, 1)[0]); @@ -213,9 +213,9 @@ describe('Unit / Request', () => { it('automatically retries specific request errors', async () => { let errors = ['ECONNREFUSED', 'EHOSTUNREACH', 'ECONNRESET', 'EAI_AGAIN']; - let spy = spyOn(OutgoingMessage.prototype, 'end').and.callFake(function() { + let spy = spyOn(http.OutgoingMessage.prototype, 'end').and.callFake(function() { if (errors.length) this.emit('error', { code: errors.splice(0, 1)[0] }); - else OutgoingMessage.prototype.end.and.originalFn.apply(this, arguments); + else http.OutgoingMessage.prototype.end.and.originalFn.apply(this, arguments); }); await expectAsync(server.request('/test')) @@ -245,7 +245,7 @@ describe('Unit / Request', () => { }); it('does not retry unknown errors', async () => { - let spy = spyOn(OutgoingMessage.prototype, 'end').and + let spy = spyOn(http.OutgoingMessage.prototype, 'end').and .callFake(function() { this.emit('error', new Error('Unknown')); }); await expectAsync(server.request('/idk')) @@ -400,8 +400,8 @@ describe('Unit / Request', () => { // different request agents need different spies let spy = serverType === 'https' - ? spyOn(require('net').Socket.prototype, 'write') - : spyOn(require('http').Agent.prototype, 'addRequest'); + ? spyOn(net.Socket.prototype, 'write') + : spyOn(http.Agent.prototype, 'addRequest'); spy.and.callThrough(); // only expected to resolve when the servers are running @@ -447,7 +447,7 @@ describe('Unit / Request', () => { let error = new Error('Unexpected'); // sabotage the underlying socket.write method to emit an error - spyOn(require('net').Socket.prototype, 'write') + spyOn(net.Socket.prototype, 'write') .and.callFake(function() { this.emit('error', error); }); diff --git a/packages/config/package.json b/packages/config/package.json index 5d2ab2377..3815ca2a8 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -11,7 +11,7 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist", @@ -19,6 +19,7 @@ ], "main": "./dist/index.js", "types": "./types/index.d.ts", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils/index.js", diff --git a/packages/config/src/defaults.js b/packages/config/src/defaults.js index f2ca9022b..ef7b094a2 100644 --- a/packages/config/src/defaults.js +++ b/packages/config/src/defaults.js @@ -1,5 +1,5 @@ -import { merge } from './utils'; -import { getSchema } from './validate'; +import { merge } from './utils/index.js'; +import { getSchema } from './validate.js'; const { isArray } = Array; const { assign, entries } = Object; diff --git a/packages/config/src/index.js b/packages/config/src/index.js index f2408ae2d..adb58f8d4 100644 --- a/packages/config/src/index.js +++ b/packages/config/src/index.js @@ -1,8 +1,8 @@ -import load, { search } from './load'; -import validate, { addSchema } from './validate'; -import migrate, { addMigration } from './migrate'; -import { merge, normalize, stringify } from './utils'; -import getDefaults from './defaults'; +import load, { search } from './load.js'; +import validate, { addSchema } from './validate.js'; +import migrate, { addMigration } from './migrate.js'; +import { merge, normalize, stringify } from './utils/index.js'; +import getDefaults from './defaults.js'; // public config API export { @@ -19,4 +19,4 @@ export { }; // export the namespace by default -export * as default from '.'; +export * as default from './index.js'; diff --git a/packages/config/src/load.js b/packages/config/src/load.js index d2219a41c..85046cea3 100644 --- a/packages/config/src/load.js +++ b/packages/config/src/load.js @@ -2,10 +2,10 @@ import fs from 'fs'; import { relative } from 'path'; import { cosmiconfigSync } from 'cosmiconfig'; import logger from '@percy/logger'; -import migrate from './migrate'; -import validate from './validate'; -import getDefaults from './defaults'; -import { inspect, normalize } from './utils'; +import migrate from './migrate.js'; +import validate from './validate.js'; +import getDefaults from './defaults.js'; +import { inspect, normalize } from './utils/index.js'; // Loaded configuration file cache export const cache = new Map(); diff --git a/packages/config/src/migrate.js b/packages/config/src/migrate.js index 1a9606ed4..68c64f04e 100644 --- a/packages/config/src/migrate.js +++ b/packages/config/src/migrate.js @@ -3,7 +3,7 @@ import { get, set, del, map, joinPropertyPath, normalize -} from './utils'; +} from './utils/index.js'; // Global set of registered migrations const migrations = new Map(); diff --git a/packages/config/src/utils/index.js b/packages/config/src/utils/index.js index a3152c151..5bdd436f9 100644 --- a/packages/config/src/utils/index.js +++ b/packages/config/src/utils/index.js @@ -1,3 +1,3 @@ -export * from './merge'; -export * from './normalize'; -export * from './stringify'; +export * from './merge.js'; +export * from './normalize.js'; +export * from './stringify.js'; diff --git a/packages/config/src/utils/normalize.js b/packages/config/src/utils/normalize.js index f9572fd95..ca8ab5e88 100644 --- a/packages/config/src/utils/normalize.js +++ b/packages/config/src/utils/normalize.js @@ -1,5 +1,5 @@ -import merge from './merge'; -import { getSchema } from '../validate'; +import merge from './merge.js'; +import { getSchema } from '../validate.js'; // Edge case camelizations const CAMELCASE_MAP = new Map([ diff --git a/packages/config/src/utils/stringify.js b/packages/config/src/utils/stringify.js index cca4ca04a..ee6f81085 100644 --- a/packages/config/src/utils/stringify.js +++ b/packages/config/src/utils/stringify.js @@ -1,6 +1,6 @@ import util from 'util'; import YAML from 'yaml'; -import getDefaults from '../defaults'; +import getDefaults from '../defaults.js'; // Provides native util.inspect with common options for printing configs. export function inspect(config) { diff --git a/packages/config/src/validate.js b/packages/config/src/validate.js index 898e908cb..1460ba8d5 100644 --- a/packages/config/src/validate.js +++ b/packages/config/src/validate.js @@ -1,11 +1,11 @@ -import AJV from 'ajv/dist/2019'; +import AJV from 'ajv/dist/2019.js'; import { set, del, filterEmpty, parsePropertyPath, joinPropertyPath, isArrayKey -} from './utils'; +} from './utils/index.js'; const { isArray } = Array; const { assign, entries } = Object; diff --git a/packages/config/test/helpers.js b/packages/config/test/helpers.js index b0f1f3ffb..4b0a54ab4 100644 --- a/packages/config/test/helpers.js +++ b/packages/config/test/helpers.js @@ -1,12 +1,13 @@ import fs from 'fs'; import os from 'os'; +import url from 'url'; import path from 'path'; import Module from 'module'; import { Volume, createFsFromVolume } from 'memfs'; -import { clearMigrations } from '../src/migrate'; -import { resetSchema } from '../src/validate'; -import { cache } from '../src/load'; +import { clearMigrations } from '../src/migrate.js'; +import { resetSchema } from '../src/validate.js'; +import { cache } from '../src/load.js'; // Reset various global @percy/config internals for testing export function resetPercyConfig(all) { @@ -22,6 +23,12 @@ const FS_CLASSES = [ 'ReadStream', 'WriteStream' ]; +// Used to bypass mocking internal package files +const INTERNAL_FILE_REG = new RegExp( + '(/|\\\\)(packages)\\1((?:(?!\\1).)+?)\\1' + + '(src|dist|test|package\\.json)(\\1|$)' +); + // Mock and spy on fs methods using an in-memory filesystem export function mockfs({ // set `true` to allow mocking files within `node_modules` (may cause dynamic import issues) @@ -33,6 +40,9 @@ export function mockfs({ } = {}) { let vol = new Volume(); + // automatically cleanup mock imports + global.__MOCK_IMPORTS__?.clear(); + // when .js files are created, also mock the module for importing spyOn(vol, 'writeFileSync').and.callFake((...args) => { if (args[0].endsWith('.js')) mockFileModule(...args); @@ -48,13 +58,13 @@ export function mockfs({ let bypass = [ // bypass babel config for runtime registration - path.resolve(__dirname, '../../../babel.config.js'), + path.resolve(url.fileURLToPath(import.meta.url), '../../../../babel.config.cjs'), // bypass descriptors that don't exist in the current volume p => typeof p === 'number' && !vol.fds[p], // bypass node_modules by default to avoid dynamic import issues p => !$modules && p.includes?.('node_modules'), - // bypass package src/dist/test files to avoid internal dynamic import issues - p => p.match?.(/(\/|\\)(packages)\1([^\1]+?)\1(src|dist|test)(\1|$)/), + // bypass internal package files to avoid dynamic import issues + p => p.match?.(INTERNAL_FILE_REG) && !vol.existsSync(p), // additional bypass matches ...$bypass ]; @@ -77,7 +87,7 @@ export function mockfs({ // allow tests access to the in-memory filesystem fs.$vol = vol; - return fs; + return vol; } // Mock module loading to avoid node using internal C++ fs bindings @@ -89,7 +99,7 @@ function mockFileModule(filepath, content = '') { let mod = new Module(); let fp = mod.filename = path.resolve(filepath); - let any = jasmine.anything(); + let any = { asymmetricMatch: () => true }; let matchFilepath = { asymmetricMatch: f => path.resolve(f) === fp || @@ -98,7 +108,7 @@ function mockFileModule(filepath, content = '') { Module._resolveFilename.withArgs(matchFilepath, any).and.returnValue(fp); Module._load.withArgs(matchFilepath, any, any).and.callFake(() => { - mod.loaded ||= (mod._compile(content, fp), true); + mod.loaded = mod.loaded || (mod._compile(content, fp), true); return mod.exports; }); } diff --git a/packages/config/test/index.test.js b/packages/config/test/index.test.js index 6c412e69f..634f9ace7 100644 --- a/packages/config/test/index.test.js +++ b/packages/config/test/index.test.js @@ -1,11 +1,11 @@ import logger from '@percy/logger/test/helpers'; -import { resetPercyConfig, mockfs, fs } from './helpers'; -import PercyConfig from '../src'; +import { resetPercyConfig, mockfs, fs } from './helpers.js'; +import PercyConfig from '@percy/config'; describe('PercyConfig', () => { - beforeEach(() => { + beforeEach(async () => { + await logger.mock(); resetPercyConfig(true); - logger.mock(); mockfs(); PercyConfig.addSchema({ @@ -638,7 +638,7 @@ describe('PercyConfig', () => { }); it('logs when no config file can be found', async () => { - let { explorer } = await import('../src/load'); + let { explorer } = await import('../src/load.js'); spyOn(explorer, 'search').and.returnValue(null); expect(PercyConfig.load({ print: true })).toEqual({ diff --git a/packages/core/package.json b/packages/core/package.json index 1e0c3a38a..521e29493 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,7 +11,7 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist", @@ -21,11 +21,12 @@ ], "main": "./dist/index.js", "types": "types/index.d.ts", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils.js", "./config": "./dist/config.js", - "./post-install": "./post-install.js", + "./install": "./dist/install.js", "./test/helpers": "./test/helpers/index.js" }, "scripts": { diff --git a/packages/core/post-install.js b/packages/core/post-install.js index 5e56d0d3b..d98f3dccf 100644 --- a/packages/core/post-install.js +++ b/packages/core/post-install.js @@ -1,23 +1,20 @@ -// Automatically download and install Chromium if the PERCY_POSTINSTALL_BROWSER environment variable -// is present and truthy, or if this module is required directly from within another module. Useful -// when running in CI environments with heavy caching of node_modules. -if (process.env.PERCY_POSTINSTALL_BROWSER || require.main !== module) { - const fs = require('fs'); - const path = require('path'); +import fs from 'fs'; - // the src directory indicates postinstall during development - const isDev = fs.existsSync(path.join(__dirname, 'src')); +try { + if (process.env.PERCY_POSTINSTALL_BROWSER) { + // Automatically download and install Chromium if PERCY_POSTINSTALL_BROWSER is set + await import('./dist/install.js').then(install => install.chromium()); + } else if (!process.send && fs.existsSync('./src')) { + // In development, fork this script with the development loader and always install + await import('child_process').then(cp => cp.fork('./post-install.js', { + execArgv: ['--no-warnings', '--loader=../../scripts/loader.js'], + env: { PERCY_POSTINSTALL_BROWSER: true } + })); + } +} catch (error) { + const { logger } = await import('@percy/logger'); + const log = logger('core:post-install'); - // register babel transforms for development install - if (isDev) require('../../scripts/babel-register'); - - // require dev or production modules - const install = require(isDev ? './src/install' : './dist/install'); - const log = require(isDev ? '../logger/src' : '@percy/logger')('core:post-install'); - - // install chromium - install.chromium().catch(error => { - log.error('Encountered an error while installing Chromium'); - log.error(error); - }); + log.error('Encountered an error while installing Chromium'); + log.error(error); } diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 7ebf38c45..2c405ebb5 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -1,11 +1,17 @@ import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; import logger from '@percy/logger'; -import Server from './server'; -import pkg from '../package.json'; +import { getPackageJSON } from './utils.js'; +import Server from './server.js'; + +// need require.resolve until import.meta.resolve can be transpiled +export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom'); // Create a Percy CLI API server instance export function createPercyServer(percy, port) { + let pkg = getPackageJSON(import.meta.url); + return new Server({ port }) // facilitate logger websocket connections .websocket(ws => logger.connect(ws)) @@ -43,7 +49,7 @@ export function createPercyServer(percy, port) { })) // convenient @percy/dom bundle .route('get', '/percy/dom.js', (req, res) => { - return res.file(200, require.resolve('@percy/dom')); + return res.file(200, PERCY_DOM); }) // legacy agent wrapper for @percy/dom .route('get', '/percy-agent.js', async (req, res) => { @@ -53,7 +59,7 @@ export function createPercyServer(percy, port) { 'See these docs for more info: https:docs.percy.io/docs/migrating-to-percy-cli' ].join(' ')); - let content = await fs.promises.readFile(require.resolve('@percy/dom'), 'utf-8'); + let content = await fs.promises.readFile(PERCY_DOM, 'utf-8'); let wrapper = '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });'; return res.send(200, 'applicaton/javascript', content.concat(wrapper)); }) diff --git a/packages/core/src/browser.js b/packages/core/src/browser.js index 44160cdb3..8b7722780 100644 --- a/packages/core/src/browser.js +++ b/packages/core/src/browser.js @@ -6,9 +6,9 @@ import EventEmitter from 'events'; import WebSocket from 'ws'; import rimraf from 'rimraf'; import logger from '@percy/logger'; -import install from './install'; -import Session from './session'; -import Page from './page'; +import install from './install.js'; +import Session from './session.js'; +import Page from './page.js'; export class Browser extends EventEmitter { log = logger('core:browser'); @@ -152,11 +152,6 @@ export class Browser extends EventEmitter { else this.ws.on('close', resolve); }) ]).then(() => { - // needed due to a bug in Node 12 - https://github.com/nodejs/node/issues/27097 - this.process?.stdin.end(); - this.process?.stdout.end(); - this.process?.stderr.end(); - /* istanbul ignore next: * this might fail on some systems but ultimately it is just a temp file */ if (this.profile) { diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index cb73a8fb0..d2892e982 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -1,5 +1,5 @@ import logger from '@percy/logger'; -import { normalizeURL, hostnameMatches, createResource } from './utils'; +import { normalizeURL, hostnameMatches, createResource } from './utils.js'; const MAX_RESOURCE_SIZE = 15 * (1024 ** 2); // 15MB const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308]; diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 2956e32d9..b9b75561b 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,7 +1,7 @@ import PercyConfig from '@percy/config'; -import * as CoreConfig from './config'; +import * as CoreConfig from './config.js'; PercyConfig.addSchema(CoreConfig.schemas); PercyConfig.addMigration(CoreConfig.migrations); -export { default, Percy } from './percy'; +export { default, Percy } from './percy.js'; diff --git a/packages/core/src/install.js b/packages/core/src/install.js index 75a0ccc82..7a68b939e 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -1,4 +1,5 @@ import fs from 'fs'; +import url from 'url'; import path from 'path'; import https from 'https'; import logger from '@percy/logger'; @@ -26,7 +27,7 @@ function formatTime(ms) { function formatProgress(prefix, total, start, progress) { let width = 20; - let ratio = Math.min(Math.max(progress / total, 0), 1); + let ratio = progress === total ? 1 : Math.min(Math.max(progress / total, 0), 1); let percent = Math.floor(ratio * 100).toFixed(0); let barLen = Math.round(width * ratio); @@ -44,61 +45,16 @@ function formatProgress(prefix, total, start, progress) { } // Returns an item from the map keyed by the current platform -function selectByPlatform(map) { +export function selectByPlatform(map) { let { platform, arch } = process; if (platform === 'win32' && arch === 'x64') platform = 'win64'; if (platform === 'darwin' && arch === 'arm64') platform = 'darwinArm'; return map[platform]; } -// Installs a revision of Chromium to a local directory -function installChromium({ - // default directory is within @percy/core package root - directory = path.resolve(__dirname, '../.local-chromium'), - // default chromium revision by platform (see installChromium.revisions) - revision = selectByPlatform(installChromium.revisions) -} = {}) { - let extract = (i, o) => require('extract-zip')(i, { dir: o }); - - let url = 'https://storage.googleapis.com/chromium-browser-snapshots/' + - selectByPlatform({ - linux: `Linux_x64/${revision}/chrome-linux.zip`, - darwin: `Mac/${revision}/chrome-mac.zip`, - darwinArm: `Mac_Arm/${revision}/chrome-mac.zip`, - win64: `Win_x64/${revision}/chrome-win.zip`, - win32: `Win/${revision}/chrome-win.zip` - }); - - let executable = selectByPlatform({ - linux: path.join('chrome-linux', 'chrome'), - win64: path.join('chrome-win', 'chrome.exe'), - win32: path.join('chrome-win', 'chrome.exe'), - darwin: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), - darwinArm: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') - }); - - return install({ - name: 'Chromium', - revision, - url, - extract, - directory, - executable - }); -} - -// default chromium revisions corresponds to v92.0.4515.x -installChromium.revisions = { - linux: '885264', - win64: '885282', - win32: '885263', - darwin: '885263', - darwinArm: '885282' -}; - -// Installs an executable from a url to a local directory, returning the full path to the extracted -// binary. Skips installation if the executable already exists at the binary path. -async function install({ +// Downloads and extracts an executable from a url into a local directory, returning the full path +// to the extracted binary. Skips installation if the executable already exists at the binary path. +export async function download({ name, revision, url, @@ -167,7 +123,50 @@ async function install({ return exec; } -// commonjs friendly -module.exports = install; -module.exports.chromium = installChromium; -module.exports.selectByPlatform = selectByPlatform; +// Installs a revision of Chromium to a local directory +export function chromium({ + // default directory is within @percy/core package root + directory = path.resolve(url.fileURLToPath(import.meta.url), '../../.local-chromium'), + // default chromium revision by platform (see chromium.revisions) + revision = selectByPlatform(chromium.revisions) +} = {}) { + let extract = (i, o) => import('extract-zip').then(ex => ex.default(i, { dir: o })); + + let url = 'https://storage.googleapis.com/chromium-browser-snapshots/' + + selectByPlatform({ + linux: `Linux_x64/${revision}/chrome-linux.zip`, + darwin: `Mac/${revision}/chrome-mac.zip`, + darwinArm: `Mac_Arm/${revision}/chrome-mac.zip`, + win64: `Win_x64/${revision}/chrome-win.zip`, + win32: `Win/${revision}/chrome-win.zip` + }); + + let executable = selectByPlatform({ + linux: path.join('chrome-linux', 'chrome'), + win64: path.join('chrome-win', 'chrome.exe'), + win32: path.join('chrome-win', 'chrome.exe'), + darwin: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + darwinArm: path.join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium') + }); + + return download({ + name: 'Chromium', + revision, + url, + extract, + directory, + executable + }); +} + +// default chromium revisions corresponds to v92.0.4515.x +chromium.revisions = { + linux: '885264', + win64: '885282', + win32: '885263', + darwin: '885263', + darwinArm: '885282' +}; + +// export the namespace by default +export * as default from './install.js'; diff --git a/packages/core/src/network.js b/packages/core/src/network.js index b044655e8..db652615d 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -1,10 +1,10 @@ import logger from '@percy/logger'; -import { waitFor } from './utils'; +import { waitFor } from './utils.js'; import { createRequestHandler, createRequestFinishedHandler, createRequestFailedHandler -} from './discovery'; +} from './discovery.js'; // The Interceptor class creates common handlers for dealing with intercepting asset requests // for a given page using various devtools protocol events and commands. @@ -227,7 +227,7 @@ export class Network { request.response = response; request.response.buffer = async () => { let result = await session.send('Network.getResponseBody', { requestId }); - return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf8'); + return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8'); }; } diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 3c9aa36e3..d85646cce 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -1,11 +1,14 @@ import fs from 'fs'; import logger from '@percy/logger'; -import Network from './network'; +import Network from './network.js'; + import { hostname, generatePromise, waitFor -} from './utils'; +} from './utils.js'; + +import { PERCY_DOM } from './api.js'; export class Page { static TIMEOUT = 30000; @@ -204,7 +207,7 @@ export class Page { /* istanbul ignore next: no instrumenting injected code */ if (await this.eval(() => !window.PercyDOM)) { this.log.debug('Inject @percy/dom', this.meta); - let script = await fs.promises.readFile(require.resolve('@percy/dom'), 'utf-8'); + let script = await fs.promises.readFile(PERCY_DOM, 'utf-8'); await this.eval(new Function(script)); /* eslint-disable-line no-new-func */ } diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 9fcd596f6..ad9ecfb69 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -1,21 +1,21 @@ import PercyClient from '@percy/client'; import PercyConfig from '@percy/config'; import logger from '@percy/logger'; -import Queue from './queue'; -import Browser from './browser'; +import Queue from './queue.js'; +import Browser from './browser.js'; import { createPercyServer, createStaticServer -} from './api'; +} from './api.js'; import { gatherSnapshots, validateSnapshotOptions, discoverSnapshotResources -} from './snapshot'; +} from './snapshot.js'; import { generatePromise -} from './utils'; +} from './utils.js'; // A Percy instance will create a new build when started, handle snapshot // creation, asset discovery, and resource uploads, and will finalize the build @@ -438,10 +438,6 @@ export class Percy { this.log.error(error, snapshot.meta); } } - - // fixes an issue in Node 12 where implicit returns do not correctly resolve the async - // generator objects — https://crbug.com/v8/10238 - return; // eslint-disable-line no-useless-return }.bind(this)); } diff --git a/packages/core/src/queue.js b/packages/core/src/queue.js index 9551f859b..4eba87e8d 100644 --- a/packages/core/src/queue.js +++ b/packages/core/src/queue.js @@ -1,7 +1,7 @@ import { generatePromise, waitFor -} from './utils'; +} from './utils.js'; export class Queue { running = true; diff --git a/packages/core/src/server.js b/packages/core/src/server.js index 22f2defa1..6d089e862 100644 --- a/packages/core/src/server.js +++ b/packages/core/src/server.js @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import http from 'http'; -import WebSocket from 'ws'; +import { WebSocketServer } from 'ws'; import mime from 'mime-types'; import disposition from 'content-disposition'; import { @@ -159,7 +159,7 @@ export class Server extends http.Server { this.#up.push({ match: pathname && pathToMatch(pathname), handle: (req, sock, head) => new Promise(resolve => { - let wss = new WebSocket.Server({ noServer: true, clientTracking: false }); + let wss = new WebSocketServer({ noServer: true, clientTracking: false }); wss.handleUpgrade(req, sock, head, resolve); }).then(ws => handle(ws, req)) }); diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 3c00395e6..9bf058f93 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -4,14 +4,14 @@ import micromatch from 'micromatch'; import { configSchema -} from './config'; +} from './config.js'; import { request, hostnameMatches, createRootResource, createPercyCSSResource, createLogResource -} from './utils'; +} from './utils.js'; // Throw a better error message for missing or invalid urls export function validURL(url, base) { diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 149c6e6ef..675a21cf4 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -1,5 +1,10 @@ -import { request, sha256hash, hostnameMatches } from '@percy/client/utils'; -export { request, hostnameMatches }; +import { sha256hash } from '@percy/client/utils'; + +export { + request, + getPackageJSON, + hostnameMatches +} from '@percy/client/utils'; // Returns the hostname portion of a URL. export function hostname(url) { diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index b4cf140ee..c891b9d9a 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -1,18 +1,18 @@ +import path from 'path'; import PercyConfig from '@percy/config'; -import { logger, setupTest } from './helpers'; -import pkg from '../package.json'; -import Percy from '../src'; +import { logger, setupTest, fs } from './helpers/index.js'; +import Percy from '@percy/core'; describe('API Server', () => { let percy; async function request(path, ...args) { - let { request } = await import('./helpers/request'); + let { request } = await import('./helpers/request.js'); return request(new URL(path, percy.address()), ...args); } - beforeEach(() => { - setupTest(); + beforeEach(async () => { + await setupTest(); percy = new Percy({ token: 'PERCY_TOKEN', @@ -39,6 +39,8 @@ describe('API Server', () => { }); it('has a /healthcheck endpoint', async () => { + let { getPackageJSON } = await import('@percy/client/utils'); + let pkg = getPackageJSON(import.meta.url); await percy.start(); let [data, res] = await request('/percy/healthcheck', true); @@ -93,7 +95,7 @@ describe('API Server', () => { await percy.start(); await expectAsync(request('/percy/dom.js')).toBeResolvedTo( - require('fs').readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }) + fs.readFileSync(path.resolve('../dom/dist/bundle.js'), 'utf-8') ); }); @@ -101,8 +103,9 @@ describe('API Server', () => { await percy.start(); await expectAsync(request('/percy-agent.js')).toBeResolvedTo( - require('fs').readFileSync(require.resolve('@percy/dom'), { encoding: 'utf-8' }) - .concat('(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });') + fs.readFileSync(path.resolve('../dom/dist/bundle.js'), 'utf-8').concat( + '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });' + ) ); expect(logger.stderr).toEqual(['[percy] Warning: ' + [ @@ -177,6 +180,7 @@ describe('API Server', () => { }); it('facilitates logger websocket connections', async () => { + let { exec } = await import('child_process'); await percy.start(); logger.reset(); @@ -184,14 +188,17 @@ describe('API Server', () => { // log from a separate async process let [stdout, stderr] = await new Promise((resolve, reject) => { - require('child_process').exec('node -e "' + [ - "let logger = require('@percy/logger');", - "let ws = new (require('ws'))('ws://localhost:1337');", + let args = ['--no-warnings', '--input-type=module', '--loader=../../scripts/loader.js']; + + exec(`node ${args.join(' ')} --eval "${[ + "import WebSocket from 'ws';", + "import logger from '@percy/logger';", + "let ws = new WebSocket('ws://localhost:1337');", "logger.loglevel('debug');", - 'logger.remote(() => ws)', - " .then(() => logger('remote-sdk').info('whoa'))", - ' .then(() => setTimeout(() => ws.close(), 100));' - ].join('') + '"', (err, stdout, stderr) => { + 'await logger.remote(() => ws);', + "logger('remote-sdk').info('whoa');", + 'setTimeout(() => ws.close(), 100);' + ].join('')}"`, (err, stdout, stderr) => { if (!err) resolve([stdout, stderr]); else reject(err); }); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 933e3d08d..1a1cbf5da 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1,6 +1,6 @@ import { sha256hash } from '@percy/client/utils'; -import { logger, api, setupTest, createTestServer, dedent } from './helpers'; -import Percy from '../src'; +import { logger, api, setupTest, createTestServer, dedent } from './helpers/index.js'; +import Percy from '@percy/core'; describe('Discovery', () => { let percy, server, captured; @@ -24,7 +24,7 @@ describe('Discovery', () => { beforeEach(async () => { captured = []; - setupTest(); + await setupTest(); api.reply('/builds/123/snapshots', ({ body }) => { // resource order is not important, stabilize it for testing @@ -638,10 +638,11 @@ describe('Discovery', () => { }); describe('idle timeout', () => { - let Network = require('../src/network').default; - let ogTimeout = Network.TIMEOUT; + let Network, timeout; - beforeEach(() => { + beforeEach(async () => { + ({ Network } = await import('../src/network.js')); + timeout = Network.TIMEOUT; Network.TIMEOUT = 500; // some async request that takes a while @@ -653,7 +654,7 @@ describe('Discovery', () => { }); afterEach(() => { - Network.TIMEOUT = ogTimeout; + Network.TIMEOUT = timeout; }); it('throws an error when requests fail to idle in time', async () => { @@ -878,15 +879,14 @@ describe('Discovery', () => { }); describe('with resource errors', () => { - const Session = require('../src/session').default; - // sabotage this method to trigger unexpected error handling - function triggerSessionEventError(event, error) { - let spy = spyOn(Session.prototype, 'send') - .and.callFake(function(...args) { - if (args[0] === event) return Promise.reject(error); - return spy.and.originalFn.apply(this, args); - }); + async function triggerSessionEventError(event, error) { + let { Session } = await import('../src/session.js'); + + let spy = spyOn(Session.prototype, 'send').and.callFake(function(...args) { + if (args[0] === event) return Promise.reject(error); + return spy.and.originalFn.apply(this, args); + }); } beforeEach(() => { @@ -895,7 +895,7 @@ describe('Discovery', () => { it('logs unhandled request errors gracefully', async () => { let err = new Error('some unhandled request error'); - triggerSessionEventError('Fetch.continueRequest', err); + await triggerSessionEventError('Fetch.continueRequest', err); await percy.snapshot({ name: 'test snapshot', @@ -916,7 +916,7 @@ describe('Discovery', () => { it('logs unhandled response errors gracefully', async () => { let err = new Error('some unhandled request error'); - triggerSessionEventError('Network.getResponseBody', err); + await triggerSessionEventError('Network.getResponseBody', err); await percy.snapshot({ name: 'test snapshot', diff --git a/packages/core/test/helpers/index.js b/packages/core/test/helpers/index.js index c7d399714..e46e28056 100644 --- a/packages/core/test/helpers/index.js +++ b/packages/core/test/helpers/index.js @@ -1,33 +1,34 @@ import { resetPercyConfig, mockfs as mfs, fs } from '@percy/config/test/helpers'; import logger from '@percy/logger/test/helpers'; import api from '@percy/client/test/helpers'; +import path from 'path'; +import url from 'url'; export function mockfs(initial) { return mfs({ ...initial, $bypass: [ - require.resolve('@percy/dom'), - require.resolve('../../../core/package.json'), - require.resolve('../../../client/package.json'), + path.resolve(url.fileURLToPath(import.meta.url), '/../../../dom/dist/bundle.js'), p => p.includes?.('.local-chromium'), ...(initial?.$bypass ?? []) ] }); } -export function setupTest({ +export async function setupTest({ resetConfig, filesystem, loggerTTY, apiDelay } = {}) { + await logger.mock({ isTTY: loggerTTY }); + await api.mock({ delay: apiDelay }); resetPercyConfig(resetConfig); - logger.mock({ isTTY: loggerTTY }); - api.mock({ delay: apiDelay }); mockfs(filesystem); } -export { createTestServer } from './server'; -export { dedent } from './dedent'; -export { logger, api, fs }; +export * from '@percy/client/test/helpers'; +export { createTestServer } from './server.js'; +export { dedent } from './dedent.js'; +export { logger, fs }; diff --git a/packages/core/test/helpers/server.js b/packages/core/test/helpers/server.js index 28903c663..532211c8a 100644 --- a/packages/core/test/helpers/server.js +++ b/packages/core/test/helpers/server.js @@ -1,7 +1,7 @@ // aliased to src for coverage during tests without needing to compile this file -const { default: Server } = require('../../dist/server'); +import Server from '../../dist/server.js'; -function createTestServer({ default: defaultReply, ...replies }, port = 8000) { +export function createTestServer({ default: defaultReply, ...replies }, port = 8000) { let server = new Server(); // alternate route handling @@ -12,7 +12,7 @@ function createTestServer({ default: defaultReply, ...replies }, port = 8000) { }; // map replies to alternate route handlers - server.reply = (p, reply) => (replies[p] = handleReply(reply)); + server.reply = (p, reply) => (replies[p] = handleReply(reply), null); for (let [p, reply] of Object.entries(replies)) server.reply(p, reply); if (defaultReply) defaultReply = handleReply(defaultReply); @@ -30,6 +30,4 @@ function createTestServer({ default: defaultReply, ...replies }, port = 8000) { return server.listen(port); }; -// support commonjs environments -module.exports = createTestServer; -module.exports.createTestServer = createTestServer; +export default createTestServer; diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 7bb2cd709..23e7c5ac4 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1,12 +1,12 @@ -import { logger, api, setupTest, createTestServer } from './helpers'; -import { generatePromise } from '../src/utils'; -import Percy from '../src'; +import { logger, api, setupTest, createTestServer } from './helpers/index.js'; +import { generatePromise } from '../src/utils.js'; +import Percy from '@percy/core'; describe('Percy', () => { let percy, server; beforeEach(async () => { - setupTest(); + await setupTest(); server = await createTestServer({ default: () => [200, 'text/html', '

Snapshot

'] @@ -193,7 +193,7 @@ describe('Percy', () => { }); it('starts a server after launching a browser', async () => { - let { request } = await import('./helpers/request'); + let { request } = await import('./helpers/request.js'); spyOn(percy.browser, 'launch').and.callThrough(); spyOn(percy.server, 'listen').and.callThrough(); @@ -452,7 +452,7 @@ describe('Percy', () => { }); it('stops the server', async () => { - let { request } = await import('./helpers/request'); + let { request } = await import('./helpers/request.js'); await expectAsync(request('http://localhost:5338', false)).toBeResolved(); await expectAsync(percy.stop()).toBeResolved(); expect(percy.server.listening).toBe(false); diff --git a/packages/core/test/snapshot-multiple.test.js b/packages/core/test/snapshot-multiple.test.js index b11dfb20a..eae935c33 100644 --- a/packages/core/test/snapshot-multiple.test.js +++ b/packages/core/test/snapshot-multiple.test.js @@ -1,13 +1,13 @@ -import { fs, logger, setupTest, createTestServer } from './helpers'; -import { generatePromise } from '../src/utils'; -import Percy from '../src'; +import { fs, logger, setupTest, createTestServer } from './helpers/index.js'; +import { generatePromise } from '@percy/core/utils'; +import Percy from '@percy/core'; describe('Snapshot multiple', () => { let percy, server, sitemap; beforeEach(async () => { sitemap = ['/']; - setupTest(); + await setupTest(); percy = await Percy.start({ token: 'PERCY_TOKEN', @@ -321,10 +321,10 @@ describe('Snapshot multiple', () => { await expectAsync(cancelable.then()).toBeRejected(); expect(logger.stderr).toEqual([]); - expect(logger.stdout).toEqual([ + expect(logger.stdout).toEqual(jasmine.arrayContaining([ '[percy] Snapshot taken: /about.html', '[percy] Snapshot taken: /index.html' - ]); + ])); }); }); }); diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index ce25cc5c8..af66b145c 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -1,14 +1,14 @@ import { sha256hash, base64encode } from '@percy/client/utils'; -import { logger, api, setupTest, createTestServer, dedent } from './helpers'; -import { waitFor } from '../src/utils'; -import Percy from '../src'; +import { logger, api, setupTest, createTestServer, dedent } from './helpers/index.js'; +import { waitFor } from '@percy/core/utils'; +import Percy from '@percy/core'; describe('Snapshot', () => { let percy, server, testDOM; beforeEach(async () => { testDOM = '

Test

'; - setupTest(); + await setupTest(); server = await createTestServer({ default: () => [200, 'text/html', testDOM], diff --git a/packages/core/test/unit/config.test.js b/packages/core/test/unit/config.test.js index 5fdd97edf..c4269ad7b 100644 --- a/packages/core/test/unit/config.test.js +++ b/packages/core/test/unit/config.test.js @@ -1,5 +1,5 @@ import logger from '@percy/logger/test/helpers'; -import { configMigration } from '../../src/config'; +import { configMigration } from '../../src/config.js'; describe('Unit / Config Migration', () => { let mocked = { @@ -8,9 +8,9 @@ describe('Unit / Config Migration', () => { del: (...a) => mocked.migrate.del.push(a) }; - beforeEach(() => { + beforeEach(async () => { mocked.migrate = { deprecate: [], map: [], del: [] }; - logger.mock(); + await logger.mock(); }); it('migrates v1 config', () => { diff --git a/packages/core/test/unit/install.test.js b/packages/core/test/unit/install.test.js index b8f483177..af29c21a0 100644 --- a/packages/core/test/unit/install.test.js +++ b/packages/core/test/unit/install.test.js @@ -1,25 +1,20 @@ import path from 'path'; -import nock from 'nock'; import logger from '@percy/logger/test/helpers'; import { mockfs, fs } from '@percy/config/test/helpers'; -import install from '../../src/install'; +import { mockRequests } from '@percy/client/test/helpers'; +import install from '../../src/install.js'; const CHROMIUM_REVISIONS = install.chromium.revisions; describe('Unit / Install', () => { - let dlnock, dlcallback, options; + let dl, options; beforeEach(async () => { - logger.mock({ isTTY: true }); + await logger.mock({ isTTY: true }); mockfs(); // mock a fake download api - nock.disableNetConnect(); - nock.enableNetConnect('localhost|127.0.0.1'); - dlcallback = jasmine.createSpy('dlcallback') - .and.callFake(s => [200, s, { 'content-length': s.length }]); - dlnock = nock('https://fake-download.org').get('/archive.zip') - .reply(() => dlcallback('archive contents')); + dl = await mockRequests('https://fake-download.org/archive.zip'); // all options are required options = { @@ -32,40 +27,36 @@ describe('Unit / Install', () => { }; }); - afterEach(() => { - nock.cleanAll(); - }); - it('does nothing if the executable already exists in the output directory', async () => { fs.existsSync.and.returnValue(true); - await install(options); + await install.download(options); expect(fs.promises.mkdir).not.toHaveBeenCalled(); expect(fs.promises.unlink).not.toHaveBeenCalled(); expect(fs.createWriteStream).not.toHaveBeenCalled(); - expect(dlnock.isDone()).toBe(false); + expect(dl).not.toHaveBeenCalled(); }); it('creates the output directory when it does not exist', async () => { - await install(options); + await install.download(options); - expect(fs.promises.mkdir) - .toHaveBeenCalledOnceWith(path.join('.downloads', 'v0'), { recursive: true }); + expect(fs.promises.mkdir).toHaveBeenCalledOnceWith( + path.join('.downloads', 'v0'), { recursive: true }); }); it('fetches the archive from the provided url', async () => { - await install(options); + await install.download(options); - expect(dlnock.isDone()).toBe(true); + expect(dl).toHaveBeenCalled(); }); it('logs progress during the archive download', async () => { let now = Date.now(); // eta is calculated by the elapsed time and remaining progress - spyOn(Date, 'now').and.callFake(() => (now += 65002)); - dlcallback.and.callFake(s => [200, s, { 'content-length': s.length * 5 }]); + spyOn(Date, 'now').and.callFake(() => (now += 65000)); + dl.and.returnValue([200, 'partial contents', { 'content-length': 80 }]); - await install(options); + await install.download(options); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([ @@ -78,14 +69,14 @@ describe('Unit / Install', () => { it('does not log progress when info logs are disabled', async () => { logger.loglevel('error'); - await install(options); + await install.download(options); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([]); }); it('extracts the downloaded archive to the output directory', async () => { - await install(options); + await install.download(options); expect(options.extract).toHaveBeenCalledOnceWith( path.join('.downloads', 'v0', 'archive.zip'), @@ -97,26 +88,22 @@ describe('Unit / Install', () => { fs.$vol.fromJSON({ '.downloads/v0/archive.zip': '' }); expect(fs.existsSync('.downloads/v0/archive.zip')).toBe(true); - await install(options); + await install.download(options); expect(fs.existsSync('.downloads/v0/archive.zip')).toBe(false); }); it('handles failed downloads', async () => { - dlcallback.and.returnValue([404]); + dl.and.returnValue([404]); - await expectAsync(install(options)) + await expectAsync(install.download(options)) .toBeRejectedWithError('Download failed: 404 - https://fake-download.org/archive.zip'); }); it('logs the file size in a readable format', async () => { - let archive = '1'.repeat(20_000_000); - - dlcallback.and.returnValue([200, archive, { - 'content-length': archive.length - }]); + dl.and.returnValue([200, '1'.repeat(20_000_000)]); - await install(options); + await install.download(options); expect(logger.stderr).toEqual([]); expect(logger.stdout).toContain( @@ -125,20 +112,19 @@ describe('Unit / Install', () => { }); it('returns the full path of the executable', async () => { - await expectAsync(install(options)) - .toBeResolvedTo(path.join('.downloads', 'v0', 'extracted', 'bin.exe')); + await expectAsync(install.download(options)).toBeResolvedTo( + path.join('.downloads', 'v0', 'extracted', 'bin.exe')); }); describe('Chromium', () => { let extractZip; - beforeEach(() => { - require('extract-zip'); // ensure dep is cached before spying on it - extractZip = spyOn(require.cache[require.resolve('extract-zip')], 'exports'); - extractZip.and.resolveTo(); + beforeEach(async () => { + dl = await mockRequests('https://storage.googleapis.com'); - dlnock = nock('https://storage.googleapis.com/chromium-browser-snapshots') - .persist().get(/.*/).reply(uri => dlcallback(uri)); + // stub extract-zip using esm loader mocks + extractZip = jasmine.createSpy('exports').and.resolveTo(); + global.__MOCK_IMPORTS__.set('extract-zip', { default: extractZip }); // make getters for jasmine property spy let { platform, arch } = process; @@ -151,7 +137,7 @@ describe('Unit / Install', () => { it('downloads from the google storage api', async () => { await install.chromium(); - expect(dlnock.isDone()).toBe(true); + expect(dl).toHaveBeenCalled(); }); it('extracts to a .local-chromium directory', async () => { @@ -202,8 +188,8 @@ describe('Unit / Install', () => { jasmine.stringMatching(expected.return.replace(/[.\\]/g, '\\$&')) ); - expect(dlnock.isDone()).toBe(true); - expect(dlcallback).toHaveBeenCalledOnceWith(expected.url); + expect(dl).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ url: expected.url })); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ diff --git a/packages/core/test/unit/queue.test.js b/packages/core/test/unit/queue.test.js index 6ed424a0e..f95d54f80 100644 --- a/packages/core/test/unit/queue.test.js +++ b/packages/core/test/unit/queue.test.js @@ -1,4 +1,4 @@ -import Queue from '../../src/queue'; +import Queue from '../../src/queue.js'; function sleep(ms = 0, v) { return new Promise(r => setTimeout(r, ms, v)); diff --git a/packages/core/test/unit/server.test.js b/packages/core/test/unit/server.test.js index 687b9ecb6..c2b2d515b 100644 --- a/packages/core/test/unit/server.test.js +++ b/packages/core/test/unit/server.test.js @@ -1,11 +1,11 @@ -import { fs, mockfs } from '../helpers'; -import Server from '../../src/server'; +import { fs, mockfs } from '../helpers/index.js'; +import Server from '../../src/server.js'; describe('Unit / Server', () => { let server; async function request(path, ...args) { - let { request } = await import('../helpers/request'); + let { request } = await import('../helpers/request.js'); return request(new URL(path, server.address()), ...args); } diff --git a/packages/dom/test/serialize-frames.test.js b/packages/dom/test/serialize-frames.test.js index cf7ab84d8..c71f99e11 100644 --- a/packages/dom/test/serialize-frames.test.js +++ b/packages/dom/test/serialize-frames.test.js @@ -80,7 +80,7 @@ describe('serializeFrames', () => { it('serializes iframes that have been interacted with', () => { expect($('#frame-input')[0].getAttribute('srcdoc')).toMatch(new RegExp([ - '^', + '^.*?', '', '$' ].join(''))); diff --git a/packages/env/package.json b/packages/env/package.json index 3a4d4d782..b1b47bd68 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -11,12 +11,13 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist" ], "main": "./dist/index.js", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils.js", diff --git a/packages/env/src/dotenv.js b/packages/env/src/dotenv.js index 9a117f241..047bccb24 100644 --- a/packages/env/src/dotenv.js +++ b/packages/env/src/dotenv.js @@ -74,3 +74,5 @@ export function load() { } } } + +export * as default from './dotenv.js'; diff --git a/packages/env/src/environment.js b/packages/env/src/environment.js index aa8bacb31..4b3aa6bce 100644 --- a/packages/env/src/environment.js +++ b/packages/env/src/environment.js @@ -2,7 +2,7 @@ import { getCommitData, getJenkinsSha, github -} from './utils'; +} from './utils.js'; export class PercyEnv { constructor(vars = process.env) { diff --git a/packages/env/src/index.js b/packages/env/src/index.js index 6cc75f48e..5a73c2a12 100644 --- a/packages/env/src/index.js +++ b/packages/env/src/index.js @@ -1,2 +1,6 @@ -export { default, PercyEnv } from './environment'; -require('./dotenv').load(); +import PercyEnv from './environment.js'; +import dotenv from './dotenv.js'; +dotenv.load(); + +export { PercyEnv }; +export default PercyEnv; diff --git a/packages/env/src/utils.js b/packages/env/src/utils.js index b0ff39444..1466ef79d 100644 --- a/packages/env/src/utils.js +++ b/packages/env/src/utils.js @@ -57,7 +57,7 @@ export function getJenkinsSha() { // github actions are triggered by webhook events which are saved to the filesystem export function github({ GITHUB_EVENT_PATH }) { if (!github.payload && GITHUB_EVENT_PATH && fs.existsSync(GITHUB_EVENT_PATH)) { - try { github.payload = JSON.parse(fs.readFileSync(GITHUB_EVENT_PATH, 'utf8')); } catch (e) {} + try { github.payload = JSON.parse(fs.readFileSync(GITHUB_EVENT_PATH)); } catch {} } return (github.payload ||= {}); diff --git a/packages/env/test/appveyor.test.js b/packages/env/test/appveyor.test.js index 0e5896c86..8bbc00d66 100644 --- a/packages/env/test/appveyor.test.js +++ b/packages/env/test/appveyor.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Appveyor', () => { let env; diff --git a/packages/env/test/azure.test.js b/packages/env/test/azure.test.js index ef29eebf3..dc1877fe6 100644 --- a/packages/env/test/azure.test.js +++ b/packages/env/test/azure.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Azure', () => { let env; diff --git a/packages/env/test/bitbucket.test.js b/packages/env/test/bitbucket.test.js index 6f79d6adc..39c0fe716 100644 --- a/packages/env/test/bitbucket.test.js +++ b/packages/env/test/bitbucket.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Bitbucket', () => { let env; diff --git a/packages/env/test/buildkite.test.js b/packages/env/test/buildkite.test.js index c9a66373b..9a2ce4807 100644 --- a/packages/env/test/buildkite.test.js +++ b/packages/env/test/buildkite.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Buildkite', () => { let env; diff --git a/packages/env/test/circle.test.js b/packages/env/test/circle.test.js index 6c79472d7..6770e1d49 100644 --- a/packages/env/test/circle.test.js +++ b/packages/env/test/circle.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('CircleCI', () => { let env; diff --git a/packages/env/test/codeship.test.js b/packages/env/test/codeship.test.js index c2dd73e56..96a36c483 100644 --- a/packages/env/test/codeship.test.js +++ b/packages/env/test/codeship.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('CodeShip', () => { let env; diff --git a/packages/env/test/defaults.test.js b/packages/env/test/defaults.test.js index 24824835d..8146f8e8f 100644 --- a/packages/env/test/defaults.test.js +++ b/packages/env/test/defaults.test.js @@ -1,5 +1,5 @@ -import { mockgit } from './helpers'; -import PercyEnv from '../src'; +import { mockgit } from './helpers.js'; +import PercyEnv from '@percy/env'; describe('Defaults', () => { let env; diff --git a/packages/env/test/dotenv.test.js b/packages/env/test/dotenv.test.js index 70de1ceae..544c59673 100644 --- a/packages/env/test/dotenv.test.js +++ b/packages/env/test/dotenv.test.js @@ -1,5 +1,5 @@ import fs from 'fs'; -import { load } from '../src/dotenv'; +import { load } from '../src/dotenv.js'; describe('dotenv files', () => { let env, dotenvs; diff --git a/packages/env/test/drone.test.js b/packages/env/test/drone.test.js index b5fefbabf..34f58df4b 100644 --- a/packages/env/test/drone.test.js +++ b/packages/env/test/drone.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Drone', () => { let env; diff --git a/packages/env/test/github.test.js b/packages/env/test/github.test.js index 95042452f..6b2f32088 100644 --- a/packages/env/test/github.test.js +++ b/packages/env/test/github.test.js @@ -1,16 +1,20 @@ import fs from 'fs'; +import url from 'url'; import path from 'path'; -import PercyEnv from '../src'; -import { github } from '../src/utils'; +import PercyEnv from '@percy/env'; +import { github } from '@percy/env/utils'; + +const GITHUB_EVENT_PATH = path.join(path.dirname( + url.fileURLToPath(import.meta.url) +), 'gh-event-file'); describe('GitHub', () => { - let ghEventFile = path.join(__dirname, 'gh-event-file'); let env; beforeEach(() => { delete github.payload; - fs.writeFileSync(ghEventFile, JSON.stringify({ + fs.writeFileSync(GITHUB_EVENT_PATH, JSON.stringify({ pull_request: { number: 10, head: { @@ -24,12 +28,12 @@ describe('GitHub', () => { PERCY_PARALLEL_TOTAL: '-1', GITHUB_RUN_ID: 'job-id', GITHUB_ACTIONS: 'true', - GITHUB_EVENT_PATH: ghEventFile + GITHUB_EVENT_PATH }); }); afterEach(() => { - fs.unlinkSync(ghEventFile); + fs.unlinkSync(GITHUB_EVENT_PATH); }); it('has the correct properties', () => { diff --git a/packages/env/test/gitlab.test.js b/packages/env/test/gitlab.test.js index 179df444a..b908a2be1 100644 --- a/packages/env/test/gitlab.test.js +++ b/packages/env/test/gitlab.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('GitLab', () => { let env; diff --git a/packages/env/test/heroku.test.js b/packages/env/test/heroku.test.js index 22e0cda1f..906d37816 100644 --- a/packages/env/test/heroku.test.js +++ b/packages/env/test/heroku.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Heroku', () => { let env; diff --git a/packages/env/test/jenkins.test.js b/packages/env/test/jenkins.test.js index 2deef7d28..0bec14f61 100644 --- a/packages/env/test/jenkins.test.js +++ b/packages/env/test/jenkins.test.js @@ -1,5 +1,5 @@ -import { mockgit } from './helpers'; -import PercyEnv from '../src'; +import { mockgit } from './helpers.js'; +import PercyEnv from '@percy/env'; describe('Jenkins', () => { let env; diff --git a/packages/env/test/netlify.test.js b/packages/env/test/netlify.test.js index 25b86e946..e05bf5f49 100644 --- a/packages/env/test/netlify.test.js +++ b/packages/env/test/netlify.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Netlify', () => { let env; diff --git a/packages/env/test/probo.test.js b/packages/env/test/probo.test.js index 02090d405..de0fd47a5 100644 --- a/packages/env/test/probo.test.js +++ b/packages/env/test/probo.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Probo', () => { let env; diff --git a/packages/env/test/semaphore.test.js b/packages/env/test/semaphore.test.js index 454ba7cc5..43c58ef6a 100644 --- a/packages/env/test/semaphore.test.js +++ b/packages/env/test/semaphore.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Semaphore', () => { let env; diff --git a/packages/env/test/travis.test.js b/packages/env/test/travis.test.js index ec609b9e2..e98b41092 100644 --- a/packages/env/test/travis.test.js +++ b/packages/env/test/travis.test.js @@ -1,4 +1,4 @@ -import PercyEnv from '../src'; +import PercyEnv from '@percy/env'; describe('Travis', () => { let env; diff --git a/packages/logger/package.json b/packages/logger/package.json index 6cac82791..ae276b3e6 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -11,7 +11,7 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist", @@ -19,13 +19,20 @@ "./test/client.js" ], "main": "./dist/index.js", - "browser": "dist/bundle.js", + "browser": "./dist/bundle.js", + "type": "module", "exports": { ".": "./dist/index.js", "./utils": "./dist/utils.js", "./test/helpers": "./test/helpers.js", "./test/client": "./test/client.js" }, + "imports": { + "#logger": { + "node": "./dist/logger.js", + "default": "./dist/browser.js" + } + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/logger/src/browser.js b/packages/logger/src/browser.js index 2738bca2b..8f525b7de 100644 --- a/packages/logger/src/browser.js +++ b/packages/logger/src/browser.js @@ -1,5 +1,5 @@ -import { ANSI_COLORS, ANSI_REG } from './utils'; -import PercyLogger from './logger'; +import { ANSI_COLORS, ANSI_REG } from './utils.js'; +import PercyLogger from './logger.js'; export class PercyBrowserLogger extends PercyLogger { write(level, message) { diff --git a/packages/logger/src/index.js b/packages/logger/src/index.js index adc4035a2..5c65ca713 100644 --- a/packages/logger/src/index.js +++ b/packages/logger/src/index.js @@ -1,10 +1,6 @@ -const { default: Logger } = ( - process.env.__PERCY_BROWSERIFIED__ - ? require('./browser') - : require('./logger') -); +import Logger from '#logger'; -function logger(name) { +export function logger(name) { return new Logger().group(name); } @@ -22,4 +18,4 @@ Object.defineProperties(logger, { stderr: { get: () => Logger.stderr } }); -module.exports = logger; +export default logger; diff --git a/packages/logger/src/logger.js b/packages/logger/src/logger.js index 85fdd2f11..64ccffb29 100644 --- a/packages/logger/src/logger.js +++ b/packages/logger/src/logger.js @@ -1,4 +1,4 @@ -import { colors } from './utils'; +import { colors } from './utils.js'; const URL_REGEXP = /\bhttps?:\/\/[^\s/$.?#].[^\s]*\b/i; const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; diff --git a/packages/logger/test/helpers.js b/packages/logger/test/helpers.js index e35bb8de7..530490935 100644 --- a/packages/logger/test/helpers.js +++ b/packages/logger/test/helpers.js @@ -1,5 +1,5 @@ -const logger = require('@percy/logger'); -const { ANSI_REG } = require('@percy/logger/utils'); +import logger from '@percy/logger'; +import { ANSI_REG } from '@percy/logger/utils'; const ELAPSED_REG = /\s\S*?\(\d+ms\)\S*/; const NEWLINE_REG = /\r\n/g; @@ -57,7 +57,7 @@ const helpers = { spy(console, 'warn'); spy(console, 'error'); } else { - let { Writable } = require('stream'); + let { Writable } = await import('stream'); for (let stdio of ['stdout', 'stderr']) { Logger[stdio] = Object.assign(new Writable(), { @@ -107,4 +107,5 @@ const helpers = { } }; -module.exports = helpers; +export { helpers as logger }; +export default helpers; diff --git a/packages/logger/test/logger.test.js b/packages/logger/test/logger.test.js index 92004b5cb..633ce99fc 100644 --- a/packages/logger/test/logger.test.js +++ b/packages/logger/test/logger.test.js @@ -1,6 +1,6 @@ -import helpers from './helpers'; -import { colors } from '../src/utils'; -import logger from '../src'; +import helpers from './helpers.js'; +import { colors } from '@percy/logger/utils'; +import logger from '@percy/logger'; describe('logger', () => { let log, inst; diff --git a/packages/logger/test/remote.test.js b/packages/logger/test/remote.test.js index 48ed20fb3..e3b060385 100644 --- a/packages/logger/test/remote.test.js +++ b/packages/logger/test/remote.test.js @@ -1,5 +1,5 @@ -import helpers from './helpers'; -import logger from '../src'; +import helpers from './helpers.js'; +import logger from '@percy/logger'; // very shallow mock websocket class MockSocket { diff --git a/packages/sdk-utils/README.md b/packages/sdk-utils/README.md index 00779b6aa..b90fd0c64 100644 --- a/packages/sdk-utils/README.md +++ b/packages/sdk-utils/README.md @@ -21,7 +21,7 @@ This object contains information about the local Percy environment and is update [`isPercyEnabled`](#ispercyenabled) is called for the first time. ``` js -const { percy } = require('@percy/sdk-utils') +import { percy } from '@percy/sdk-utils' // reflects/updates process.env.PERCY_SERVER_ADDRESS percy.address === 'http://localhost:5338' @@ -47,7 +47,7 @@ log a message unless the CLI loglevel is `quiet` or `silent`. Upon a successful remote logging connection is also established. ``` js -const { isPercyEnabled } = require('@percy/sdk-utils') +import { isPercyEnabled } from '@percy/sdk-utils' // CLI API not running await isPercyEnabled() === false @@ -64,7 +64,7 @@ resulting string can be evaulated within a browser context to add the `PercyDOM. to the global scope. Subsequent calls return the first cached result. ``` js -const { fetchPercyDOM } = require('@percy/sdk-utils') +import { fetchPercyDOM } from '@percy/sdk-utils' let script = await fetchPercyDOM() @@ -84,7 +84,7 @@ browser.executeScript(script) Posts snapshot options to the local Percy API server. ``` js -const { postSnapshot } = require('@percy/sdk-utils') +import { postSnapshot } from '@percy/sdk-utils' await postSnapshot({ // required @@ -106,7 +106,7 @@ await postSnapshot({ Sends a request to the local Percy API server. Used internally by the other SDK utils. ``` js -const { request } = require('@percy/sdk-utils') +import { request } from '@percy/sdk-utils' await request('/percy/idle') await request('/percy/stop') @@ -122,7 +122,7 @@ The returned object must contain the following normalized properties from the re `status`, `statusText`, `headers`, `body` ``` js -const { request } = require('@percy/sdk-utils') +import { request } from '@percy/sdk-utils' // Cypress SDK example request.fetch = async function fetch(url, options) { diff --git a/packages/sdk-utils/package.json b/packages/sdk-utils/package.json index c962b82ea..53cc12eca 100644 --- a/packages/sdk-utils/package.json +++ b/packages/sdk-utils/package.json @@ -11,7 +11,7 @@ "access": "public" }, "engines": { - "node": ">=12" + "node": ">=14" }, "files": [ "./dist", @@ -21,6 +21,7 @@ ], "main": "./dist/index.js", "browser": "./dist/bundle.js", + "type": "module", "exports": { ".": "./dist/index.js", "./test/server": "./test/server.js", @@ -34,7 +35,7 @@ "test:coverage": "yarn test --coverage" }, "karma": { - "run_start": "node test/server start &", + "run_start": "node test/server start", "run_complete": "node test/server stop" }, "rollup": { diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index b77e496f9..26996b47e 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -1,13 +1,10 @@ import logger from '@percy/logger'; -import percy from './percy-info'; -import request from './request'; -import isPercyEnabled from './percy-enabled'; -import waitForPercyIdle from './percy-idle'; -import fetchPercyDOM from './percy-dom'; -import postSnapshot from './post-snapshot'; - -// export the namespace by default -export * as default from '.'; +import percy from './percy-info.js'; +import request from './request.js'; +import isPercyEnabled from './percy-enabled.js'; +import waitForPercyIdle from './percy-idle.js'; +import fetchPercyDOM from './percy-dom.js'; +import postSnapshot from './post-snapshot.js'; export { logger, @@ -18,3 +15,6 @@ export { fetchPercyDOM, postSnapshot }; + +// export the namespace by default +export * as default from './index.js'; diff --git a/packages/sdk-utils/src/percy-dom.js b/packages/sdk-utils/src/percy-dom.js index 4219ea11a..cdee15e52 100644 --- a/packages/sdk-utils/src/percy-dom.js +++ b/packages/sdk-utils/src/percy-dom.js @@ -1,5 +1,5 @@ -import percy from './percy-info'; -import request from './request'; +import percy from './percy-info.js'; +import request from './request.js'; // Fetch and cache the @percy/dom script export async function fetchPercyDOM() { diff --git a/packages/sdk-utils/src/percy-enabled.js b/packages/sdk-utils/src/percy-enabled.js index 53cb8510b..69709ecde 100644 --- a/packages/sdk-utils/src/percy-enabled.js +++ b/packages/sdk-utils/src/percy-enabled.js @@ -1,6 +1,6 @@ import logger from '@percy/logger'; -import percy from './percy-info'; -import request from './request'; +import percy from './percy-info.js'; +import request from './request.js'; // Create a socket to connect to a remote logger async function connectRemoteLogger() { diff --git a/packages/sdk-utils/src/percy-idle.js b/packages/sdk-utils/src/percy-idle.js index e4fb88cff..4ef47c0bf 100644 --- a/packages/sdk-utils/src/percy-idle.js +++ b/packages/sdk-utils/src/percy-idle.js @@ -1,4 +1,4 @@ -import request from './request'; +import request from './request.js'; const RETRY_ERROR_CODES = ['ECONNRESET', 'ETIMEDOUT']; diff --git a/packages/sdk-utils/src/post-snapshot.js b/packages/sdk-utils/src/post-snapshot.js index cf1ef7734..b7709e717 100644 --- a/packages/sdk-utils/src/post-snapshot.js +++ b/packages/sdk-utils/src/post-snapshot.js @@ -1,5 +1,5 @@ -import percy from './percy-info'; -import request from './request'; +import percy from './percy-info.js'; +import request from './request.js'; // Post snapshot data to the snapshot endpoint. If the snapshot endpoint responds with a closed // error message, signal that Percy has been disabled. diff --git a/packages/sdk-utils/src/request.js b/packages/sdk-utils/src/request.js index a5a722df4..8fffac4db 100644 --- a/packages/sdk-utils/src/request.js +++ b/packages/sdk-utils/src/request.js @@ -1,4 +1,4 @@ -import percy from './percy-info'; +import percy from './percy-info.js'; // Helper to send a request to the local CLI API export async function request(path, options = {}) { @@ -46,9 +46,9 @@ if (process.env.__PERCY_BROWSERIFIED__) { }; } else { // use http.request in node - const http = require('http'); - request.fetch = async function fetch(url, options) { + let { default: http } = await import('http'); + return new Promise((resolve, reject) => { http.request(url, options) .on('response', response => { diff --git a/packages/sdk-utils/test/helpers.js b/packages/sdk-utils/test/helpers.js index da2258c6a..f17b3ae20 100644 --- a/packages/sdk-utils/test/helpers.js +++ b/packages/sdk-utils/test/helpers.js @@ -1,7 +1,7 @@ -const logger = require('@percy/logger/test/helpers'); -const utils = require('@percy/sdk-utils'); +import logger from '@percy/logger/test/helpers'; +import utils from '@percy/sdk-utils'; -const helpers = { +export const helpers = { logger, async setup() { @@ -11,7 +11,7 @@ const helpers = { delete utils.percy.domScript; delete process.env.PERCY_SERVER_ADDRESS; await helpers.call('server.mock'); - logger.mock(); + await logger.mock(); }, teardown: () => helpers.call('server.close'), @@ -70,8 +70,11 @@ if (process.env.__PERCY_BROWSERIFIED__) { )); }; } else { - helpers.context = require('./server').context(); - helpers.call = helpers.context.call; + helpers.call = async function call() { + let { context } = await import('./server.js'); + helpers.context = (helpers.context || await context()); + return helpers.context.call(...arguments); + }; } -module.exports = helpers; +export default helpers; diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 9bfcfc05d..4ac3d263e 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -1,5 +1,5 @@ -import helpers from './helpers'; -import utils from '../src'; +import helpers from './helpers.js'; +import utils from '@percy/sdk-utils'; describe('SDK Utils', () => { beforeEach(async () => { diff --git a/packages/sdk-utils/test/server.js b/packages/sdk-utils/test/server.js index eadfc7110..11dbf5e37 100644 --- a/packages/sdk-utils/test/server.js +++ b/packages/sdk-utils/test/server.js @@ -1,6 +1,10 @@ +import fs from 'fs'; +import url from 'url'; +import path from 'path'; + // create a testing context for mocking the local percy server and a local testing site -function context() { - let { createTestServer } = require('@percy/core/test/helpers'); +export async function context() { + let { createTestServer } = await import('@percy/core/test/helpers'); let ctx = { async call(path, ...args) { @@ -105,9 +109,13 @@ function context() { } // start a testing server to control a context remotely -async function start(args, log) { +export async function start(args) { + let { logger } = await import('@percy/logger'); + let { WebSocketServer } = await import('ws'); + let log = logger('utils:test/server'); + let startSocketServer = (tries = 0) => new Promise((resolve, reject) => { - let server = new (require('ws').Server)({ port: 5339 }); + let server = new WebSocketServer({ port: 5339 }); server.on('listening', () => resolve(server)).on('error', reject); }).catch(err => { if (err.code === 'EADDRINUSE' && tries < 10) { @@ -116,7 +124,7 @@ async function start(args, log) { }); let wss = await startSocketServer(); - let ctx = context(); + let ctx = await context(); let close = () => { if (close.called) return; @@ -124,7 +132,7 @@ async function start(args, log) { if (ctx) ctx.call('close'); for (let ws of wss.clients) ws.terminate(); - wss.close(() => log('info', 'Closed SDK testing server')); + wss.close(() => log.info('Closed SDK testing server')); }; wss.on('connection', ws => { @@ -140,70 +148,61 @@ async function start(args, log) { }).catch(err => { let error = { message: err.message, stack: err.stack }; ws.send(JSON.stringify({ id, reject: { error } })); - log('debug', `${event}: ${error.stack}`); + log.debug(`${event}: ${error.stack}`); }); }); }); await ctx.call('mockAll'); - log('info', 'Started SDK testing server'); + log.info('Started SDK testing server'); } // stop any existing testing server -async function stop() { +export async function stop() { + let { default: WS } = await import('ws'); + await new Promise(resolve => { - let ws = new (require('ws'))('ws://localhost:5339'); + let ws = new WS('ws://localhost:5339'); ws.on('open', () => ws.send('CLOSE')); ws.on('close', () => resolve()); }); } // start & stop a testing server around a command -function exec(args, log) { +export async function exec(args) { let argsep = args.indexOf('--'); if (argsep < 0) throw new Error('Must supply a command after `--`'); - let startargs = args.slice(0, argsep); + let { spawn } = await import('child_process'); let [cmd, ...cmdargs] = args.slice(argsep + 1); + let startargs = args.slice(0, argsep); + await start(startargs); - return start(startargs, log).then(async () => { - let { spawn } = require('child_process'); - spawn(cmd, cmdargs, { stdio: 'inherit' }) - .on('exit', process.exit) - .on('error', error => { - console.error(error); - process.exit(1); - }); - }); + spawn(cmd, cmdargs, { stdio: 'inherit' }) + .on('exit', process.exit) + .on('error', error => { + console.error(error); + process.exit(1); + }); } // allow invoking start/stop/exec as CLI commands -if (require.main === module) { - let path = require('path'); - let { existsSync } = require('fs'); - let [,, cmd, ...args] = process.argv; - - let logger; - if (existsSync(path.join(__dirname, '../src'))) { - require('../../../scripts/babel-register'); - logger = require('../../logger/src'); - } else { - logger = require('@percy/logger'); - } - - let run = { start, stop, exec }[cmd]; - let log = (lvl, msg) => logger('utils:test/server')[lvl](msg); - - if (run) { - run(args, log).catch(console.error); +const filename = url.fileURLToPath(import.meta.url); +const [program, ...args] = process.argv.slice(1); + +if (program === filename || `${program}.js` === filename) { + const run = { start, stop, exec }[args[0]]; + + if (!run) { + process.stderr.write('usage: node test/server \n'); + } else if (!process.send && fs.existsSync(path.join(filename, '../../src'))) { + await import('child_process').then(cp => cp.fork(filename, args, { + execArgv: ['--no-warnings', '--loader=../../scripts/loader.js'] + })); } else { - process.stderr.write( - 'usage: node test/server \n' - ); + await run(args.slice(1)).catch(console.error); } } -module.exports.context = context; -module.exports.start = start; -module.exports.stop = stop; -module.exports.exec = exec; +// export the namespace by default +export * as default from './server.js'; diff --git a/rollup.config.js b/rollup.config.js index 1ad40ac63..a812e36d3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,11 +1,13 @@ -const path = require('path'); -const alias = require('@rollup/plugin-alias'); -const babel = require('@rollup/plugin-babel').default; -const resolve = require('@rollup/plugin-node-resolve').default; -const commonjs = require('@rollup/plugin-commonjs'); +import fs from 'fs'; +import path from 'path'; +import alias from '@rollup/plugin-alias'; +import { babel } from '@rollup/plugin-babel'; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import { LOADER_ALIAS } from './scripts/loader.js'; const cwd = process.cwd(); -const pkg = require(`${cwd}/package.json`); +const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'))); const config = path => path.split('.').reduce((v, k) => v && v[k], pkg.rollup); // constants and functions used for external bundles @@ -41,11 +43,8 @@ const IGNORE_WARNINGS = [ const plugins = { alias: alias({ entries: [{ - find: /^@percy\/([^/]+)$/, - replacement: path.join(__dirname, '/packages/$1/src/index.js') - }, { - find: /^@percy\/([^/]+)\/dist\/(.+)$/, - replacement: path.join(__dirname, '/packages/$1/src/$2') + find: LOADER_ALIAS.find, + replacement: LOADER_ALIAS.replace }] }), babel: babel({ @@ -54,7 +53,7 @@ const plugins = { presets: [ ['@babel/env', { targets: { - node: '12', + node: '14', browsers: [ 'last 2 versions and supports async-functions' ] @@ -88,6 +87,17 @@ const plugins = { } } } + }, + transformTestHelpers: { + name: 'transform-test-helpers', + transform(code, id) { + if (this.getModuleInfo(id).isEntry && config('test.output.exports') !== 'named') { + code = code.replace(/^export {.*};?$/gms, ''); + code = code.replace(/^export ((?!default))/gm, '$1'); + } + + return { code, map: null }; + } } }; @@ -130,7 +140,7 @@ const base = { }; // test config used for test bundles -const test = { +export const test = { ...base, output: { ...base.output, @@ -145,7 +155,7 @@ const test = { }; // test config used to bundle test helpers -const testHelpers = { +export const testHelpers = { ...test, external: (id, parent) => ( isLocalLib(id) || @@ -169,12 +179,13 @@ const testHelpers = { plugins.resolve, plugins.commonjs, plugins.babel, - plugins.customWrapper + plugins.customWrapper, + plugins.transformTestHelpers ] }; // test config used to bundle test files -const testFiles = { +export const testFiles = { ...testHelpers, output: { ...testHelpers.output, @@ -199,7 +210,4 @@ if (pkg.files.includes('test/client.js')) { }); } -module.exports.default = bundles; -module.exports.test = test; -module.exports.testHelpers = testHelpers; -module.exports.testFiles = testFiles; +export default bundles; diff --git a/scripts/babel-register.js b/scripts/babel-register.js deleted file mode 100644 index e1f08511a..000000000 --- a/scripts/babel-register.js +++ /dev/null @@ -1,16 +0,0 @@ -const path = require('path'); - -require('@babel/register')({ - // allow monorepos to share a single babel config - rootMode: 'upward', - babelrcRoots: ['.'], - - only: [ - // specified without the cwd so tests can share helpers - new RegExp( - ['(@percy|packages)', '.+?', '(src|test|.*\\.js)'] - // escape windows path separators and escape the escape - .join(path.sep === '/' ? '/' : '\\\\') - ) - ] -}); diff --git a/scripts/build.js b/scripts/build.js index fa81a8584..35f404d9a 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,12 +1,14 @@ /* eslint-disable import/no-extraneous-dependencies */ -const cwd = process.cwd(); -const path = require('path'); -const colors = require('colors/safe'); +import fs from 'fs'; +import path from 'path'; +import colors from 'colors/safe.js'; +import parse from 'yargs-parser'; +const cwd = process.cwd(); process.env.NODE_ENV = 'production'; // borrow yargs-parser to process command arguments -const argv = require('yargs-parser')(process.argv.slice(2), { +const argv = parse(process.argv.slice(2), { alias: { node: 'n', bundle: 'b', watch: 'w' }, boolean: ['node', 'bundle', 'watch'] }); @@ -14,28 +16,30 @@ const argv = require('yargs-parser')(process.argv.slice(2), { // main program async function main({ node, bundle } = argv) { // determine default options based on package.json values - let pkg = require(path.join(cwd, 'package.json')); + let pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'))); let buildNode = node != null ? node : (!bundle && pkg.main !== pkg.browser); let buildBundle = bundle != null ? node : (!node && pkg.browser); if (buildNode) { console.log(colors.magenta('Building node modules...')); + let { default: babel } = await import('@babel/cli/lib/babel/dir.js'); + let cliOptions = { filenames: ['src'], outDir: 'dist' }; + let babelOptions = { rootMode: 'upward' }; // $ babel /src --out-dir /dist --root-mode upward - await require('@babel/cli/lib/babel/dir').default({ - cliOptions: { filenames: ['src'], outDir: 'dist' }, - babelOptions: { rootMode: 'upward' } - }); + await babel.default({ cliOptions, babelOptions }); } if (buildBundle) { if (buildNode) process.stdout.write('\n'); console.log(colors.magenta('Building browser bundle...')); + let rollupConfig = await import('../rollup.config.js'); + let { rollup } = await import('rollup'); let start = Date.now(); // $ rollup --config /rollup.config.js - for (let config of require('../rollup.config').default) { - let bundle = await require('rollup').rollup(config); + for (let config of rollupConfig.default) { + let bundle = await rollup(config); await bundle.write(config.output); await bundle.close(); @@ -55,5 +59,5 @@ function handleError(err) { // run everything and maybe watch for changes main().catch(handleError).then(() => argv.watch && ( - require('./watch')(() => main().catch(handleError)) + import('./watch').then(w => w.watch(() => main().catch(handleError))) )); diff --git a/scripts/chromium-revision b/scripts/chromium-revision index 1ca3581af..a8ed7455e 100755 --- a/scripts/chromium-revision +++ b/scripts/chromium-revision @@ -1,6 +1,10 @@ #!/usr/bin/env node -const SCRIPT_NAME = require('path').basename(__filename); +const url = await import('url'); +const path = await import('path'); +const readline = await import('readline'); + +const SCRIPT_NAME = path.basename(url.fileURLToPath(import.meta.url)); // Usage/help output if (!process.argv[2]) (console.log(`\ @@ -17,9 +21,8 @@ EXAMPLE `), process.exit()); // Required after usage for speedy help output -const readline = require('readline'); -const request = require('@percy/client/dist/request').default; -const logger = require('@percy/logger'); +const { request } = await import('@percy/client/utils'); +const logger = await import('@percy/logger'); const log = logger('script'); // Chromium GitHub constants @@ -154,8 +157,7 @@ async function printVersionRevisions(version) { } // call the script with the first provided arg -printVersionRevisions(process.argv[2]) - .catch(error => { - // request errors have a response body - log.error(error.response?.body?.message || error); - }); +printVersionRevisions(process.argv[2]).catch(error => { + // request errors have a response body + log.error(error.response?.body?.message || error); +}); diff --git a/scripts/loader.js b/scripts/loader.js new file mode 100644 index 000000000..9b909ec07 --- /dev/null +++ b/scripts/loader.js @@ -0,0 +1,112 @@ +import fs from 'fs'; +import url from 'url'; +import path from 'path'; +import babel from '@babel/core'; + +const ROOT = path.resolve(url.fileURLToPath(import.meta.url), '../..'); +const BABEL_REG = /(\/|\\)(@percy|packages)\1(.+?)\1(src|test|.*\\.js)/; +const CJS_REG = /(^|\n)(module\.)?(exports)/; +const MOCK_REG = /^mock:\/\/|\?.+$/g; + +// global mocks can be added from tests +export const MOCK_IMPORTS = global.__MOCK_IMPORTS__ = global.__MOCK_IMPORTS__ || + new Proxy(Object.assign(new Map(), { __uid__: 0 }), { + get(target, prop, reciever) { + if (typeof target[prop] !== 'function') return target[prop]; + + return prop === 'set' ? (key, value) => { + return target[prop](key, (target.__uid__++, value)); + } : (prop === 'get' || prop === 'has') ? key => { + return target[prop](key.replace(MOCK_REG, '')); + } : target[prop].bind(target); + } + }); + +// matches and rewrites internal imports into absolute src paths +export const LOADER_ALIAS = { + find: /^@percy\/([^/]+)(?:\/(.+))?$|(^[./]+?)\/dist\/(.+\.js)$/, + replace: (specifier, name, subpath, rel, filename) => { + if (rel) return `${rel}/src/${filename}`; + if (!subpath) return path.resolve(ROOT, `./packages/${name}/src/index.js`); + let pkg = JSON.parse(fs.readFileSync(path.join(ROOT, `./packages/${name}/package.json`))); + let alias = pkg.exports?.[`./${subpath}`].replace('./dist', './src'); + if (alias) return path.resolve(ROOT, `./packages/${name}/${alias}`); + return specifier; + } +}; + +// resolve specifier file url +export async function resolve(specifier, context, defaultResolve) { + // check for import or filesystem mocks + if (MOCK_IMPORTS.has(specifier)) { + return { url: `mock://${specifier}?__mock__=${MOCK_IMPORTS.__uid__}&module` }; + } else if (context.parentURL && '$vol' in fs) { + let filename = specifier.startsWith('file:') ? url.fileURLToPath(specifier) : specifier; + let filepath = path.resolve(path.dirname(url.fileURLToPath(context.parentURL)), filename); + + if (fs.$vol.existsSync(filepath)) { + let fmt = CJS_REG.test(fs.$vol.readFileSync(filepath)) ? 'commonjs' : 'module'; + return { url: `${url.pathToFileURL(filepath)}?__mock__=${MOCK_IMPORTS.__uid__}&${fmt}` }; + } + } + + // rewrite dist to src in development + if (specifier.startsWith('#')) { + let pkgRoot = url.fileURLToPath(context.parentURL.replace(/(packages\/[^/]+\/).+$/, '$1')); + let pkgJSON = JSON.parse(fs.readFileSync(path.resolve(pkgRoot, 'package.json'))); + let alias = pkgJSON.imports[specifier]?.node?.replace('./dist', './src'); + if (alias) specifier = path.resolve(pkgRoot, alias); + } else { + specifier = specifier.replace(LOADER_ALIAS.find, LOADER_ALIAS.replace); + } + + // transform absolute filepaths into absolute file urls + if (specifier.startsWith(ROOT)) specifier = url.pathToFileURL(specifier).href; + + // use default resolve when not mocked + return defaultResolve(specifier, context, defaultResolve); +} + +// get module format for loader mocks +export async function getFormat(srcURL, context, defaultGetFormat) { + return srcURL.includes('?__mock__') + ? { format: srcURL.split('?')[1].split('&')[1] } + : defaultGetFormat(srcURL, context, defaultGetFormat); +} + +// generate mock sources for mocked modules +function mockSource(mockURL) { + if (MOCK_IMPORTS.has(mockURL)) { + let key = `global.__MOCK_IMPORTS__.get("${mockURL}")`; + + return Object.keys(MOCK_IMPORTS.get(mockURL)).reduce((src, name) => src + ( + `export ${name === 'default' ? name : `const ${name} =`} ${key}.${name};\n` + ), ''); + } else { + return fs.$vol.readFileSync(url.fileURLToPath(mockURL)); + } +} + +// return loader mocks as module sources +export async function getSource(srcURL, context, defaultGetSource) { + if (srcURL.includes('?__mock__')) return { source: mockSource(srcURL) }; + return defaultGetSource(srcURL, context, defaultGetSource); +} + +// return loader mocks or transform sources using babel +export async function transformSource(source, context, defaultTransformSource) { + let callback = (src = source) => defaultTransformSource(src, context, defaultTransformSource); + if (context.format !== 'module' && context.format !== 'commonjs') return callback(); + if (context.url.startsWith('mock://')) return callback(); + + if (typeof source !== 'string') source = Buffer.from(source); + if (Buffer.isBuffer(source)) source = source.toString(); + + return callback((await babel.transformAsync(source, { + filename: url.fileURLToPath(context.url), + sourceType: 'module', + rootMode: 'upward', + babelrcRoots: ['.'], + only: [BABEL_REG] + }))?.code); +} diff --git a/scripts/test-helpers.js b/scripts/test-helpers.js index b5e48537f..2053ac072 100644 --- a/scripts/test-helpers.js +++ b/scripts/test-helpers.js @@ -58,15 +58,12 @@ const { DUMP_FAILED_TEST_LOGS } = ( if (DUMP_FAILED_TEST_LOGS) { // add a spec reporter to dump failed logs env.addReporter({ - specDone: ({ status }) => { + specDone: async ({ status }) => { let logger = typeof window !== 'undefined' ? (window.PercyLogger && window.PercyLogger.TestHelpers) || (window.PercySDKUtils && window.PercySDKUtils.TestHelpers.logger) - : require('@percy/logger/test/helpers'); - - if (logger && status === 'failed') { - logger.dump(); - } + : (await import('@percy/logger/test/helpers')).logger; + if (logger && status === 'failed') logger.dump(); } }); } diff --git a/scripts/test.js b/scripts/test.js index f5b937169..5df431fe8 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -1,12 +1,18 @@ /* eslint-disable import/no-extraneous-dependencies */ +import fs from 'fs'; +import url from 'url'; +import path from 'path'; +import cp from 'child_process'; +import parse from 'yargs-parser'; +import colors from 'colors/safe.js'; + const cwd = process.cwd(); -const path = require('path'); -const colors = require('colors/safe'); +const filename = url.fileURLToPath(import.meta.url); process.env.NODE_ENV = 'test'; // borrow yargs-parser to process command arguments -const argv = require('yargs-parser')(process.argv.slice(2), { +const argv = parse(process.argv.slice(2), { configuration: { 'strip-aliased': true }, alias: { node: 'n', browsers: 'b', coverage: 'c', reporter: 'r', watch: 'w' }, boolean: ['node', 'browsers', 'coverage', 'watch'], @@ -27,7 +33,7 @@ function child(type, cmd, args, options) { args = args.filter(Boolean); return new Promise((resolve, reject) => { - require('child_process')[type](cmd, args, options) + cp[type](cmd, args, options) .on('exit', exitCode => exitCode ? reject(Object.assign(new Error(`EEXIT ${exitCode}`), { exitCode })) : resolve()) @@ -64,7 +70,7 @@ async function main({ karma: karmaArgs } = argv) { // determine arg defaults based on package.json values - let pkg = require(path.join(cwd, 'package.json')); + let pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'))); let testNode = node != null ? node : (!browsers && pkg.main !== pkg.browser); let testBrowsers = browsers != null ? browsers : (!node && pkg.browser); @@ -73,36 +79,37 @@ async function main({ // nyc --silent --no-clean node /test.js ... && // nyc report --reporter let flags = flagify({ node, browsers }); - let nycbin = require.resolve('nyc/bin/nyc'); - let rimraf = require('rimraf'); + let nycbin = path.resolve(filename, '../../node_modules/.bin/nyc'); + let { default: rimraf } = await import('rimraf'); await new Promise(r => rimraf(path.join(cwd, '{.nyc_output,coverage}'), r)); - await child('spawn', nycbin, ['--silent', '--no-clean', 'node', __filename, ...flags]); + await child('spawn', nycbin, ['--silent', '--no-clean', 'node', filename, ...flags]); await child('spawn', nycbin, ['report', '--check-coverage', ...flagify({ reporter })]); } else if (!process.send) { // test runners assume they have control over the entire process, so give them each forks let flags = flagify({ coverage, karma: karmaArgs }); + let loader = url.pathToFileURL(path.resolve(filename, '../loader.js')).href; + let opts = { execArgv: ['--loader', loader, ...process.execArgv] }; if (testNode) { - await child('fork', __filename, ['--node', ...flags]); + await child('fork', filename, ['--node', ...flags], opts); process.stdout.write('\n'); } if (testBrowsers) { - await child('fork', __filename, ['--browsers', ...flags]); + await child('fork', filename, ['--browsers', ...flags], opts); process.stdout.write('\n'); } } else if (testNode) { // $ jasmine /test/**/*.test.js --config - let Jasmine = require('jasmine'); - let { SpecReporter } = require('jasmine-spec-reporter'); + let { default: Jasmine } = await import('jasmine'); + let { SpecReporter } = await import('jasmine-spec-reporter'); let jasmine = new Jasmine(); jasmine.loadConfig({ spec_dir: 'test', spec_files: ['**/*.test.js'], - requires: [require.resolve('./babel-register')], - helpers: [require.resolve('./test-helpers')], + helpers: [path.resolve(filename, '../test-helpers.js')], random: false }); @@ -121,11 +128,14 @@ async function main({ await jasmine.execute(); } else if (testBrowsers) { // $ karma start --config /karma.config.js - let { Server: KarmaServer, config: { parseConfig } } = require('karma'); + let { default: Karma } = await import('karma'); + let { Server: KarmaServer, config: { parseConfig } } = Karma; - let configFile = require.resolve('../karma.config'); - let config = parseConfig(configFile, karmaArgs, { throwErrors: true }); - let karma = new KarmaServer(config); + let configFile = path.resolve(filename, '../../karma.config.cjs'); + let karma = new KarmaServer(await parseConfig(configFile, karmaArgs, { + promiseConfig: true, + throwErrors: true + })); // attach any karma hooks if (pkg.karma) { @@ -135,7 +145,9 @@ async function main({ } // collect coverage for nyc here rather than use a karma plugin - let cov = require('istanbul-lib-coverage').createCoverageMap(); + let { default: istcov } = await import('istanbul-lib-coverage'); + let cov = istcov.createCoverageMap(); + karma.on('browser_complete', (b, r) => r && cov.merge(r.coverage)); karma.on('run_complete', () => (global.__coverage__ = cov.toJSON())); @@ -152,5 +164,5 @@ function handleError(err) { // run everything and maybe watch for changes main().catch(handleError).then(() => argv.watch && ( - require('./watch')(() => main().catch(handleError)) + import('./watch').then(w => w.watch(() => main().catch(handleError))) )); diff --git a/scripts/watch.js b/scripts/watch.js index eb21a279c..6fc976c80 100644 --- a/scripts/watch.js +++ b/scripts/watch.js @@ -1,14 +1,16 @@ /* eslint-disable import/no-extraneous-dependencies */ -const path = require('path'); -const { readFileSync } = require('fs'); -const colors = require('colors/safe'); -const gaze = require('gaze'); +import fs from 'fs'; +import url from 'url'; +import path from 'path'; +import gaze from 'gaze'; +import colors from 'colors/safe.js'; // executes the callback when files within the current working directory have been modified -module.exports = function watch(callback) { +export function watch(callback) { + let ignorefile = path.resolve(url.fileURLToPath(import.meta.url), '../../.gitignore'); + // ignore file patterns are not globs, we need to convert them - let ignorefile = path.join(__dirname, '../.gitignore'); - let ignorePatterns = readFileSync(ignorefile, 'utf8') + let ignorePatterns = fs.readFileSync(ignorefile, 'utf-8') .split('\n').filter(p => !!p && p[0] !== '#') // remove empties and comments .map(p => p[0] === '!' ? ['', p.substr(1)] : ['!', p]) // invert negations .filter(p => p[1].indexOf('/.') === -1 && p[1].indexOf('.') !== 0) // remove dotfiles @@ -21,3 +23,5 @@ module.exports = function watch(callback) { callback(); }); }; + +export default watch; diff --git a/yarn.lock b/yarn.lock index 41b5bab5a..643dee455 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2359,17 +2359,6 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^5.0.4" test-exclude "^6.0.0" -babel-plugin-module-resolver@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/babel-plugin-module-resolver/-/babel-plugin-module-resolver-4.1.0.tgz#22a4f32f7441727ec1fbf4967b863e1e3e9f33e2" - integrity sha512-MlX10UDheRr3lb3P0WcaIdtCSRlxdQsB1sBqL7W0raF070bGl1HQQq5K3T2vf2XAYie+ww+5AKC/WrkjRO2knA== - dependencies: - find-babel-config "^1.2.0" - glob "^7.1.6" - pkg-up "^3.1.0" - reselect "^4.0.0" - resolve "^1.13.1" - babel-plugin-polyfill-corejs2@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.0.tgz#407082d0d355ba565af24126fb6cb8e9115251fd" @@ -3734,14 +3723,6 @@ finalhandler@1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -find-babel-config@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/find-babel-config/-/find-babel-config-1.2.0.tgz#a9b7b317eb5b9860cda9d54740a8c8337a2283a2" - integrity sha512-jB2CHJeqy6a820ssiqwrKMeyC6nNdmrcgkKWJWmpoxpE8RKciYJXCcXRq1h2AzCo5I5BJeN2tkGEO3hLTuePRA== - dependencies: - json5 "^0.5.1" - path-exists "^3.0.0" - find-cache-dir@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -4877,11 +4858,6 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= -json5@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= - json5@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" @@ -5136,11 +5112,6 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.set@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" - integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -5550,16 +5521,6 @@ neo-async@^2.6.0: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -nock@^13.1.1: - version "13.2.4" - resolved "https://registry.yarnpkg.com/nock/-/nock-13.2.4.tgz#43a309d93143ee5cdcca91358614e7bde56d20e1" - integrity sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug== - dependencies: - debug "^4.1.0" - json-stringify-safe "^5.0.1" - lodash.set "^4.3.2" - propagate "^2.0.0" - node-fetch@^2.6.1: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -6218,13 +6179,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-up@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" - integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== - dependencies: - find-up "^3.0.0" - plur@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/plur/-/plur-4.0.0.tgz#729aedb08f452645fe8c58ef115bf16b0a73ef84" @@ -6274,11 +6228,6 @@ promzard@^0.3.0: dependencies: read "1" -propagate@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" - integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -6650,11 +6599,6 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= -reselect@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" - integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== - resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -6672,7 +6616,7 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve@^1.1.6, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0: +resolve@^1.1.6, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0: version "1.21.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f" integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA==