creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
269 lines • 13.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.definePlaywrightTests = definePlaywrightTests;
const path_1 = __importDefault(require("path"));
const assert_1 = __importDefault(require("assert"));
const fs_1 = require("fs");
const test_1 = require("@playwright/test");
const isEqual_js_1 = __importDefault(require("lodash/isEqual.js"));
const types_1 = require("../types");
const compare_1 = require("../server/compare");
const webdriver_1 = require("../server/webdriver");
const helpers_1 = require("./helpers");
const cacheDir = process.env.CREEVEY_CACHE_DIR;
const defaultConfig = {
diffOptions: { threshold: 0.1, includeAA: false },
odiffOptions: { threshold: 0.1, antialiasing: true },
comparisonLibrary: 'pixelmatch',
reusePageContext: true,
trace: false,
};
// TODO: Use this Storybook function for building args for query params
// export const buildArgsParam = (initialArgs: Args | undefined, args: Args): string => {
// TODO: Pass globals to story
function appendStoryQueryParams(url, storyId) {
return `${url}?args=&globals=&id=${storyId}`;
}
function assertWrapper(assert) {
return async function assertImage(actual, imageName) {
try {
const errorMessage = await assert(actual, imageName);
if (errorMessage) {
throw new Error(errorMessage);
}
}
catch (error) {
if (error instanceof Error) {
error.stack = error.stack
?.split('\n')
.filter((line) => !line.includes('at assertImage'))
.join('\n');
}
throw error;
}
};
}
async function takeScreenshot(page, storyId, captureElement, ignoreElements) {
const ignore = ignoreElements ? (Array.isArray(ignoreElements) ? ignoreElements : [ignoreElements]) : [];
const mask = ignore.map((selector) => page.locator(selector));
if (captureElement) {
// TODO Use page.locator(captureElement) instead of page.$(captureElement)
// TODO Test `#storybook-root > *` selector, probably we don't need `> *` and use `#storybook-root >*:first-child` instead
const element = await page.$(captureElement);
if (!element)
throw new Error(`Capture element '${captureElement}' not found for story '${storyId}'`);
return element.screenshot({
style: ':root { overflow: hidden !important; }',
animations: 'disabled',
mask,
});
}
else {
return page.screenshot({
animations: 'disabled',
mask,
});
}
}
// TODO: To support parallel tests, we need to define each test suite in separate file
// TODO: How to support custom interactions for different tests
// Main function to define tests using Playwright's API
function definePlaywrightTests(config) {
(0, assert_1.default)(cacheDir, 'Cache directory not found');
const stories = JSON.parse((0, fs_1.readFileSync)(path_1.default.join(cacheDir, 'stories.json'), 'utf-8'));
let globals = {};
let reusedContext;
let reusedPage;
const { diffOptions, odiffOptions, comparisonLibrary, reusePageContext, trace } = {
...defaultConfig,
...config,
};
async function updateGlobals(page, storybookGlobals) {
if (storybookGlobals && typeof storybookGlobals === 'object' && !(0, isEqual_js_1.default)(globals, storybookGlobals)) {
globals = storybookGlobals;
await page.evaluate((globals) => {
window.__STORYBOOK_ADDONS_CHANNEL__.emit(types_1.StorybookEvents.UPDATE_GLOBALS, { globals });
}, globals);
}
}
test_1.test.describe('Creevey Tests', () => {
const imagesContext = {
attachments: [],
testFullPath: [],
images: {},
};
let assertImage;
test_1.test.beforeAll('Setup images context', async ({ browser }, { project }) => {
const { snapshotDir, outputDir, use: { viewport }, } = project;
if (reusePageContext) {
const storybookUrl = project.use.baseURL;
(0, assert_1.default)(storybookUrl, 'Storybook URL not found');
// TODO Record video
reusedContext = await browser.newContext({
viewport,
screen: viewport ?? undefined,
// recordVideo: trace ? { dir: path.join(cacheDir, `${process.pid}`), size: viewport ?? undefined } : undefined,
});
reusedPage = await reusedContext.newPage();
if (trace) {
// TODO: Add logger for tracing
await reusedContext.tracing.start(typeof trace === 'object' ? trace : { screenshots: true, snapshots: true, sources: true });
}
await reusedPage.goto((0, webdriver_1.appendIframePath)(storybookUrl), { timeout: 60000 });
await reusedPage.waitForLoadState('networkidle');
await (0, helpers_1.waitForStorybookReady)(reusedPage);
}
if (comparisonLibrary === 'pixelmatch') {
const { default: pixelmatch } = await import('pixelmatch');
assertImage = assertWrapper((0, compare_1.getPixelmatchAssert)(pixelmatch, imagesContext, { screenDir: snapshotDir, reportDir: outputDir, diffOptions }));
}
else {
const { compare } = await import('odiff-bin');
assertImage = assertWrapper((0, compare_1.getOdiffAssert)(compare, imagesContext, { screenDir: snapshotDir, reportDir: outputDir, odiffOptions }));
}
});
test_1.test.beforeEach('Switch story', async ({ page }, { annotations, project }) => {
const { description: storyId } = annotations.find((annotation) => annotation.type === 'storyId') ?? {};
(0, assert_1.default)(storyId, 'Cannot get storyId. It seems like inner test annotation is missing');
const story = stories[storyId];
(0, assert_1.default)(story, `Story '${storyId}' not found in stories cache`);
const { title, name } = story;
const storybookGlobals = project.metadata.storybookGlobals;
imagesContext.attachments = [];
imagesContext.testFullPath = [...title.split('/').map((x) => x.trim()), name, project.name];
imagesContext.images = {};
if (!reusePageContext) {
const storybookUrl = project.use.baseURL;
(0, assert_1.default)(storybookUrl, 'Storybook URL not found');
await page.goto(appendStoryQueryParams((0, webdriver_1.appendIframePath)(storybookUrl), storyId), { timeout: 60000 });
await page.waitForLoadState('networkidle');
await (0, helpers_1.waitForStorybookReady)(page);
// TODO: Pass globals to story
await updateGlobals(page, storybookGlobals);
return;
}
// 1. Update Storybook Globals
await updateGlobals(reusedPage, storybookGlobals);
// 2. Reset Mouse Position
await reusedPage.mouse.move(0, 0);
// 3. Select Story
const errorMessage = await reusedPage.evaluate(async ({ storyId, StorybookEvents }) => {
// TODO: DRY with withCreevey.ts
// NOTE: Copy-pasted from withCreevey.ts
const channel = window.__STORYBOOK_ADDONS_CHANNEL__;
async function sequence(fns) {
for (const fn of fns) {
await fn();
}
}
let rejectCallback;
const renderErrorPromise = new Promise((_resolve, reject) => (rejectCallback = reject));
function errorHandler({ title, description }) {
rejectCallback({
message: title,
stack: description,
});
}
function exceptionHandler(exception) {
rejectCallback(exception);
}
function removeErrorHandlers() {
channel.off(StorybookEvents.STORY_ERRORED, errorHandler);
channel.off(StorybookEvents.STORY_THREW_EXCEPTION, errorHandler);
}
channel.once(StorybookEvents.STORY_ERRORED, errorHandler);
channel.once(StorybookEvents.STORY_THREW_EXCEPTION, exceptionHandler);
let resolveCallback;
const storyRenderedPromise = new Promise((resolve) => (resolveCallback = resolve));
function renderHandler() {
resolveCallback();
}
function removeRenderHandlers() {
channel.off(StorybookEvents.STORY_RENDERED, renderHandler);
}
channel.once(StorybookEvents.STORY_RENDERED, renderHandler);
setTimeout(() => {
channel.emit(StorybookEvents.SET_CURRENT_STORY, { storyId });
}, 0);
try {
await Promise.race([
renderErrorPromise,
sequence([() => storyRenderedPromise, () => document.fonts.ready]),
]);
}
catch (reason) {
// NOTE Event `STORY_THREW_EXCEPTION` triggered only in react and vue frameworks and return Error instance
// NOTE Event `STORY_ERRORED` return error-like object without `name` field
const errorMessage = reason instanceof Error
? (reason.stack ?? reason.message)
: (0, types_1.isObject)(reason)
? `${reason.message}\n ${reason.stack}`
: reason;
return errorMessage;
}
finally {
removeErrorHandlers();
removeRenderHandlers();
}
return null;
}, { storyId: story.id, StorybookEvents: types_1.StorybookEvents });
if (errorMessage) {
throw new Error(`Failed to select story '${story.id}': ${errorMessage}`);
}
});
test_1.test.afterEach('Save screenshot', () => {
const { name: projectName } = test_1.test.info().project;
// TODO: Use another way to handle attachments
// NOTE: Don't need to copy files for assertImage, because it's done internally
const { actual, diff, expect } = imagesContext.images[projectName] ?? {};
for (const image of imagesContext.attachments) {
switch (true) {
case image.includes('actual') && !!actual: {
test_1.test.info().attachments.push({ name: actual, path: image, contentType: 'image/png' });
// await test.info().attach(actual, { path: image });
break;
}
case image.includes('expect') && !!expect: {
test_1.test.info().attachments.push({ name: expect, path: image, contentType: 'image/png' });
// await test.info().attach(expect, { path: image });
break;
}
case image.includes('diff') && !!diff: {
test_1.test.info().attachments.push({ name: diff, path: image, contentType: 'image/png' });
// await test.info().attach(diff, { path: image });
break;
}
}
}
});
if (trace && reusePageContext) {
test_1.test.afterAll('Save trace', async ({ browser: _ }, { project }) => {
const { outputDir, name: projectName } = project;
await reusedContext.tracing.stop({
path: `${outputDir}/traces/${projectName}-${process.pid}.zip`,
});
// await reusedPage.video()?.saveAs(`${outputDir}/traces/${projectName}-${process.pid}.webm`);
});
}
for (const story of Object.values(stories)) {
const { name, title, parameters } = story;
const { captureElement, ignoreElements } = (parameters.creevey ?? {});
test_1.test.describe(title, () => {
// TODO: Support creevey.skip
(0, test_1.test)(name, { annotation: [{ type: 'storyId', description: story.id }] }, async ({ page }) => {
// 4. Take Screenshot
const screenshot = await takeScreenshot(reusePageContext ? reusedPage : page, story.id, captureElement, ignoreElements);
// TODO: Support this
// NOTE: Bear in mind that page.locator('#root > *') is not working
// await expect(page.locator(captureElement)).toHaveScreenshot(name);
// 5. Assert Image
await assertImage(screenshot);
});
});
}
});
}
//# sourceMappingURL=generator.js.map