Skip to content

Commit

Permalink
Merge pull request #670 from KshitijThareja/visual-testing
Browse files Browse the repository at this point in the history
Percy and jest-puppeteer environment setup for visual testing
  • Loading branch information
bjester committed Jul 10, 2024
2 parents 0beb457 + 2b06d96 commit 905e770
Show file tree
Hide file tree
Showing 13 changed files with 1,304 additions and 86 deletions.
10 changes: 10 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,14 @@ esLintConfig.settings['import/resolver'].nuxt = {
nuxtSrcDir: 'docs',
};

// Remove linting errors for the globals defined in the jest-puppeteer package
esLintConfig.globals = {
...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;
140 changes: 91 additions & 49 deletions lib/buttons-and-links/__tests__/KButton.spec.js
Original file line number Diff line number Diff line change
@@ -1,67 +1,109 @@
import { shallowMount } from '@vue/test-utils';
import percySnapshot from '@percy/puppeteer';
import KButton from '../KButton.vue';
import { canTakeScreenshot } from '../../../jest.conf/testUtils';

describe('KButton', () => {
describe('icon related props', () => {
it('should render an icon before the text with the icon string passed to the `icon` prop', () => {
const wrapper = shallowMount(KButton, {
propsData: {
icon: 'add',
},
if (!canTakeScreenshot()) {
describe('icon related props', () => {
it('should render an icon before the text with the icon string passed to the `icon` prop', () => {
const wrapper = shallowMount(KButton, {
propsData: {
icon: 'add',
},
});
expect(wrapper.find('[data-test="iconBefore"]').exists()).toBe(true);
});
expect(wrapper.find('[data-test="iconBefore"]').exists()).toBe(true);
});
it('should render an icon after the text with the icon string pased to the `iconAfter` prop', () => {
const wrapper = shallowMount(KButton, {
propsData: {
iconAfter: 'video',
},
it('should render an icon after the text with the icon string pased to the `iconAfter` prop', () => {
const wrapper = shallowMount(KButton, {
propsData: {
iconAfter: 'video',
},
});
expect(wrapper.find('[data-test="iconAfter"]').exists()).toBe(true);
});
expect(wrapper.find('[data-test="iconAfter"]').exists()).toBe(true);
});
it('should render a dropdown icon when hasDropdown is true', () => {
const wrapper = shallowMount(KButton, {
propsData: {
hasDropdown: true,
},
it('should render a dropdown icon when hasDropdown is true', () => {
const wrapper = shallowMount(KButton, {
propsData: {
hasDropdown: true,
},
});
expect(wrapper.find('[data-test="dropdownIcon"]').exists()).toBe(true);
});
expect(wrapper.find('[data-test="dropdownIcon"]').exists()).toBe(true);
});
});

describe('text prop and slots', () => {
it('should render the text prop if nothing is in the default slot', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
describe('text prop and slots', () => {
it('should render the text prop if nothing is in the default slot', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
});
expect(wrapper.text()).toContain('test');
});

it('should render the slot when the slot has content', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
slots: {
default: '<span>slot</span>',
},
});
expect(wrapper.text()).toContain('slot');
expect(wrapper.text()).toContain('test');
});
expect(wrapper.text()).toContain('test');
});

it('should render the slot when the slot has content', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
slots: {
default: '<span>slot</span>',
},
describe('event handling', () => {
it('should emit a click event when clicked', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
});
wrapper.trigger('click');
expect(wrapper.emitted().click).toBeTruthy();
});
expect(wrapper.text()).toContain('slot');
expect(wrapper.text()).toContain('test');
});
});
} else {
describe('KButton Visual Tests', () => {
beforeAll(async () => {
await page.goto('http://localhost:4000/testing-playground', { waitUntil: 'networkidle2' });
});

async function renderComponent(component, props) {
await page.evaluate(
({ component, props }) => {
window.postMessage(
{
type: 'RENDER_COMPONENT',
component: component,
props: props,
},
'*'
);
},
{ component, props }
);
await page.waitForSelector('#testing-playground');

const isComponentRendered = await page.evaluate(() => {
const testing_playground = document.querySelector('#testing-playground');
return testing_playground && testing_playground.children.length > 0;
});

if (!isComponentRendered) {
// eslint-disable-next-line no-console
console.error('Component did not render in the testing playground');
}
}

describe('event handling', () => {
it('should emit a click event when clicked', () => {
const wrapper = shallowMount(KButton, {
propsData: {
text: 'test',
},
it('renders correctly with default props', async () => {
await renderComponent('KButton', { text: 'Test Button' });
await percySnapshot(page, 'KButton - Default');
});
wrapper.trigger('click');
expect(wrapper.emitted().click).toBeTruthy();
});
});
}
});
2 changes: 1 addition & 1 deletion lib/composables/useKResponsiveWindow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function initProps() {
if (isNuxtServerSideRendering()) {
return;
}
if (window.matchMedia) {
if (typeof window !== 'undefined' && window.matchMedia) {
orientationQuery.eventHandler(orientationQuery.mediaQueryList);
heightQuery.eventHandler(heightQuery.mediaQueryList);
}
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"precompile-custom-svgs": "node utils/precompileSvgs/index.js --custom && yarn run pregenerate",
"_lint-watch-fix": "yarn lint -w -m",
"test": "jest --config=jest.conf/index.js",
"test:visual": "JEST_PUPPETEER_CONFIG=./jest-puppeteer.config.js node startVisualTests.js",
"_api-watch": "chokidar \"**/lib/**\" -c \"node utils/extractApi.js\""
},
"files": [
Expand All @@ -41,6 +42,8 @@
"devDependencies": {
"@material-icons/svg": "git+https://github.com/material-icons/material-icons.git",
"@mdi/svg": "^5.9.55",
"@percy/cli": "^1.28.7",
"@percy/puppeteer": "^2.0.2",
"@vuedoc/parser": "^3.4.0",
"babel-jest": "^29.7.0",
"browserslist-config-kolibri": "0.16.0-dev.7",
Expand All @@ -50,6 +53,7 @@
"globby": "^6.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-puppeteer": "^10.0.1",
"jest-serializer-vue": "^3.1.0",
"kolibri-tools": "0.16.1-dev.1",
"lockr": "^0.8.4",
Expand All @@ -59,6 +63,8 @@
"npm-run-all": "^4.1.5",
"nuxt": "2.15.8",
"prismjs": "^1.27.0",
"ps-tree": "^1.2.0",
"puppeteer": "^22.11.0",
"raw-loader": "0.5.1",
"sass-loader": "^10.5.2",
"svg-icon-inline-loader": "^3.1.0",
Expand Down
Loading

0 comments on commit 905e770

Please sign in to comment.