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 all 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
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 = {
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;
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');
AlexVelezLl marked this conversation as resolved.
Show resolved Hide resolved

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
Loading