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

Percy and jest-puppeteer environment setup for visual testing #670

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,19 @@ esLintConfig.settings['import/resolver'].nuxt = {
nuxtSrcDir: 'docs',
};

// Remove linting errors for the globals defined in the jest-puppeteer package
esLintConfig.env = {
...esLintConfig.env,
'jest': true,
Copy link
Member

Choose a reason for hiding this comment

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

I dont think we should add this here, because this lint config is for the whole package, including the development environment where we are not running jest, and I think this could cause unwanted behaviours. Probably we could set these settings in a specific file for tests?

Copy link
Member

Choose a reason for hiding this comment

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

Hi @KshitijThareja! why do we need this property here in the esLintConfig env?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had followed troubleshooting guidelines as specified here: Jest-Puppeteer
This was basically to recognize jest's global variables and not flag their use while running linting tests.
But on taking a closer look now, I can see that this property has already been included in the main esLint config import (kolibri-tools/.eslintrc). I'll make the required changes.

};

esLintConfig.globals = {
AlexVelezLl marked this conversation as resolved.
Show resolved Hide resolved
...esLintConfig.globals,
page: true,
browser: true,
context: true,
puppeteerConfig: true,
jestPuppeteer: true,
};

module.exports = esLintConfig;
19 changes: 19 additions & 0 deletions .percy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
snapshot:
widths:
- 375
- 1280
minHeight: 1024
percyCSS: ""
enableJavaScript: false #disable Javascript by default to capture initial page state without JS-driven changes
cliEnableJavaScript: true #enable Javascript when running Percy through CLI, for dynamic content
disableShadowDOM: false
discovery:
allowedHostnames: []
disallowedHostnames: []
networkIdleTimeout: 100
captureMockedServiceWorker: false
upload:
files: "**/*.{png,jpg,jpeg}"
ignore: ""
stripExtensions: false
67 changes: 67 additions & 0 deletions docs/pages/testing-playground.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<template>

<!--
Testing Playground: A dedicated page for component visual testing
*****************************************************

Please do not modify the contents of this file.
-->
<div id="testing-playground" style="padding: 24px">
<component :is="component" v-bind="componentProps" />
</div>

</template>


<script>

/**
* Renders the components for visual testing
* to ensure expected visual behavior under
* various conditions.
*/
export default {
name: 'VisualTestingPlayground',
data() {
return {
/**
* @type {string|null} The name of the component to be dynamically rendered.
*/
component: null,
/**
* @type {Object} The props to be passed to the dynamically rendered component.
*/
componentProps: {},
};
},

/**
* Adds an event listener for messages from the test runner.
* This listener will trigger the `handleMessage` method.
*/
mounted() {
window.addEventListener('message', this.handleMessage);
},

/**
* Removes the event listener for messages from the test runner.
*/
beforeDestroy() {
window.removeEventListener('message', this.handleMessage);
},

methods: {
/**
* Handles messages received from the test runner to render a specified component.
* @param {MessageEvent} event - The message event containing the component and its props.
*/
handleMessage(event) {
if (event.data.type === 'RENDER_COMPONENT') {
this.component = event.data.component;
this.componentProps = event.data.props;
}
},
},
};

</script>
7 changes: 7 additions & 0 deletions jest-puppeteer.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
launch: {
headless: true,
timeout: 180000,
},
browserContext: 'default',
};
4 changes: 3 additions & 1 deletion jest.conf/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ Vue.config.silent = true;
Vue.config.devtools = false;
Vue.config.productionTip = false;

Object.defineProperty(window, 'scrollTo', { value: () => {}, writable: true });
if (process.env.TEST_TYPE !== 'visual' && typeof window !== 'undefined') {
Object.defineProperty(window, 'scrollTo', { value: () => {}, writable: true });
}

// Shows better NodeJS unhandled promise rejection errors
process.on('unhandledRejection', (reason, p) => {
Expand Down
11 changes: 11 additions & 0 deletions jest.conf/testUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ export const resizeWindow = (width, height = 768) => {
});
};

export function canTakeScreenshot() {
const percyToken = process.env.PERCY_TOKEN;
const runVisualTests = process.env.TEST_TYPE === 'visual';
if (runVisualTests && !percyToken) {
throw new Error(
'Error: Visual tests cannot be run because PERCY_TOKEN environment variable is not set.'
);
}
return runVisualTests && percyToken;
}

export const testAfterResize = testFunction => {
let animationFrameId;
const assertAfterResize = () => {
Expand Down
30 changes: 30 additions & 0 deletions jest.conf/visual.index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const path = require('node:path');

const moduleNameMapper = {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css)$': path.resolve(
__dirname,
'./fileMock.js'
),
};

module.exports = {
rootDir: path.resolve(__dirname, '..'),
preset: 'jest-puppeteer',
testTimeout: 50000,
moduleFileExtensions: ['js', 'json', 'vue'],
moduleNameMapper,
transform: {
'^.+\\.js$': require.resolve('babel-jest'),
'^.+\\.vue$': require.resolve('vue-jest'),
},
snapshotSerializers: ['jest-serializer-vue'],
globals: {
HOST: 'http://localhost:4000/',
'vue-jest': {
hideStyleWarn: true,
experimentalCSSCompile: true,
},
},
setupFilesAfterEnv: [path.resolve(__dirname, './visual.setup')],
verbose: true,
};
7 changes: 7 additions & 0 deletions jest.conf/visual.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import './setup';
import { percySnapshot } from '@percy/puppeteer';

// Set the test type to visual
process.env.TEST_TYPE = 'visual';

global.percySnapshot = percySnapshot;
71 changes: 28 additions & 43 deletions lib/KImg/__tests__/KImg.spec.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import KImg from '../';

function makeWrapper(opts) {
return shallowMount(KImg, opts);
function makeWrapper({ propsData = {} } = {}) {
return shallowMount(KImg, { propsData });
}

describe('KImg', () => {
it(`renders without any errors when a valid 'src' and 'altText' are provided`, () => {
const error = jest.fn();
const wrapper = makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo' },
listeners: { error },
});

expect(wrapper.exists()).toBe(true);
expect(error).not.toHaveBeenCalled();

const img = wrapper.find('img');
expect(img.exists()).toBe(true);
Expand All @@ -23,26 +20,20 @@ describe('KImg', () => {
});

it(`throws an error when no 'altText' is provided`, () => {
const error = jest.fn();
makeWrapper({
propsData: { src: '/le-logo.svg', altText: undefined },
listeners: { error },
});
expect(error).toHaveBeenCalled();
expect(error.mock.calls[0][0]).toBeInstanceOf(Error);
expect(error.mock.calls[0][0].message).toBe(
'Missing required prop - provide altText or indicate isDecorative'
);
expect(() =>
Copy link
Member

Choose a reason for hiding this comment

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

Hi @KshitijThareja! I think we can let this file without modifications. In this case it seems it is the expected behaviour to pass an error listener to catch these errors. See #645 (comment).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I'll make the changes. I was currently working on finding a way to use concurrently as discussed, so was a bit busy with that. I'll get this resolved by today at the earliest.

makeWrapper({
propsData: { src: '/le-logo.svg', altText: undefined },
})
).toThrow();
});

describe(`when no 'altText' is provided and it is a decorative image`, () => {
it(`does not throw an error`, () => {
const error = jest.fn();
makeWrapper({
propsData: { src: '/le-logo.svg', altText: undefined, isDecorative: true },
listeners: { error },
});
expect(error).not.toHaveBeenCalled();
expect(() =>
makeWrapper({
propsData: { src: '/le-logo.svg', altText: undefined, isDecorative: true },
})
).not.toThrow();
});

it(`sets 'alt' attribute to an empty string`, () => {
Expand All @@ -55,38 +46,32 @@ describe('KImg', () => {
});

it(`throws an error when 'aspectRatio' has an invalid format`, () => {
const error = jest.fn();
makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo', aspectRatio: '16/9' },
listeners: { error },
});
expect(error).toHaveBeenCalled();
expect(error.mock.calls[0][0]).toBeInstanceOf(Error);
expect(error.mock.calls[0][0].message).toBe('Invalid aspect ratio provided: 16/9');
expect(() =>
makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo', aspectRatio: '16/9' },
})
).toThrow();
});

it(`doesn't throw an error when 'aspectRatio' has a valid format`, () => {
const error = jest.fn();
makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo', aspectRatio: '16:9' },
listeners: { error },
});
expect(error).not.toHaveBeenCalled();
expect(() =>
makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo', aspectRatio: '16:9' },
})
).not.toThrow();
});

it(`emits an 'error' event when there is an error in loading the image`, async () => {
const error = jest.fn();
it(`emits an 'error' event when there is an error in loading the image`, () => {
const wrapper = makeWrapper({
propsData: { src: '/le-logo.svg', altText: 'LE logo' },
listeners: { error },
});

// Manually trigger the onError method to simulate the image load failure
const e = new Event('error');
wrapper.vm.onError(e);
wrapper.vm.onError(new Event('error'));

expect(error).toHaveBeenCalled();
expect(error.mock.calls[0][0]).toBeInstanceOf(Event);
expect(error.mock.calls[0][0]).toEqual(e);
// Check if the "error" event has been emitted with the DOM event payload
const emittedEvent = wrapper.emitted().error;
expect(emittedEvent).toBeTruthy();
expect(emittedEvent[0][0]).toBeInstanceOf(Event);
});
});
Loading
Loading