creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
130 lines (115 loc) • 4.76 kB
text/typescript
import { createHash } from 'crypto';
import _ from 'lodash';
import type {
TestData,
CreeveyStoryParams,
StoriesRaw,
SkipOptions,
ServerTest,
StoryInput,
CreeveyTestFunction,
CreeveyTestContext,
} from '../types.js';
import { isDefined } from '../types.js';
import { shouldSkip } from './utils.js';
function storyTestFabric(delay?: number, testFn?: CreeveyTestFunction) {
return async function storyTest(context: CreeveyTestContext) {
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
await (testFn
? testFn(context)
: context.screenshots.length > 0
? context.matchImages(
context.screenshots.reduce(
(screenshots, { imageName, screenshot }, index) => ({
...screenshots,
[imageName ?? `screenshot_${index}`]: screenshot,
}),
{},
),
)
: context.matchImage(await context.takeScreenshot()));
};
}
function createCreeveyTest(
browser: string,
storyMeta: StoryInput,
skipOptions?: SkipOptions,
testName?: string,
): TestData {
const { title, name, id: storyId } = storyMeta;
const testPath = [title, name, testName, browser].filter(isDefined);
const skip = skipOptions ? shouldSkip(browser, { title, name }, skipOptions, testName) : false;
const id = createHash('sha1').update(testPath.join('/')).digest('hex');
return {
id,
skip,
browser,
testName,
storyPath: [...title.split('/').map((x) => x.trim()), name],
storyId,
};
}
function convertStories(browserName: string, stories: StoriesRaw | StoryInput[]): Partial<Record<string, ServerTest>> {
const tests: Record<string, ServerTest> = {};
(Array.isArray(stories) ? stories : Object.values(stories)).forEach((storyMeta) => {
// TODO Skip docsOnly stories for now
if (storyMeta.parameters.docsOnly) return;
const { delay: delayParam, tests: storyTests, skip } = (storyMeta.parameters.creevey ?? {}) as CreeveyStoryParams;
const delay =
typeof delayParam == 'number' ? delayParam : delayParam?.for.includes(browserName) ? delayParam.ms : 0;
// typeof tests === "undefined" => rootSuite -> kindSuite -> storyTest -> [browsers.png]
// typeof tests === "function" => rootSuite -> kindSuite -> storyTest -> browser -> [images.png]
// typeof tests === "object" => rootSuite -> kindSuite -> storySuite -> test -> [browsers.png]
// typeof tests === "object" => rootSuite -> kindSuite -> storySuite -> test -> browser -> [images.png]
if (!storyTests) {
const test = createCreeveyTest(browserName, storyMeta, skip);
tests[test.id] = { ...test, storyId: storyMeta.id, story: storyMeta, fn: storyTestFabric(delay) };
return;
}
Object.entries(storyTests).forEach(([testName, testFn]) => {
const test = createCreeveyTest(browserName, storyMeta, skip, testName);
tests[test.id] = { ...test, storyId: storyMeta.id, story: storyMeta, fn: storyTestFabric(delay, testFn) };
});
});
return tests;
}
export async function loadTestsFromStories(
browsers: string[],
provider: (storiesListener: (stories: Map<string, StoryInput[]>) => void) => Promise<StoriesRaw>,
update?: (testsDiff: Partial<Record<string, ServerTest>>) => void,
): Promise<Partial<Record<string, ServerTest>>> {
const testIdsByFiles = new Map<string, string[]>();
const stories = await provider((storiesByFiles) => {
const testsDiff: Partial<Record<string, ServerTest>> = {};
const tests: Partial<Record<string, ServerTest>> = {};
browsers.forEach((browser) => {
Array.from(storiesByFiles.entries()).forEach(([filename, stories]) => {
Object.assign(tests, convertStories(browser, stories));
const changed = Object.keys(tests);
const removed = testIdsByFiles.get(filename)?.filter((testId) => !tests[testId]) ?? [];
if (changed.length == 0) testIdsByFiles.delete(filename);
else testIdsByFiles.set(filename, changed);
Object.assign(testsDiff, tests);
removed.forEach((testId) => (testsDiff[testId] = undefined));
});
});
update?.(testsDiff);
});
const tests = browsers.reduce(
(tests: Partial<Record<string, ServerTest>>, browser) => Object.assign(tests, convertStories(browser, stories)),
{},
);
Object.values(tests)
.filter(isDefined)
.forEach(
({
id,
story: {
parameters: { fileName },
},
}) =>
// TODO Don't use filename as a key, due possible collisions if two require.context with same structure of modules are defined
testIdsByFiles.set(fileName as string, [...(testIdsByFiles.get(fileName as string) ?? []), id]),
);
return tests;
}