UNPKG

@wdio/visual-service

Version:

Image comparison / visual regression testing for WebdriverIO

480 lines (479 loc) 18.8 kB
import { mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { $, browser } from '@wdio/globals'; import logger from '@wdio/logger'; import { deviceDescriptors } from './deviceDescriptors.js'; const log = logger('@wdio/visual-service:storybook-utils'); /** * Check if we run for Storybook */ export function isStorybookMode() { return process.argv.includes('--storybook'); } /** * Check if the framework is cucumber */ export function isCucumberFramework(framework) { return framework.toLowerCase() === 'cucumber'; } /** * Check if the framework is Jasmine */ export function isJasmineFramework(framework) { return framework.toLowerCase() === 'jasmine'; } /** * Check if the framework is Mocha */ export function isMochaFramework(framework) { return framework.toLowerCase() === 'mocha'; } /** * Check if there is an instance of Storybook running */ export async function checkStorybookIsRunning(url) { try { const res = await fetch(url, { method: 'GET', headers: {} }); if (res.status !== 200) { throw new Error(`Unexpected status: ${res.status}`); } } catch (_e) { log.error(`It seems that the Storybook instance is not running at: ${url}. Are you sure it's running?`); process.exit(1); } } /** * Sanitize the URL to ensure it's in a proper format */ export function sanitizeURL(url) { if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://' + url; } url = url.replace(/(iframe\.html|index\.html)\s*$/, ''); if (!url.endsWith('/')) { url += '/'; } return url; } /** * Extract the category and component from the story ID */ export function extractCategoryAndComponent(id) { // The ID is in the format of `category-component--storyName` const [categoryComponent] = id.split('--'); const [category, component] = categoryComponent.split('-'); return { category, component }; } /** * Get the stories JSON from the Storybook instance */ export async function getStoriesJson(url) { const indexJsonUrl = new URL('index.json', url).toString(); const storiesJsonUrl = new URL('stories.json', url).toString(); const fetchOptions = { headers: {} }; try { const [indexRes, storiesRes] = await Promise.all([ fetch(indexJsonUrl, fetchOptions), fetch(storiesJsonUrl, fetchOptions), ]); for (const response of [storiesRes, indexRes]) { if (response.ok) { try { const data = await response.json(); return data.stories || data.entries; } catch (_ign) { // Ignore the json parse error } } } } catch (_ign) { // Ignore the error } throw new Error(`Failed to fetch index data from the project. Ensure URLs are available with valid data: ${storiesJsonUrl}, ${indexJsonUrl}.`); } /** * Get arg value from the process.argv */ export function getArgvValue(argName, parseFunc) { const argWithEqual = argName + '='; const argv = process.argv; for (let i = 0; i < argv.length; i++) { if (argv[i] === argName && i + 1 < argv.length) { return parseFunc(argv[i + 1]); } else if (argv[i].startsWith(argWithEqual)) { const value = argv[i].slice(argWithEqual.length); return parseFunc(value); } } return undefined; } /** * Get the story baseline path for the given category and component */ const getStoriesBaselinePathFn = ((category, component) => `./${category}/${component}/`); /** * Creates a it function for the test file * @TODO: improve this */ export function itFunction({ additionalSearchParams, clip, clipSelector, compareOptions, folders, framework, skipStories, storyData, storybookUrl, getStoriesBaselinePath = getStoriesBaselinePathFn }) { const { id } = storyData; const screenshotType = clip ? 'n element' : ' viewport'; const DEFAULT_IT_TEXT = 'it'; let itText = DEFAULT_IT_TEXT; if (isJasmineFramework(framework)) { itText = 'xit'; } else if (isMochaFramework(framework)) { itText = 'it.skip'; } if (Array.isArray(skipStories)) { itText = skipStories.includes(id) ? itText : DEFAULT_IT_TEXT; } else if (skipStories instanceof RegExp) { itText = skipStories.test(id) ? itText : DEFAULT_IT_TEXT; } // Setup the folder structure const { category, component } = extractCategoryAndComponent(id); const storiesBaselinePath = getStoriesBaselinePath(category, component); const checkMethodOptions = { ...compareOptions, actualFolder: join(folders.actualFolder, storiesBaselinePath), baselineFolder: join(folders.baselineFolder, storiesBaselinePath), diffFolder: join(folders.diffFolder, storiesBaselinePath), }; const it = ` ${itText}(\`should take a${screenshotType} screenshot of ${id}\`, async () => { await browser.waitForStorybookComponentToBeLoaded({ clipSelector: '${clipSelector}', id: '${id}', storybookUrl: '${storybookUrl}', additionalSearchParams: new URLSearchParams('${additionalSearchParams.toString()}'), }); ${clip ? `await expect($('${clipSelector}')).toMatchElementSnapshot('${id}-element', ${JSON.stringify(checkMethodOptions)})` : `await expect(browser).toMatchScreenSnapshot('${id}', ${JSON.stringify(checkMethodOptions)})`} }); `; return it; } /** * Write the test file */ export function writeTestFile(directoryPath, fileID, testContent) { const filePath = join(directoryPath, `${fileID}.test.js`); try { writeFileSync(filePath, testContent); log.info(`Test file created at: ${filePath}`); } catch (err) { log.error(`It seems that the writing the file to '${filePath}' didn't succeed due to the following error: ${err}`); process.exit(1); } } /** * Create the test content */ export function createTestContent({ additionalSearchParams, clip, clipSelector, compareOptions, folders, framework, getStoriesBaselinePath, skipStories, stories, storybookUrl }, // For testing purposes only itFunc = itFunction) { const itFunctionOptions = { additionalSearchParams, clip, clipSelector, compareOptions, folders, framework, getStoriesBaselinePath, skipStories, storybookUrl }; return stories.reduce((acc, storyData) => acc + itFunc({ ...itFunctionOptions, storyData }), ''); } /** * The custom command */ export async function waitForStorybookComponentToBeLoaded(options, // For testing purposes only isStorybookModeFunc = isStorybookMode) { const isStorybook = isStorybookModeFunc(); if (isStorybook) { const { additionalSearchParams, clipSelector = process.env.VISUAL_STORYBOOK_CLIP_SELECTOR, id, url = process.env.VISUAL_STORYBOOK_URL, timeout = 11000, } = options; const baseUrl = new URL('iframe.html', url); const searchParams = new URLSearchParams({ id }); if (additionalSearchParams) { for (const [key, value] of additionalSearchParams) { searchParams.append(key, value); } } baseUrl.search = searchParams.toString(); await browser.url(baseUrl.toString()); await $(clipSelector).waitForDisplayed(); await browser.executeAsync(async (timeout, done) => { let timedOut = false; const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => { timedOut = true; reject('Timeout: Not all images loaded within 11 seconds'); }, timeout); }); const isImageLoaded = (img) => img.complete && img.naturalWidth > 0; // Check for <img> elements const imgElements = Array.from(document.querySelectorAll('img')); const imgPromises = imgElements.map(img => isImageLoaded(img) ? Promise.resolve() : new Promise(resolve => { img.onload = () => { if (!timedOut) { resolve(); } }; img.onerror = () => { if (!timedOut) { resolve(); } }; })); // Check for CSS background images const allElements = Array.from(document.querySelectorAll('*')); const bgImagePromises = allElements.map(el => { const bgImage = window.getComputedStyle(el).backgroundImage; if (bgImage && bgImage !== 'none' && bgImage.startsWith('url')) { const imageUrl = bgImage.slice(5, -2); // Extract URL from the 'url("")' const image = new Image(); image.src = imageUrl; return isImageLoaded(image) ? Promise.resolve() : new Promise(resolve => { image.onload = () => { if (!timedOut) { resolve(); } }; image.onerror = () => { if (!timedOut) { resolve(); } }; }); } return Promise.resolve(); }); try { await Promise.race([Promise.all([...imgPromises, ...bgImagePromises]), timeoutPromise]); done(); } catch (error) { done(error); } }, timeout); } else { throw new Error('The method `waitForStorybookComponentToBeLoaded` can only be used in Storybook mode.'); } } /** * Create the file data */ export function createFileData(describeTitle, testContent) { return ` describe(\`${describeTitle}\`, () => { ${testContent} }); `; } /** * Filter the stories, by default only keep the stories, not the docs */ function filterStories(storiesJson) { return Object.values(storiesJson) // storyData?.type === 'story' is V7+, storyData.parameters?.docsOnly is V6 .filter((storyData) => storyData.type === 'story' || (storyData.parameters && !storyData.parameters.docsOnly)); } /** * Create the test files */ export function createTestFiles({ additionalSearchParams, clip, clipSelector, compareOptions, directoryPath, folders, framework, getStoriesBaselinePath, numShards, skipStories, storiesJson, storybookUrl }, // For testing purposes only createTestCont = createTestContent, createFileD = createFileData, writeTestF = writeTestFile) { const fileNamePrefix = 'visual-storybook'; const createTestContentData = { additionalSearchParams, clip, clipSelector, compareOptions, folders, framework, getStoriesBaselinePath, skipStories, stories: storiesJson, storybookUrl }; if (numShards === 1) { const testContent = createTestCont(createTestContentData); const fileData = createFileD('All stories', testContent); writeTestF(directoryPath, `${fileNamePrefix}-1-1`, fileData); } else { const totalStories = storiesJson.length; const storiesPerShard = Math.ceil(totalStories / numShards); for (let shard = 0; shard < numShards; shard++) { const startIndex = shard * storiesPerShard; const endIndex = Math.min(startIndex + storiesPerShard, totalStories); const shardStories = storiesJson.slice(startIndex, endIndex); const testContent = createTestCont({ ...createTestContentData, stories: shardStories }); const fileId = `${fileNamePrefix}-${shard + 1}-${numShards}`; const describeTitle = `Shard ${shard + 1} of ${numShards}`; const fileData = createFileD(describeTitle, testContent); writeTestF(directoryPath, fileId, fileData); } } } export function createChromeCapabilityWithEmulation({ screen: { width, height, dpr }, name, userAgent }, isHeadless) { return { browserName: 'chrome', 'goog:chromeOptions': { args: [ 'disable-infobars', ...(isHeadless ? ['--headless'] : []), ], mobileEmulation: { deviceMetrics: { width, height, pixelRatio: dpr, }, userAgent, }, }, 'wdio-ics:options': { logName: `local-chrome-${name.replace(/\s+/g, '-')}`, }, }; } /** * Throw an error message if the capabilities are not set up correctly */ export function capabilitiesErrorMessage(browsers, capabilityMap, devices, deviceDescriptors, isMobileEmulation) { let errorMessage = 'No capabilities were added. Please ensure that '; const browserIssues = browsers.some((browser) => !(browser in capabilityMap)); const deviceIssues = isMobileEmulation && devices.some((deviceName) => !deviceDescriptors.some(device => device.name === deviceName)); if (browserIssues && deviceIssues) { errorMessage += `the browsers '${browsers.join(',')}' and devices '${devices.join(',')}' are supported.`; } else if (browserIssues) { errorMessage += `the browsers '${browsers.join(',')}' are supported.`; } else if (deviceIssues) { errorMessage += `the devices '${devices.join(',')}' are supported.`; } else { errorMessage += 'the specified configuration is correct.'; } throw new Error(errorMessage); } /** * Create the storybook capabilities based on the specified browsers */ export function createStorybookCapabilities(capabilities, // For testing purposes only createChromeCapabilityWithEmulationFunc = createChromeCapabilityWithEmulation, capabilitiesErrorMessageFunc = capabilitiesErrorMessage) { if (!Array.isArray(capabilities)) { log.error('The capabilities are not an array'); return; } const isHeadless = getArgvValue('--headless', value => value !== 'false') ?? true; const browsers = getArgvValue('--browsers', (value) => value.split(',')) ?? ['chrome']; const devices = getArgvValue('--devices', (value) => value.split(',')) ?? []; const isMobileEmulation = devices.length > 0; const chromeCapability = { browserName: 'chrome', 'goog:chromeOptions': { args: [ 'disable-infobars', ...(isHeadless ? ['--headless'] : []), ], }, 'wdio-ics:options': { logName: 'local-chrome', }, }; const edgeCapability = { browserName: 'MicrosoftEdge', 'ms:edgeOptions': { args: [...(isHeadless ? ['--headless'] : [])], }, 'wdio-ics:options': { logName: 'local-edge', }, }; const firefoxCapability = { browserName: 'firefox', 'moz:firefoxOptions': { args: [...(isHeadless ? ['-headless'] : []),] }, 'wdio-ics:options': { logName: 'local-firefox', }, }; const safariCapability = { browserName: 'safari', 'wdio-ics:options': { logName: 'local-safari', }, }; const capabilityMap = { chrome: chromeCapability, edge: edgeCapability, firefox: firefoxCapability, safari: safariCapability, }; const standardCapabilities = browsers .filter((browser) => browser in capabilityMap && browser.toLowerCase() !== 'chrome') .map((browser) => capabilityMap[browser]); capabilities.push(...standardCapabilities); if (isMobileEmulation) { devices.forEach((deviceName) => { const foundDevice = deviceDescriptors.find((device) => device.name === deviceName); if (foundDevice) { const chromeMobileCapability = createChromeCapabilityWithEmulationFunc(foundDevice, isHeadless); capabilities.push(chromeMobileCapability); } else { log.error(`The device ${deviceName} is not supported. Please choose from the following devices: ${deviceDescriptors.map(device => device.name).join(', ')}`); } }); } else if (browsers.includes('chrome')) { capabilities.push(chromeCapability); } if (capabilities.length === 0) { capabilitiesErrorMessageFunc(browsers, capabilityMap, devices, deviceDescriptors, isMobileEmulation); } log.info('Added new storybook capabilities:', JSON.stringify(capabilities, null, 2)); } /** * Scan the storybook instance */ export async function scanStorybook(config, options, // For testing purposes only getArgvVal = getArgvValue, checkStorybookIsRun = checkStorybookIsRunning, sanitizeURLFunc = sanitizeURL, getStoriesJsonFunc = getStoriesJson) { // Prepare storybook scanning const cliUrl = getArgvVal('--url', value => value); const rawStorybookUrl = cliUrl ?? process.env.STORYBOOK_URL ?? options?.storybook?.url ?? 'http://127.0.0.1:6006'; await checkStorybookIsRun(rawStorybookUrl); const storybookUrl = sanitizeURLFunc(rawStorybookUrl); // Create a temporary folder for test files and add that to the specs const tempDir = resolve(tmpdir(), `wdio-storybook-tests-${Date.now()}`); mkdirSync(tempDir); log.info(`Using temporary folder for storybook specs: ${tempDir}`); // Get the stories const storiesJson = await getStoriesJsonFunc(storybookUrl); const filteredStories = filterStories(storiesJson); // Check if the specs are provided via the CLI so they can be added to the specs // when users provide stories with interactive components const isCliSpecs = getArgvVal('--spec', value => value); const cliSpecs = []; if (isCliSpecs) { cliSpecs.push(...config.specs); } config.specs = [join(tempDir, '*.{js,mjs,ts}'), ...cliSpecs]; return { storiesJson: filteredStories, storybookUrl, tempDir, }; } /** * Parse the stories to skip */ export function parseSkipStories(skipStories) { if (Array.isArray(skipStories)) { return skipStories; } const regexPattern = /^\/.*\/[gimyus]*$/; if (regexPattern.test(skipStories)) { try { const match = skipStories.match(/^\/(.+)\/([gimyus]*)$/); if (match) { const [, pattern, flags] = match; return new RegExp(pattern, flags); } } catch (error) { log.error('Invalid regular expression:', error, '. Not using a regular expression to skip stories.'); } } return skipStories.split(',').map(skipped => skipped.trim()); }