diff --git a/.eslintrc b/.eslintrc index 4b7ce7998..a156d7954 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,6 +18,7 @@ rules: - before: false after: false named: after + method: before space-before-function-paren: - error - anonymous: never diff --git a/.github/.cache-key b/.github/.cache-key index 5f64e58c2..b25300ecf 100644 --- a/.github/.cache-key +++ b/.github/.cache-key @@ -7,3 +7,4 @@ / \ _____________/_ __ \_____________ Times we have broken CI: 3 +Times Windows has broken CI: 99+ diff --git a/babel.config.js b/babel.config.js index f3c6a06e1..a745bd534 100644 --- a/babel.config.js +++ b/babel.config.js @@ -14,9 +14,6 @@ const base = { node: '12' } }] - ], - plugins: [ - '@babel/proposal-class-properties' ] }] }; @@ -27,7 +24,7 @@ const development = { cwd: __dirname, alias: { '^@percy/((?!dom)[^/]+)$': './packages/\\1/src', - '^@percy/(.+)/dist/(.+)$': './packages/\\1/src/\\2' + '^@percy/([^/]+)/((?!test).+)$': './packages/\\1/src/\\2' } }] ] diff --git a/package.json b/package.json index a78dfded3..1497be547 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@babel/cli": "^7.11.6", "@babel/core": "^7.14.6", "@babel/eslint-parser": "^7.14.7", - "@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/preset-env": "^7.14.7", "@babel/register": "^7.11.5", "@rollup/plugin-alias": "^3.1.3", @@ -53,7 +52,6 @@ "memfs": "^3.4.0", "nock": "^13.1.1", "nyc": "^15.1.0", - "quibble": "^0.6.8", "rollup": "^2.53.2", "tsd": "^0.19.0" } diff --git a/packages/cli-build/package.json b/packages/cli-build/package.json index 3f35e771a..7fd85338c 100644 --- a/packages/cli-build/package.json +++ b/packages/cli-build/package.json @@ -10,13 +10,14 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist" + ], + "main": "./dist/index.js", + "exports": "./dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -30,7 +31,6 @@ ] }, "dependencies": { - "@percy/cli-command": "1.0.0-beta.76", - "@percy/logger": "1.0.0-beta.76" + "@percy/cli-command": "1.0.0-beta.76" } } diff --git a/packages/cli-build/test/finalize.test.js b/packages/cli-build/test/finalize.test.js index d084aadc3..c3d706cc4 100644 --- a/packages/cli-build/test/finalize.test.js +++ b/packages/cli-build/test/finalize.test.js @@ -1,11 +1,9 @@ -import mockAPI from '@percy/client/test/helpers'; -import logger from '@percy/logger/test/helpers'; +import { logger, setupTest } from '@percy/cli-command/test/helpers'; import finalize from '../src/finalize'; describe('percy build:finalize', () => { beforeEach(() => { - mockAPI.start(); - logger.mock(); + setupTest(); }); afterEach(() => { diff --git a/packages/cli-build/test/wait.test.js b/packages/cli-build/test/wait.test.js index d2690102d..7001fd027 100644 --- a/packages/cli-build/test/wait.test.js +++ b/packages/cli-build/test/wait.test.js @@ -1,5 +1,4 @@ -import logger from '@percy/logger/test/helpers'; -import mockAPI from '@percy/client/test/helpers'; +import { logger, api, setupTest } from '@percy/cli-command/test/helpers'; import wait from '../src/wait'; describe('percy build:wait', () => { @@ -19,8 +18,7 @@ describe('percy build:wait', () => { beforeEach(() => { process.env.PERCY_TOKEN = '<>'; - mockAPI.start(); - logger.mock({ isTTY: true }); + setupTest({ loggerTTY: true }); }); afterEach(() => { @@ -48,7 +46,7 @@ describe('percy build:wait', () => { }); it('logs while recieving snapshots', async () => { - mockAPI + api .reply('/builds/123', () => [200, build({ state: 'pending' })]) @@ -66,7 +64,7 @@ describe('percy build:wait', () => { }); it('logs while processing snapshots', async () => { - mockAPI + api .reply('/builds/123', () => [200, build({ 'total-comparisons-finished': 16 })]) @@ -91,7 +89,7 @@ describe('percy build:wait', () => { }); it('logs found diffs when finished', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ 'total-comparisons-diff': 16, state: 'finished' })]); @@ -106,7 +104,7 @@ describe('percy build:wait', () => { }); it('errors and logs found diffs when finished', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ 'total-comparisons-diff': 16, state: 'finished' })]); @@ -121,7 +119,7 @@ describe('percy build:wait', () => { }); it('does not error when diffs are not found', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ 'total-comparisons-diff': 0, state: 'finished' })]); @@ -136,7 +134,7 @@ describe('percy build:wait', () => { }); it('errors and logs the build state when unrecognized', async () => { - mockAPI.reply('/builds/123', () => [200, build({ state: 'expired' })]); + api.reply('/builds/123', () => [200, build({ state: 'expired' })]); await expectAsync(wait(['--build=123'])).toBeRejected(); expect(logger.stdout).toEqual([]); @@ -146,7 +144,7 @@ describe('percy build:wait', () => { }); it('stops waiting on process termination', async () => { - mockAPI.reply('/builds/123', () => [200, build()]); + api.reply('/builds/123', () => [200, build()]); let waiting = wait(['--build=123']); @@ -165,7 +163,7 @@ describe('percy build:wait', () => { describe('failure messages', () => { it('logs an error when there are no snapshots', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'render_timeout' })]); @@ -181,7 +179,7 @@ describe('percy build:wait', () => { }); it('logs an error when there are no snapshots', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'no_snapshots' })]); @@ -196,7 +194,7 @@ describe('percy build:wait', () => { }); it('logs an error when the build was not finalized', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'missing_finalize' })]); @@ -211,7 +209,7 @@ describe('percy build:wait', () => { }); it('logs an error and exits when build is missing resources', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'missing_resources' })]); @@ -226,7 +224,7 @@ describe('percy build:wait', () => { }); it('logs an error and exits when parallel builds are missing', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'missing_resources', 'failure-details': { @@ -246,7 +244,7 @@ describe('percy build:wait', () => { }); it('logs the failure reason and exits when the reason is unrecognized', async () => { - mockAPI.reply('/builds/123', () => [200, build({ + api.reply('/builds/123', () => [200, build({ state: 'failed', 'failure-reason': 'unrecognized_reason' })]); diff --git a/packages/cli-command/package.json b/packages/cli-command/package.json index 38f4fda9c..8365a364e 100644 --- a/packages/cli-command/package.json +++ b/packages/cli-command/package.json @@ -10,16 +10,21 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "bin": { - "percy-cli-readme": "bin/readme" - }, "files": [ - "dist" + "./dist" ], "engines": { "node": ">=12" }, + "bin": { + "percy-cli-readme": "./bin/readme" + }, + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js", + "./test/helpers": "./test/helpers.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-command/src/command.js b/packages/cli-command/src/command.js index 3363f9c5c..2c2c31e04 100644 --- a/packages/cli-command/src/command.js +++ b/packages/cli-command/src/command.js @@ -1,7 +1,7 @@ import logger from '@percy/logger'; import PercyConfig from '@percy/config'; -import { set, del } from '@percy/config/dist/utils'; -import * as CoreConfig from '@percy/core/dist/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'; diff --git a/packages/cli-command/src/index.js b/packages/cli-command/src/index.js index 516c1da54..0d4040524 100644 --- a/packages/cli-command/src/index.js +++ b/packages/cli-command/src/index.js @@ -1,2 +1,5 @@ export { default, command } from './command'; export { legacyCommand, legacyFlags as flags } from './legacy'; +// 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 51c6e97de..518623835 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 { merge } from '@percy/config/dist/utils'; // 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 b11015790..ef211743b 100644 --- a/packages/cli-command/src/parse.js +++ b/packages/cli-command/src/parse.js @@ -1,5 +1,5 @@ import logger from '@percy/logger'; -import { camelcase } from '@percy/config/dist/normalize'; +import { camelcase } from '@percy/config/utils'; import { flagUsage } from './help'; // Make it possible to identify parse errors. diff --git a/packages/cli-command/src/utils.js b/packages/cli-command/src/utils.js new file mode 100644 index 000000000..38d587709 --- /dev/null +++ b/packages/cli-command/src/utils.js @@ -0,0 +1,2 @@ +export * from '@percy/config/utils'; +export * from '@percy/core/utils'; diff --git a/packages/cli-command/test/command.test.js b/packages/cli-command/test/command.test.js index 26b929640..8ae14f245 100644 --- a/packages/cli-command/test/command.test.js +++ b/packages/cli-command/test/command.test.js @@ -1,5 +1,4 @@ -import logger from '@percy/logger/test/helpers'; -import dedent from '@percy/core/test/helpers/dedent'; +import { logger, dedent } from './helpers'; import command from '../src'; describe('Command', () => { diff --git a/packages/cli-command/test/flags.test.js b/packages/cli-command/test/flags.test.js index 356439dd8..a5fa2cdae 100644 --- a/packages/cli-command/test/flags.test.js +++ b/packages/cli-command/test/flags.test.js @@ -1,5 +1,4 @@ -import logger from '@percy/logger/test/helpers'; -import dedent from '@percy/core/test/helpers/dedent'; +import { logger, dedent } from './helpers'; import command from '../src'; describe('Built-in flags:', () => { diff --git a/packages/cli-command/test/help.test.js b/packages/cli-command/test/help.test.js index 192a69dc7..185e3bf2a 100644 --- a/packages/cli-command/test/help.test.js +++ b/packages/cli-command/test/help.test.js @@ -1,5 +1,4 @@ -import logger from '@percy/logger/test/helpers'; -import dedent from '@percy/core/test/helpers/dedent'; +import { logger, dedent } from './helpers'; import command from '../src'; describe('Help output', () => { diff --git a/packages/cli-command/test/helpers.js b/packages/cli-command/test/helpers.js new file mode 100644 index 000000000..736fc9c22 --- /dev/null +++ b/packages/cli-command/test/helpers.js @@ -0,0 +1 @@ +export * from '@percy/core/test/helpers'; diff --git a/packages/cli-command/test/legacy.test.js b/packages/cli-command/test/legacy.test.js index 5a3c08252..6d1c1f146 100644 --- a/packages/cli-command/test/legacy.test.js +++ b/packages/cli-command/test/legacy.test.js @@ -1,5 +1,4 @@ -import logger from '@percy/logger/test/helpers'; -import dedent from '@percy/core/test/helpers/dedent'; +import { logger, dedent } from './helpers'; import { command, legacyCommand, flags } from '../src'; describe('Legacy support', () => { diff --git a/packages/cli-command/test/parse.test.js b/packages/cli-command/test/parse.test.js index 34e86b662..ef5cdd837 100644 --- a/packages/cli-command/test/parse.test.js +++ b/packages/cli-command/test/parse.test.js @@ -1,4 +1,4 @@ -import logger from '@percy/logger/test/helpers'; +import { logger } from './helpers'; import command from '../src'; describe('Option parsing', () => { diff --git a/packages/cli-config/package.json b/packages/cli-config/package.json index 8284356a0..8004a6278 100644 --- a/packages/cli-config/package.json +++ b/packages/cli-config/package.json @@ -10,13 +10,14 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist" + ], + "main": "dist/index.js", + "exports": "dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -30,7 +31,6 @@ ] }, "dependencies": { - "@percy/cli-command": "1.0.0-beta.76", - "@percy/config": "1.0.0-beta.76" + "@percy/cli-command": "1.0.0-beta.76" } } diff --git a/packages/cli-config/src/create.js b/packages/cli-config/src/create.js index 467ea0c8e..88ea7eef5 100644 --- a/packages/cli-config/src/create.js +++ b/packages/cli-config/src/create.js @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; -import command from '@percy/cli-command'; +import command, { + PercyConfig +} from '@percy/cli-command'; const DEFAULT_FILES = { rc: '.percyrc', @@ -58,8 +60,6 @@ export const create = command('create', { '$0 ./config/percy.yml' ] }, async ({ flags, args, log, exit }) => { - let PercyConfig = await import('@percy/config'); - // discern the filetype let filetype = args.filepath ? path.extname(args.filepath).replace(/^./, '') @@ -80,6 +80,7 @@ export const create = command('create', { // write stringified default config options to the filepath let format = ['rc', 'yaml', 'yml'].includes(filetype) ? 'yaml' : filetype; + fs.mkdirSync(path.dirname(filepath), { recursive: true }); fs.writeFileSync(filepath, PercyConfig.stringify(format)); log.info(`Created Percy config: ${filepath}`); }); diff --git a/packages/cli-config/src/migrate.js b/packages/cli-config/src/migrate.js index c352c4d68..9ecf1fbd4 100644 --- a/packages/cli-config/src/migrate.js +++ b/packages/cli-config/src/migrate.js @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; -import command from '@percy/cli-command'; +import command, { + PercyConfig +} from '@percy/cli-command'; export const migrate = command('migrate', { description: 'Migrate a Percy config file to the latest version', @@ -26,8 +28,6 @@ export const migrate = command('migrate', { '$0 .percy.yml .percy.js' ] }, async ({ args, flags, log, exit }) => { - let PercyConfig = await import('@percy/config'); - let { config, filepath: input } = PercyConfig.search(args.filepath); let output = args.output ? path.resolve(args.output) : input; @@ -64,8 +64,8 @@ export const migrate = command('migrate', { content = PercyConfig.stringify(format, { ...pkg, percy: migrated }); } else if (input === output) { // rename input if it is the output - let old = input.replace(path.extname(input), '.old$&'); - fs.renameSync(input, old); + let { dir, name, ext } = path.parse(input); + fs.renameSync(input, path.join(dir, `${name}.old${ext}`)); } // write to output diff --git a/packages/cli-config/src/validate.js b/packages/cli-config/src/validate.js index 11bf44ead..b3e23bef7 100644 --- a/packages/cli-config/src/validate.js +++ b/packages/cli-config/src/validate.js @@ -1,4 +1,4 @@ -import command from '@percy/cli-command'; +import command, { PercyConfig } from '@percy/cli-command'; export const validate = command('validate', { description: 'Validate a Percy config file', @@ -13,8 +13,6 @@ export const validate = command('validate', { '$0 ./config/percy.yml' ] }, async ({ args, log, exit }) => { - let PercyConfig = await import('@percy/config'); - // verify a config file can be located let { config, filepath } = PercyConfig.search(args.filepath); if (!config) exit(1, 'Config file not found'); diff --git a/packages/cli-config/test/create.test.js b/packages/cli-config/test/create.test.js index 0957d3d7a..fdd1704bf 100644 --- a/packages/cli-config/test/create.test.js +++ b/packages/cli-config/test/create.test.js @@ -1,49 +1,53 @@ import path from 'path'; -import { logger, getMockConfig } from './helpers'; -import PercyConfig from '@percy/config'; +import { PercyConfig } from '@percy/cli-command'; +import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; import create from '../src/create'; describe('percy config:create', () => { + beforeEach(() => { + setupTest(); + }); + it('creates a .percy.yml config file by default', async () => { await create(); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percy.yml']); - expect(getMockConfig('.percy.yml')).toBe(PercyConfig.stringify('yaml')); + expect(fs.readFileSync('.percy.yml', 'utf-8')).toBe(PercyConfig.stringify('yaml')); }); it('can create a .percyrc config file', async () => { await create(['--rc']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percyrc']); - expect(getMockConfig('.percyrc')).toBe(PercyConfig.stringify('yaml')); + expect(fs.readFileSync('.percyrc', 'utf-8')).toBe(PercyConfig.stringify('yaml')); }); it('can create a .percy.yaml config file', async () => { await create(['--yaml']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percy.yaml']); - expect(getMockConfig('.percy.yaml')).toBe(PercyConfig.stringify('yaml')); + expect(fs.readFileSync('.percy.yaml', 'utf-8')).toBe(PercyConfig.stringify('yaml')); }); it('can create a .percy.yml config file', async () => { await create(['--yml']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percy.yml']); - expect(getMockConfig('.percy.yml')).toBe(PercyConfig.stringify('yaml')); + expect(fs.readFileSync('.percy.yml', 'utf-8')).toBe(PercyConfig.stringify('yaml')); }); it('can create a .percy.json config file', async () => { await create(['--json']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percy.json']); - expect(getMockConfig('.percy.json')).toBe(PercyConfig.stringify('json')); + expect(fs.readFileSync('.percy.json', 'utf-8')).toBe(PercyConfig.stringify('json')); }); it('can create a .percy.js config file', async () => { await create(['--js']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(['[percy] Created Percy config: .percy.js']); - expect(getMockConfig('.percy.js')).toBe(PercyConfig.stringify('js')); + expect(fs.readFileSync('.percy.js', 'utf-8')).toBe(PercyConfig.stringify('js')); }); it('can create specific config files', async () => { @@ -52,7 +56,7 @@ describe('percy config:create', () => { expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([`[percy] Created Percy config: ${filename}`]); - expect(getMockConfig(filename)).toBe(PercyConfig.stringify('js')); + expect(fs.readFileSync(filename, 'utf-8')).toBe(PercyConfig.stringify('js')); }); it('errors when the filetype is unsupported', async () => { diff --git a/packages/cli-config/test/helpers.js b/packages/cli-config/test/helpers.js deleted file mode 100644 index 9651d9a1c..000000000 --- a/packages/cli-config/test/helpers.js +++ /dev/null @@ -1,9 +0,0 @@ -// required before fs is mocked by the config helper -import logger from '@percy/logger/test/helpers'; - -beforeEach(() => { - logger.mock(); -}); - -export { logger }; -export { mockConfig, getMockConfig } from '@percy/config/test/helpers'; diff --git a/packages/cli-config/test/migrate.test.js b/packages/cli-config/test/migrate.test.js index c54075b57..80aacdb46 100644 --- a/packages/cli-config/test/migrate.test.js +++ b/packages/cli-config/test/migrate.test.js @@ -1,20 +1,18 @@ import path from 'path'; -import { logger, mockConfig, getMockConfig } from './helpers'; -import PercyConfig from '@percy/config'; +import { PercyConfig } from '@percy/cli-command'; +import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; import migrate from '../src/migrate'; describe('percy config:migrate', () => { - beforeEach(async () => { - mockConfig('.percy.yml', 'version: 1\n'); + beforeEach(() => { + let filesystem = { '.percy.yml': 'version: 1\n' }; + setupTest({ filesystem, resetConfig: true }); + PercyConfig.addMigration((config, util) => { if (config.migrate) util.map('migrate', 'migrated', v => v.replace('old', 'new')); }); }); - afterEach(() => { - PercyConfig.clearMigrations(); - }); - it('by default, renames the config before writing', async () => { await migrate(); @@ -25,14 +23,14 @@ describe('percy config:migrate', () => { '[percy] Config file migrated!' ]); - expect(getMockConfig('.percy.old.yml')).toContain('version: 1'); - expect(getMockConfig('.percy.yml')).toContain('version: 2'); + expect(fs.readFileSync('.percy.old.yml', 'utf-8')).toContain('version: 1'); + expect(fs.readFileSync('.percy.yml', 'utf-8')).toContain('version: 2'); }); it('prints config with the --dry-run flag', async () => { await migrate(['--dry-run']); - expect(getMockConfig('.percy.yml')).toContain('version: 1'); + expect(fs.readFileSync('.percy.yml', 'utf-8')).toContain('version: 1'); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([ '[percy] Found config file: .percy.yml', @@ -43,10 +41,10 @@ describe('percy config:migrate', () => { }); it('works with rc configs', async () => { - mockConfig('.percyrc', 'version: 1\n'); + fs.writeFileSync('.percyrc', 'version: 1\n'); await migrate(['.percyrc']); - expect(getMockConfig('.percyrc')).toEqual('version: 2\n'); + expect(fs.readFileSync('.percyrc', 'utf-8')).toEqual('version: 2\n'); }); it('works with package.json configs', async () => { @@ -61,12 +59,10 @@ describe('percy config:migrate', () => { devDependencies: {} }; - // this is mocked and reflected in `getMockConfig` - require('fs').writeFileSync('package.json', json(pkg)); - + fs.writeFileSync('package.json', json(pkg)); await migrate(['package.json']); - expect(getMockConfig('package.json')).toEqual( + expect(fs.readFileSync('package.json', 'utf-8')).toEqual( json({ ...pkg, percy: { version: 2 } }) ); }); @@ -74,7 +70,7 @@ describe('percy config:migrate', () => { it('can convert between config types', async () => { await migrate(['.percy.yml', '.percy.js']); - expect(getMockConfig('.percy.js')) + expect(fs.readFileSync('.percy.js', 'utf-8')) .toEqual('module.exports = {\n version: 2\n}\n'); }); @@ -90,10 +86,10 @@ describe('percy config:migrate', () => { }); it('errors when a config cannot be parsed', async () => { - let filename = path.join('.config', 'percy.yml'); - mockConfig(filename, () => { throw new Error('test'); }); + fs.writeFileSync('.error.yml', ''); + fs.readFileSync.and.throwError(new Error('test')); - await expectAsync(migrate([filename])).toBeRejected(); + await expectAsync(migrate(['.error.yml'])).toBeRejected(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -102,7 +98,7 @@ describe('percy config:migrate', () => { }); it('warns when a config is already the latest version', async () => { - mockConfig('.percy.yml', 'version: 2\n'); + fs.writeFileSync('.percy.yml', 'version: 2\n'); await migrate(); expect(logger.stdout).toEqual([ @@ -112,18 +108,18 @@ describe('percy config:migrate', () => { '[percy] Config is already the latest version' ]); - expect(getMockConfig('.percy.old.yml')).toBeUndefined(); + expect(fs.existsSync('.percy.old.yml')).toBe(false); }); it('runs registered migrations on the config', async () => { - mockConfig('.percy.yml', [ + fs.writeFileSync('.percy.yml', [ 'version: 1', 'migrate: old-value' ].join('\n')); await migrate(); - expect(getMockConfig('.percy.yml')).toEqual([ + expect(fs.readFileSync('.percy.yml', 'utf-8')).toEqual([ 'version: 2', 'migrated: new-value' ].join('\n') + '\n'); diff --git a/packages/cli-config/test/validate.test.js b/packages/cli-config/test/validate.test.js index 64edf07a5..56bfb1817 100644 --- a/packages/cli-config/test/validate.test.js +++ b/packages/cli-config/test/validate.test.js @@ -1,10 +1,12 @@ import path from 'path'; -import { logger, mockConfig } from './helpers'; -import PercyConfig from '@percy/config'; +import { PercyConfig } from '@percy/cli-command'; +import { fs, logger, setupTest } from '@percy/cli-command/test/helpers'; import validate from '../src/validate'; describe('percy config:validate', () => { beforeEach(() => { + setupTest({ resetConfig: true }); + PercyConfig.addSchema({ test: { type: 'object', @@ -19,13 +21,8 @@ describe('percy config:validate', () => { }); }); - afterEach(() => { - PercyConfig.cache.clear(); - PercyConfig.resetSchema(); - }); - it('logs debug info for a valid config file', async () => { - mockConfig('.percy.yml', 'version: 2\ntest:\n value: percy'); + fs.writeFileSync('.percy.yml', 'version: 2\ntest:\n value: percy'); await validate(); expect(logger.stderr).toEqual([]); @@ -44,7 +41,7 @@ describe('percy config:validate', () => { it('logs debug info for a provided valid config file', async () => { let filename = path.join('.config', 'percy.yml'); - mockConfig(filename, 'version: 2\ntest:\n value: config'); + fs.$vol.fromJSON({ [filename]: 'version: 2\ntest:\n value: config' }); await validate([filename]); expect(logger.stderr).toEqual([]); @@ -62,7 +59,7 @@ describe('percy config:validate', () => { }); it('errors with invalid or unkown config options', async () => { - mockConfig('.invalid.yml', 'version: 2\ntest:\n value: false\nbar: baz'); + fs.writeFileSync('.invalid.yml', 'version: 2\ntest:\n value: false\nbar: baz'); await expectAsync(validate(['.invalid.yml'])).toBeRejected(); expect(logger.stdout).toEqual([ diff --git a/packages/cli-exec/package.json b/packages/cli-exec/package.json index 8733e414d..63703a581 100644 --- a/packages/cli-exec/package.json +++ b/packages/cli-exec/package.json @@ -10,13 +10,14 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist" + ], + "main": "dist/index.js", + "exports": "dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -31,7 +32,6 @@ }, "dependencies": { "@percy/cli-command": "1.0.0-beta.76", - "@percy/core": "1.0.0-beta.76", "cross-spawn": "^7.0.3", "which": "^2.0.2" } diff --git a/packages/cli-exec/src/ping.js b/packages/cli-exec/src/ping.js index c35344eac..0f3ad56b9 100644 --- a/packages/cli-exec/src/ping.js +++ b/packages/cli-exec/src/ping.js @@ -8,7 +8,7 @@ export const ping = command('ping', { }, async ({ flags, percy, log, exit }) => { if (!percy) exit(0, 'Percy is disabled'); - let { request } = await import('@percy/core/dist/utils'); + let { request } = await import('@percy/cli-command/utils'); let ping = `http://localhost:${flags.port}/percy/healthcheck`; try { diff --git a/packages/cli-exec/src/stop.js b/packages/cli-exec/src/stop.js index 19f503419..0a8af5689 100644 --- a/packages/cli-exec/src/stop.js +++ b/packages/cli-exec/src/stop.js @@ -8,7 +8,7 @@ export const stop = command('stop', { }, async ({ flags, percy, log, exit }) => { if (!percy) exit(0, 'Percy is disabled'); - let { request } = await import('@percy/core/dist/utils'); + let { request } = await import('@percy/cli-command/utils'); let stop = `http://localhost:${flags.port}/percy/stop`; let ping = `http://localhost:${flags.port}/percy/healthcheck`; diff --git a/packages/cli-exec/test/exec.test.js b/packages/cli-exec/test/exec.test.js index 977f60f59..aa727be12 100644 --- a/packages/cli-exec/test/exec.test.js +++ b/packages/cli-exec/test/exec.test.js @@ -1,10 +1,21 @@ -import mock from 'mock-require'; -import { logger, mockAPI } from './helpers'; +import { logger, api, setupTest } from '@percy/cli-command/test/helpers'; import exec from '../src/exec'; describe('percy exec', () => { + beforeEach(async () => { + process.env.PERCY_TOKEN = '<>'; + setupTest(); + + delete require.cache[require.resolve('which')]; + let { default: which } = await import('which'); + spyOn(which, 'sync').and.returnValue(true); + }); + afterEach(() => { - mock.stopAll(); + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_ENABLE; + delete process.env.PERCY_PARALLEL_TOTAL; + delete process.env.PERCY_PARTIAL_BUILD; }); it('logs an error when no command is provided', async () => { @@ -20,6 +31,9 @@ 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); + await expectAsync(exec(['--', 'foobar'])).toBeRejected(); expect(logger.stdout).toEqual([]); @@ -91,7 +105,7 @@ describe('percy exec', () => { it('does not run the command if canceled beforehand', async () => { // delay build creation to give time to cancel - mockAPI.reply('/builds', () => new Promise(resolve => { + api.reply('/builds', () => new Promise(resolve => { setTimeout(resolve, 1000, [201, { data: { attributes: {} } }]); })); @@ -110,10 +124,6 @@ describe('percy exec', () => { }); it('throws when the command receives an error event and stops percy', async () => { - // skip our own ENOENT check to trigger a child process error event - mock('which', { sync: () => true }); - let { exec } = mock.reRequire('../src/exec'); - await expectAsync(exec(['--', 'foobar'])).toBeRejected(); expect(logger.stderr).toEqual([ @@ -152,7 +162,7 @@ describe('percy exec', () => { it('provides the child process with a percy server address env var', async () => { await exec(['--port=1234', '--', 'node', '--eval', [ - 'require("@percy/client/dist/request")', + 'require("@percy/cli-command/utils")', '.request(new URL("/percy/healthcheck", process.env.PERCY_SERVER_ADDRESS))', '.catch(e => (console.error(e), process.exit(1)))' ].join('')]); diff --git a/packages/cli-exec/test/helpers.js b/packages/cli-exec/test/helpers.js deleted file mode 100644 index 8bc7a3386..000000000 --- a/packages/cli-exec/test/helpers.js +++ /dev/null @@ -1,15 +0,0 @@ -import { logger, mockAPI } from '@percy/core/test/helpers'; - -beforeEach(() => { - process.env.PERCY_TOKEN = '<>'; -}); - -afterEach(() => { - delete process.env.PERCY_TOKEN; - delete process.env.PERCY_ENABLE; - delete process.env.PERCY_PARALLEL_TOTAL; - delete process.env.PERCY_PARTIAL_BUILD; -}); - -export { logger, mockAPI }; -export { createTestServer } from '@percy/core/test/helpers/server'; diff --git a/packages/cli-exec/test/ping.test.js b/packages/cli-exec/test/ping.test.js index 6d82a619a..4bdcc8f67 100644 --- a/packages/cli-exec/test/ping.test.js +++ b/packages/cli-exec/test/ping.test.js @@ -1,10 +1,19 @@ -import { logger, createTestServer } from './helpers'; +import { logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; import ping from '../src/ping'; describe('percy exec:ping', () => { let percyServer; + beforeEach(() => { + process.env.PERCY_TOKEN = '<>'; + setupTest(); + }); + afterEach(async () => { + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_ENABLE; + delete process.env.PERCY_PARALLEL_TOTAL; + delete process.env.PERCY_PARTIAL_BUILD; await percyServer?.close(); }); diff --git a/packages/cli-exec/test/start.test.js b/packages/cli-exec/test/start.test.js index 37eba03d4..90d36390a 100644 --- a/packages/cli-exec/test/start.test.js +++ b/packages/cli-exec/test/start.test.js @@ -1,5 +1,5 @@ -import { request } from '@percy/core/dist/utils'; -import { logger } from './helpers'; +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'; @@ -14,12 +14,20 @@ describe('percy exec:start', () => { } beforeEach(async () => { + process.env.PERCY_TOKEN = '<>'; + setupTest(); + started = start(['--quiet']); started.then(() => (started = null)); await ping(); }); afterEach(async () => { + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_ENABLE; + delete process.env.PERCY_PARALLEL_TOTAL; + delete process.env.PERCY_PARTIAL_BUILD; + // it's important that percy is still running or we terminate the test process if (started) process.emit('SIGTERM'); await started; diff --git a/packages/cli-exec/test/stop.test.js b/packages/cli-exec/test/stop.test.js index 12202f60b..7f2d2917a 100644 --- a/packages/cli-exec/test/stop.test.js +++ b/packages/cli-exec/test/stop.test.js @@ -1,10 +1,19 @@ -import { logger, createTestServer } from './helpers'; +import { logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; import stop from '../src/stop'; describe('percy exec:stop', () => { let percyServer; + beforeEach(() => { + process.env.PERCY_TOKEN = '<>'; + setupTest(); + }); + afterEach(async () => { + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_ENABLE; + delete process.env.PERCY_PARALLEL_TOTAL; + delete process.env.PERCY_PARTIAL_BUILD; await percyServer?.close(); }); diff --git a/packages/cli-snapshot/package.json b/packages/cli-snapshot/package.json index 4f226786c..f9b4a6eb4 100644 --- a/packages/cli-snapshot/package.json +++ b/packages/cli-snapshot/package.json @@ -10,13 +10,14 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "dist" + ], + "main": "dist/index.js", + "exports": "dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -31,8 +32,6 @@ }, "dependencies": { "@percy/cli-command": "1.0.0-beta.76", - "@percy/config": "1.0.0-beta.76", - "@percy/core": "1.0.0-beta.76", "yaml": "^1.10.0" } } diff --git a/packages/cli-snapshot/src/index.js b/packages/cli-snapshot/src/index.js index 934cace14..b32d17794 100644 --- a/packages/cli-snapshot/src/index.js +++ b/packages/cli-snapshot/src/index.js @@ -1 +1 @@ -export { snapshot } from './snapshot'; +export { default, snapshot } from './snapshot'; diff --git a/packages/cli-snapshot/test/common.test.js b/packages/cli-snapshot/test/common.test.js index e4a594f8b..84f2d19cf 100644 --- a/packages/cli-snapshot/test/common.test.js +++ b/packages/cli-snapshot/test/common.test.js @@ -1,22 +1,18 @@ -import fs from 'fs'; -import rimraf from 'rimraf'; -import logger from '@percy/logger/test/helpers'; +import { logger, setupTest } from '@percy/cli-command/test/helpers'; import snapshot from '../src/snapshot'; describe('percy snapshot', () => { beforeEach(() => { - fs.mkdirSync('tmp'); - logger.mock(); + setupTest(); }); afterEach(() => { delete process.env.PERCY_ENABLE; - rimraf.sync('tmp'); }); it('skips snapshotting when Percy is disabled', async () => { process.env.PERCY_ENABLE = '0'; - await snapshot(['./tmp']); + await snapshot(['./']); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -34,7 +30,7 @@ describe('percy snapshot', () => { }); it('errors when there are no snapshots to take', async () => { - await expectAsync(snapshot(['./tmp'])).toBeRejected(); + await expectAsync(snapshot(['./'])).toBeRejected(); expect(logger.stdout).toEqual([ '[percy] Percy has started!', diff --git a/packages/cli-snapshot/test/directory.test.js b/packages/cli-snapshot/test/directory.test.js index 1e6c2dbb1..5794bc052 100644 --- a/packages/cli-snapshot/test/directory.test.js +++ b/packages/cli-snapshot/test/directory.test.js @@ -1,46 +1,30 @@ -import fs from 'fs'; -import path from 'path'; -import rimraf from 'rimraf'; -import PercyConfig from '@percy/config'; -import { logger, mockAPI } from '@percy/core/test/helpers'; +import { logger, setupTest, fs } from '@percy/cli-command/test/helpers'; import snapshot from '../src/snapshot'; describe('percy snapshot ', () => { - let tmp = path.join(__dirname, 'tmp'); - let cwd = process.cwd(); - beforeEach(() => { - process.chdir(__dirname); - fs.mkdirSync(tmp); - - fs.writeFileSync(path.join(tmp, 'test-1.html'), '

Test 1

'); - fs.writeFileSync(path.join(tmp, 'test-2.html'), '

Test 2

'); - fs.writeFileSync(path.join(tmp, 'test-3.html'), '

Test 3

'); - fs.writeFileSync(path.join(tmp, 'test-4.xml'), '

Test 4

'); - fs.writeFileSync(path.join(tmp, 'test-5.xml'), '

Test 5

'); - - fs.mkdirSync(path.join(tmp, 'test-index')); - fs.writeFileSync(path.join(tmp, 'test-index', 'index.html'), '

Test Index

'); - process.env.PERCY_TOKEN = '<>'; - mockAPI.start(50); - logger.mock(); + + setupTest({ + filesystem: { + 'test-1.html': '

Test 1

', + 'test-2.html': '

Test 2

', + 'test-3.html': '

Test 3

', + 'test-4.xml': '

Test 4

', + 'test-5.xml': '

Test 5

', + 'test-index/index.html': '

Test Index

' + } + }); }); afterEach(() => { - try { fs.unlinkSync('.percy.yml'); } catch {} - try { fs.unlinkSync('.percy.js'); } catch {} - process.chdir(cwd); - rimraf.sync(tmp); - delete process.env.PERCY_TOKEN; delete process.env.PERCY_ENABLE; - PercyConfig.cache.clear(); }); it('errors when the base-url is invalid', async () => { await expectAsync( - snapshot(['./tmp', '--base-url=wrong']) + snapshot(['./', '--base-url=wrong']) ).toBeRejected(); expect(logger.stdout).toEqual([]); @@ -51,7 +35,7 @@ describe('percy snapshot ', () => { }); it('starts a static server and snapshots matching files', async () => { - await snapshot(['./tmp', '--include=test-*.html']); + await snapshot(['./', '--include=test-*.html']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -65,7 +49,7 @@ describe('percy snapshot ', () => { }); it('snapshots matching files hosted with a base-url', async () => { - await snapshot(['./tmp', '--base-url=/base']); + await snapshot(['./', '--base-url=/base']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -80,7 +64,7 @@ describe('percy snapshot ', () => { }); it('does not take snapshots and prints a list with --dry-run', async () => { - await snapshot(['./tmp', '--dry-run']); + await snapshot(['./', '--dry-run']); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -106,7 +90,7 @@ describe('percy snapshot ', () => { ' name: First' ].join('\n')); - await snapshot(['./tmp', '--dry-run']); + await snapshot(['./', '--dry-run']); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -126,7 +110,7 @@ describe('percy snapshot ', () => { }); it('rewrites file and index URLs with --clean-urls', async () => { - await snapshot(['./tmp', '--dry-run', '--clean-urls']); + await snapshot(['./', '--dry-run', '--clean-urls']); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -150,7 +134,7 @@ describe('percy snapshot ', () => { ' /:path/:n: /:path-:n.html' ].join('\n')); - await snapshot(['./tmp', '--dry-run']); + await snapshot(['./', '--dry-run']); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -177,7 +161,7 @@ describe('percy snapshot ', () => { '}' ].join('\n')); - await snapshot(['./tmp', '--dry-run']); + await snapshot(['./', '--dry-run']); expect(logger.stderr).toEqual([ '[percy] Build not created' diff --git a/packages/cli-snapshot/test/file.test.js b/packages/cli-snapshot/test/file.test.js index 4baa8a196..81cc73b53 100644 --- a/packages/cli-snapshot/test/file.test.js +++ b/packages/cli-snapshot/test/file.test.js @@ -1,60 +1,46 @@ -import fs from 'fs'; -import path from 'path'; import { inspect } from 'util'; -import rimraf from 'rimraf'; -import { logger, mockAPI, createTestServer } from '@percy/core/test/helpers'; +import { fs, logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; import snapshot from '../src/snapshot'; describe('percy snapshot ', () => { - let tmp = path.join(__dirname, 'tmp'); - let cwd = process.cwd(); let server; beforeEach(async () => { - fs.mkdirSync(tmp); - process.chdir(tmp); + process.env.PERCY_TOKEN = '<>'; server = await createTestServer({ default: () => [200, 'text/html', '

Test

'] }); - fs.writeFileSync('pages.yml', [ - '- name: YAML Snapshot', - ' url: http://localhost:8000' - ].join('\n')); - - fs.writeFileSync('pages.js', 'module.exports = ' + inspect([{ - name: 'JS Snapshot', - url: 'http://localhost:8000', - additionalSnapshots: [ - { suffix: ' 2' }, - { prefix: 'Other ' } - ] - }], { depth: null })); - - fs.writeFileSync('pages-fn.js', 'module.exports = () => (' + inspect([{ - name: 'JS Function Snapshot', - url: 'http://localhost:8000' - }], { depth: null }) + ')'); - - fs.writeFileSync('pages-default.js', 'export default ' + inspect([{ - name: 'JS Default Snapshot', - url: 'http://localhost:8000' - }], { depth: null })); - - fs.writeFileSync('urls.yml', [ - '- /', '- /one', '- /two' - ].join('\n')); - - process.env.PERCY_TOKEN = '<>'; - mockAPI.start(50); - logger.mock(); + setupTest({ + filesystem: { + 'pages.yml': [ + '- name: YAML Snapshot', + ' url: http://localhost:8000' + ].join('\n'), + + 'pages.js': 'module.exports = ' + inspect([{ + name: 'JS Snapshot', + url: 'http://localhost:8000', + additionalSnapshots: [ + { suffix: ' 2' }, + { prefix: 'Other ' } + ] + }], { depth: null }), + + 'pages-fn.js': 'module.exports = () => (' + inspect([{ + name: 'JS Function Snapshot', + url: 'http://localhost:8000' + }], { depth: null }) + ')', + + 'urls.yml': [ + '- /', '- /one', '- /two' + ].join('\n') + } + }); }); afterEach(async () => { - process.chdir(cwd); - rimraf.sync(path.join(__dirname, 'tmp')); - delete process.env.PERCY_TOKEN; await server.close(); }); @@ -149,18 +135,6 @@ describe('percy snapshot ', () => { ]); }); - it('snapshots pages from .js files that have a default export', async () => { - await snapshot(['./pages-default.js']); - - expect(logger.stderr).toEqual([]); - expect(logger.stdout).toEqual([ - '[percy] Percy has started!', - '[percy] Snapshot taken: JS Default Snapshot', - '[percy] Uploading 1 snapshot...', - '[percy] Finalized build #1: https://percy.io/test/test/123' - ]); - }); - it('snapshots pages from a list of URLs', async () => { await snapshot(['./urls.yml', '--base-url=http://localhost:8000']); @@ -230,8 +204,7 @@ describe('percy snapshot ', () => { it('logs validation warnings', async () => { fs.writeFileSync('invalid.yml', [ 'snapshots:', - ' - foo: bar', - ' name: Test snap' + ' foo: bar' ].join('\n')); await expectAsync( @@ -244,8 +217,7 @@ describe('percy snapshot ', () => { ]); expect(logger.stderr).toEqual([ '[percy] Invalid snapshot options:', - '[percy] - snapshots[0].url: missing required property', - '[percy] - snapshots[0].foo: unknown property', + '[percy] - snapshots: must be an array, received an object', '[percy] Build not created', '[percy] Error: No snapshots found' ]); diff --git a/packages/cli-snapshot/test/sitemap.test.js b/packages/cli-snapshot/test/sitemap.test.js index 9a7b21756..d9380e71b 100644 --- a/packages/cli-snapshot/test/sitemap.test.js +++ b/packages/cli-snapshot/test/sitemap.test.js @@ -1,17 +1,12 @@ -import fs from 'fs'; -import path from 'path'; -import rimraf from 'rimraf'; -import { logger, mockAPI, createTestServer } from '@percy/core/test/helpers'; +import { fs, logger, setupTest, createTestServer } from '@percy/cli-command/test/helpers'; import snapshot from '../src/snapshot'; describe('percy snapshot ', () => { - let tmp = path.join(__dirname, 'tmp'); - let cwd = process.cwd(); let server; beforeEach(async () => { - process.chdir(__dirname); - fs.mkdirSync(tmp); + process.env.PERCY_TOKEN = '<>'; + setupTest(); server = await createTestServer({ default: () => [200, 'text/html', '

Test

'], @@ -33,17 +28,9 @@ describe('percy snapshot ', () => { '' ].join('\n')] }); - - process.env.PERCY_TOKEN = '<>'; - mockAPI.start(50); - logger.mock(); }); afterEach(async () => { - try { fs.unlinkSync('.percy.yml'); } catch {} - process.chdir(cwd); - rimraf.sync(tmp); - delete process.env.PERCY_TOKEN; await server.close(); }); diff --git a/packages/cli-upload/package.json b/packages/cli-upload/package.json index 01299755f..854d39093 100644 --- a/packages/cli-upload/package.json +++ b/packages/cli-upload/package.json @@ -10,13 +10,14 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist" + ], + "main": "dist/index.js", + "exports": "dist/index.js", "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -31,9 +32,7 @@ }, "dependencies": { "@percy/cli-command": "1.0.0-beta.76", - "@percy/client": "1.0.0-beta.76", - "@percy/logger": "1.0.0-beta.76", - "globby": "^11.0.4", + "fast-glob": "^3.2.11", "image-size": "^1.0.0" } } diff --git a/packages/cli-upload/src/index.js b/packages/cli-upload/src/index.js index 383aa2bb0..71d6da9d1 100644 --- a/packages/cli-upload/src/index.js +++ b/packages/cli-upload/src/index.js @@ -1 +1 @@ -module.exports = require('./commands/upload').Upload; +export { default, upload } from './upload'; diff --git a/packages/cli-upload/src/resources.js b/packages/cli-upload/src/resources.js index 84a75dbfb..63f5f209e 100644 --- a/packages/cli-upload/src/resources.js +++ b/packages/cli-upload/src/resources.js @@ -1,5 +1,5 @@ import path from 'path'; -import { sha256hash } from '@percy/client/dist/utils'; +import { sha256hash } from '@percy/client/utils'; // Returns a root resource object with a sha and mimetype. function createRootResource(url, content) { diff --git a/packages/cli-upload/src/upload.js b/packages/cli-upload/src/upload.js index 0d363c524..eca547a10 100644 --- a/packages/cli-upload/src/upload.js +++ b/packages/cli-upload/src/upload.js @@ -63,10 +63,11 @@ export const upload = command('upload', { if (!percy) exit(0, 'Percy is disabled'); let config = percy.config.upload; - let { default: globby } = await import('globby'); - let pathnames = yield globby(config.files, { + let { default: glob } = await import('fast-glob'); + let pathnames = yield glob(config.files, { ignore: [].concat(config.ignore || []), - cwd: args.dirname + cwd: args.dirname, + fs }); if (!pathnames.length) { @@ -80,7 +81,7 @@ export const upload = command('upload', { percy.setConfig({ discovery: { concurrency: config.concurrency } }); // do not launch a browser when starting - yield percy.start({ browser: false }); + yield* percy.start({ browser: false }); for (let filename of pathnames) { let file = path.parse(filename); diff --git a/packages/cli-upload/test/upload.test.js b/packages/cli-upload/test/upload.test.js index 66047b4c1..ea610c2d2 100644 --- a/packages/cli-upload/test/upload.test.js +++ b/packages/cli-upload/test/upload.test.js @@ -1,46 +1,29 @@ -import fs from 'fs'; -import path from 'path'; -import rimraf from 'rimraf'; -import mockAPI from '@percy/client/test/helpers'; -import logger from '@percy/logger/test/helpers'; +import { fs, logger, api, setupTest } from '@percy/cli-command/test/helpers'; import upload from '../src/upload'; // http://png-pixel.com/ -const pixel = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'); -const tmp = path.join(__dirname, 'tmp'); -const cwd = process.cwd(); +const pixel = Buffer.from(( + 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' +), 'base64').toString(); describe('percy upload', () => { - beforeAll(() => { - fs.mkdirSync(tmp); - process.chdir(tmp); - - fs.mkdirSync('images'); - fs.writeFileSync(path.join('images', 'test-1.png'), pixel); - fs.writeFileSync(path.join('images', 'test-2.jpg'), pixel); - fs.writeFileSync(path.join('images', 'test-3.jpeg'), pixel); - fs.writeFileSync(path.join('images', 'test-4.gif'), pixel); - fs.writeFileSync('nope', 'not here'); - }); - - afterAll(() => { - process.chdir(cwd); - rimraf.sync(tmp); - }); - beforeEach(() => { process.env.PERCY_TOKEN = '<>'; - mockAPI.start(); - logger.mock(); + + setupTest({ + filesystem: { + 'images/test-1.png': pixel, + 'images/test-2.jpg': pixel, + 'images/test-3.jpeg': pixel, + 'images/test-4.gif': pixel, + './nope': 'not here' + } + }); }); afterEach(() => { delete process.env.PERCY_TOKEN; delete process.env.PERCY_ENABLE; - - if (fs.existsSync('.percy.yml')) { - fs.unlinkSync('.percy.yml'); - } }); it('skips uploading when percy is disabled', async () => { @@ -93,7 +76,7 @@ describe('percy upload', () => { '[percy] Finalized build #1: https://percy.io/test/test/123' ])); - expect(mockAPI.requests['/builds/123/snapshots'][0].body).toEqual({ + expect(api.requests['/builds/123/snapshots'][0].body).toEqual({ data: { type: 'snapshots', attributes: { @@ -182,7 +165,7 @@ describe('percy upload', () => { }); it('stops uploads on process termination', async () => { - mockAPI.start(100); + api.mock({ delay: 100 }); // specify a low concurrency to interupt the queue later fs.writeFileSync('.percy.yml', [ @@ -195,7 +178,7 @@ describe('percy upload', () => { // wait for the first upload before terminating await new Promise(resolve => (function check() { - let done = !!mockAPI.requests['/builds/123/snapshots']; + let done = !!api.requests['/builds/123/snapshots']; setTimeout(done ? resolve : check, 10); }())); diff --git a/packages/cli/package.json b/packages/cli/package.json index db8b57993..952f7d0c4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,17 +10,18 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "bin": { - "percy": "bin/run" - }, "files": [ - "bin", - "dist" + "./bin", + "./dist" ], "engines": { "node": ">=12" }, + "bin": { + "percy": "./bin/run" + }, + "main": "./dist/index.js", + "exports": "./dist/index.js", "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 893fbef82..0e85998d3 100644 --- a/packages/cli/src/commands.js +++ b/packages/cli/src/commands.js @@ -1,9 +1,8 @@ import os from 'os'; import fs from 'fs'; import path from 'path'; -import { findPnpApi } from 'module'; -import logger from '@percy/logger'; -import { command, legacyCommand } from '@percy/cli-command'; +import Module from 'module'; +import { command, legacyCommand, logger } from '@percy/cli-command'; // Helper to simplify reducing async functions async function reduceAsync(iter, reducer, accum = []) { @@ -53,7 +52,7 @@ const PERCY_PKG_REG = /^(@percy\/|percy-cli-)/; // Returns the paths of potential percy packages found within yarn's pnp system function findPnpPackages(dir) { - let pnpapi = findPnpApi?.(`${dir}/`); + let pnpapi = Module.findPnpApi?.(`${dir}/`); let pkgLoc = pnpapi?.findPackageLocator(`${dir}/`); let pkgInfo = pkgLoc && pnpapi?.getPackageInformation(pkgLoc); let pkgDeps = pkgInfo?.packageDependencies.entries() ?? []; @@ -69,8 +68,8 @@ function findPnpPackages(dir) { // Helper to import and wrap legacy percy commands for reverse compatibility function importLegacyCommands(commandsPath) { return reduceFiles(commandsPath, async (cmds, file) => { + let filepath = path.join(commandsPath, file.name); let { name } = path.parse(file.name); - let filepath = path.join(commandsPath, name); if (file.isDirectory()) { // recursively import nested commands and find the index command @@ -110,7 +109,7 @@ export async function importCommands() { // reduce found packages to functions which import cli commands let cmdImports = await reduceAsync(cmdPkgs, async (pkgs, pkgPath) => { - let pkg = require(path.join(pkgPath, 'package.json')); + let pkg = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'))); // do not include self if (pkg.name === '@percy/cli') return pkgs; diff --git a/packages/cli/src/update.js b/packages/cli/src/update.js index 02102fb92..84f0d1f9c 100644 --- a/packages/cli/src/update.js +++ b/packages/cli/src/update.js @@ -1,9 +1,9 @@ import fs from 'fs'; import path from 'path'; import logger from '@percy/logger'; -import { colors } from '@percy/logger/dist/util'; -import pkg from '../package.json'; +import { colors } from '@percy/logger/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'); // max age the cache should be used for (3 days) @@ -44,8 +44,8 @@ function writeToCache(data) { } // Fetch and return release information for @percy/cli. -async function fetchReleases() { - let { request } = await import('@percy/client/dist/request'); +async function fetchReleases(pkg) { + let { request } = await import('@percy/client/utils'); // fetch releases from the github api without retries let api = 'https://api.github.com/repos/percy/cli/releases'; @@ -65,12 +65,13 @@ async function fetchReleases() { // 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 log = logger('cli:update'); try { // request new release information if needed if (!releases) { - releases = await fetchReleases(); + releases = await fetchReleases(pkg); if (!cacheError) writeToCache(releases, log); } diff --git a/packages/cli/test/commands.test.js b/packages/cli/test/commands.test.js index 6b0f47d49..5bc5e062a 100644 --- a/packages/cli/test/commands.test.js +++ b/packages/cli/test/commands.test.js @@ -1,27 +1,13 @@ import path from 'path'; -import logger from '@percy/logger/test/helpers'; - -import { - mockfs, - mockRequire, - mockModuleCommands, - mockPnpCommands, - mockLegacyCommands -} from './helpers'; +import { logger, mockfs, fs } from '@percy/cli-command/test/helpers'; +import { mockModuleCommands, mockPnpCommands, mockLegacyCommands } from './helpers'; +import { importCommands } from '../src/commands'; describe('CLI commands', () => { - let importCommands; - beforeEach(() => { - mockfs(); logger.mock(); logger.loglevel('debug'); - ({ importCommands } = mockRequire.reRequire('../src/commands')); - }); - - afterEach(() => { - mockfs.reset(); - mockRequire.stopAll(); + mockfs({ $modules: true }); }); describe('from node_modules', () => { @@ -65,8 +51,8 @@ describe('CLI commands', () => { }); it('handles errors and logs debug info', async () => { - mockfs.mkdirSync('node_modules', { recursive: true }); - mockfs.spyOn('readdirSync').and.throwError(new Error('EACCES')); + fs.$vol.fromJSON({ './node_modules': null }); + fs.readdirSync.and.throwError(new Error('EACCES')); await expectAsync(importCommands()).toBeResolvedTo([]); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -76,10 +62,15 @@ describe('CLI commands', () => { }); describe('from yarn pnp', () => { - beforeEach(() => { - let findPnpApi = jasmine.createSpy('findPnpApi'); - mockRequire('module', { findPnpApi: findPnpApi.and.returnValue() }); - ({ importCommands } = mockRequire.reRequire('../src/commands')); + let Module, plugPnpApi; + + beforeEach(async () => { + ({ default: Module } = await import('module')); + Module.findPnpApi ||= (plugPnpApi = jasmine.createSpy('findPnpApi')); + }); + + afterEach(() => { + if (plugPnpApi) delete Module.findPnpApi; }); it('imports from the yarn pnp api', async () => { @@ -125,13 +116,14 @@ 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 } + '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 ff4af2f6f..c7fb77043 100644 --- a/packages/cli/test/helpers.js +++ b/packages/cli/test/helpers.js @@ -1,61 +1,52 @@ import path from 'path'; -import * as memfs from 'memfs'; -import mockRequire from 'mock-require'; - -export { mockRequire }; - -// Helper function to mock fs with memfs and proxy methods to memfs.vol -export const mockfs = new Proxy(() => { - mockfs.mkdirSync(process.cwd(), { recursive: true }); - return mockRequire('fs', memfs.fs); -}, { - get: (_, prop) => prop === 'spyOn' - ? spyOn.bind(null, memfs.fs) - : memfs.vol[prop].bind(memfs.vol) -}); +import { mockfs, fs } from '@percy/cli-command/test/helpers'; // Mocks the update cache file with the provided data and timestamp export function mockUpdateCache(data, createdAt = Date.now()) { - mockfs.writeFileSync('.releases', JSON.stringify({ data, createdAt })); + fs.$vol.fromJSON({ '.releases': JSON.stringify({ data, createdAt }) }); + return path.join(process.cwd(), '.releases'); } // Mocks the filesystem and require cache to simulate installed commands export function mockModuleCommands(atPath, cmdMocks) { - let modulesPath = path.join(atPath, 'node_modules'); - let mockModules = {}; + let modulesPath = `${atPath}/node_modules`; + let mockModules = { $modules: true }; for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { - mockModules[pkgName] = {}; - + let pkgPath = `${modulesPath}/${pkgName}`; let mockPkg = { name: pkgName }; - mockRequire(path.join(modulesPath, pkgName, 'package.json'), mockPkg); if (cmdMock) { - let mockCmd = { name: cmdMock.name }; - if (cmdMock.callback) mockCmd.callback = () => {}; - mockPkg['@percy/cli'] = { commands: ['command'] }; - mockRequire(path.join(modulesPath, pkgName, 'command'), mockCmd); + mockPkg['@percy/cli'] = { commands: ['command.js'] }; + + mockModules[`${pkgPath}/command.js`] = [ + `exports.name = "${cmdMock.name}"`, + (cmdMock.callback ? 'exports.callback = () => {}' : '') + ].join(''); if (cmdMock.multiple) { - mockPkg['@percy/cli'].commands.push('other'); - let mockOther = { name: cmdMock.name + '-other' }; - mockRequire(path.join(modulesPath, pkgName, 'other'), mockOther); + mockPkg['@percy/cli'].commands.push('other.js'); + + mockModules[`${pkgPath}/other.js`] = [ + `exports.name = "${cmdMock.name}-other"` + ].join(''); } } + + mockModules[`${pkgPath}/package.json`] = JSON.stringify(mockPkg); } // for coverage - mockModules['@percy/.DS_Store'] = 'Not a directory'; - mockModules['.DS_Store'] = 'Not a directory'; + mockModules[`${modulesPath}/@percy/.DS_Store`] = 'Not a directory'; + mockModules[`${modulesPath}/.DS_Store`] = 'Not a directory'; - mockfs.fromJSON(mockModules, modulesPath); + return mockfs(mockModules); } // Mocks Yarn's PnP APIs to work as expected for installed commands export async function mockPnpCommands(atPath, cmdMocks) { - let { findPnpApi } = await import('module'); - if (!jasmine.isSpy(findPnpApi)) return; - + let Module = await import('module'); + let findPnpApi = spyOn(Module, 'findPnpApi').and.callThrough(); let projectLoc = { name: 'project', ref: '' }; let projectInfo = { packageLocation: `${atPath}/`, packageDependencies: new Map() }; let findPackageLocator = jasmine.createSpy('findPackageLocator'); @@ -68,73 +59,65 @@ export async function mockPnpCommands(atPath, cmdMocks) { getPackageInformation.withArgs(projectLoc).and.returnValue(projectInfo); let pnpPath = path.join('/.yarn/berry/cache'); + let mockModules = { $modules: true }; for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { let pkgLoc = { name: pkgName, ref: `<${pkgName}-pnpref>` }; - let pkgInfo = { packageLocation: path.join(pnpPath, pkgName) }; + let pkgInfo = { packageLocation: `${pnpPath}/${pkgName}` }; + let mockPkg = { name: pkgName }; projectInfo.packageDependencies.set(pkgName, pkgLoc.ref); getLocator.withArgs(pkgName, pkgLoc.ref).and.returnValue(pkgLoc); getPackageInformation.withArgs(pkgLoc).and.returnValue(pkgInfo); - let mockPkg = { name: pkgName }; - mockRequire(path.join(pnpPath, pkgName, 'package.json'), mockPkg); - if (cmdMock) { - let mockCmd = { name: cmdMock.name }; - mockPkg['@percy/cli'] = { commands: ['command'] }; - mockRequire(path.join(pnpPath, pkgName, 'command'), mockCmd); + mockPkg['@percy/cli'] = { commands: ['command.js'] }; + mockModules[`${pnpPath}/${pkgName}/command.js`] = `exports.name = "${cmdMock.name}"`; } + + mockModules[`${pnpPath}/${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 = path.join(atPath, 'node_modules'); - let mockModules = {}; + let modulesPath = `${atPath}/node_modules`; + let mockModules = { $modules: true }; for (let [pkgName, cmdMock] of Object.entries(cmdMocks)) { + let pkgPath = `${modulesPath}/${pkgName}`; let mockPkg = { name: pkgName }; - mockRequire(path.join(modulesPath, pkgName, 'package.json'), mockPkg); if (cmdMock) { - let mockPath = path.join(pkgName, 'commands', cmdMock.name); + let entryPath = `${pkgPath}/commands/${cmdMock.name}`; mockPkg.oclif = { bin: 'percy' }; if (cmdMock.topic || cmdMock.index) { - mockModules[path.join(mockPath, 'subcmd.js')] = ''; - mockModules[path.join(mockPath, 'notcmd.js')] = ''; + mockModules[`${entryPath}/notcmd.js`] = 'module.exports = {}'; + mockModules[`${entryPath}/subcmd.js`] = 'exports.Command = ' + + 'class LegacySubCmd { run() {} }'; if (cmdMock.index) { - mockModules[path.join(mockPath, 'index.js')] = ''; + mockModules[`${entryPath}/index.js`] = 'exports.Command = ' + + 'class LegacyIndex { run() {} }'; } } else { - mockModules[mockPath + '.js'] = ''; + mockModules[`${entryPath}.js`] = 'exports.Command = ' + + 'class LegacyCommand { run() {} }'; } if (cmdMock.init) { - mockPkg.oclif.hooks = { init: 'init' }; - mockRequire(path.join(modulesPath, pkgName, 'init'), cmdMock.init); + mockModules[`${pkgPath}/init.js`] = `module.exports = ${cmdMock.init}`; + mockPkg.oclif.hooks = { init: 'init.js' }; } else { - let cmdsPath = path.join(modulesPath, pkgName, 'commands'); mockPkg.oclif.commands = 'commands'; - - if (cmdMock.topic || cmdMock.index) { - mockRequire(path.join(cmdsPath, cmdMock.name, 'notcmd'), {}); - mockRequire(path.join(cmdsPath, cmdMock.name, 'subcmd'), ( - { Command: class LegacySubCmd { run() {} } })); - - if (cmdMock.index) { - mockRequire(path.join(cmdsPath, cmdMock.name, 'index'), ( - { Command: class LegacyIndex { run() {} } })); - } - } else { - mockRequire(path.join(cmdsPath, cmdMock.name), ( - { Command: class LegacyCommand { run() {} } })); - } } } + + mockModules[`${pkgPath}/package.json`] = JSON.stringify(mockPkg); } - mockfs.fromJSON(mockModules, modulesPath); + return mockfs(mockModules); } diff --git a/packages/cli/test/update.test.js b/packages/cli/test/update.test.js index 9069c6583..2e9fdc117 100644 --- a/packages/cli/test/update.test.js +++ b/packages/cli/test/update.test.js @@ -1,44 +1,42 @@ import nock from 'nock'; -import logger from '@percy/logger/test/helpers'; - -import { - mockfs, - mockRequire, - mockUpdateCache -} from './helpers'; +import { logger, mockfs, fs } from '@percy/cli-command/test/helpers'; +import { mockUpdateCache } from './helpers'; +import { checkForUpdate } from '../src/update'; describe('CLI update check', () => { - let checkForUpdate, request; + let request; beforeEach(async () => { - mockfs(); logger.mock(); request = nock('https://api.github.com/repos/percy/cli', { reqheaders: { 'User-Agent': ua => !!ua } }); - mockRequire('../package.json', { name: '@percy/cli', version: '1.0.0' }); - ({ checkForUpdate } = mockRequire.reRequire('../src/update')); + mockfs({ + './package.json': JSON.stringify({ + name: '@percy/cli', + version: '1.0.0' + }) + }); }); afterEach(() => { - mockfs.reset(); nock.cleanAll(); }); it('fetches and caches the latest release information', async () => { request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]); - expect(mockfs.existsSync('.releases')).toBe(false); + expect(fs.existsSync('.releases')).toBe(false); await checkForUpdate(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([]); expect(request.isDone()).toBe(true); - expect(mockfs.existsSync('.releases')).toBe(true); - expect(JSON.parse(mockfs.readFileSync('.releases'))) + expect(fs.existsSync('.releases')).toBe(true); + expect(JSON.parse(fs.readFileSync('.releases'))) .toHaveProperty('data', [{ tag: 'v1.0.0' }]); }); @@ -63,7 +61,7 @@ describe('CLI update check', () => { expect(logger.stderr).toEqual([]); expect(request.isDone()).toBe(true); - expect(JSON.parse(mockfs.readFileSync('.releases'))) + expect(JSON.parse(fs.readFileSync('.releases'))) .toHaveProperty('data', [{ tag: 'v1.0.0' }]); }); @@ -89,8 +87,8 @@ describe('CLI update check', () => { }); it('handles errors reading from cache and logs debug info', async () => { - mockUpdateCache([{ tag: 'v1.0.0' }]); - mockfs.spyOn('readFileSync').and.throwError(new Error('EACCES')); + 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(); await checkForUpdate(); @@ -110,7 +108,7 @@ describe('CLI update check', () => { }); it('handles errors writing to cache and logs debug info', async () => { - mockfs.spyOn('writeFileSync').and.throwError(new Error('EACCES')); + fs.writeFileSync.and.throwError(new Error('EACCES')); request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]).persist(); await checkForUpdate(); @@ -127,7 +125,7 @@ describe('CLI update check', () => { ]); expect(request.isDone()).toEqual(true); - expect(mockfs.existsSync('.releases')).toBe(false); + expect(fs.existsSync('.releases')).toBe(false); }); it('handles request errors and logs debug info', async () => { diff --git a/packages/client/package.json b/packages/client/package.json index 1438f256c..0dd8b45cb 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,14 +10,19 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist", - "test/helpers.js" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist", + "./test/helpers.js" + ], + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js", + "./test/helpers": "./test/helpers.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", @@ -27,8 +32,5 @@ "dependencies": { "@percy/env": "1.0.0-beta.76", "@percy/logger": "1.0.0-beta.76" - }, - "devDependencies": { - "mock-require": "^3.0.3" } } diff --git a/packages/client/src/client.js b/packages/client/src/client.js index d59d64905..cbc6f01c7 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -1,10 +1,14 @@ import PercyEnv from '@percy/env'; -import { git } from '@percy/env/dist/utils'; +import { git } from '@percy/env/utils'; import logger from '@percy/logger'; import pkg from '../package.json'; -import { sha256hash, base64encode, pool } from './utils'; -import request from './request'; +import { + request, + sha256hash, + base64encode, + pool +} from './utils'; // 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; diff --git a/packages/client/src/request.js b/packages/client/src/proxy.js similarity index 61% rename from packages/client/src/request.js rename to packages/client/src/proxy.js index 3ccd54c4b..3167e78ba 100644 --- a/packages/client/src/request.js +++ b/packages/client/src/proxy.js @@ -3,14 +3,47 @@ import tls from 'tls'; import http from 'http'; import https from 'https'; import logger from '@percy/logger'; -import { retry, hostnameMatches } from './utils'; const CRLF = '\r\n'; const STATUS_REG = /^HTTP\/1.[01] (\d*)/; -const RETRY_ERROR_CODES = [ - 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', - 'EHOSTUNREACH', 'EAI_AGAIN' -]; + +// Returns true if the URL hostname matches any patterns +export function hostnameMatches(patterns, url) { + let subject = new URL(url); + + /* istanbul ignore next: only strings are provided internally by the client proxy; core (which + * borrows this util) sometimes provides an array of patterns or undefined */ + patterns = typeof patterns === 'string' + ? patterns.split(/[\s,]+/) + : [].concat(patterns); + + for (let pattern of patterns) { + if (pattern === '*') return true; + if (!pattern) continue; + + // parse pattern + let { groups: rule } = pattern.match( + /^(?.+?)(?::(?\d+))?$/ + ); + + // missing a hostname or ports do not match + if (!rule.hostname || (rule.port && rule.port !== subject.port)) { + continue; + } + + // wildcards are treated the same as leading dots + rule.hostname = rule.hostname.replace(/^\*/, ''); + + // hostnames are equal or end with a wildcard rule + if (rule.hostname === subject.hostname || + (rule.hostname.startsWith('.') && + subject.hostname.endsWith(rule.hostname))) { + return true; + } + } + + return false; +} // Returns the port number of a URL object. Defaults to port 443 for https // protocols or port 80 otherwise. @@ -19,12 +52,14 @@ export function port(options) { return options.protocol === 'https:' ? 443 : 80; } +// Returns a string representation of a URL-like object export function href(options) { let { protocol, hostname, path, pathname, search, hash } = options; return `${protocol}//${hostname}:${port(options)}` + (path || `${pathname || ''}${search || ''}${hash || ''}`); }; +// Returns the proxy URL for a set of request options export function getProxy(options) { let proxyUrl = (options.protocol === 'https:' && (process.env.https_proxy || process.env.HTTPS_PROXY)) || @@ -180,93 +215,3 @@ export function proxyAgentFor(url, options) { return cache.get(cachekey); } - -// Proxified request function that resolves with the response body when the request is successful -// and rejects when a non-successful response is received. The rejected error contains response data -// and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by -// default, and 404 errors may also be optionally retried. If a callback is provided, it is called -// with the parsed response body and response details. If the callback returns a value, that value -// will be returned in the final resolved promise instead of the response body. -export function request(url, options = {}, callback) { - // accept `request(url, callback)` - if (typeof options === 'function') [options, callback] = [{}, options]; - - // gather request options - let { body, headers, retries, retryNotFound, interval, noProxy, ...requestOptions } = options; - let { protocol, hostname, port, pathname, search, hash } = new URL(url); - - // automatically stringify body content - if (body && typeof body !== 'string') { - headers = { 'Content-Type': 'application/json', ...headers }; - body = JSON.stringify(body); - } - - // combine request options - Object.assign(requestOptions, { - agent: requestOptions.agent || - (!noProxy && proxyAgentFor(url)) || null, - path: pathname + search + hash, - protocol, - hostname, - headers, - port - }); - - return retry((resolve, reject, retry) => { - let handleError = error => { - if (handleError.handled) return; - handleError.handled = true; - - let shouldRetry = error.response - // maybe retry 404s and always retry 500s - ? ((retryNotFound && error.response.statusCode === 404) || - (error.response.statusCode >= 500 && error.response.statusCode < 600)) - // retry specific error codes - : (!!error.code && RETRY_ERROR_CODES.includes(error.code)); - - return shouldRetry ? retry(error) : reject(error); - }; - - let handleFinished = async (body, res) => { - let raw = body; - - // attempt to parse the body as json - try { body = JSON.parse(body); } catch (e) {} - - try { - if (res.statusCode >= 200 && res.statusCode < 300) { - // resolve successful statuses after the callback - resolve(await callback?.(body, res) ?? body); - } else { - // use the first error detail or the status message - throw new Error(body?.errors?.find(e => e.detail)?.detail || ( - `${res.statusCode} ${res.statusMessage || raw}` - )); - } - } catch (error) { - handleError(Object.assign(error, { - response: { - statusCode: res.statusCode, - headers: res.headers, - body - } - })); - } - }; - - let handleResponse = res => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', chunk => (body += chunk)); - res.on('end', () => handleFinished(body, res)); - res.on('error', handleError); - }; - - let req = (protocol === 'https:' ? https : http).request(requestOptions); - req.on('response', handleResponse); - req.on('error', handleError); - req.end(body); - }, { retries, interval }); -} - -export default request; diff --git a/packages/client/src/utils.js b/packages/client/src/utils.js index 6d57f6b5a..72cb052d3 100644 --- a/packages/client/src/utils.js +++ b/packages/client/src/utils.js @@ -77,40 +77,96 @@ export function retry(fn, { retries = 5, interval = 50 }) { }); } -// Returns true if the URL hostname matches any patterns -export function hostnameMatches(patterns, url) { - let subject = new URL(url); - - /* istanbul ignore next: only strings are provided internally by the client proxy; core (which - * borrows this util) sometimes provides an array of patterns or undefined */ - patterns = typeof patterns === 'string' - ? patterns.split(/[\s,]+/) - : [].concat(patterns); - - for (let pattern of patterns) { - if (pattern === '*') return true; - if (!pattern) continue; - - // parse pattern - let { groups: rule } = pattern.match( - /^(?.+?)(?::(?\d+))?$/ - ); - - // missing a hostname or ports do not match - if (!rule.hostname || (rule.port && rule.port !== subject.port)) { - continue; - } - - // wildcards are treated the same as leading dots - rule.hostname = rule.hostname.replace(/^\*/, ''); - - // hostnames are equal or end with a wildcard rule - if (rule.hostname === subject.hostname || - (rule.hostname.startsWith('.') && - subject.hostname.endsWith(rule.hostname))) { - return true; - } +// Used by the request util when retrying specific errors +const RETRY_ERROR_CODES = [ + 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', + 'EHOSTUNREACH', 'EAI_AGAIN' +]; + +// Proxified request function that resolves with the response body when the request is successful +// and rejects when a non-successful response is received. The rejected error contains response data +// and any received error details. Server 500 errors are retried up to 5 times at 50ms intervals by +// default, and 404 errors may also be optionally retried. If a callback is provided, it is called +// with the parsed response body and response details. If the callback returns a value, that value +// will be returned in the final resolved promise instead of the response body. +export async function request(url, options = {}, callback) { + // accept `request(url, callback)` + if (typeof options === 'function') [options, callback] = [{}, options]; + + // 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'); + + // automatically stringify body content + if (body && typeof body !== 'string') { + headers = { 'Content-Type': 'application/json', ...headers }; + body = JSON.stringify(body); } - return false; + // combine request options + Object.assign(requestOptions, { + agent: requestOptions.agent || (!noProxy && proxyAgentFor(url)) || null, + path: pathname + search + hash, + protocol, + hostname, + headers, + port + }); + + return retry((resolve, reject, retry) => { + let handleError = error => { + if (handleError.handled) return; + handleError.handled = true; + + // maybe retry 404s, always retry 500s, or retry specific errors + let shouldRetry = error.response + ? ((retryNotFound && error.response.statusCode === 404) || + (error.response.statusCode >= 500 && error.response.statusCode < 600)) + : (!!error.code && RETRY_ERROR_CODES.includes(error.code)); + + return shouldRetry ? retry(error) : reject(error); + }; + + let handleFinished = async (body, res) => { + let { statusCode, headers } = res; + let raw = body; + + // attempt to parse the body as json + try { body = JSON.parse(body); } catch (e) {} + + try { + if (statusCode >= 200 && statusCode < 300) { + resolve(await callback?.(body, res) ?? body); + } else { + let err = body?.errors?.find(e => e.detail)?.detail; + throw new Error(err || `${statusCode} ${res.statusMessage || raw}`); + } + } catch (error) { + let response = { statusCode, headers, body }; + handleError(Object.assign(error, { response })); + } + }; + + let handleResponse = res => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', chunk => (body += chunk)); + res.on('end', () => handleFinished(body, res)); + res.on('error', handleError); + }; + + let req = request(requestOptions); + req.on('response', handleResponse); + req.on('error', handleError); + req.end(body); + }, { retries, interval }); } + +export { + hostnameMatches, + ProxyHttpAgent, + ProxyHttpsAgent, + proxyAgentFor +} from './proxy'; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index b34728af2..f88d84865 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -1,25 +1,22 @@ -import mock from 'mock-require'; +import fs from 'fs'; +import logger from '@percy/logger/test/helpers'; import { mockgit } from '@percy/env/test/helpers'; -import PercyClient from '../src'; +import api from './helpers'; + import { sha256hash, base64encode } from '../src/utils'; -import mockAPI from './helpers'; -import logger from '@percy/logger/test/helpers'; +import PercyClient from '../src'; describe('PercyClient', () => { let client; beforeEach(() => { - mockAPI.start(); + api.mock(); logger.mock(); client = new PercyClient({ token: 'PERCY_TOKEN' }); }); - afterEach(() => { - mock.stopAll(); - }); - describe('#userAgent()', () => { it('contains client and environment information', () => { expect(client.userAgent()).toMatch( @@ -106,8 +103,8 @@ describe('PercyClient', () => { describe('#get()', () => { it('sends a GET request to the API', async () => { await expectAsync(client.get('foobar')).toBeResolved(); - expect(mockAPI.requests['/foobar'][0].method).toBe('GET'); - expect(mockAPI.requests['/foobar'][0].headers).toEqual( + expect(api.requests['/foobar'][0].method).toBe('GET'); + expect(api.requests['/foobar'][0].headers).toEqual( jasmine.objectContaining({ authorization: 'Token token=PERCY_TOKEN' }) @@ -123,9 +120,9 @@ describe('PercyClient', () => { describe('#post()', () => { it('sends a POST request to the API', async () => { await expectAsync(client.post('foobar', { test: '123' })).toBeResolved(); - expect(mockAPI.requests['/foobar'][0].body).toEqual({ test: '123' }); - expect(mockAPI.requests['/foobar'][0].method).toBe('POST'); - expect(mockAPI.requests['/foobar'][0].headers).toEqual( + expect(api.requests['/foobar'][0].body).toEqual({ test: '123' }); + 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' @@ -151,7 +148,7 @@ describe('PercyClient', () => { } }); - expect(mockAPI.requests['/builds'][0].body.data) + expect(api.requests['/builds'][0].body.data) .toEqual(jasmine.objectContaining({ attributes: { branch: client.env.git.branch, @@ -193,7 +190,7 @@ describe('PercyClient', () => { } }); - expect(mockAPI.requests['/builds'][0].body.data) + expect(api.requests['/builds'][0].body.data) .toEqual(jasmine.objectContaining({ relationships: { resources: { @@ -227,7 +224,7 @@ describe('PercyClient', () => { }); it('gets build data', async () => { - mockAPI.reply('/builds/100', () => [200, { data: '<>' }]); + api.reply('/builds/100', () => [200, { data: '<>' }]); await expectAsync(client.getBuild(100)).toBeResolvedTo({ data: '<>' }); }); }); @@ -244,12 +241,12 @@ describe('PercyClient', () => { }); it('gets project builds data', async () => { - mockAPI.reply('/projects/foo/bar/builds', () => [200, { data: ['<>'] }]); + api.reply('/projects/foo/bar/builds', () => [200, { data: ['<>'] }]); await expectAsync(client.getBuilds('foo/bar')).toBeResolvedTo({ data: ['<>'] }); }); it('gets project builds data filtered by a sha', async () => { - mockAPI.reply('/projects/foo/bar/builds?filter[sha]=test-sha', () => ( + api.reply('/projects/foo/bar/builds?filter[sha]=test-sha', () => ( [200, { data: ['<>'] }] )); @@ -258,7 +255,7 @@ describe('PercyClient', () => { }); it('gets project builds data filtered by state, branch, and shas', async () => { - mockAPI.reply('/projects/foo/bar/builds?' + [ + api.reply('/projects/foo/bar/builds?' + [ 'filter[branch]=master', 'filter[state]=finished', 'filter[shas][]=test-sha-1', @@ -298,7 +295,7 @@ describe('PercyClient', () => { it('invokes the callback when data changes while waiting', async () => { let progress = 0; - mockAPI + api .reply('/builds/123', () => [200, { data: { attributes: { state: 'processing' } } }]) @@ -318,7 +315,7 @@ describe('PercyClient', () => { }); it('throws when no update happens within the timeout', async () => { - mockAPI.reply('/builds/123', () => [200, { + api.reply('/builds/123', () => [200, { data: { attributes: { state: 'processing' } } }]); @@ -327,7 +324,7 @@ describe('PercyClient', () => { }); it('resolves when the build completes', async () => { - mockAPI + api .reply('/builds/123', () => [200, { data: { attributes: { state: 'processing' } } }]) @@ -340,15 +337,13 @@ describe('PercyClient', () => { }); it('resolves when the build matching a commit revision completes', async () => { - mockgit.commit - .withArgs([jasmine.anything(), 'HEAD']) - .and.returnValue('parsed-sha'); + mockgit().and.returnValue('COMMIT_SHA:commit-sha'); - mockAPI - .reply('/projects/foo/bar/builds?filter[sha]=parsed-sha', () => [200, { + api + .reply('/projects/foo/bar/builds?filter[sha]=commit-sha', () => [200, { data: [{ attributes: { state: 'processing' } }] }]) - .reply('/projects/foo/bar/builds?filter[sha]=parsed-sha', () => [200, { + .reply('/projects/foo/bar/builds?filter[sha]=commit-sha', () => [200, { data: [{ attributes: { state: 'finished' } }] }]); @@ -357,9 +352,9 @@ describe('PercyClient', () => { }); it('defaults to the provided commit when revision parsing fails', async () => { - mockgit.commit.and.throwError(new Error('test')); + mockgit().and.throwError(new Error('test')); - mockAPI.reply('/projects/foo/bar/builds?filter[sha]=abcdef', () => [200, { + api.reply('/projects/foo/bar/builds?filter[sha]=abcdef', () => [200, { data: [{ attributes: { state: 'finished' } }] }]); @@ -378,12 +373,12 @@ describe('PercyClient', () => { it('finalizes the build', async () => { await expectAsync(client.finalizeBuild(123)).toBeResolved(); - expect(mockAPI.requests['/builds/123/finalize']).toBeDefined(); + expect(api.requests['/builds/123/finalize']).toBeDefined(); }); it('can finalize all shards of a build', async () => { await expectAsync(client.finalizeBuild(123, { all: true })).toBeResolved(); - expect(mockAPI.requests['/builds/123/finalize?all-shards=true']).toBeDefined(); + expect(api.requests['/builds/123/finalize?all-shards=true']).toBeDefined(); }); }); @@ -398,7 +393,7 @@ describe('PercyClient', () => { it('uploads a resource for a build', async () => { await expectAsync(client.uploadResource(123, { content: 'foo' })).toBeResolved(); - expect(mockAPI.requests['/builds/123/resources'][0].body).toEqual({ + expect(api.requests['/builds/123/resources'][0].body).toEqual({ data: { type: 'resources', id: sha256hash('foo'), @@ -410,14 +405,14 @@ describe('PercyClient', () => { }); it('can upload a resource from a local path', async () => { - mock('fs', { readFileSync: path => `contents of ${path}` }); + spyOn(fs, 'readFileSync').and.callFake(p => `contents of ${p}`); await expectAsync(client.uploadResource(123, { sha: 'foo-sha', filepath: 'foo/bar' })).toBeResolved(); - expect(mockAPI.requests['/builds/123/resources'][0].body).toEqual({ + expect(api.requests['/builds/123/resources'][0].body).toEqual({ data: { type: 'resources', id: 'foo-sha', @@ -445,14 +440,14 @@ describe('PercyClient', () => { let content = 'foo'; // to test this, the API is set to delay responses by 15ms... - mockAPI.reply('/builds/123/resources', async () => { + api.reply('/builds/123/resources', async () => { await new Promise(r => setTimeout(r, 12)); return [201, { success: content }]; }); // ...after 20ms (enough time for a single request) the contents change... setTimeout(() => (content = 'bar'), 20); - mock('fs', { readFileSync: () => content }); + spyOn(fs, 'readFileSync').and.returnValue(content); // ... which should result in every 2 uploads being identical await expectAsync(client.uploadResources(123, [ @@ -497,7 +492,7 @@ describe('PercyClient', () => { }] })).toBeResolved(); - expect(mockAPI.requests['/builds/123/snapshots'][0].headers).toEqual( + expect(api.requests['/builds/123/snapshots'][0].headers).toEqual( jasmine.objectContaining({ 'user-agent': jasmine.stringMatching( /^Percy\/v1 @percy\/client\/\S+ sdk\/info \(sdk\/env; node\/v[\d.]+.*\)$/ @@ -505,7 +500,7 @@ describe('PercyClient', () => { }) ); - expect(mockAPI.requests['/builds/123/snapshots'][0].body).toEqual({ + expect(api.requests['/builds/123/snapshots'][0].body).toEqual({ data: { type: 'snapshots', attributes: { @@ -536,7 +531,7 @@ describe('PercyClient', () => { client.createSnapshot(123, { resources: [{ sha: 'sha' }] }) ).toBeResolved(); - expect(mockAPI.requests['/builds/123/snapshots'][0].body).toEqual({ + expect(api.requests['/builds/123/snapshots'][0].body).toEqual({ data: { type: 'snapshots', attributes: { @@ -571,7 +566,7 @@ describe('PercyClient', () => { it('finalizes a snapshot', async () => { await expectAsync(client.finalizeSnapshot(123)).toBeResolved(); - expect(mockAPI.requests['/snapshots/123/finalize']).toBeDefined(); + expect(api.requests['/snapshots/123/finalize']).toBeDefined(); }); }); @@ -597,7 +592,7 @@ describe('PercyClient', () => { }) ).toBeResolved(); - expect(mockAPI.requests['/builds/123/snapshots'][0].body).toEqual({ + expect(api.requests['/builds/123/snapshots'][0].body).toEqual({ data: { type: 'snapshots', attributes: { @@ -636,7 +631,7 @@ describe('PercyClient', () => { }) ).toBeResolved(); - expect(mockAPI.requests['/builds/123/resources'][0].body).toEqual({ + expect(api.requests['/builds/123/resources'][0].body).toEqual({ data: { type: 'resources', id: sha256hash(testDOM), @@ -649,7 +644,7 @@ describe('PercyClient', () => { it('finalizes a snapshot', async () => { await expectAsync(client.sendSnapshot(123, { name: 'test snapshot name' })).toBeResolved(); - expect(mockAPI.requests['/snapshots/4567/finalize']).toBeDefined(); + expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); }); }); }); diff --git a/packages/client/test/helpers.js b/packages/client/test/helpers.js index df57cf8ef..dfb7dc994 100644 --- a/packages/client/test/helpers.js +++ b/packages/client/test/helpers.js @@ -25,12 +25,12 @@ const DEFAULT_REPLIES = { }] }; -const mockAPI = { +const api = { nock: null, requests: null, replies: null, - start(delay = 0) { + mock(options) { nock.cleanAll(); nock.disableNetConnect(); nock.enableNetConnect('storage.googleapis.com|localhost|127.0.0.1'); @@ -62,6 +62,7 @@ const mockAPI = { ); } + let { delay = 0 } = options || {}; n.get(/.*/).delay(delay).reply(intercept); n.post(/.*/).delay(delay).reply(intercept); }, @@ -70,12 +71,7 @@ const mockAPI = { this.replies[path] = this.replies[path] || []; this.replies[path].push(handler); return this; - }, - - cleanAll() { - nock.cleanAll(); - return this; } }; -module.exports = mockAPI; +module.exports = api; diff --git a/packages/client/test/unit/request.test.js b/packages/client/test/unit/request.test.js index 2a2d2504a..d88eab28e 100644 --- a/packages/client/test/unit/request.test.js +++ b/packages/client/test/unit/request.test.js @@ -1,10 +1,7 @@ import fs from 'fs'; import path from 'path'; -import request, { - port, href, - ProxyHttpAgent, - proxyAgentFor -} from '../../src/request'; +import { request, ProxyHttpAgent } from '../../src/utils'; +import { port, href, proxyAgentFor } from '../../src/proxy'; const ssl = { cert: fs.readFileSync(path.resolve(__dirname, '../certs/test.crt')), diff --git a/packages/config/package.json b/packages/config/package.json index 24d8da02c..5d2ab2377 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -10,15 +10,20 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "types": "types/index.d.ts", - "files": [ - "dist", - "types/index.d.ts" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist", + "./types/index.d.ts" + ], + "main": "./dist/index.js", + "types": "./types/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils/index.js", + "./test/helpers": "./test/helpers.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/config/src/index.js b/packages/config/src/index.js index 6483dbf4d..f2408ae2d 100644 --- a/packages/config/src/index.js +++ b/packages/config/src/index.js @@ -1,39 +1,22 @@ -import load, { cache, explorer, search } from './load'; -import validate, { addSchema, resetSchema } from './validate'; -import migrate, { addMigration, clearMigrations } from './migrate'; +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 normalize from './normalize'; -import stringify from './stringify'; +// public config API export { load, search, - cache, - explorer, validate, addSchema, - resetSchema, migrate, addMigration, - clearMigrations, getDefaults, + merge, normalize, stringify }; -// mirror the namespace as the default export -export default { - load, - search, - cache, - explorer, - validate, - addSchema, - resetSchema, - migrate, - addMigration, - clearMigrations, - getDefaults, - normalize, - stringify -}; +// export the namespace by default +export * as default from '.'; diff --git a/packages/config/src/load.js b/packages/config/src/load.js index c1afb390f..d2219a41c 100644 --- a/packages/config/src/load.js +++ b/packages/config/src/load.js @@ -1,12 +1,11 @@ +import fs from 'fs'; import { relative } from 'path'; -import { statSync } from 'fs'; import { cosmiconfigSync } from 'cosmiconfig'; import logger from '@percy/logger'; -import getDefaults from './defaults'; import migrate from './migrate'; -import normalize from './normalize'; -import { inspect } from './stringify'; import validate from './validate'; +import getDefaults from './defaults'; +import { inspect, normalize } from './utils'; // Loaded configuration file cache export const cache = new Map(); @@ -28,7 +27,7 @@ export const explorer = cosmiconfigSync('percy', { // Searches within a provided directory, or loads the provided config path export function search(path) { try { - let result = (path && !statSync(path).isDirectory()) + let result = (path && !fs.statSync(path).isDirectory()) ? explorer.load(path) : explorer.search(path); return result || {}; } catch (error) { diff --git a/packages/config/src/migrate.js b/packages/config/src/migrate.js index c401024c4..1a9606ed4 100644 --- a/packages/config/src/migrate.js +++ b/packages/config/src/migrate.js @@ -1,8 +1,8 @@ import logger from '@percy/logger'; -import normalize from './normalize'; import { get, set, del, map, - joinPropertyPath + joinPropertyPath, + normalize } from './utils'; // Global set of registered migrations diff --git a/packages/config/src/utils/index.js b/packages/config/src/utils/index.js new file mode 100644 index 000000000..a3152c151 --- /dev/null +++ b/packages/config/src/utils/index.js @@ -0,0 +1,3 @@ +export * from './merge'; +export * from './normalize'; +export * from './stringify'; diff --git a/packages/config/src/utils.js b/packages/config/src/utils/merge.js similarity index 99% rename from packages/config/src/utils.js rename to packages/config/src/utils/merge.js index 9f7ce5053..07562bcc1 100644 --- a/packages/config/src/utils.js +++ b/packages/config/src/utils/merge.js @@ -103,6 +103,31 @@ function walk(object, fn, path = []) { } } +// Recursively mutate and filter empty values from arrays and objects +export function filterEmpty(subject) { + if (typeof subject === 'object') { + if (isArray(subject)) { + for (let i = 0; i < subject.length; i++) { + if (!filterEmpty(subject[i])) { + subject.splice(i--, 1); + } + } + + return subject.length > 0; + } else { + for (let k in subject) { + if (!filterEmpty(subject[k])) { + delete subject[k]; + } + } + + return entries(subject).length > 0; + } + } else { + return subject != null; + } +} + // Merges source values and returns a new merged value. The map function will be called with a // property's path, previous value, and next value; it should return an array containing any // replacement path and value; when a replacement value not defined, values will be merged. @@ -147,27 +172,4 @@ export function merge(sources, map) { }, undefined); } -// Recursively mutate and filter empty values from arrays and objects -export function filterEmpty(subject) { - if (typeof subject === 'object') { - if (isArray(subject)) { - for (let i = 0; i < subject.length; i++) { - if (!filterEmpty(subject[i])) { - subject.splice(i--, 1); - } - } - - return subject.length > 0; - } else { - for (let k in subject) { - if (!filterEmpty(subject[k])) { - delete subject[k]; - } - } - - return entries(subject).length > 0; - } - } else { - return subject != null; - } -} +export default merge; diff --git a/packages/config/src/normalize.js b/packages/config/src/utils/normalize.js similarity index 92% rename from packages/config/src/normalize.js rename to packages/config/src/utils/normalize.js index 0d5174cd4..f9572fd95 100644 --- a/packages/config/src/normalize.js +++ b/packages/config/src/utils/normalize.js @@ -1,5 +1,5 @@ -import { getSchema } from './validate'; -import { merge } from './utils'; +import merge from './merge'; +import { getSchema } from '../validate'; // Edge case camelizations const CAMELCASE_MAP = new Map([ @@ -37,6 +37,7 @@ export function kebabcase(str) { // allows deep merging with options.overrides, converting keys to kebab-case with options.kebab, // and normalizing against a schema with options.schema. export function normalize(object, options) { + if (typeof options === 'string') options = { schema: options }; let keycase = options?.kebab ? kebabcase : camelcase; return merge([object, options?.overrides], path => { diff --git a/packages/config/src/stringify.js b/packages/config/src/utils/stringify.js similarity index 94% rename from packages/config/src/stringify.js rename to packages/config/src/utils/stringify.js index 258d4c964..cca4ca04a 100644 --- a/packages/config/src/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'; // Provides native util.inspect with common options for printing configs. export function inspect(config) { diff --git a/packages/config/test/helpers.js b/packages/config/test/helpers.js index f35d68f02..b0f1f3ffb 100644 --- a/packages/config/test/helpers.js +++ b/packages/config/test/helpers.js @@ -1,49 +1,107 @@ +import fs from 'fs'; +import os from 'os'; import path from 'path'; -import mock from 'mock-require'; +import Module from 'module'; +import { Volume, createFsFromVolume } from 'memfs'; -export const configs = new Map(); +import { clearMigrations } from '../src/migrate'; +import { resetSchema } from '../src/validate'; +import { cache } from '../src/load'; -export function mockConfig(f, c) { - configs.set(f, () => typeof c === 'function' ? c() : c); +// Reset various global @percy/config internals for testing +export function resetPercyConfig(all) { + if (all) clearMigrations(); + if (all) resetSchema(); + cache.clear(); } -export function getMockConfig(f) { - return configs.get(f)?.(); -} +// When mocking fs, these classes should not be spied on +const FS_CLASSES = [ + 'Stats', 'Dirent', + 'StatWatcher', 'FSWatcher', + 'ReadStream', 'WriteStream' +]; + +// 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) + $modules = false, + // list of filepaths or function matchers to allow direct access to the real filesystem + $bypass = [], + // initial flat map of files and/or directories to create + ...initial +} = {}) { + let vol = new Volume(); + + // when .js files are created, also mock the module for importing + spyOn(vol, 'writeFileSync').and.callFake((...args) => { + if (args[0].endsWith('.js')) mockFileModule(...args); + return vol.writeFileSync.and.originalFn.apply(vol, args); + }); + + // initial volume contents include the cwd and tmpdir + vol.fromJSON({ + [process.cwd()]: null, + [os.tmpdir()]: null, + ...initial + }); -function rel(filepath) { - return path.relative('', filepath); + let bypass = [ + // bypass babel config for runtime registration + path.resolve(__dirname, '../../../babel.config.js'), + // 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|$)/), + // additional bypass matches + ...$bypass + ]; + + // spies on fs methods and calls in-memory methods unless bypassed + let installFakes = (og, fake) => { + for (let k in og) { + if (k in fake && typeof og[k] === 'function' && !FS_CLASSES.includes(k)) { + spyOn(og, k).and.callFake((...args) => bypass.some(p => ( + typeof p === 'function' ? p(...args) : (p === args[0]) + )) ? og[k].and.originalFn(...args) : fake[k](...args)); + } + } + }; + + // mock and install fs methods using the in-memory filesystem + let mock = createFsFromVolume(vol); + installFakes(fs.promises, mock.promises); + installFakes(fs, mock); + + // allow tests access to the in-memory filesystem + fs.$vol = vol; + return fs; } -// required before mocking fs so functionality is unaffected -require('@percy/logger'); - -mock('fs', Object.assign({}, require('fs'), { - readFileSync: f => getMockConfig(rel(f)) || null, - existsSync: f => configs.has(rel(f)), - statSync: f => ({ - // rudimentary check for tests - not a config and is not a dotfile - isDirectory: () => !configs.has(rel(f)) && - !path.extname(f) && !path.basename(f).includes('.') - }), - // support tests for writing configs - writeFileSync: (f, c) => mockConfig(rel(f), c), - renameSync: (f, t) => { - f = rel(f); - let conf = configs.get(f); - if (configs.delete(f)) configs.set(rel(t), conf); +// Mock module loading to avoid node using internal C++ fs bindings +function mockFileModule(filepath, content = '') { + if (!jasmine.isSpy(Module._load)) { + spyOn(Module, '_load').and.callThrough(); + spyOn(Module, '_resolveFilename').and.callThrough(); } -})); -// re-required so future imports are not cached -mock.reRequire('fs'); + let mod = new Module(); + let fp = mod.filename = path.resolve(filepath); + let any = jasmine.anything(); -afterAll(() => { - mock.stopAll(); -}); + let matchFilepath = { + asymmetricMatch: f => path.resolve(f) === fp || + fp.endsWith(path.join('node_modules', f)) + }; -afterEach(() => { - configs.clear(); -}); + Module._resolveFilename.withArgs(matchFilepath, any).and.returnValue(fp); + Module._load.withArgs(matchFilepath, any, any).and.callFake(() => { + mod.loaded ||= (mod._compile(content, fp), true); + return mod.exports; + }); +} -export default mockConfig; +// export fs for convenience +export { fs }; diff --git a/packages/config/test/index.test.js b/packages/config/test/index.test.js index 4aa8b6cff..6c412e69f 100644 --- a/packages/config/test/index.test.js +++ b/packages/config/test/index.test.js @@ -1,11 +1,13 @@ -import path from 'path'; import logger from '@percy/logger/test/helpers'; -import mockConfig from './helpers'; +import { resetPercyConfig, mockfs, fs } from './helpers'; import PercyConfig from '../src'; describe('PercyConfig', () => { beforeEach(() => { + resetPercyConfig(true); logger.mock(); + mockfs(); + PercyConfig.addSchema({ test: { type: 'object', @@ -20,12 +22,6 @@ describe('PercyConfig', () => { }); }); - afterEach(() => { - PercyConfig.cache.clear(); - PercyConfig.resetSchema(); - PercyConfig.clearMigrations(); - }); - describe('.addSchema()', () => { it('adds additional properties to the schema', () => { PercyConfig.addSchema({ @@ -522,19 +518,19 @@ describe('PercyConfig', () => { } }); - mockConfig('.percy.yml', [ + fs.writeFileSync('.percy.yml', [ 'version: 2', 'test:', ' value: percy' ].join('\n')); - mockConfig('.bar.yml', [ + fs.writeFileSync('.bar.yml', [ 'version: 2', 'test:', ' value: bar' ].join('\n')); - mockConfig('.defaults.yml', [ + fs.writeFileSync('.defaults.yml', [ 'version: 2', 'arr: [merged]' ].join('\n')); @@ -575,25 +571,28 @@ describe('PercyConfig', () => { ); }); - it('can search a provided directory for a config file', () => { - let filename = path.join('config', '.percy.yml'); + it('can search a provided directory for a config file', async () => { + let path = await import('path'); + let filepath = path.join('config', '.percy.yml'); logger.loglevel('debug'); - mockConfig(filename, [ + fs.mkdirSync('config'); + fs.writeFileSync(filepath, [ 'version: 2', 'test:', ' value: config/percy' ].join('\n')); - expect(PercyConfig.load({ path: 'config' })) - .toEqual({ - version: 2, - test: { value: 'config/percy' } - }); + expect(PercyConfig.load({ + path: './config' + })).toEqual({ + version: 2, + test: { value: 'config/percy' } + }); expect(logger.stdout).toEqual([]); expect(logger.stderr).toContain( - `[percy:config] Found config file: ${filename}` + `[percy:config] Found config file: ${filepath}` ); }); @@ -607,21 +606,19 @@ describe('PercyConfig', () => { }); it('caches config files on subsequent loads', () => { - let loads = 0; - mockConfig('.cached.yml', () => ++loads && 'version: 2'); + fs.writeFileSync('.cached.yml', 'version: 2'); PercyConfig.load({ path: '.cached.yml' }); PercyConfig.load({ path: '.cached.yml' }); PercyConfig.load({ path: '.cached.yml' }); - expect(loads).toBe(1); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); }); it('reloads cached config files when `reload` is true', () => { - let loads = 0; - mockConfig('.cached.yml', () => ++loads && 'version: 2'); + fs.writeFileSync('.cached.yml', 'version: 2'); PercyConfig.load({ path: '.cached.yml' }); PercyConfig.load({ path: '.cached.yml' }); PercyConfig.load({ path: '.cached.yml', reload: true }); - expect(loads).toBe(2); + expect(fs.readFileSync).toHaveBeenCalledTimes(2); }); it('logs when a config file cannot be found', () => { @@ -640,10 +637,22 @@ describe('PercyConfig', () => { ]); }); - it('logs when a config directory does not exist', () => { - spyOn(PercyConfig.explorer, 'search').and.throwError( - Object.assign(new Error(), { code: 'ENOENT' })); + it('logs when no config file can be found', async () => { + let { explorer } = await import('../src/load'); + spyOn(explorer, 'search').and.returnValue(null); + expect(PercyConfig.load({ print: true })).toEqual({ + version: 2, + test: { value: 'foo' } + }); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Config file not found' + ]); + }); + + it('logs when a config directory does not exist', async () => { expect(PercyConfig.load({ path: 'no-configs-here/', print: true @@ -673,7 +682,8 @@ describe('PercyConfig', () => { }); it('logs when failing to load or parse the config file', () => { - mockConfig('.error.yml', () => { throw new Error('test'); }); + fs.writeFileSync('.error.yml', ''); + fs.readFileSync.and.throwError(new Error('test')); expect(PercyConfig.load({ path: '.error.yml', @@ -690,7 +700,8 @@ describe('PercyConfig', () => { }); it('optionally bails when failing to load or parse the config file', () => { - mockConfig('.error.yml', () => { throw new Error('test'); }); + fs.writeFileSync('.error.yml', ''); + fs.readFileSync.and.throwError(new Error('test')); logger.loglevel('debug'); expect(PercyConfig.load({ @@ -715,7 +726,7 @@ describe('PercyConfig', () => { } }); - mockConfig('.foo.yml', [ + fs.writeFileSync('.foo.yml', [ 'version: 2', 'foo-bar: baz', 'arr: [one, two]', @@ -763,7 +774,7 @@ describe('PercyConfig', () => { }); it('warns with a missing version and uses default options', () => { - mockConfig('.no-version.yml', 'test:\n value: no-version'); + fs.writeFileSync('.no-version.yml', 'test:\n value: no-version'); logger.loglevel('debug'); expect(PercyConfig.load({ path: '.no-version.yml' })) @@ -780,7 +791,7 @@ describe('PercyConfig', () => { }); it('warns with an unsupported version and uses default options', () => { - mockConfig('.bad-version.yml', 'version: 3\ntest:\n value: bad-version'); + fs.writeFileSync('.bad-version.yml', 'version: 3\ntest:\n value: bad-version'); logger.loglevel('debug'); expect(PercyConfig.load({ path: '.bad-version.yml' })) @@ -797,7 +808,7 @@ describe('PercyConfig', () => { }); it('warns with an older version and uses migrated options', () => { - mockConfig('.old-version.yml', 'version: 1\nvalue: old-value'); + fs.writeFileSync('.old-version.yml', 'version: 1\nvalue: old-value'); logger.loglevel('debug'); PercyConfig.addMigration((config, util) => { @@ -827,7 +838,9 @@ describe('PercyConfig', () => { }); it('logs validation warnings and scrubs failing properties', () => { - mockConfig('.invalid.yml', 'version: 2\nfoo: bar'); + fs.writeFileSync('.invalid.yml', 'version: 2\nfoo: bar'); + logger.loglevel('debug'); + PercyConfig.addSchema({ obj: { type: 'object', @@ -838,7 +851,6 @@ describe('PercyConfig', () => { } }); - logger.loglevel('debug'); expect(PercyConfig.load({ path: '.invalid.yml', overrides: { @@ -872,7 +884,7 @@ describe('PercyConfig', () => { }); it('returns undefined on validation warnings when `bail` is true', () => { - mockConfig('.invalid.yml', 'version: 2\nfoo: bar'); + fs.writeFileSync('.invalid.yml', 'version: 2\nfoo: bar'); logger.loglevel('debug'); expect(PercyConfig.load({ @@ -1017,9 +1029,7 @@ describe('PercyConfig', () => { [true, { 'qux-four': 8 }], 'xyzzy' ] - }, { - schema: '/test' - })).toEqual({ + }, '/test')).toEqual({ fooOne: { 'Foo-Bar-Baz': 123, 'Baz-Bar-Foo': 321 diff --git a/packages/core/package.json b/packages/core/package.json index d867fe914..1e0c3a38a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,17 +10,24 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "types": "types/index.d.ts", - "files": [ - "dist", - "post-install.js", - "types/index.d.ts", - "test/helpers/server.js" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist", + "./post-install.js", + "./types/index.d.ts", + "./test/helpers/server.js" + ], + "main": "./dist/index.js", + "types": "types/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js", + "./config": "./dist/config.js", + "./post-install": "./post-install.js", + "./test/helpers": "./test/helpers/index.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 6331d682a..7ebf38c45 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -89,7 +89,7 @@ export function createStaticServer(options) { // include automatic sitemap route server.route('get', '/sitemap.xml', async (req, res) => { let { default: glob } = await import('fast-glob'); - let files = await glob('**/*.html', { cwd: serve }); + let files = await glob('**/*.html', { cwd: serve, fs }); return res.send(200, 'application/xml', [ '', diff --git a/packages/core/src/browser.js b/packages/core/src/browser.js index 421fa190f..44160cdb3 100644 --- a/packages/core/src/browser.js +++ b/packages/core/src/browser.js @@ -1,6 +1,6 @@ +import fs from 'fs'; import os from 'os'; import path from 'path'; -import { promises as fs, existsSync } from 'fs'; import spawn from 'cross-spawn'; import EventEmitter from 'events'; import WebSocket from 'ws'; @@ -96,7 +96,7 @@ export class Browser extends EventEmitter { this.readyState = 0; // check if any provided executable exists - if (this.executable && !existsSync(this.executable)) { + if (this.executable && !fs.existsSync(this.executable)) { this.log.error(`Browser executable not found: ${this.executable}`); this.executable = null; } @@ -104,7 +104,7 @@ export class Browser extends EventEmitter { // download and install the browser if not already present this.executable ||= await install.chromium(); // create a temporary profile directory - this.profile = await fs.mkdtemp(path.join(os.tmpdir(), 'percy-browser-')); + this.profile = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'percy-browser-')); // spawn the browser process detached in its own group and session let args = this.args.concat(`--user-data-dir=${this.profile}`); diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 437e5bfa8..2956e32d9 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1,10 +1,7 @@ -const PercyConfig = require('@percy/config'); -const CoreConfig = require('./config'); -const { Percy } = require('./percy'); +import PercyConfig from '@percy/config'; +import * as CoreConfig from './config'; PercyConfig.addSchema(CoreConfig.schemas); PercyConfig.addMigration(CoreConfig.migrations); -// export the Percy class with commonjs compatibility -module.exports = Percy; -module.exports.Percy = Percy; +export { default, Percy } from './percy'; diff --git a/packages/core/src/install.js b/packages/core/src/install.js index b74e67b04..75a0ccc82 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import https from 'https'; import logger from '@percy/logger'; -import { ProxyHttpsAgent } from '@percy/client/dist/request'; +import { ProxyHttpsAgent } from '@percy/client/utils'; // Formats a raw byte integer as a string function formatBytes(int) { diff --git a/packages/core/src/page.js b/packages/core/src/page.js index c3d929135..3c9aa36e3 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -1,4 +1,4 @@ -import { promises as fs } from 'fs'; +import fs from 'fs'; import logger from '@percy/logger'; import Network from './network'; import { @@ -204,7 +204,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.readFile(require.resolve('@percy/dom'), 'utf-8'); + let script = await fs.promises.readFile(require.resolve('@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 56f73d03c..795d5ec51 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -1,6 +1,5 @@ import PercyClient from '@percy/client'; import PercyConfig from '@percy/config'; -import { merge } from '@percy/config/dist/utils'; import logger from '@percy/logger'; import Queue from './queue'; import Browser from './browser'; @@ -122,7 +121,7 @@ export class Percy { } // merge and override existing config options - this.config = merge([this.config, config], (path, prev, next) => { + this.config = PercyConfig.merge([this.config, config], (path, prev, next) => { // replace arrays instead of merging return Array.isArray(next) && [path, next]; }); diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index 289374f64..5e420c2c3 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -1,12 +1,12 @@ import logger from '@percy/logger'; import PercyConfig from '@percy/config'; -import { merge } from '@percy/config/dist/utils'; import micromatch from 'micromatch'; import { configSchema } from './config'; import { + request, hostnameMatches, createRootResource, createPercyCSSResource, @@ -147,8 +147,6 @@ export function validateSnapshotOptions(options) { // Fetches a sitemap and parses it into a list of URLs for taking snapshots. Duplicate URLs, // including a trailing slash, are removed from the resulting list. export async function getSitemapSnapshots(options) { - let { request } = await import('@percy/client/dist/request'); - return request(options.sitemap, (body, res) => { // validate sitemap content-type let [contentType] = res.headers['content-type'].split(';'); @@ -171,7 +169,7 @@ export async function getSitemapSnapshots(options) { // Return snapshot options merged with defaults and global options. export function getSnapshotConfig(percy, options) { - return merge([{ + return PercyConfig.merge([{ widths: configSchema.snapshot.properties.widths.default, discovery: { allowedHostnames: [validURL(options.url).hostname] }, meta: { snapshot: { name: options.name }, build: percy.build } diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 3a3048e65..149c6e6ef 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -1,6 +1,5 @@ -import { sha256hash, hostnameMatches } from '@percy/client/dist/utils'; -export { request } from '@percy/client/dist/request'; -export { hostnameMatches }; +import { request, sha256hash, hostnameMatches } from '@percy/client/utils'; +export { request, hostnameMatches }; // 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 79518d427..b4cf140ee 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -1,7 +1,7 @@ import PercyConfig from '@percy/config'; -import Percy from '../src'; +import { logger, setupTest } from './helpers'; import pkg from '../package.json'; -import { logger } from './helpers'; +import Percy from '../src'; describe('API Server', () => { let percy; @@ -12,6 +12,8 @@ describe('API Server', () => { } beforeEach(() => { + setupTest(); + percy = new Percy({ token: 'PERCY_TOKEN', port: 1337 diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 8b503c988..933e3d08d 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1,5 +1,5 @@ -import { sha256hash } from '@percy/client/dist/utils'; -import { mockAPI, createTestServer, dedent, logger } from './helpers'; +import { sha256hash } from '@percy/client/utils'; +import { logger, api, setupTest, createTestServer, dedent } from './helpers'; import Percy from '../src'; describe('Discovery', () => { @@ -24,8 +24,9 @@ describe('Discovery', () => { beforeEach(async () => { captured = []; + setupTest(); - mockAPI.reply('/builds/123/snapshots', ({ body }) => { + api.reply('/builds/123/snapshots', ({ body }) => { // resource order is not important, stabilize it for testing captured.push(body.data.relationships.resources.data.sort((a, b) => ( a.attributes['resource-url'].localeCompare(b.attributes['resource-url']) diff --git a/packages/core/test/helpers/index.js b/packages/core/test/helpers/index.js index 04e772690..c7d399714 100644 --- a/packages/core/test/helpers/index.js +++ b/packages/core/test/helpers/index.js @@ -1,21 +1,33 @@ -import os from 'os'; -import path from 'path'; -import rimraf from 'rimraf'; +import { resetPercyConfig, mockfs as mfs, fs } from '@percy/config/test/helpers'; import logger from '@percy/logger/test/helpers'; -import mockAPI from '@percy/client/test/helpers'; +import api from '@percy/client/test/helpers'; -beforeEach(() => { - // mock logging - logger.mock(); - // mock API - mockAPI.start(); -}); +export function mockfs(initial) { + return mfs({ + ...initial, -afterEach(done => { - // cleanup tmp files - rimraf(path.join(os.tmpdir(), 'percy'), () => done()); -}); + $bypass: [ + require.resolve('@percy/dom'), + require.resolve('../../../core/package.json'), + require.resolve('../../../client/package.json'), + p => p.includes?.('.local-chromium'), + ...(initial?.$bypass ?? []) + ] + }); +} + +export function setupTest({ + resetConfig, + filesystem, + loggerTTY, + apiDelay +} = {}) { + resetPercyConfig(resetConfig); + logger.mock({ isTTY: loggerTTY }); + api.mock({ delay: apiDelay }); + mockfs(filesystem); +} -export { logger, mockAPI }; export { createTestServer } from './server'; export { dedent } from './dedent'; +export { logger, api, fs }; diff --git a/packages/core/test/helpers/request.js b/packages/core/test/helpers/request.js index 61dc18b8f..64e4b3c86 100644 --- a/packages/core/test/helpers/request.js +++ b/packages/core/test/helpers/request.js @@ -1,11 +1,12 @@ -import req from '@percy/client/dist/request'; - export async function request(url, method = 'GET', handle) { if (typeof method === 'boolean' || typeof method === 'function') [handle, method] = [method, 'GET']; let cb = typeof handle === 'boolean' ? (handle ? (...a) => a : (_, r) => r) : handle; let options = typeof method === 'string' ? { method } : method; + let { request } = await import('@percy/client/utils'); - try { return await req(url, options, cb); } catch (error) { + try { + return await request(url, options, cb); + } catch (error) { if (typeof handle !== 'boolean') throw error; return handle ? [error.response.body, error.response] : error.response; } diff --git a/packages/core/test/helpers/server.js b/packages/core/test/helpers/server.js index 972e8d47c..28903c663 100644 --- a/packages/core/test/helpers/server.js +++ b/packages/core/test/helpers/server.js @@ -1,5 +1,5 @@ // aliased to src for coverage during tests without needing to compile this file -const { default: Server } = require('@percy/core/dist/server'); +const { default: Server } = require('../../dist/server'); function createTestServer({ default: defaultReply, ...replies }, port = 8000) { let server = new Server(); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index ef7587ed0..f15e83444 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -1,10 +1,12 @@ +import { logger, api, setupTest, createTestServer } from './helpers'; import Percy from '../src'; -import { mockAPI, logger, createTestServer } from './helpers'; describe('Percy', () => { let percy, server; beforeEach(async () => { + setupTest(); + server = await createTestServer({ default: () => [200, 'text/html', '

Snapshot

'] }); @@ -38,7 +40,9 @@ describe('Percy', () => { }); await expectAsync(percy.stop()).toBeResolved(); - expect(logger.stderr).toEqual(['[percy] Warning: Missing `clientInfo` and/or `environmentInfo` properties']); + expect(logger.stderr).toEqual([ + '[percy] Warning: Missing `clientInfo` and/or `environmentInfo` properties' + ]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ '[percy] Percy has started!', '[percy] Snapshot taken: test snapshot' @@ -173,7 +177,7 @@ describe('Percy', () => { describe('#start()', () => { it('creates a new build', async () => { await expectAsync(percy.start()).toBeResolved(); - expect(mockAPI.requests['/builds']).toBeDefined(); + expect(api.requests['/builds']).toBeDefined(); }); it('launches a browser after creating a new build', async () => { @@ -238,7 +242,7 @@ describe('Percy', () => { }); it('does not start when encountering an error', async () => { - mockAPI.reply('/builds', () => [401, { + api.reply('/builds', () => [401, { errors: [{ detail: 'build error' }] }]); @@ -258,13 +262,13 @@ describe('Percy', () => { it('queues build creation when uploads are deferred', async () => { percy = new Percy({ token: 'PERCY_TOKEN', deferUploads: true }); await expectAsync(percy.start()).toBeResolved(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); // process deferred uploads percy.snapshot('http://localhost:8000'); await percy.flush(); - expect(mockAPI.requests['/builds']).toBeDefined(); + expect(api.requests['/builds']).toBeDefined(); }); it('cancels deferred build creation when interupted', async () => { @@ -289,40 +293,40 @@ describe('Percy', () => { // processing deferred uploads should not result in a new build await percy.flush(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); }); it('does not create an empty build when uploads are deferred', async () => { percy = new Percy({ token: 'PERCY_TOKEN', deferUploads: true }); await expectAsync(percy.start()).toBeResolved(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); // flush queues without uploads await percy.flush(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); // flush a snapshot to create a build percy.snapshot('http://localhost:8000'); await percy.flush(); - expect(mockAPI.requests['/builds']).toBeDefined(); + expect(api.requests['/builds']).toBeDefined(); }); it('does not create a build when uploads are skipped', async () => { percy = new Percy({ token: 'PERCY_TOKEN', skipUploads: true }); await expectAsync(percy.start()).toBeResolved(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); // process deferred uploads await percy.flush(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); // stopping should also skip uploads await percy.stop(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -333,11 +337,11 @@ describe('Percy', () => { percy = new Percy({ token: 'PERCY_TOKEN', dryRun: true }); await expectAsync(percy.start()).toBeResolved(); expect(percy.browser.isConnected()).toBe(false); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); await percy.stop(); - expect(mockAPI.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); expect(logger.stderr).toEqual([ '[percy] Build not created' @@ -345,7 +349,7 @@ describe('Percy', () => { }); it('stops accepting snapshots when a queued build fails to be created', async () => { - mockAPI.reply('/builds', () => [401, { + api.reply('/builds', () => [401, { errors: [{ detail: 'build error' }] }]); @@ -357,14 +361,14 @@ describe('Percy', () => { url: 'http://localhost:8000' })).toBeResolved(); - expect(mockAPI.requests['/builds']).toBeUndefined(); - expect(mockAPI.requests['/builds/123/snapshots']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds/123/snapshots']).toBeUndefined(); // process deferred uploads await percy.flush(); - expect(mockAPI.requests['/builds']).toBeDefined(); - expect(mockAPI.requests['/builds/123/snapshots']).toBeUndefined(); + expect(api.requests['/builds']).toBeDefined(); + expect(api.requests['/builds/123/snapshots']).toBeUndefined(); // throws synchronously expect(() => percy.snapshot({ @@ -383,7 +387,7 @@ describe('Percy', () => { }); it('stops accepting snapshots when an in-progress build fails', async () => { - mockAPI.reply('/builds/123/snapshots', () => [422, { + api.reply('/builds/123/snapshots', () => [422, { errors: [{ detail: 'Build has failed', source: { pointer: '/data/attributes/build' } @@ -414,7 +418,7 @@ describe('Percy', () => { '[percy] Error: Build has failed' ])); - expect(mockAPI.requests['/builds/123/snapshots'].length).toEqual(1); + expect(api.requests['/builds/123/snapshots'].length).toEqual(1); // stops accepting snapshots expect(() => percy.snapshot({ @@ -428,7 +432,7 @@ describe('Percy', () => { // stop the previously started instance and clear requests async function reset(options) { await percy.stop().then(() => logger.reset()); - Object.keys(mockAPI.requests).map(k => delete mockAPI.requests[k]); + Object.keys(api.requests).map(k => delete api.requests[k]); percy = await Percy.start({ token: 'PERCY_TOKEN', ...options }); } @@ -438,7 +442,7 @@ describe('Percy', () => { it('finalizes the build', async () => { await expectAsync(percy.stop()).toBeResolved(); - expect(mockAPI.requests['/builds/123/finalize']).toBeDefined(); + expect(api.requests['/builds/123/finalize']).toBeDefined(); expect(logger.stderr).toEqual([]); expect(logger.stdout).toContain( @@ -464,8 +468,8 @@ describe('Percy', () => { await expectAsync(percy.stop(true)).toBeResolved(); // no build should be created or finalized - expect(mockAPI.requests['/builds']).toBeUndefined(); - expect(mockAPI.requests['/builds/123/finalize']).toBeUndefined(); + expect(api.requests['/builds']).toBeUndefined(); + expect(api.requests['/builds/123/finalize']).toBeUndefined(); expect(logger.stdout).toContain( '[percy] Stopping percy...' @@ -514,7 +518,7 @@ describe('Percy', () => { }); it('cleans up the server and browser before finalizing', async () => { - mockAPI.reply('/builds/123/finalize', () => [401, { + api.reply('/builds/123/finalize', () => [401, { errors: [{ detail: 'finalize error' }] }]); @@ -542,7 +546,7 @@ describe('Percy', () => { expect(percy.readyState).toEqual(1); expect(percy.server.listening).toBe(true); expect(percy.browser.isConnected()).toBe(true); - expect(mockAPI.requests['/builds/123/finalize']).toBeUndefined(); + expect(api.requests['/builds/123/finalize']).toBeUndefined(); expect(logger.stdout).toEqual([ '[percy] Percy has started!', @@ -556,7 +560,7 @@ describe('Percy', () => { expect(percy.readyState).toEqual(3); expect(percy.server.listening).toBe(false); expect(percy.browser.isConnected()).toBe(false); - expect(mockAPI.requests['/builds/123/finalize']).toBeDefined(); + expect(api.requests['/builds/123/finalize']).toBeDefined(); expect(logger.stdout).toEqual(jasmine.arrayContaining([ '[percy] Snapshot taken: /two', @@ -582,7 +586,7 @@ describe('Percy', () => { }); it('logs when the build has failed upstream', async () => { - mockAPI.reply('/builds/123/snapshots', () => [422, { + api.reply('/builds/123/snapshots', () => [422, { errors: [ { detail: 'Cannot create snapshot in failed builds' }, { detail: 'Build has failed', source: { pointer: '/data/attributes/build' } } @@ -623,11 +627,11 @@ describe('Percy', () => { widths: [1000] }); - expect(mockAPI.requests['/builds/123/snapshots']).toBeUndefined(); + expect(api.requests['/builds/123/snapshots']).toBeUndefined(); await percy.idle(); - expect(mockAPI.requests['/builds/123/snapshots']).toHaveSize(1); + expect(api.requests['/builds/123/snapshots']).toHaveSize(1); }); }); diff --git a/packages/core/test/snapshot-multiple.test.js b/packages/core/test/snapshot-multiple.test.js index eaf53d3f5..5a09ef914 100644 --- a/packages/core/test/snapshot-multiple.test.js +++ b/packages/core/test/snapshot-multiple.test.js @@ -1,13 +1,12 @@ -import quibble from 'quibble'; -import * as memfs from 'memfs'; -import { logger, createTestServer } from './helpers'; +import { fs, logger, setupTest, createTestServer } from './helpers'; import Percy from '../src'; describe('Snapshot multiple', () => { let percy, server, sitemap; beforeEach(async () => { - logger.mock(); + sitemap = ['/']; + setupTest(); percy = await Percy.start({ token: 'PERCY_TOKEN', @@ -18,8 +17,6 @@ describe('Snapshot multiple', () => { server: false }); - sitemap = ['/']; - server = await createTestServer({ default: () => [200, 'text/html', '

Test

'], '/sitemap.xml': () => [200, 'application/xml', [ @@ -206,10 +203,8 @@ describe('Snapshot multiple', () => { }); describe('server syntax', () => { - beforeEach(() => { - quibble('fs', memfs.fs); - - memfs.vol.fromJSON({ + beforeEach(async () => { + fs.$vol.fromJSON({ './public/index.html': 'index', './public/about.html': 'about', './public/blog/foo.html': 'foo', @@ -217,11 +212,6 @@ describe('Snapshot multiple', () => { }); }); - afterEach(() => { - quibble.reset(true); - memfs.vol.reset(); - }); - it('serves and snapshots a static directory', async () => { await percy.snapshot({ serve: './public' }); diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index b6b40dc44..4e3199a7c 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -1,5 +1,5 @@ -import { sha256hash, base64encode } from '@percy/client/dist/utils'; -import { mockAPI, logger, createTestServer, dedent } from './helpers'; +import { sha256hash, base64encode } from '@percy/client/utils'; +import { logger, api, setupTest, createTestServer, dedent } from './helpers'; import { waitFor } from '../src/utils'; import Percy from '../src'; @@ -7,9 +7,8 @@ describe('Snapshot', () => { let percy, server, testDOM; beforeEach(async () => { - logger.mock(); - testDOM = '

Test

'; + setupTest(); server = await createTestServer({ default: () => [200, 'text/html', testDOM], @@ -188,7 +187,7 @@ describe('Snapshot', () => { }); it('logs any encountered errors when uploading', async () => { - mockAPI.reply('/builds/123/snapshots', () => [401, { + api.reply('/builds/123/snapshots', () => [401, { errors: [{ detail: 'unexpected upload error' }] }]); @@ -416,7 +415,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test

'); }); @@ -435,7 +434,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test

'); }); @@ -454,7 +453,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test

'); }); @@ -469,7 +468,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test

'); }); @@ -509,7 +508,7 @@ describe('Snapshot', () => { await percy.idle(); let dom = i => Buffer.from(( - mockAPI.requests['/builds/123/resources'][i * 2] + api.requests['/builds/123/resources'][i * 2] .body.data.attributes['base64-content'] ), 'base64').toString(); @@ -536,7 +535,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Foo

'); }); @@ -560,7 +559,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test

'); }); @@ -586,7 +585,7 @@ describe('Snapshot', () => { await percy.idle(); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch(/Foo<\/p>/); }); @@ -638,7 +637,7 @@ describe('Snapshot', () => { ]); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch('

Test...

'); }); @@ -679,7 +678,7 @@ describe('Snapshot', () => { ]); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][0] + api.requests['/builds/123/resources'][0] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch([ '

afterNavigation - http://localhost:8000/

', @@ -687,7 +686,7 @@ describe('Snapshot', () => { ].join('')); expect(Buffer.from(( - mockAPI.requests['/builds/123/resources'][2] + api.requests['/builds/123/resources'][2] .body.data.attributes['base64-content'] ), 'base64').toString()).toMatch([ '

beforeResize - 400

', @@ -700,7 +699,7 @@ describe('Snapshot', () => { describe('with percy-css', () => { let getResourceData = () => ( - mockAPI.requests['/builds/123/snapshots'][0] + api.requests['/builds/123/snapshots'][0] .body.data.relationships.resources.data); beforeEach(() => { @@ -766,7 +765,7 @@ describe('Snapshot', () => { await percy.idle(); - let root = mockAPI.requests['/builds/123/resources'][0].body.data; + let root = api.requests['/builds/123/resources'][0].body.data; let cssURL = new URL(getResourceData()[1].attributes['resource-url']); let injectedDOM = testDOM.replace('', ( `` diff --git a/packages/core/test/unit/install.test.js b/packages/core/test/unit/install.test.js index 356c43f78..b8f483177 100644 --- a/packages/core/test/unit/install.test.js +++ b/packages/core/test/unit/install.test.js @@ -1,34 +1,17 @@ import path from 'path'; -import * as memfs from 'memfs'; -import quibble from 'quibble'; import nock from 'nock'; import logger from '@percy/logger/test/helpers'; +import { mockfs, fs } from '@percy/config/test/helpers'; import install from '../../src/install'; const CHROMIUM_REVISIONS = install.chromium.revisions; describe('Unit / Install', () => { - let install, dlnock, dlcallback, options; + let dlnock, dlcallback, options; beforeEach(async () => { - quibble('fs', memfs.fs); - memfs.vol.fromJSON({ './': null }); - ({ default: install } = await import('../../src/install')); - logger.mock(); - - // emulate tty properties for testing - Object.assign(logger.constructor.stdout, { - isTTY: true, - columns: 80, - cursorTo() {}, - clearLine() {} - }); - - // spy on fs methods - spyOn(memfs.fs.promises, 'mkdir').and.callThrough(); - spyOn(memfs.fs.promises, 'unlink').and.callThrough(); - spyOn(memfs.fs, 'createWriteStream').and.callThrough(); - spyOn(memfs.fs, 'existsSync').and.callThrough(); + logger.mock({ isTTY: true }); + mockfs(); // mock a fake download api nock.disableNetConnect(); @@ -50,25 +33,23 @@ describe('Unit / Install', () => { }); afterEach(() => { - memfs.vol.reset(); - quibble.reset(); nock.cleanAll(); }); it('does nothing if the executable already exists in the output directory', async () => { - memfs.fs.existsSync.and.returnValue(true); + fs.existsSync.and.returnValue(true); await install(options); - expect(memfs.fs.promises.mkdir).not.toHaveBeenCalled(); - expect(memfs.fs.promises.unlink).not.toHaveBeenCalled(); - expect(memfs.fs.createWriteStream).not.toHaveBeenCalled(); + expect(fs.promises.mkdir).not.toHaveBeenCalled(); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + expect(fs.createWriteStream).not.toHaveBeenCalled(); expect(dlnock.isDone()).toBe(false); }); it('creates the output directory when it does not exist', async () => { await install(options); - expect(memfs.fs.promises.mkdir) + expect(fs.promises.mkdir) .toHaveBeenCalledOnceWith(path.join('.downloads', 'v0'), { recursive: true }); }); @@ -113,10 +94,12 @@ describe('Unit / Install', () => { }); it('cleans up the archive after downloading and extracting', async () => { - memfs.vol.fromJSON({ '.downloads/v0/archive.zip': '' }); + fs.$vol.fromJSON({ '.downloads/v0/archive.zip': '' }); + expect(fs.existsSync('.downloads/v0/archive.zip')).toBe(true); + await install(options); - expect(memfs.vol.existsSync('.downloads/v0/archive.zip')).toBe(false); + expect(fs.existsSync('.downloads/v0/archive.zip')).toBe(false); }); it('handles failed downloads', async () => { @@ -150,8 +133,9 @@ describe('Unit / Install', () => { let extractZip; beforeEach(() => { - extractZip = jasmine.createSpy('extract-zip').and.resolveTo(); - quibble('extract-zip', extractZip); + require('extract-zip'); // ensure dep is cached before spying on it + extractZip = spyOn(require.cache[require.resolve('extract-zip')], 'exports'); + extractZip.and.resolveTo(); dlnock = nock('https://storage.googleapis.com/chromium-browser-snapshots') .persist().get(/.*/).reply(uri => dlcallback(uri)); diff --git a/packages/core/test/unit/server.test.js b/packages/core/test/unit/server.test.js index 2d4eb6292..687b9ecb6 100644 --- a/packages/core/test/unit/server.test.js +++ b/packages/core/test/unit/server.test.js @@ -1,27 +1,23 @@ -import quibble from 'quibble'; -import * as memfs from 'memfs'; +import { fs, mockfs } from '../helpers'; +import Server from '../../src/server'; describe('Unit / Server', () => { - let Server, server; + let server; async function request(path, ...args) { let { request } = await import('../helpers/request'); return request(new URL(path, server.address()), ...args); } - beforeEach(async () => { - quibble('fs', memfs.fs); - memfs.vol.mkdirSync(process.cwd(), { recursive: true }); - ({ Server } = await import('../../src/server')); + beforeEach(() => { server = new Server({ port: 8000 }); + mockfs(); }); afterEach(async () => { await server.close(); // wait 2 ticks before reseting memfs too quickly await new Promise(r => setImmediate(setImmediate, r)); - memfs.vol.reset(); - quibble.reset(); }); describe('#port', () => { @@ -262,7 +258,7 @@ describe('Unit / Server', () => { beforeEach(async () => { await server.listen(); - memfs.vol.fromJSON({ + fs.$vol.fromJSON({ './public/index.html': '

test

', './public/foo.html': '

foo

', './public/foo/bar.html': '

foo/bar

' @@ -327,8 +323,8 @@ describe('Unit / Server', () => { it('serves static error pages if present', async () => { server.serve('./public'); - memfs.vol.writeFileSync('./public/400.html', '

Wat?

'); - memfs.vol.writeFileSync('./public/404.html', '

Not here

'); + fs.writeFileSync('./public/400.html', '

Wat?

'); + fs.writeFileSync('./public/404.html', '

Not here

'); let e1 = await request('/%E0%A4%A').catch(e => e.response); let e2 = await request('/foobar').catch(e => e.response); @@ -376,7 +372,7 @@ describe('Unit / Server', () => { it('protects against path traversal', async () => { server.serve('./public'); - memfs.vol.writeFileSync('./secret', '*wags finger* ☝️'); + fs.writeFileSync('./secret', '*wags finger* ☝️'); // by encoding `../` we can sneak past `new URL().pathname` sanitization await expectAsync(request('/%2E%2E%2Fsecret')).toBeRejectedWithError('400 Bad Request'); }); diff --git a/packages/env/package.json b/packages/env/package.json index 0197c58a4..3a4d4d782 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -10,20 +10,22 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "files": [ - "dist" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist" + ], + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js", + "./test/helpers": "./test/helpers.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", "test": "node ../../scripts/test", "test:coverage": "yarn test --coverage" - }, - "devDependencies": { - "mock-require": "^3.0.3" } } diff --git a/packages/env/src/utils.js b/packages/env/src/utils.js index 7baec6fc1..b0ff39444 100644 --- a/packages/env/src/utils.js +++ b/packages/env/src/utils.js @@ -1,5 +1,5 @@ -import { execSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import fs from 'fs'; +import cp from 'child_process'; const GIT_COMMIT_FORMAT = [ 'COMMIT_SHA:%H', @@ -14,7 +14,7 @@ const GIT_COMMIT_FORMAT = [ export function git(args) { try { - return execSync(`git ${args}`, { + return cp.execSync(`git ${args}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }); @@ -29,7 +29,7 @@ export function getCommitData(sha, branch, vars = {}) { // prioritize PERCY_GIT_* vars and fallback to GIT_* vars let get = key => vars[`PERCY_GIT_${key}`] || - raw.match(new RegExp(`${key}:(.*)`, 'm'))?.[1] || + raw.match(new RegExp(`^${key}:(.*)$`, 'm'))?.[1] || vars[`GIT_${key}`] || null; return { @@ -56,8 +56,8 @@ 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 && existsSync(GITHUB_EVENT_PATH)) { - try { github.payload = JSON.parse(readFileSync(GITHUB_EVENT_PATH, 'utf8')); } catch (e) {} + if (!github.payload && GITHUB_EVENT_PATH && fs.existsSync(GITHUB_EVENT_PATH)) { + try { github.payload = JSON.parse(fs.readFileSync(GITHUB_EVENT_PATH, 'utf8')); } catch (e) {} } return (github.payload ||= {}); diff --git a/packages/env/test/defaults.test.js b/packages/env/test/defaults.test.js index 9caed463c..24824835d 100644 --- a/packages/env/test/defaults.test.js +++ b/packages/env/test/defaults.test.js @@ -32,9 +32,7 @@ describe('Defaults', () => { }); it('reads and parses live git commit data', () => { - mockgit.branch.and.returnValue('mock-branch'); - - mockgit.commit.and.returnValue([ + let git = mockgit('mock-branch').and.returnValue([ 'COMMIT_SHA:mock sha', 'AUTHOR_NAME:mock author', 'AUTHOR_EMAIL:mock author@email.com', @@ -53,23 +51,20 @@ describe('Defaults', () => { expect(env.git).toHaveProperty('committerEmail', 'mock committer@email.com'); expect(env.git).toHaveProperty('message', 'mock commit'); - expect(mockgit.branch) - .toHaveBeenCalledOnceWith(['rev-parse', '--abbrev-ref', 'HEAD']); - expect(mockgit.commit) - .toHaveBeenCalledOnceWith(['show', 'HEAD', '--quiet', jasmine.stringMatching(/--format=.*/)]); + expect(git).toHaveBeenCalledWith('rev-parse', '--abbrev-ref', 'HEAD'); + expect(git).toHaveBeenCalledWith('show', 'HEAD', '--quiet', jasmine.stringMatching(/--format=.*/)); }); it('uses raw branch data when git commit data is missing', () => { - mockgit.branch.and.returnValue('mock-branch'); + let git = mockgit('mock-branch'); expect(env.git).toHaveProperty('branch', 'mock-branch'); - expect(mockgit.branch) - .toHaveBeenCalledOnceWith(['rev-parse', '--abbrev-ref', 'HEAD']); + expect(git).toHaveBeenCalledWith('rev-parse', '--abbrev-ref', 'HEAD'); }); it('uses the raw commit sha when the env sha is invalid', () => { - mockgit.commit.and.returnValue('COMMIT_SHA:fully-valid-git-sha\n'); + mockgit().and.returnValue('COMMIT_SHA:fully-valid-git-sha\n'); env = new PercyEnv({ BITBUCKET_BUILD_NUMBER: 'bitbucket-build-number', @@ -83,7 +78,7 @@ describe('Defaults', () => { }); it('can be overridden with PERCY env vars', () => { - mockgit.commit.and.returnValue([ + mockgit().and.returnValue([ 'COMMIT_SHA:mock sha', 'AUTHOR_NAME:mock author', 'AUTHOR_EMAIL:mock author@email.com', @@ -148,8 +143,7 @@ describe('Defaults', () => { }); it('falls back to GIT env vars with missing or invalid git commit data', () => { - mockgit.commit.and.returnValue('missing or invalid'); - mockgit.branch.and.returnValue('mock branch'); + mockgit('mock branch').and.returnValue('invalid'); env = new PercyEnv({ PERCY_COMMIT: 'not-long-enough-sha', @@ -173,7 +167,7 @@ describe('Defaults', () => { }); it('catches git errors, if there are any', () => { - mockgit.commit.and.throwError(new Error('test')); + mockgit().and.throwError(new Error('test')); expect(env).toHaveProperty('git.sha', null); }); diff --git a/packages/env/test/dotenv.test.js b/packages/env/test/dotenv.test.js index 7860ba0e3..70de1ceae 100644 --- a/packages/env/test/dotenv.test.js +++ b/packages/env/test/dotenv.test.js @@ -1,17 +1,16 @@ -import mock from 'mock-require'; +import fs from 'fs'; +import { load } from '../src/dotenv'; describe('dotenv files', () => { let env, dotenvs; beforeAll(() => { env = process.env; - mock('fs', { readFileSync: path => dotenvs[path] ?? '' }); - mock.reRequire('../src/dotenv'); + spyOn(fs, 'readFileSync').and.callFake(p => dotenvs[p] ?? ''); }); afterAll(() => { process.env = env; - mock.stop('fs'); }); beforeEach(() => { @@ -22,7 +21,7 @@ describe('dotenv files', () => { }); it('loads .env and .env.local files', () => { - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_1', '1'); expect(process.env).toHaveProperty('TEST_2', 'two'); @@ -31,7 +30,7 @@ describe('dotenv files', () => { it('does not override existing environment variables', () => { process.env.TEST_1 = 'uno'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_1', 'uno'); }); @@ -40,7 +39,7 @@ describe('dotenv files', () => { dotenvs['.env.dev'] = 'TEST_3=dev_3'; dotenvs['.env.dev.local'] = 'TEST_2=dev_two'; process.env.NODE_ENV = 'dev'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_1', '1'); expect(process.env).toHaveProperty('TEST_2', 'dev_two'); @@ -50,7 +49,7 @@ describe('dotenv files', () => { it('does not load .env.local when NODE_ENV is "test"', () => { dotenvs['.env.test'] = 'TEST_3=test_3'; process.env.NODE_ENV = 'test'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_1', '1'); expect(process.env).toHaveProperty('TEST_2', '2'); @@ -59,13 +58,14 @@ describe('dotenv files', () => { it('does not load any files when PERCY_DISABLE_DOTENV is set', () => { process.env.PERCY_DISABLE_DOTENV = 'true'; - mock.reRequire('../src'); + load(); + expect(process.env).toEqual({ PERCY_DISABLE_DOTENV: 'true' }); }); it('expands newlines within double quotes', () => { dotenvs['.env'] = 'TEST_NEWLINES="foo\nbar\r\nbaz\\nqux\\r\\nxyzzy"'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_NEWLINES', 'foo\nbar\r\nbaz\nqux\r\nxyzzy'); }); @@ -73,7 +73,7 @@ describe('dotenv files', () => { it('interpolates variable substitutions', () => { // eslint-disable-next-line no-template-curly-in-string dotenvs['.env'] += '\nTEST_4=$TEST_1${TEST_2}\nTEST_5=$TEST_4${TEST_3}four'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_4', '1two'); expect(process.env).toHaveProperty('TEST_5', '1two3four'); @@ -82,7 +82,7 @@ describe('dotenv files', () => { it('interpolates undefined variables with empty strings', () => { // eslint-disable-next-line no-template-curly-in-string dotenvs['.env'] += '\nTEST_TWO=2 > ${TEST_ONE}\nTEST_THREE='; - mock.reRequire('../src'); + load(); expect(process.env).not.toHaveProperty('TEST_ONE'); expect(process.env).toHaveProperty('TEST_TWO', '2 > '); @@ -91,14 +91,14 @@ describe('dotenv files', () => { it('does not interpolate single quoted strings', () => { dotenvs['.env'] += "\nTEST_STRING='$TEST_1'"; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_STRING', '$TEST_1'); }); it('does not interpolate escaped dollar signs', () => { dotenvs['.env'] += '\nTEST_ESC=\\$TEST_1'; - mock.reRequire('../src'); + load(); expect(process.env).toHaveProperty('TEST_ESC', '$TEST_1'); }); diff --git a/packages/env/test/helpers.js b/packages/env/test/helpers.js index 0955f6f60..b6b07fd9e 100644 --- a/packages/env/test/helpers.js +++ b/packages/env/test/helpers.js @@ -1,35 +1,19 @@ -import mock from 'mock-require'; +import cp from 'child_process'; -export const mockgit = {}; +export function mockgit(branch = '') { + let spy = jasmine.createSpy('git'); -beforeEach(() => { - mockgit.branch = jasmine.createSpy('branch').and.returnValue(''); - mockgit.commit = jasmine.createSpy('commit').and.returnValue(''); -}); - -function gitmock(args) { - if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { - return mockgit.branch(args); - } else if (args[0] === 'show' || args[0] === 'rev-parse') { - let raw = mockgit.commit(args); - return args[0] !== 'show' && raw.startsWith('COMMIT_SHA') - ? raw.match(/COMMIT_SHA:(.*)/)?.[1] : raw; - } else { - return ''; - } -} - -mock('child_process', { - execSync(...args) { - if (args[0].match(/^git\b/)) { - return gitmock(args[0].split(' ').slice(1)); + spyOn(cp, 'execSync').and.callFake(function(cmd, options) { + if (cmd.match(/^git\b/)) { + let result = spy(...cmd.split(' ').slice(1)) ?? ''; + if (!cmd.match(/\b(show|rev-parse)\b/)) return ''; + if (!cmd.includes('rev-parse')) return result; + if (cmd.includes('--abbrev-ref')) return branch; + return result.match(/^COMMIT_SHA:(.*)$/m)?.[1]; } else { - return ''; + return cp.execSync.and.originalFn.call(this, cmd, options); } - } -}); + }); -mock.reRequire('child_process'); -mock.reRequire('../src/utils'); -mock.reRequire('../src/environment'); -mock.reRequire('../src'); + return spy; +} diff --git a/packages/env/test/jenkins.test.js b/packages/env/test/jenkins.test.js index 0d64050a3..2deef7d28 100644 --- a/packages/env/test/jenkins.test.js +++ b/packages/env/test/jenkins.test.js @@ -43,7 +43,7 @@ describe('Jenkins', () => { CHANGE_BRANCH: 'jenkins-branch' }); - mockgit.commit.and.callFake(([, sha]) => [ + mockgit().and.callFake((show, sha) => [ `COMMIT_SHA:${sha === 'HEAD' ? 'jenkins-merge-sha' : 'jenkins-non-merge-sha'}`, `AUTHOR_NAME:${sha === 'HEAD' ? 'Jenkins' : 'mock author'}`, `AUTHOR_EMAIL:${sha === 'HEAD' ? 'nobody@nowhere' : 'mock author@email.com'}`, diff --git a/packages/logger/package.json b/packages/logger/package.json index e83ee9e54..6cac82791 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -10,16 +10,22 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "browser": "dist/bundle.js", - "files": [ - "dist", - "test/helpers.js", - "test/client.js" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist", + "./test/helpers.js", + "./test/client.js" + ], + "main": "./dist/index.js", + "browser": "dist/bundle.js", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js", + "./test/helpers": "./test/helpers.js", + "./test/client": "./test/client.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 5bb0db518..2738bca2b 100644 --- a/packages/logger/src/browser.js +++ b/packages/logger/src/browser.js @@ -1,4 +1,4 @@ -import { ANSI_COLORS, ANSI_REG } from './util'; +import { ANSI_COLORS, ANSI_REG } from './utils'; import PercyLogger from './logger'; export class PercyBrowserLogger extends PercyLogger { diff --git a/packages/logger/src/logger.js b/packages/logger/src/logger.js index b5f161141..85fdd2f11 100644 --- a/packages/logger/src/logger.js +++ b/packages/logger/src/logger.js @@ -1,4 +1,4 @@ -import { colors } from './util'; +import { colors } from './utils'; const URL_REGEXP = /\bhttps?:\/\/[^\s/$.?#].[^\s]*\b/i; const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; diff --git a/packages/logger/src/util.js b/packages/logger/src/utils.js similarity index 100% rename from packages/logger/src/util.js rename to packages/logger/src/utils.js diff --git a/packages/logger/test/helpers.js b/packages/logger/test/helpers.js index 49789825d..e35bb8de7 100644 --- a/packages/logger/test/helpers.js +++ b/packages/logger/test/helpers.js @@ -1,6 +1,5 @@ const logger = require('@percy/logger'); -const { ANSI_REG } = require('@percy/logger/dist/util'); -const { Logger } = logger; +const { ANSI_REG } = require('@percy/logger/utils'); const ELAPSED_REG = /\s\S*?\(\d+ms\)\S*/; const NEWLINE_REG = /\r\n/g; @@ -17,35 +16,15 @@ function sanitizeLog(str, { ansi, elapsed } = {}) { return str.replace(LASTLINE_REG, ''); } -function TestIO(data, options) { - if (!process.env.__PERCY_BROWSERIFIED__) { - let { Writable } = require('stream'); - - return Object.assign(new Writable(), { - isTTY: options && options.isTTY, - cursorTo() {}, - clearLine() {}, - - _write(chunk, encoding, callback) { - data.push(sanitizeLog(chunk.toString(), options)); - callback(); - } - }); - } -} - function spy(object, method, func) { - if (object[method].reset) { - object[method].reset(); - return object[method]; - } + if (object[method].restore) object[method].restore(); let spy = Object.assign(function spy(...args) { spy.calls.push(args); if (func) return func.apply(this, args); }, { restore: () => (object[method] = spy.originalValue), - reset: () => (spy.calls.length = 0), + reset: () => (spy.calls.length = 0) || spy, originalValue: object[method], calls: [] }); @@ -54,39 +33,50 @@ function spy(object, method, func) { return spy; } +const { + Logger, + loglevel +} = logger; + const helpers = { - constructor: Logger, - loglevel: logger.loglevel, stdout: [], stderr: [], + loglevel, - get messages() { - return Logger.instance && - Logger.instance.messages; - }, - - mock(options) { + async mock(options = {}) { helpers.reset(); - helpers.options = options; - if (!process.env.__PERCY_BROWSERIFIED__) { - Logger.stdout = TestIO(helpers.stdout, options); - Logger.stderr = TestIO(helpers.stderr, options); - } else { + if (process.env.__PERCY_BROWSERIFIED__) { spy(Logger.prototype, 'write', function(lvl, msg) { let stdio = lvl === 'info' ? 'stdout' : 'stderr'; - helpers[stdio].push(sanitizeLog(msg, helpers.options)); + helpers[stdio].push(sanitizeLog(msg, options)); return this.write.originalValue.call(this, lvl, msg); }); spy(console, 'log'); spy(console, 'warn'); spy(console, 'error'); + } else { + let { Writable } = require('stream'); + + for (let stdio of ['stdout', 'stderr']) { + Logger[stdio] = Object.assign(new Writable(), { + columns: options.isTTY ? 100 : null, + isTTY: options.isTTY, + cursorTo() {}, + clearLine() {}, + + _write(chunk, encoding, callback) { + helpers[stdio].push(sanitizeLog(chunk.toString(), options)); + callback(); + } + }); + } } }, reset(soft) { - if (soft) Logger.instance.loglevel('info'); + if (soft) loglevel('info'); else delete Logger.instance; helpers.stdout.length = 0; @@ -100,21 +90,20 @@ const helpers = { }, dump() { - if (!helpers.messages || !helpers.messages.size) return; - if (console.log.and) console.log.and.callThrough(); + let msgs = Array.from((Logger.instance && Logger.instance.messages) || []); + if (!msgs.length) return; - let write = m => process.env.__PERCY_BROWSERIFIED__ - ? console.log(m) : process.stderr.write(`${m}\n`); - let logs = Array.from(helpers.messages); + let log = m => process.env.__PERCY_BROWSERIFIED__ ? ( + console.log.and ? console.log.and.originalFn(m) : console.log(m) + ) : process.stderr.write(`${m}\n`); logger.loglevel('debug'); + log(logger.format('testing', 'warn', '--- DUMPING LOGS ---')); - write(logger.format('testing', 'warn', '--- DUMPING LOGS ---')); - - logs.reduce((lastlog, { debug, level, message, timestamp }) => { - write(logger.format(debug, level, message, timestamp - lastlog)); + msgs.reduce((last, { debug, level, message, timestamp }) => { + log(logger.format(debug, level, message, timestamp - last)); return timestamp; - }, logs[0].timestamp); + }, msgs[0].timestamp); } }; diff --git a/packages/logger/test/logger.test.js b/packages/logger/test/logger.test.js index 94fddce9d..92004b5cb 100644 --- a/packages/logger/test/logger.test.js +++ b/packages/logger/test/logger.test.js @@ -1,13 +1,15 @@ import helpers from './helpers'; -import { colors } from '../src/util'; +import { colors } from '../src/utils'; import logger from '../src'; describe('logger', () => { - let log; + let log, inst; + + beforeEach(async () => { + await helpers.mock({ ansi: true }); - beforeEach(() => { - helpers.mock({ ansi: true }); log = logger('test'); + inst = logger.Logger.instance; }); afterEach(() => { @@ -44,7 +46,7 @@ describe('logger', () => { meta }); - expect(helpers.messages).toEqual(new Set([ + expect(inst.messages).toEqual(new Set([ entry('info', 'Info log', { foo: 'bar' }), entry('warn', 'Warn log', { bar: 'baz' }), entry('error', 'Error log', { to: 'be' }), @@ -85,7 +87,7 @@ describe('logger', () => { let error = new Error('test'); log.error(error); - expect(helpers.messages).toContain({ + expect(inst.messages).toContain({ debug: 'test', level: 'error', message: error.stack, @@ -245,7 +247,7 @@ describe('logger', () => { }); it('logs elapsed time when loglevel is "debug"', async () => { - helpers.mock({ elapsed: true }); + await helpers.mock({ elapsed: true }); logger.loglevel('debug'); log = logger('test'); @@ -274,9 +276,9 @@ describe('logger', () => { helpers.reset(); }); - it('enables debug logging when PERCY_DEBUG is defined', () => { + it('enables debug logging when PERCY_DEBUG is defined', async () => { process.env.PERCY_DEBUG = '*'; - helpers.mock({ ansi: true }); + await helpers.mock({ ansi: true }); logger('test').debug('Debug log'); @@ -286,9 +288,9 @@ describe('logger', () => { ]); }); - it('filters specific logs for debugging', () => { + it('filters specific logs for debugging', async () => { process.env.PERCY_DEBUG = 'test:*,-test:2,'; - helpers.mock({ ansi: true }); + await helpers.mock({ ansi: true }); logger('test').debug('Debug test'); logger('test:1').debug('Debug test 1'); @@ -302,9 +304,9 @@ describe('logger', () => { ]); }); - it('does not do anything when PERCY_DEBUG is blank', () => { + it('does not do anything when PERCY_DEBUG is blank', async () => { process.env.PERCY_DEBUG = ' '; - helpers.mock({ ansi: true }); + await helpers.mock({ ansi: true }); logger('test').debug('Debug log'); diff --git a/packages/logger/test/remote.test.js b/packages/logger/test/remote.test.js index 2104337a3..48ed20fb3 100644 --- a/packages/logger/test/remote.test.js +++ b/packages/logger/test/remote.test.js @@ -14,11 +14,12 @@ class MockSocket { } describe('remote logging', () => { - let log, socket; + let log, inst, socket; beforeEach(async () => { - helpers.mock(); + await helpers.mock(); log = logger('remote'); + inst = logger.Logger.instance; socket = new MockSocket(); }); @@ -206,20 +207,19 @@ describe('remote logging', () => { }); it('does not connect to more than one socket', async () => { - let { instance } = helpers.constructor; let socket2 = new MockSocket(); socket.readyState = 1; socket2.readyState = 1; - expect(instance.socket).toBeUndefined(); + expect(inst.socket).toBeUndefined(); await logger.remote(() => socket); - expect(instance.socket).toBe(socket); + expect(inst.socket).toBe(socket); await logger.remote(() => socket2); - expect(instance.socket).not.toBe(socket2); - expect(instance.socket).toBe(socket); + expect(inst.socket).not.toBe(socket2); + expect(inst.socket).toBe(socket); }); it('can accept incoming connections and sends env info', () => { @@ -240,7 +240,7 @@ describe('remote logging', () => { send({ foo: 'bar' }); expect(helpers.stdout).toEqual(['[percy] Test 2']); - expect(helpers.messages).toEqual(new Set([{ + expect(inst.messages).toEqual(new Set([{ debug: 'test1', level: 'warn', message: 'Test 1' diff --git a/packages/sdk-utils/package.json b/packages/sdk-utils/package.json index 1d7e12f24..c962b82ea 100644 --- a/packages/sdk-utils/package.json +++ b/packages/sdk-utils/package.json @@ -10,17 +10,23 @@ "publishConfig": { "access": "public" }, - "main": "dist/index.js", - "browser": "dist/bundle.js", - "files": [ - "dist", - "test/helpers.js", - "test/server.js", - "test/client.js" - ], "engines": { "node": ">=12" }, + "files": [ + "./dist", + "./test/server.js", + "./test/client.js", + "./test/helpers.js" + ], + "main": "./dist/index.js", + "browser": "./dist/bundle.js", + "exports": { + ".": "./dist/index.js", + "./test/server": "./test/server.js", + "./test/client": "./test/client.js", + "./test/helpers": "./test/helpers.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 516606471..460cc9ade 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -5,16 +5,10 @@ import isPercyEnabled from './percy-enabled'; import fetchPercyDOM from './percy-dom'; import postSnapshot from './post-snapshot'; -export { - logger, - percy, - request, - isPercyEnabled, - fetchPercyDOM, - postSnapshot -}; +// export the namespace by default +export * as default from '.'; -export default { +export { logger, percy, request, diff --git a/packages/sdk-utils/test/server.js b/packages/sdk-utils/test/server.js index 02874e669..6729904f5 100644 --- a/packages/sdk-utils/test/server.js +++ b/packages/sdk-utils/test/server.js @@ -1,6 +1,6 @@ // create a testing context for mocking the local percy server and a local testing site function context() { - let createTestServer = require('@percy/core/test/helpers/server'); + let { createTestServer } = require('@percy/core/test/helpers'); let ctx = { async call(path, ...args) { @@ -185,7 +185,7 @@ if (require.main === module) { let logger; if (existsSync(path.join(__dirname, '../src'))) { require('../../../scripts/babel-register'); - logger = require('@percy/logger/src'); + logger = require('../../logger/src'); } else { logger = require('@percy/logger'); } diff --git a/scripts/test-helpers.js b/scripts/test-helpers.js index 096686f24..b5e48537f 100644 --- a/scripts/test-helpers.js +++ b/scripts/test-helpers.js @@ -1,10 +1,14 @@ /* eslint-env jasmine */ /* eslint-disable import/no-extraneous-dependencies */ +const env = jasmine.getEnv(); beforeAll(() => { // default timeout to 10s jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; + // allow re-spying + env.allowRespy(true); + // add or patch missing or broken matchers jasmine.addMatchers({ // If any property within the path is not defined, it will show a failure rather than error @@ -53,7 +57,7 @@ const { DUMP_FAILED_TEST_LOGS } = ( if (DUMP_FAILED_TEST_LOGS) { // add a spec reporter to dump failed logs - jasmine.getEnv().addReporter({ + env.addReporter({ specDone: ({ status }) => { let logger = typeof window !== 'undefined' ? (window.PercyLogger && window.PercyLogger.TestHelpers) || diff --git a/yarn.lock b/yarn.lock index 1e851102f..36b1e3e87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -371,7 +371,7 @@ "@babel/helper-remap-async-to-generator" "^7.16.8" "@babel/plugin-syntax-async-generators" "^7.8.4" -"@babel/plugin-proposal-class-properties@^7.14.5", "@babel/plugin-proposal-class-properties@^7.16.7": +"@babel/plugin-proposal-class-properties@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.16.7.tgz#925cad7b3b1a2fcea7e59ecc8eb5954f961f91b0" integrity sha512-IobU0Xme31ewjYOShSIqd/ZGM/r/cuOz2z0MDbNrhF5FW+ZVgi0f2lyeoj9KFPDOAqsYxmLWZte1WOwlvY9aww== @@ -3912,11 +3912,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-caller-file@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" - integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== - get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -4085,7 +4080,7 @@ globals@^13.6.0, globals@^13.9.0: dependencies: type-fest "^0.20.2" -globby@^11.0.1, globby@^11.0.2, globby@^11.0.4: +globby@^11.0.1, globby@^11.0.2: version "11.0.4" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== @@ -5504,14 +5499,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mock-require@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/mock-require/-/mock-require-3.0.3.tgz#ccd544d9eae81dd576b3f219f69ec867318a1946" - integrity sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg== - dependencies: - get-caller-file "^1.0.2" - normalize-path "^2.1.1" - modify-values@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -5660,13 +5647,6 @@ normalize-package-data@^3.0.0: semver "^7.3.2" validate-npm-package-license "^3.0.1" -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -6373,14 +6353,6 @@ queue@6.0.2: dependencies: inherits "~2.0.3" -quibble@^0.6.8: - version "0.6.8" - resolved "https://registry.yarnpkg.com/quibble/-/quibble-0.6.8.tgz#cd7d485e5fd985217b8f064596523a3ee554ec00" - integrity sha512-HQ89ZADQ4uZjyePn1yACZADE3OUQ16Py5gkVxcoPvV6IRiItAgGMBkeQ5f1rOnnnsVKccMdhic0xABd7+cY/jA== - dependencies: - lodash "^4.17.21" - resolve "^1.20.0" - quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -6625,11 +6597,6 @@ release-zalgo@^1.0.0: dependencies: es6-error "^4.0.1" -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - repeating@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"