diff --git a/packages/cli-app/README.md b/packages/cli-app/README.md index 8d752e374..d0de3713d 100644 --- a/packages/cli-app/README.md +++ b/packages/cli-app/README.md @@ -33,6 +33,7 @@ Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) -h, --help Display command help ``` diff --git a/packages/cli-build/README.md b/packages/cli-build/README.md index a6f2ab2d3..230f90c73 100644 --- a/packages/cli-build/README.md +++ b/packages/cli-build/README.md @@ -7,6 +7,10 @@ Commands for interacting with Percy builds * [`percy build:finalize`](#percy-buildfinalize) * [`percy build:wait`](#percy-buildwait) * [`percy build:id`](#percy-buildid) +* [`percy build:approve`](#percy-buildapprove) +* [`percy build:unapprove`](#percy-buildunapprove) +* [`percy build:reject`](#percy-buildreject) +* [`percy build:delete`](#percy-builddelete) ### `percy build:finalize` @@ -17,10 +21,11 @@ Usage: $ percy build:finalize [options] Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help ``` ### `percy build:wait` @@ -38,12 +43,14 @@ Options: -t, --timeout Timeout before exiting without updates, defaults to 10 minutes -i, --interval Interval at which to poll for updates, defaults to 10 second -f, --fail-on-changes Exit with an error when diffs are found - --pass-if-approved Doesn't Exit with an error if the build is approved, requires '--fail-on-changes' + --pass-if-approved Doesn't exit with an error if the build is approved, regardless of if + diffs are found. Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) -h, --help Display command help Examples: @@ -60,12 +67,129 @@ Usage: $ percy build:id [options] Percy options: - -P, --port [number] Local CLI server port (default: 5338) + -P, --port [number] Local CLI server port (default: 5338) + +Global options: + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help +``` + +### `percy build:approve` + +Approve Percy builds + +``` +Usage: + $ percy build:approve [options] + +Arguments: + build-id Build ID to approve + +Options: + --username Username for authentication (can also be set via BROWSERSTACK_USERNAME env + var) + --access-key Access key for authentication (can also be set via BROWSERSTACK_ACCESS_KEY + env var) + +Global options: + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help + +Examples: + $ percy build:approve + $ percy build:approve --username username --access-key **key** +``` + +### `percy build:unapprove` + +Unapprove Percy builds + +``` +Usage: + $ percy build:unapprove [options] + +Arguments: + build-id Build ID to approve + +Options: + --username Username for authentication (can also be set via BROWSERSTACK_USERNAME env + var) + --access-key Access key for authentication (can also be set via BROWSERSTACK_ACCESS_KEY + env var) + +Global options: + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help + +Examples: + $ percy build:unapprove + $ percy build:unapprove --username username --access-key **key** +``` + +### `percy build:reject` + +Reject Percy builds + +``` +Usage: + $ percy build:reject [options] + +Arguments: + build-id Build ID to approve + +Options: + --username Username for authentication (can also be set via BROWSERSTACK_USERNAME env + var) + --access-key Access key for authentication (can also be set via BROWSERSTACK_ACCESS_KEY + env var) + +Global options: + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help + +Examples: + $ percy build:reject + $ percy build:reject --username username --access-key **key** +``` + +### `percy build:delete` + +Delete Percy builds + +``` +Usage: + $ percy build:delete [options] + +Arguments: + build-id Build ID to approve + +Options: + --username Username for authentication (can also be set via BROWSERSTACK_USERNAME env + var) + --access-key Access key for authentication (can also be set via BROWSERSTACK_ACCESS_KEY + env var) Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help + +Examples: + $ percy build:delete + $ percy build:delete --username username --access-key **key** ``` diff --git a/packages/cli-build/package.json b/packages/cli-build/package.json index caaf7c99f..63d8ae88b 100644 --- a/packages/cli-build/package.json +++ b/packages/cli-build/package.json @@ -19,7 +19,10 @@ ], "main": "./dist/index.js", "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./utils": "./dist/utils.js" + }, "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", diff --git a/packages/cli-build/src/approve.js b/packages/cli-build/src/approve.js new file mode 100644 index 000000000..afcd95495 --- /dev/null +++ b/packages/cli-build/src/approve.js @@ -0,0 +1,49 @@ +import command from '@percy/cli-command'; +import { fetchCredentials, reviewCommandConfig } from './utils.js'; + +/** + * Approve command definition for Percy builds + * Allows users to approve builds using build ID and authentication credentials + */ +export const approve = command('approve', { + description: 'Approve Percy builds', + ...reviewCommandConfig +}, async ({ flags, args, percy, log, exit }) => { + // Early return if Percy is disabled + if (!percy) { + exit(0, 'Percy is disabled'); + } + + // Validate and get authentication credentials + const { username, accessKey } = fetchCredentials(flags); + + if (!username || !accessKey) { + exit(1, 'Username and access key are required to approve builds.'); + } + + log.info(`Approving build ${args.buildId}...`); + + try { + // Call the Percy API to approve the build + const buildApprovalResponse = await percy.client.approveBuild( + args.buildId, + username, + accessKey + ); + + const approvedBy = buildApprovalResponse.data.attributes['action-performed-by'] || { + user_email: 'unknown@example.com', + user_name: username + }; + log.info(`Build ${args.buildId} approved successfully!`); + log.info(`Approved by: ${approvedBy.user_name} (${approvedBy.user_email})`); + } catch (error) { + log.error(`Failed to approve build ${args.buildId}`); + log.error(error); + + // Provide user-friendly error message + exit(1, 'Failed to approve the build'); + } +}); + +export default approve; diff --git a/packages/cli-build/src/build.js b/packages/cli-build/src/build.js index b3f00a928..8b4549086 100644 --- a/packages/cli-build/src/build.js +++ b/packages/cli-build/src/build.js @@ -2,10 +2,14 @@ import command from '@percy/cli-command'; import finalize from './finalize.js'; import wait from './wait.js'; import id from './id.js'; +import approve from './approve.js'; +import reject from './reject.js'; +import unapprove from './unapprove.js'; +import deleteBuild from './delete.js'; export const build = command('build', { description: 'Finalize and wait on Percy builds', - commands: [finalize, wait, id] + commands: [finalize, wait, id, approve, unapprove, reject, deleteBuild] }); export default build; diff --git a/packages/cli-build/src/delete.js b/packages/cli-build/src/delete.js new file mode 100644 index 000000000..75b1e56a0 --- /dev/null +++ b/packages/cli-build/src/delete.js @@ -0,0 +1,49 @@ +import command from '@percy/cli-command'; +import { fetchCredentials, reviewCommandConfig } from './utils.js'; + +/** + * Delete command definition for Percy builds + * Allows users to delete builds using build ID and authentication credentials + */ +export const deleteBuild = command('delete', { + description: 'Delete Percy builds', + ...reviewCommandConfig +}, async ({ flags, args, percy, log, exit }) => { + // Early return if Percy is disabled + if (!percy) { + exit(0, 'Percy is disabled'); + } + + // Validate and get authentication credentials + const { username, accessKey } = fetchCredentials(flags); + + if (!username || !accessKey) { + exit(1, 'Username and access key are required to delete builds.'); + } + + log.info(`Deleting build ${args.buildId}...`); + + try { + // Call the Percy API to delete the build + const buildDeletionResponse = await percy.client.deleteBuild( + args.buildId, + username, + accessKey + ); + const deletedBy = buildDeletionResponse['action-performed-by'] || { + user_email: 'unknown@example.com', + user_name: username + }; + + log.info(`Build ${args.buildId} deleted successfully!`); + log.info(`Deleted by: ${deletedBy.user_name} (${deletedBy.user_email})`); + } catch (error) { + log.error(`Failed to delete build ${args.buildId}`); + log.error(error); + + // Provide user-friendly error message + exit(1, 'Failed to delete the build'); + } +}); + +export default deleteBuild; diff --git a/packages/cli-build/src/index.js b/packages/cli-build/src/index.js index 1b03bf77b..c9b63c428 100644 --- a/packages/cli-build/src/index.js +++ b/packages/cli-build/src/index.js @@ -2,3 +2,7 @@ export { default, build } from './build.js'; export { finalize } from './finalize.js'; export { wait } from './wait.js'; export { id } from './id.js'; +export { approve } from './approve.js'; +export { reject } from './reject.js'; +export { unapprove } from './unapprove.js'; +export { deleteBuild } from './delete.js'; diff --git a/packages/cli-build/src/reject.js b/packages/cli-build/src/reject.js new file mode 100644 index 000000000..1d8fc1325 --- /dev/null +++ b/packages/cli-build/src/reject.js @@ -0,0 +1,49 @@ +import command from '@percy/cli-command'; +import { fetchCredentials, reviewCommandConfig } from './utils.js'; + +/** + * Reject command definition for Percy builds + * Allows users to reject builds using build ID and authentication credentials + */ +export const reject = command('reject', { + description: 'Reject Percy builds', + ...reviewCommandConfig +}, async ({ flags, args, percy, log, exit }) => { + // Early return if Percy is disabled + if (!percy) { + exit(0, 'Percy is disabled'); + } + + // Validate and get authentication credentials + const { username, accessKey } = fetchCredentials(flags); + + if (!username || !accessKey) { + exit(1, 'Username and access key are required to reject builds.'); + } + + log.info(`Rejecting build ${args.buildId}...`); + + try { + // Call the Percy API to reject the build + const buildRejectionResponse = await percy.client.rejectBuild( + args.buildId, + username, + accessKey + ); + + const rejectedBy = buildRejectionResponse.data.attributes['action-performed-by'] || { + user_email: 'unknown@example.com', + user_name: username + }; + log.info(`Build ${args.buildId} rejected successfully!`); + log.info(`Rejected by: ${rejectedBy.user_name} (${rejectedBy.user_email})`); + } catch (error) { + log.error(`Failed to reject build ${args.buildId}`); + log.error(error); + + // Provide user-friendly error message + exit(1, 'Failed to reject the build'); + } +}); + +export default reject; diff --git a/packages/cli-build/src/unapprove.js b/packages/cli-build/src/unapprove.js new file mode 100644 index 000000000..207d9d6fa --- /dev/null +++ b/packages/cli-build/src/unapprove.js @@ -0,0 +1,49 @@ +import command from '@percy/cli-command'; +import { fetchCredentials, reviewCommandConfig } from './utils.js'; + +/** + * Unapprove command definition for Percy builds + * Allows users to unapprove builds using build ID and authentication credentials + */ +export const unapprove = command('unapprove', { + description: 'Unapprove Percy builds', + ...reviewCommandConfig +}, async ({ flags, args, percy, log, exit }) => { + // Early return if Percy is disabled + if (!percy) { + exit(0, 'Percy is disabled'); + } + + // Validate and get authentication credentials + const { username, accessKey } = fetchCredentials(flags); + + if (!username || !accessKey) { + exit(1, 'Username and access key are required to unapprove builds.'); + } + + log.info(`Unapproving build ${args.buildId}...`); + + try { + // Call the Percy API to unapprove the build + const buildUnapprovalResponse = await percy.client.unapproveBuild( + args.buildId, + username, + accessKey + ); + + const unapprovedBy = buildUnapprovalResponse.data.attributes['action-performed-by'] || { + user_email: 'unknown@example.com', + user_name: username + }; + log.info(`Build ${args.buildId} unapproved successfully!`); + log.info(`Unapproved by: ${unapprovedBy.user_name} (${unapprovedBy.user_email})`); + } catch (error) { + log.error(`Failed to unapprove build ${args.buildId}`); + log.error(error); + + // Provide user-friendly error message + exit(1, 'Failed to unapprove the build'); + } +}); + +export default unapprove; diff --git a/packages/cli-build/src/utils.js b/packages/cli-build/src/utils.js new file mode 100644 index 000000000..fd15b5387 --- /dev/null +++ b/packages/cli-build/src/utils.js @@ -0,0 +1,54 @@ +/** + * Constants for environment variable names and error messages + */ +const ENV_VARS = { + BROWSERSTACK_USERNAME: 'BROWSERSTACK_USERNAME', + BROWSERSTACK_ACCESS_KEY: 'BROWSERSTACK_ACCESS_KEY' +}; + +/** + * Validates that required authentication credentials are present + * @param {Object} flags - Command flags object + * @returns {Object} Validated credentials object + */ +export function fetchCredentials(flags) { + // Use flags if provided, otherwise fallback to environment variables + const username = flags.username || process.env[ENV_VARS.BROWSERSTACK_USERNAME]; + const accessKey = flags.accessKey || process.env[ENV_VARS.BROWSERSTACK_ACCESS_KEY]; + + return { username, accessKey }; +} + +/** * Configuration for review commands (approve, reject, unapprove) + * Contains common arguments and flags used across these commands + */ +export const reviewCommandConfig = { + args: [ + { + name: 'build-id', + description: 'Build ID to approve', + type: 'id', + required: true + } + ], + + flags: [ + { + name: 'username', + description: 'Username for authentication (can also be set via BROWSERSTACK_USERNAME env var)', + type: 'string' + }, + { + name: 'access-key', + description: 'Access key for authentication (can also be set via BROWSERSTACK_ACCESS_KEY env var)', + type: 'string' + } + ], + + examples: [ + '$0 ', + '$0 --username username --access-key **key**' + ], + + percy: true +}; diff --git a/packages/cli-build/test/approve.test.js b/packages/cli-build/test/approve.test.js new file mode 100644 index 000000000..3ba7e200e --- /dev/null +++ b/packages/cli-build/test/approve.test.js @@ -0,0 +1,312 @@ +import { logger, setupTest } from '@percy/cli-command/test/helpers'; +import { base64encode } from '@percy/client/utils'; +import api from '@percy/client/test/helpers'; +import { approve } from '@percy/cli-build'; + +describe('percy build:approve', () => { + let successResponse = { + data: { + attributes: { + action: 'approve', + 'action-performed-by': { + user_email: 'test@test.com', + user_name: 'testuser' + } + } + } + }; + + beforeEach(async () => { + await setupTest(); + }); + + afterEach(() => { + delete process.env.PERCY_ENABLE; + delete process.env.BROWSERSTACK_USERNAME; + delete process.env.BROWSERSTACK_ACCESS_KEY; + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_FORCE_PKG_VALUE; + }); + + it('does nothing and logs when percy is not enabled', async () => { + process.env.PERCY_ENABLE = '0'; + await approve(['123']); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Percy is disabled' + ]); + }); + + it('logs an error when build ID is not provided', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(approve([])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + "[percy] ParseError: Missing required argument 'build-id'" + ]); + }); + + it('logs an error when username is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + await expectAsync(approve(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to approve builds.' + ]); + }); + + it('logs an error when access key is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + await expectAsync(approve(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to approve builds.' + ]); + }); + + it('logs an error when both username and access key are missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(approve(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to approve builds.' + ]); + }); + + it('uses username and access key from environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'approve' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:env-access-key')}`); + return [200, successResponse]; + }); + + await approve(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: testuser (test@test.com)' + ]); + }); + + it('doesnot require percy token', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => [200, successResponse]); + + await approve(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: testuser (test@test.com)' + ]); + }); + + it('uses username and access key from flags over environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await approve(['123', '--username=flag-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: testuser (test@test.com)' + ]); + }); + + it('handles mixed flag and environment variable usage', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + // Only access key from flag + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await approve(['123', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: testuser (test@test.com)' + ]); + }); + + it('handles username from flag and access key from environment', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + // Only username from flag + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'approve' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:env-access-key')}`); + return [200, successResponse]; + }); + + await approve(['123', '--username=flag-username']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: testuser (test@test.com)' + ]); + }); + + it('logs an error when build approval fails with 401 Unauthorized', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'invalid-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'invalid-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'approve' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('invalid-username:invalid-access-key')}`); + return [401, { errors: [{ detail: 'Unauthorized' }] }]; + }); + + await expectAsync(approve(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to approve build 123', + '[percy] Error: Unauthorized', + '[percy] Error: Failed to approve the build' + ]); + }); + + it('logs an error when build approval fails with 403 Forbidden', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'approve' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + return [403, { errors: [{ detail: 'Forbidden' }] }]; + }); + + await expectAsync(approve(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to approve build 123', + '[percy] Error: Forbidden', + '[percy] Error: Failed to approve the build' + ]); + }); + + it('uses fallback user info when latest-action-performed-by is not in response', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + + api.reply('/reviews', (req) => [200, { + data: { + attributes: { + action: 'approve' + } + } + }] + ); + + await approve(['123', '--username=temp-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Approving build 123...', + '[percy] Build 123 approved successfully!', + '[percy] Approved by: temp-username (unknown@example.com)' + ]); + }); +}); diff --git a/packages/cli-build/test/delete.test.js b/packages/cli-build/test/delete.test.js new file mode 100644 index 000000000..3d0817182 --- /dev/null +++ b/packages/cli-build/test/delete.test.js @@ -0,0 +1,241 @@ +import { logger, setupTest } from '@percy/cli-command/test/helpers'; +import { base64encode } from '@percy/client/utils'; +import api from '@percy/client/test/helpers'; +import { deleteBuild } from '@percy/cli-build'; + +describe('percy build:delete', () => { + let successResponse = { + success: true, + 'action-performed-by': { user_email: 'test@test.com', user_name: 'testuser' } + }; + + beforeEach(async () => { + await setupTest(); + }); + + afterEach(() => { + delete process.env.PERCY_ENABLE; + delete process.env.BROWSERSTACK_USERNAME; + delete process.env.BROWSERSTACK_ACCESS_KEY; + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_FORCE_PKG_VALUE; + }); + + it('does nothing and logs when percy is not enabled', async () => { + process.env.PERCY_ENABLE = '0'; + await deleteBuild(['123']); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Percy is disabled' + ]); + }); + + it('logs an error when build ID is not provided', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(deleteBuild([])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + "[percy] ParseError: Missing required argument 'build-id'" + ]); + }); + + it('logs an error when username is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + await expectAsync(deleteBuild(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to delete builds.' + ]); + }); + + it('logs an error when access key is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + await expectAsync(deleteBuild(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to delete builds.' + ]); + }); + + it('logs an error when both username and access key are missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(deleteBuild(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to delete builds.' + ]); + }); + + it('uses username and access key from environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/builds/123/delete', (req) => { + expect(req.body).toEqual({}); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:env-access-key')}`); + return [200, successResponse]; + }); + + await deleteBuild(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: testuser (test@test.com)' + ]); + }); + + it('doesnot require percy token', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/builds/123/delete', (req) => [200, successResponse]); + + await deleteBuild(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: testuser (test@test.com)' + ]); + }); + + it('uses username and access key from flags over environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/builds/123/delete', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await deleteBuild(['123', '--username=flag-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: testuser (test@test.com)' + ]); + }); + + it('handles mixed flag and environment variable usage', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + // Only access key from flag + + api.reply('/builds/123/delete', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await deleteBuild(['123', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: testuser (test@test.com)' + ]); + }); + + it('handles username from flag and access key from environment', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + // Only username from flag + + api.reply('/builds/123/delete', (req) => { + expect(req.body).toEqual({}); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:env-access-key')}`); + return [200, successResponse]; + }); + + await deleteBuild(['123', '--username=flag-username']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: testuser (test@test.com)' + ]); + }); + + it('logs an error when build deletion fails with 401 Unauthorized', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'invalid-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'invalid-access-key'; + + api.reply('/builds/123/delete', (req) => { + expect(req.body).toEqual({}); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('invalid-username:invalid-access-key')}`); + return [401, { errors: [{ detail: 'Unauthorized' }] }]; + }); + + await expectAsync(deleteBuild(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to delete build 123', + '[percy] Error: Unauthorized', + '[percy] Error: Failed to delete the build' + ]); + }); + + it('logs an error when build deletion fails with 403 Forbidden', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + + api.reply('/builds/123/delete', (req) => { + expect(req.body).toEqual({}); + return [403, { errors: [{ detail: 'Forbidden' }] }]; + }); + + await expectAsync(deleteBuild(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to delete build 123', + '[percy] Error: Forbidden', + '[percy] Error: Failed to delete the build' + ]); + }); + + it('uses fallback user info when latest-action-performed-by is not in response', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + + api.reply('/builds/123/delete', (req) => { + expect(req.body).toEqual({}); + return [200, { success: true }]; + }); + + await expectAsync(deleteBuild(['123', '--username=temp-username', '--access-key=flag-access-key'])).toBeResolved(); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Deleting build 123...', + '[percy] Build 123 deleted successfully!', + '[percy] Deleted by: temp-username (unknown@example.com)' + ]); + }); +}); diff --git a/packages/cli-build/test/reject.test.js b/packages/cli-build/test/reject.test.js new file mode 100644 index 000000000..0602d6e3d --- /dev/null +++ b/packages/cli-build/test/reject.test.js @@ -0,0 +1,312 @@ +import { logger, setupTest } from '@percy/cli-command/test/helpers'; +import { base64encode } from '@percy/client/utils'; +import api from '@percy/client/test/helpers'; +import { reject } from '@percy/cli-build'; + +describe('percy build:reject', () => { + let successResponse = { + data: { + attributes: { + action: 'approve', + 'action-performed-by': { + user_email: 'test@test.com', + user_name: 'testuser' + } + } + } + }; + + beforeEach(async () => { + await setupTest(); + }); + + afterEach(() => { + delete process.env.PERCY_ENABLE; + delete process.env.BROWSERSTACK_USERNAME; + delete process.env.BROWSERSTACK_ACCESS_KEY; + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_FORCE_PKG_VALUE; + }); + + it('does nothing and logs when percy is not enabled', async () => { + process.env.PERCY_ENABLE = '0'; + await reject(['123']); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Percy is disabled' + ]); + }); + + it('logs an error when build ID is not provided', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(reject([])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + "[percy] ParseError: Missing required argument 'build-id'" + ]); + }); + + it('logs an error when username is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + await expectAsync(reject(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to reject builds.' + ]); + }); + + it('logs an error when access key is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + await expectAsync(reject(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to reject builds.' + ]); + }); + + it('logs an error when both username and access key are missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(reject(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to reject builds.' + ]); + }); + + it('uses username and access key from environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'reject' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:env-access-key')}`); + return [200, successResponse]; + }); + + await reject(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: testuser (test@test.com)' + ]); + }); + + it('doesnot require percy token', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => [200, successResponse]); + + await reject(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: testuser (test@test.com)' + ]); + }); + + it('uses username and access key from flags over environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await reject(['123', '--username=flag-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: testuser (test@test.com)' + ]); + }); + + it('handles mixed flag and environment variable usage', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + // Only access key from flag + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await reject(['123', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: testuser (test@test.com)' + ]); + }); + + it('handles username from flag and access key from environment', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + // Only username from flag + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'reject' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:env-access-key')}`); + return [200, successResponse]; + }); + + await reject(['123', '--username=flag-username']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: testuser (test@test.com)' + ]); + }); + + it('logs an error when build approval fails with 401 Unauthorized', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'invalid-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'invalid-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'reject' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('invalid-username:invalid-access-key')}`); + return [401, { errors: [{ detail: 'Unauthorized' }] }]; + }); + + await expectAsync(reject(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to reject build 123', + '[percy] Error: Unauthorized', + '[percy] Error: Failed to reject the build' + ]); + }); + + it('logs an error when build approval fails with 403 Forbidden', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'reject' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + return [403, { errors: [{ detail: 'Forbidden' }] }]; + }); + + await expectAsync(reject(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to reject build 123', + '[percy] Error: Forbidden', + '[percy] Error: Failed to reject the build' + ]); + }); + + it('uses fallback user info when latest-action-performed-by is not in response', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + + api.reply('/reviews', (req) => [200, { + data: { + attributes: { + action: 'reject' + } + } + }] + ); + + await reject(['123', '--username=temp-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Rejecting build 123...', + '[percy] Build 123 rejected successfully!', + '[percy] Rejected by: temp-username (unknown@example.com)' + ]); + }); +}); diff --git a/packages/cli-build/test/unapprove.test.js b/packages/cli-build/test/unapprove.test.js new file mode 100644 index 000000000..101fe978c --- /dev/null +++ b/packages/cli-build/test/unapprove.test.js @@ -0,0 +1,312 @@ +import { logger, setupTest } from '@percy/cli-command/test/helpers'; +import { base64encode } from '@percy/client/utils'; +import api from '@percy/client/test/helpers'; +import { unapprove } from '@percy/cli-build'; + +describe('percy build:unapprove', () => { + let successResponse = { + data: { + attributes: { + action: 'unapprove', + 'action-performed-by': { + user_email: 'test@test.com', + user_name: 'testuser' + } + } + } + }; + + beforeEach(async () => { + await setupTest(); + }); + + afterEach(() => { + delete process.env.PERCY_ENABLE; + delete process.env.BROWSERSTACK_USERNAME; + delete process.env.BROWSERSTACK_ACCESS_KEY; + delete process.env.PERCY_TOKEN; + delete process.env.PERCY_FORCE_PKG_VALUE; + }); + + it('does nothing and logs when percy is not enabled', async () => { + process.env.PERCY_ENABLE = '0'; + await unapprove(['123']); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Percy is disabled' + ]); + }); + + it('logs an error when build ID is not provided', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(unapprove([])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + "[percy] ParseError: Missing required argument 'build-id'" + ]); + }); + + it('logs an error when username is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + await expectAsync(unapprove(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to unapprove builds.' + ]); + }); + + it('logs an error when access key is missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + await expectAsync(unapprove(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to unapprove builds.' + ]); + }); + + it('logs an error when both username and access key are missing', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + await expectAsync(unapprove(['123'])).toBeRejected(); + + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Error: Username and access key are required to unapprove builds.' + ]); + }); + + it('uses username and access key from environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'unapprove' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:env-access-key')}`); + return [200, successResponse]; + }); + + await unapprove(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: testuser (test@test.com)' + ]); + }); + + it('doesnot require percy token', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => [200, successResponse]); + + await unapprove(['123']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: testuser (test@test.com)' + ]); + }); + + it('uses username and access key from flags over environment variables', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await unapprove(['123', '--username=flag-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: testuser (test@test.com)' + ]); + }); + + it('handles mixed flag and environment variable usage', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'env-username'; + // Only access key from flag + + api.reply('/reviews', (req) => { + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('env-username:flag-access-key')}`); + return [200, successResponse]; + }); + + await unapprove(['123', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: testuser (test@test.com)' + ]); + }); + + it('handles username from flag and access key from environment', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_ACCESS_KEY = 'env-access-key'; + // Only username from flag + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'unapprove' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('flag-username:env-access-key')}`); + return [200, successResponse]; + }); + + await unapprove(['123', '--username=flag-username']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: testuser (test@test.com)' + ]); + }); + + it('logs an error when build approval fails with 401 Unauthorized', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'invalid-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'invalid-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'unapprove' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + expect(req.headers.Authorization).toEqual(`Basic ${base64encode('invalid-username:invalid-access-key')}`); + return [401, { errors: [{ detail: 'Unauthorized' }] }]; + }); + + await expectAsync(unapprove(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to unapprove build 123', + '[percy] Error: Unauthorized', + '[percy] Error: Failed to unapprove the build' + ]); + }); + + it('logs an error when build approval fails with 403 Forbidden', async () => { + process.env.PERCY_TOKEN = '<>'; + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + process.env.BROWSERSTACK_USERNAME = 'test-username'; + process.env.BROWSERSTACK_ACCESS_KEY = 'test-access-key'; + + api.reply('/reviews', (req) => { + expect(req.body).toEqual({ + data: { + type: 'reviews', + attributes: { + action: 'unapprove' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + } + } + }); + return [403, { errors: [{ detail: 'Forbidden' }] }]; + }); + + await expectAsync(unapprove(['123'])).toBeRejected(); + + expect(logger.stderr).toEqual([ + '[percy] Failed to unapprove build 123', + '[percy] Error: Forbidden', + '[percy] Error: Failed to unapprove the build' + ]); + }); + + it('uses fallback user info when latest-action-performed-by is not in response', async () => { + process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' }); + + api.reply('/reviews', (req) => [200, { + data: { + attributes: { + action: 'unapprove' + } + } + }] + ); + + await unapprove(['123', '--username=temp-username', '--access-key=flag-access-key']); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Unapproving build 123...', + '[percy] Build 123 unapproved successfully!', + '[percy] Unapproved by: temp-username (unknown@example.com)' + ]); + }); +}); diff --git a/packages/cli-config/README.md b/packages/cli-config/README.md index a3068ae69..1eaa0652b 100644 --- a/packages/cli-config/README.md +++ b/packages/cli-config/README.md @@ -18,20 +18,21 @@ Usage: $ percy config:create [options] [filepath] Arguments: - filepath Optional config filepath + filepath Optional config filepath Options: - --rc Create a .percyrc file - --yaml Create a .percy.yaml file - --yml Create a .percy.yml file - --json Create a .percy.json file - --js Create a .percy.js file + --rc Create a .percyrc file + --yaml Create a .percy.yaml file + --yml Create a .percy.yml file + --json Create a .percy.json file + --js Create a .percy.js file Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help Examples: $ percy config:create @@ -51,13 +52,14 @@ Usage: $ percy config:validate [options] [filepath] Arguments: - filepath Config filepath, detected by default + filepath Config filepath, detected by default Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help Examples: $ percy config:validate @@ -73,17 +75,18 @@ Usage: $ percy config:migrate [options] [filepath] [output] Arguments: - filepath Current config filepath, detected by default - output New config filepath to write to, defaults to 'filepath' + filepath Current config filepath, detected by default + output New config filepath to write to, defaults to 'filepath' Options: - -d, --dry-run Print the new config without writing it + -d, --dry-run Print the new config without writing it Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help Examples: $ percy config:migrate diff --git a/packages/cli-exec/README.md b/packages/cli-exec/README.md index 92d9c1c38..93ef12762 100644 --- a/packages/cli-exec/README.md +++ b/packages/cli-exec/README.md @@ -41,6 +41,7 @@ Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) --help Display command help Examples: @@ -70,6 +71,7 @@ Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) --help Display command help Examples: @@ -85,13 +87,14 @@ Usage: $ percy exec:stop [options] Percy options: - -P, --port [number] Local CLI server port (default: 5338) + -P, --port [number] Local CLI server port (default: 5338) Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help ``` ### `percy exec:ping` @@ -103,12 +106,13 @@ Usage: $ percy exec:ping [options] Percy options: - -P, --port [number] Local CLI server port (default: 5338) + -P, --port [number] Local CLI server port (default: 5338) Global options: - -v, --verbose Log everything - -q, --quiet Log errors only - -s, --silent Log nothing - -h, --help Display command help + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) + -h, --help Display command help ``` diff --git a/packages/cli-snapshot/README.md b/packages/cli-snapshot/README.md index 93e37d4c8..4426c078e 100644 --- a/packages/cli-snapshot/README.md +++ b/packages/cli-snapshot/README.md @@ -38,6 +38,7 @@ Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) --help Display command help Examples: diff --git a/packages/cli-upload/README.md b/packages/cli-upload/README.md index 1265bd8e1..faf6e6ddd 100644 --- a/packages/cli-upload/README.md +++ b/packages/cli-upload/README.md @@ -31,6 +31,7 @@ Global options: -v, --verbose Log everything -q, --quiet Log errors only -s, --silent Log nothing + -l, --labels Associates labels to the build (ex: --labels=dev,prod ) -h, --help Display command help Examples: diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 1afbd5292..c361626c4 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -133,9 +133,9 @@ export class PercyClient { // Returns common headers used for each request with additional // headers. Throws an error when the token is missing, which is a required // authorization header. - headers(headers) { + headers(headers, raiseIfMissing = true) { return Object.assign({ - Authorization: `Token token=${this.getToken()}`, + Authorization: `Token token=${this.getToken(raiseIfMissing)}`, 'User-Agent': this.userAgent() }, headers); } @@ -154,10 +154,13 @@ export class PercyClient { } // Performs a POST request to a JSON API endpoint with appropriate headers. - post(path, body = {}, { ...meta } = {}) { + post(path, body = {}, { ...meta } = {}, customHeaders = {}, raiseIfMissing = true) { return logger.measure('client:post', meta.identifier || 'Unknown', meta, () => { return request(`${this.apiUrl}/${path}`, { - headers: this.headers({ 'Content-Type': 'application/vnd.api+json' }), + headers: this.headers({ + 'Content-Type': 'application/vnd.api+json', + ...customHeaders + }, raiseIfMissing), method: 'POST', body, meta @@ -710,6 +713,67 @@ export class PercyClient { }, { identifier: 'error.analysis.get', ...meta }); } + // Performs a review action (approve, unapprove, reject) on a specific build. + // This function handles the common logic for sending review requests. + async reviewBuild(buildId, action, username, accessKey) { + validateId('build', buildId); + this.log.debug(`Sending ${action} action for build ${buildId}...`); + + const requestBody = { + data: { + attributes: { + action: action + }, + relationships: { + build: { + data: { + type: 'builds', + id: buildId + } + } + }, + type: 'reviews' + } + }; + + // For the review action, we use accessKey and username in custom headers + // and do not require a project token. + return this.post( + 'reviews', + requestBody, + { identifier: `build.${action}` }, + { Authorization: `Basic ${base64encode(`${username}:${accessKey}`)}` }, + false + ); + } + + async approveBuild(buildId, username, accessKey) { + return this.reviewBuild(buildId, 'approve', username, accessKey); + } + + async unapproveBuild(buildId, username, accessKey) { + return this.reviewBuild(buildId, 'unapprove', username, accessKey); + } + + async rejectBuild(buildId, username, accessKey) { + return this.reviewBuild(buildId, 'reject', username, accessKey); + } + + async deleteBuild(buildId, username, accessKey) { + validateId('build', buildId); + this.log.debug(`Sending Delete action for build ${buildId}...`); + + // For the delete action, we use accessKey and username in custom headers + // and do not require a project token. + return this.post( + `builds/${buildId}/delete`, + {}, + { identifier: 'build.delete' }, + { Authorization: `Basic ${base64encode(`${username}:${accessKey}`)}` }, + false + ); + } + mayBeLogUploadSize(contentSize, meta = {}) { if (contentSize >= 25 * 1024 * 1024) { this.log.error('Uploading resource above 25MB might fail the build...', meta); diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index 311e2f8a9..3a65231fc 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -165,6 +165,32 @@ describe('PercyClient', () => { expect(() => new PercyClient().post('foobar', {})) .toThrowError('Missing Percy token'); }); + + it('sends a POST request with both custom headers and projectTokenRequired=false', async () => { + const customHeaders = { + 'X-Custom-Header': 'custom-value', + 'Content-Type': 'application/json' + }; + + spyOn(client, 'headers').and.callThrough(); + + await expectAsync(client.post('foobar', { test: '123' }, {}, customHeaders, false)).toBeResolved(); + + expect(client.headers).toHaveBeenCalledWith( + jasmine.objectContaining({ + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value' + }), + false + ); + + expect(api.requests['/foobar'][0].headers).toEqual( + jasmine.objectContaining({ + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value' + }) + ); + }); }); describe('#createBuild()', () => { @@ -2173,4 +2199,264 @@ describe('PercyClient', () => { expect(client.getToken()).toBe('USE_THIS_TOKEN'); }); }); + + describe('#headers()', () => { + it('returns default headers with Authorization and User-Agent', () => { + const headers = client.headers(); + + expect(headers).toEqual({ + Authorization: 'Token token=PERCY_TOKEN', + 'User-Agent': jasmine.stringMatching(/^Percy\/v1 @percy\/client\/\S+ \(node\/v[\d.]+.*\)$/) + }); + }); + + it('merges additional headers with default headers', () => { + const additionalHeaders = { + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value' + }; + + const headers = client.headers(additionalHeaders); + + expect(headers).toEqual({ + Authorization: 'Token token=PERCY_TOKEN', + 'User-Agent': jasmine.stringMatching(/^Percy\/v1 @percy\/client\/\S+ \(node\/v[\d.]+.*\)$/), + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value' + }); + }); + + it('calls getToken with projectTokenRequired=true by default', () => { + spyOn(client, 'getToken').and.returnValue('TEST_TOKEN'); + + client.headers(); + + expect(client.getToken).toHaveBeenCalledWith(true); + }); + + it('calls getToken with projectTokenRequired=false when specified', () => { + spyOn(client, 'getToken').and.returnValue('TEST_TOKEN'); + + client.headers({}, false); + + expect(client.getToken).toHaveBeenCalledWith(false); + }); + }); + + describe('#reviewBuild()', () => { + it('sends a review request with correct parameters', async () => { + await expectAsync(client.reviewBuild('123', 'approve', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].method).toBe('POST'); + expect(api.requests['/reviews'][0].headers).toEqual( + jasmine.objectContaining({ + Authorization: `Basic ${base64encode('testuser:testkey')}` + }) + ); + expect(api.requests['/reviews'][0].body).toEqual({ + data: { + attributes: { + action: 'approve' + }, + relationships: { + build: { + data: { + type: 'builds', + id: '123' + } + } + }, + type: 'reviews' + } + }); + }); + + it('calls post with projectTokenRequired=false', async () => { + spyOn(client, 'post').and.callThrough(); + + await expectAsync(client.reviewBuild('123', 'reject', 'testuser', 'testkey')).toBeResolved(); + + expect(client.post).toHaveBeenCalledWith( + 'reviews', + jasmine.any(Object), + { identifier: 'build.reject' }, + { Authorization: `Basic ${base64encode('testuser:testkey')}` }, + false + ); + }); + + it('validates build ID', async () => { + await expectAsync(client.reviewBuild(null, 'approve', 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + + await expectAsync(client.reviewBuild('', 'approve', 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + + await expectAsync(client.reviewBuild({}, 'approve', 'testuser', 'testkey')) + .toBeRejectedWithError('Invalid build ID'); + }); + + it('logs debug message with action and build ID', async () => { + spyOn(client.log, 'debug'); + + await expectAsync(client.reviewBuild('456', 'unapprove', 'testuser', 'testkey')).toBeResolved(); + + expect(client.log.debug).toHaveBeenCalledWith('Sending unapprove action for build 456...'); + }); + + it('works with different action types', async () => { + spyOn(client, 'post').and.callThrough(); + + await expectAsync(client.reviewBuild('123', 'custom-action', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].body.data.attributes.action).toBe('custom-action'); + expect(client.post).toHaveBeenCalledWith( + 'reviews', + jasmine.any(Object), + { identifier: 'build.custom-action' }, + jasmine.any(Object), + false + ); + }); + + it('accepts numeric build ID', async () => { + await expectAsync(client.reviewBuild(123, 'approve', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].body.data.relationships.build.data.id).toBe(123); + }); + }); + + describe('#approveBuild()', () => { + it('calls reviewBuild with approve action', async () => { + spyOn(client, 'reviewBuild').and.returnValue(Promise.resolve({ success: true })); + + const result = await client.approveBuild('123', 'testuser', 'testkey'); + + expect(client.reviewBuild).toHaveBeenCalledWith('123', 'approve', 'testuser', 'testkey'); + expect(result).toEqual({ success: true }); + }); + + it('sends approve request to API', async () => { + await expectAsync(client.approveBuild('123', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].body.data.attributes.action).toBe('approve'); + expect(api.requests['/reviews'][0].headers).toEqual( + jasmine.objectContaining({ + Authorization: `Basic ${base64encode('testuser:testkey')}` + }) + ); + }); + + it('validates build ID', async () => { + await expectAsync(client.approveBuild(null, 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + }); + }); + + describe('#unapproveBuild()', () => { + it('calls reviewBuild with unapprove action', async () => { + spyOn(client, 'reviewBuild').and.returnValue(Promise.resolve({ success: true })); + + const result = await client.unapproveBuild('456', 'testuser', 'testkey'); + + expect(client.reviewBuild).toHaveBeenCalledWith('456', 'unapprove', 'testuser', 'testkey'); + expect(result).toEqual({ success: true }); + }); + + it('sends unapprove request to API', async () => { + await expectAsync(client.unapproveBuild('456', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].body.data.attributes.action).toBe('unapprove'); + expect(api.requests['/reviews'][0].headers).toEqual( + jasmine.objectContaining({ + Authorization: `Basic ${base64encode('testuser:testkey')}` + }) + ); + }); + + it('validates build ID', async () => { + await expectAsync(client.unapproveBuild('', 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + }); + }); + + describe('#rejectBuild()', () => { + it('calls reviewBuild with reject action', async () => { + spyOn(client, 'reviewBuild').and.returnValue(Promise.resolve({ success: true })); + + const result = await client.rejectBuild('789', 'testuser', 'testkey'); + + expect(client.reviewBuild).toHaveBeenCalledWith('789', 'reject', 'testuser', 'testkey'); + expect(result).toEqual({ success: true }); + }); + + it('sends reject request to API', async () => { + await expectAsync(client.rejectBuild('789', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/reviews'][0].body.data.attributes.action).toBe('reject'); + expect(api.requests['/reviews'][0].headers).toEqual( + jasmine.objectContaining({ + Authorization: `Basic ${base64encode('testuser:testkey')}` + }) + ); + }); + + it('validates build ID', async () => { + await expectAsync(client.rejectBuild({}, 'testuser', 'testkey')) + .toBeRejectedWithError('Invalid build ID'); + }); + }); + + describe('#deleteBuild()', () => { + it('sends a delete request with correct parameters', async () => { + await expectAsync(client.deleteBuild('123', 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/builds/123/delete'][0].method).toBe('POST'); + expect(api.requests['/builds/123/delete'][0].headers).toEqual( + jasmine.objectContaining({ + Authorization: `Basic ${base64encode('testuser:testkey')}` + }) + ); + expect(api.requests['/builds/123/delete'][0].body).toEqual({}); + }); + + it('calls post with projectTokenRequired=false', async () => { + spyOn(client, 'post').and.callThrough(); + + await expectAsync(client.deleteBuild('123', 'testuser', 'testkey')).toBeResolved(); + + expect(client.post).toHaveBeenCalledWith( + 'builds/123/delete', + {}, + { identifier: 'build.delete' }, + { Authorization: `Basic ${base64encode('testuser:testkey')}` }, + false + ); + }); + + it('validates build ID', async () => { + await expectAsync(client.deleteBuild(null, 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + + await expectAsync(client.deleteBuild('', 'testuser', 'testkey')) + .toBeRejectedWithError('Missing build ID'); + + await expectAsync(client.deleteBuild({}, 'testuser', 'testkey')) + .toBeRejectedWithError('Invalid build ID'); + }); + + it('logs debug message with build ID', async () => { + spyOn(client.log, 'debug'); + + await expectAsync(client.deleteBuild('456', 'testuser', 'testkey')).toBeResolved(); + + expect(client.log.debug).toHaveBeenCalledWith('Sending Delete action for build 456...'); + }); + + it('accepts numeric build ID', async () => { + await expectAsync(client.deleteBuild(123, 'testuser', 'testkey')).toBeResolved(); + + expect(api.requests['/builds/123/delete'][0].method).toBe('POST'); + }); + }); });