Skip to content

Commit

Permalink
✨ adding endpoint for sending failed event (#1401)
Browse files Browse the repository at this point in the history
* intial POC

* intial POC

* ✨ adding endpoint for sending failed event

* lint fix and client test

* test fix

* test fix

* tests for core

* tests for exec

* tests for exec

* coverage completed

* deleting extra comments

* changing default value of errorKind to sdk

* creating helper method to send failed event from js sdk's

* addressing comments

* addressing comments: making sending event more genric for future use
  • Loading branch information
prklm10 authored Oct 25, 2023
1 parent d0eea0c commit ea13206
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 6 deletions.
39 changes: 35 additions & 4 deletions packages/cli-exec/src/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import command from '@percy/cli-command';
import start from './start.js';
import stop from './stop.js';
import ping from './ping.js';
import { getPackageJSON } from '@percy/cli-command/utils';

const pkg = getPackageJSON(import.meta.url);

export const exec = command('exec', {
description: 'Start and stop Percy around a supplied command',
Expand Down Expand Up @@ -83,7 +86,7 @@ export const exec = command('exec', {

// run the provided command
log.info(`Running "${[command, ...args].join(' ')}"`);
let [status, error] = yield* spawn(command, args);
let [status, error] = yield* spawn(command, args, percy);

// stop percy if running (force stop if there is an error);
await percy?.stop(!!error);
Expand All @@ -94,15 +97,43 @@ export const exec = command('exec', {

// Spawn a command with cross-spawn and return an array containing the resulting status code along
// with any error encountered while running. Uses a generator pattern to handle interupt signals.
async function* spawn(cmd, args) {
async function* spawn(cmd, args, percy) {
let { default: crossSpawn } = await import('cross-spawn');
let proc, closed, error;

try {
proc = crossSpawn(cmd, args, { stdio: 'inherit' });
proc.on('close', code => (closed = code));
proc = crossSpawn(cmd, args, { stdio: 'pipe' });
// Writing stdout of proc to process
if (proc.stdout) {
proc.stdout.on('data', (data) => {
process.stdout.write(`${data}`);
});
}

if (proc.stderr) {
proc.stderr.on('data', (data) => {
process.stderr.write(`${data}`);
});
}

proc.on('error', err => (error = err));

proc.on('close', code => {
closed = code;
if (code !== 0) {
// Only send event when there is a global error code and
// percy token is present
if (process.env.PERCY_TOKEN) {
const myObject = {
errorKind: 'cli',
cliVersion: pkg.version,
message: '1'
};
percy.client.sendBuildEvents(percy.build.id, myObject);
}
}
});

// run until an event is triggered
/* eslint-disable-next-line no-unmodified-loop-condition */
while (closed == null && error == null) {
Expand Down
58 changes: 58 additions & 0 deletions packages/cli-exec/test/exec.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { logger, api, setupTest } from '@percy/cli-command/test/helpers';
import exec from '@percy/cli-exec';
import { getPackageJSON } from '@percy/cli-command/utils';

describe('percy exec', () => {
beforeEach(async () => {
Expand Down Expand Up @@ -130,6 +131,63 @@ describe('percy exec', () => {
]);
});

it('tests process.stdout', async () => {
let stdoutSpy = spyOn(process.stdout, 'write').and.resolveTo('some response');
await exec(['--', 'echo', 'Hi!']);

expect(stdoutSpy).toHaveBeenCalled();
expect(logger.stderr).toEqual([]);
expect(logger.stdout).toEqual([
'[percy] Percy has started!',
'[percy] Running "echo Hi!"',
'[percy] Finalized build #1: https://percy.io/test/test/123'
]);
});

it('tests process.stderr when token is present', async () => {
const pkg = getPackageJSON(import.meta.url);
let stderrSpy = spyOn(process.stderr, 'write').and.resolveTo('some response');
await expectAsync(
exec(['--', 'node', 'random.js']) // invalid command
).toBeRejectedWithError('EEXIT: 1');

expect(stderrSpy).toHaveBeenCalled();
expect(logger.stderr).toEqual([]);
expect(logger.stdout).toEqual([
'[percy] Percy has started!',
'[percy] Running "node random.js"',
'[percy] Finalized build #1: https://percy.io/test/test/123'
]);

expect(api.requests['/builds/123/send-events']).toBeDefined();
expect(api.requests['/builds/123/send-events'][0].body).toEqual({
data: {
errorKind: 'cli',
cliVersion: pkg.version,
message: '1'
}
});
});

it('tests process.stderr when token is not present', async () => {
delete process.env.PERCY_TOKEN;
let stderrSpy = spyOn(process.stderr, 'write').and.resolveTo('some response');
await expectAsync(
exec(['--', 'node', 'random.js']) // invalid command
).toBeRejectedWithError('EEXIT: 1');

expect(stderrSpy).toHaveBeenCalled();
expect(logger.stderr).toEqual([
'[percy] Skipping visual tests',
'[percy] Error: Missing Percy token'
]);
expect(logger.stdout).toEqual([
'[percy] Running "node random.js"'
]);

expect(api.requests['/builds/123/send-events']).not.toBeDefined();
});

it('does not run the command if canceled beforehand', async () => {
// delay build creation to give time to cancel
api.reply('/builds', () => new Promise(resolve => {
Expand Down
8 changes: 8 additions & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,14 @@ export class PercyClient {
return comparison;
}

async sendBuildEvents(buildId, body) {
validateId('build', buildId);
this.log.debug('Sending Build Events');
return this.post(`builds/${buildId}/send-events`, {
data: body
});
}

// decides project type
tokenType() {
let token = this.getToken(false) || '';
Expand Down
24 changes: 24 additions & 0 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,30 @@ describe('PercyClient', () => {
});
});

describe('sendBuildEvents', () => {
it('should send build event with default values', async () => {
await expectAsync(client.sendBuildEvents(123, {
errorKind: 'cli',
client: 'percy-appium-dotnet',
clientVersion: '3.0.1',
cliVersion: '1.27.3',
message: 'some error'
})).toBeResolved();

expect(api.requests['/builds/123/send-events']).toBeDefined();
expect(api.requests['/builds/123/send-events'][0].method).toBe('POST');
expect(api.requests['/builds/123/send-events'][0].body).toEqual({
data: {
errorKind: 'cli',
client: 'percy-appium-dotnet',
clientVersion: '3.0.1',
cliVersion: '1.27.3',
message: 'some error'
}
});
});
});

describe('#getToken', () => {
afterEach(() => {
delete process.env.PERCY_TOKEN;
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'path';
import { createRequire } from 'module';
import logger from '@percy/logger';
import { normalize } from '@percy/config/utils';
import { getPackageJSON, Server, percyAutomateRequestHandler } from './utils.js';
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler } from './utils.js';
import WebdriverUtils from '@percy/webdriver-utils';
// need require.resolve until import.meta.resolve can be transpiled
export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom');
Expand Down Expand Up @@ -121,6 +121,12 @@ export function createPercyServer(percy, port) {
percy.upload(await WebdriverUtils.automateScreenshot(req.body));
res.json(200, { success: true });
})
// Recieves events from sdk's.
.route('post', '/percy/events', async (req, res) => {
const body = percyBuildEventHandler(req, pkg.version);
await percy.client.sendBuildEvents(percy.build?.id, body);
res.json(200, { success: true });
})
// stops percy at the end of the current event loop
.route('/percy/stop', (req, res) => {
setImmediate(() => percy.stop());
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,42 @@ export function percyAutomateRequestHandler(req, percy) {
req.body.buildInfo = percy.build;
}

// Returns the body for sendEvent structure
export function percyBuildEventHandler(req, cliVersion) {
if (Array.isArray(req.body)) {
return req.body.map(item => processSendEventData(item, cliVersion));
} else {
// Treat the input as an object and perform instructions
return processSendEventData(req.body, cliVersion);
}
}

// Process sendEvent object
function processSendEventData(input, cliVersion) {
// Add Properties here to send to eventData
const allowedEventProperties = ['message', 'cliVersion', 'clientInfo', 'errorKind', 'extra'];
const extractedData = {};
for (const property of allowedEventProperties) {
if (Object.prototype.hasOwnProperty.call(input, property)) {
extractedData[property] = input[property];
}
}

if (extractedData.clientInfo) {
const [client, clientVersion] = extractedData.clientInfo.split('/');

// Add the client and clientVersion fields to the object
extractedData.client = client;
extractedData.clientVersion = clientVersion;
delete extractedData.clientInfo;
}

if (!input.cliVersion) {
extractedData.cliVersion = cliVersion;
}
return extractedData;
}

// Creates a local resource object containing the resource URL, mimetype, content, sha, and any
// other additional resources attributes.
export function createResource(url, content, mimetype, attrs) {
Expand Down
94 changes: 94 additions & 0 deletions packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,100 @@ describe('API Server', () => {
resolve(); // no hanging promises
});

it('has a /events endpoint that calls #sendBuildEvents() async with provided options with clientInfo present', async () => {
let { getPackageJSON } = await import('@percy/client/utils');
let pkg = getPackageJSON(import.meta.url);
let resolve, test = new Promise(r => (resolve = r));
let sendBuildEventsSpy = spyOn(percy.client, 'sendBuildEvents').and.resolveTo('some response');

await percy.start();

await expectAsync(request('/percy/events', {
body: {
message: 'some error',
clientInfo: 'percy-appium-dotnet/3.0.1'
},
method: 'post'
})).toBeResolvedTo({ success: true });

expect(sendBuildEventsSpy).toHaveBeenCalledOnceWith(percy.build.id, jasmine.objectContaining({
message: 'some error',
client: 'percy-appium-dotnet',
clientVersion: '3.0.1',
cliVersion: pkg.version
}));

await expectAsync(test).toBePending();
resolve(); // no hanging promises
});

it('has a /events endpoint called with body array that calls #sendBuildEvents() async with provided options with clientInfo present', async () => {
let { getPackageJSON } = await import('@percy/client/utils');
let pkg = getPackageJSON(import.meta.url);
let resolve, test = new Promise(r => (resolve = r));
let sendBuildEventsSpy = spyOn(percy.client, 'sendBuildEvents').and.resolveTo('some response');

await percy.start();

await expectAsync(request('/percy/events', {
body: [
{
message: 'some error 1',
clientInfo: 'percy-appium-dotnet/3.0.1'
},
{
message: 'some error 2',
clientInfo: 'percy-appium-dotnet/3.0.1'
}
],
method: 'post'
})).toBeResolvedTo({ success: true });

expect(sendBuildEventsSpy).toHaveBeenCalledOnceWith(percy.build.id, jasmine.objectContaining(
[
{
message: 'some error 1',
client: 'percy-appium-dotnet',
clientVersion: '3.0.1',
cliVersion: pkg.version
},
{
message: 'some error 2',
client: 'percy-appium-dotnet',
clientVersion: '3.0.1',
cliVersion: pkg.version

}
]
));

await expectAsync(test).toBePending();
resolve(); // no hanging promises
});

it('has a /events endpoint that calls #sendBuildEvents() async with provided options with clientInfo absent', async () => {
let resolve, test = new Promise(r => (resolve = r));
let sendBuildEventsSpy = spyOn(percy.client, 'sendBuildEvents').and.resolveTo('some response');

await percy.start();

await expectAsync(request('/percy/events', {
body: {
message: 'some error',
cliVersion: '1.2.3'
},
method: 'post'
})).toBeResolvedTo({ success: true });

expect(sendBuildEventsSpy).toHaveBeenCalledOnceWith(percy.build.id, jasmine.objectContaining({
message: 'some error',
cliVersion: '1.2.3'
}));

await expectAsync(test).toBePending();
resolve(); // no hanging promises
});

it('returns a 500 error when an endpoint throws', async () => {
spyOn(percy, 'snapshot').and.rejectWith(new Error('test error'));
await percy.start();
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import waitForPercyIdle from './percy-idle.js';
import fetchPercyDOM from './percy-dom.js';
import postSnapshot from './post-snapshot.js';
import postComparison from './post-comparison.js';
import postBuildEvents from './post-build-event.js';
import flushSnapshots from './flush-snapshots.js';
import captureAutomateScreenshot from './post-screenshot.js';

Expand All @@ -19,7 +20,8 @@ export {
postSnapshot,
postComparison,
flushSnapshots,
captureAutomateScreenshot
captureAutomateScreenshot,
postBuildEvents
};

// export the namespace by default
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk-utils/src/post-build-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import request from './request.js';

// Post failed event data to the CLI event endpoint.
export async function postBuildEvents(options) {
return await request.post('/percy/events', options).catch(err => {
throw err;
});
}

export default postBuildEvents;
Loading

0 comments on commit ea13206

Please sign in to comment.