Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ adding endpoint for sending failed event #1401

Merged
merged 16 commits into from
Oct 25, 2023
35 changes: 31 additions & 4 deletions packages/cli-exec/src/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,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 +94,42 @@ 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment around this condition?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment

const myObject = {
errorKind: 'cli',
errorMessage: '1'
};
percy.client.sendFailedEvents(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
59 changes: 59 additions & 0 deletions packages/cli-exec/test/exec.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,65 @@ 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 () => {
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/failed-events']).toBeDefined();
expect(api.requests['/builds/123/failed-events'][0].body).toEqual({
data: {
buildId: '123',
errorKind: 'cli',
client: null,
clientVersion: null,
cliVersion: null,
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/failed-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
15 changes: 15 additions & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,21 @@ export class PercyClient {
return comparison;
}

async sendFailedEvents(buildId, { errorKind = 'sdk', client = null, clientVersion = null, cliVersion = null, errorMessage = null } = {}) {
validateId('build', buildId);
this.log.debug('Sending FailedEvents');
return this.post(`builds/${buildId}/failed-events`, {
ninadbstack marked this conversation as resolved.
Show resolved Hide resolved
data: {
buildId: buildId,
ninadbstack marked this conversation as resolved.
Show resolved Hide resolved
errorKind: errorKind,
client: client,
clientVersion: clientVersion,
cliVersion: cliVersion,
message: errorMessage
}
});
}

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

describe('sendFailedEvents', () => {
it('should send failed event with default values', async () => {
await expectAsync(client.sendFailedEvents(123)).toBeResolved();
expect(api.requests['/builds/123/failed-events']).toBeDefined();
expect(api.requests['/builds/123/failed-events'][0].method).toBe('POST');
expect(api.requests['/builds/123/failed-events'][0].body).toEqual({
data: {
buildId: 123,
errorKind: 'sdk',
client: null,
clientVersion: null,
cliVersion: null,
message: null
}
});
});

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

expect(api.requests['/builds/123/failed-events']).toBeDefined();
expect(api.requests['/builds/123/failed-events'][0].method).toBe('POST');
expect(api.requests['/builds/123/failed-events'][0].body).toEqual({
data: {
buildId: 123,
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, percyFailedEventHandler } 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) => {
percyFailedEventHandler(req, pkg.version);
await percy.client.sendFailedEvents(percy.build?.id, req.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
17 changes: 17 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ export function percyAutomateRequestHandler(req, percy) {
req.body.buildInfo = percy.build;
}

// Returns the body for failedEvent structure
export function percyFailedEventHandler(req, cliVersion) {
if (req.body.clientInfo) {
const [client, clientVersion] = req.body.clientInfo.split('/');

// Add the client and clientVersion fields to the existing object
req.body.client = client;
req.body.clientVersion = clientVersion;

// Remove the original clientInfo field
delete req.body.clientInfo;
}
if (!req.body.cliVersion) {
req.body.cliVersion = cliVersion;
}
}

// 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
50 changes: 50 additions & 0 deletions packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,56 @@ describe('API Server', () => {
resolve(); // no hanging promises
});

it('has a /events endpoint that calls #sendFailedEvents() 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 sendFailedEventsSpy = spyOn(percy.client, 'sendFailedEvents').and.resolveTo('some response');

await percy.start();

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

expect(sendFailedEventsSpy).toHaveBeenCalledOnceWith(percy.build.id, jasmine.objectContaining({
errorMessage: '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 that calls #sendFailedEvents() async with provided options with clientInfo absent', async () => {
let resolve, test = new Promise(r => (resolve = r));
let sendFailedEventsSpy = spyOn(percy.client, 'sendFailedEvents').and.resolveTo('some response');

await percy.start();

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

expect(sendFailedEventsSpy).toHaveBeenCalledOnceWith(percy.build.id, jasmine.objectContaining({
errorMessage: '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 postFailedEvent from './post-failed-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,
postFailedEvent
};

// export the namespace by default
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk-utils/src/post-failed-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 postFailedEvent(options) {
return await request.post('/percy/events', options).catch(err => {
throw err;
});
}

export default postFailedEvent;
26 changes: 26 additions & 0 deletions packages/sdk-utils/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,32 @@ describe('SDK Utils', () => {
});
});

describe('postFailedEvent(options)', () => {
let { postFailedEvent } = utils;
let options;

beforeEach(() => {
options = {
errorMessage: 'someError',
errorKind: 'sdk',
cliVersion: '1.2.3'
};
});

it('posts comparison options to the CLI API event endpoint', async () => {
spyOn(utils.request, 'post').and.callFake(() => Promise.resolve());
await expectAsync(postFailedEvent(options)).toBeResolved();
await expectAsync(helpers.get('requests')).toBeResolvedTo({});
});

it('throws when the event API fails', async () => {
await helpers.test('error', '/percy/events');

await expectAsync(postFailedEvent({}))
.toBeRejectedWithError('testing');
});
});

describe('flushSnapshots([options])', () => {
let { flushSnapshots } = utils;

Expand Down
Loading