creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
417 lines (349 loc) • 14 kB
text/typescript
import { parse, stringify } from 'qs';
import { RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { TestData, isTest, isDefined, TestStatus, CreeveySuite, CreeveyTest, CreeveyStatus } from '../../types.js';
export interface CreeveyViewFilter {
status: TestStatus | null;
subStrings: string[];
}
export interface CreeveyTestsStatus {
successCount: number;
failedCount: number;
pendingCount: number;
approvedCount: number;
}
const statusUpdatesMap = new Map<TestStatus | undefined, RegExp>([
[undefined, /(unknown|success|approved|failed|pending|running)/],
['unknown', /(success|approved|failed|pending|running)/],
['success', /(approved|failed|pending|running)/],
['approved', /(failed|pending|running)/],
['failed', /(pending|running)/],
['pending', /running/],
]);
function makeEmptySuiteNode(path: string[] = []): CreeveySuite {
return {
path,
skip: true,
opened: false,
checked: true,
indeterminate: false,
children: {},
};
}
export function calcStatus(oldStatus?: TestStatus, newStatus?: TestStatus): TestStatus | undefined {
return newStatus && statusUpdatesMap.get(oldStatus)?.test(newStatus) ? newStatus : oldStatus;
}
export function getTestPath(test: Pick<TestData, 'browser' | 'testName' | 'storyPath'>): string[] {
const { browser, testName, storyPath } = test;
return [...storyPath, testName, browser].filter(isDefined);
}
export function getSuiteByPath(suite: CreeveySuite, path: string[]): CreeveySuite | CreeveyTest | undefined {
return path.reduce(
(suiteOrTest: CreeveySuite | CreeveyTest | undefined, pathToken) =>
isTest(suiteOrTest) ? suiteOrTest : suiteOrTest?.children[pathToken],
suite,
);
}
export function getTestByPath(suite: CreeveySuite, path: string[]): CreeveyTest | null {
const test = getSuiteByPath(suite, path) ?? suite;
return isTest(test) ? test : null;
}
export function getTestsByStoryId(suite: CreeveySuite, storyId: string): CreeveyTest[] {
return Object.values(suite.children)
.filter(isDefined)
.flatMap((suiteOrTest) => {
if (isTest(suiteOrTest)) return suiteOrTest.storyId === storyId ? suiteOrTest : [];
return getTestsByStoryId(suiteOrTest, storyId);
})
.filter(isDefined);
}
function checkTests(suiteOrTest: CreeveySuite | CreeveyTest, checked: boolean): void {
suiteOrTest.checked = checked;
if (!isTest(suiteOrTest)) {
suiteOrTest.indeterminate = false;
Object.values(suiteOrTest.children)
.filter(isDefined)
.forEach((child) => {
checkTests(child, checked);
});
}
}
function updateChecked(suite: CreeveySuite): void {
const children = Object.values(suite.children)
.filter(isDefined)
.filter((child) => !child.skip);
const checkedEvery = children.every((test) => test.checked);
const checkedSome = children.some((test) => test.checked);
const indeterminate =
children.some((test) => (isTest(test) ? false : test.indeterminate)) || (!checkedEvery && checkedSome);
const checked = indeterminate || suite.checked == checkedEvery ? suite.checked : checkedEvery;
suite.checked = checked;
suite.indeterminate = indeterminate;
}
export function checkSuite(suite: CreeveySuite, path: string[], checked: boolean): void {
const subSuite = getSuiteByPath(suite, path);
if (subSuite) checkTests(subSuite, checked);
path
.slice(0, -1)
.map((_, index, tokens) => tokens.slice(0, tokens.length - index))
.forEach((parentPath) => {
const parentSuite = getSuiteByPath(suite, parentPath);
if (isTest(parentSuite)) return;
if (parentSuite) updateChecked(parentSuite);
});
updateChecked(suite);
}
export function treeifyTests(testsById: CreeveyStatus['tests']): CreeveySuite {
const rootSuite: CreeveySuite = makeEmptySuiteNode();
rootSuite.opened = true;
Object.values(testsById).forEach((test) => {
if (!test) return;
const [browser, ...testPath] = getTestPath(test).reverse();
const lastSuite = testPath.reverse().reduce((suite, token) => {
const subSuite = suite.children[token] ?? makeEmptySuiteNode([...suite.path, token]);
subSuite.status = calcStatus(subSuite.status, test.status);
if (!test.skip) subSuite.skip = false;
if (!subSuite.skip) suite.skip = false;
suite.children[token] = subSuite;
suite.status = calcStatus(suite.status, subSuite.status);
if (isTest(subSuite)) {
throw new Error(`Suite and Test should not have same path '${JSON.stringify(getTestPath(subSuite))}'`);
}
return subSuite;
}, rootSuite);
lastSuite.children[browser] = { ...test, checked: true };
});
return rootSuite;
}
export function getCheckedTests(suite: CreeveySuite): CreeveyTest[] {
return Object.values(suite.children)
.filter(isDefined)
.flatMap((suiteOrTest) => {
if (isTest(suiteOrTest)) return suiteOrTest.checked ? suiteOrTest : [];
if (!suiteOrTest.checked && !suiteOrTest.indeterminate) return [];
return getCheckedTests(suiteOrTest);
});
}
export function getFailedTests(suite: CreeveySuite): CreeveyTest[] {
return Object.values(suite.children)
.filter(isDefined)
.flatMap((suiteOrTest) => {
if (isTest(suiteOrTest)) return suiteOrTest.status === 'failed' ? suiteOrTest : [];
return getFailedTests(suiteOrTest);
});
}
export function updateTestStatus(suite: CreeveySuite, path: string[], update: Partial<TestData>): void {
const title = path.shift();
if (!title) return;
const suiteOrTest =
suite.children[title] ??
(suite.children[title] = {
...(path.length == 0 ? (update as TestData) : makeEmptySuiteNode([...suite.path, title])),
checked: suite.checked,
});
if (isTest(suiteOrTest)) {
const test = suiteOrTest;
const { skip, status, results, approved } = update;
if (isDefined(skip)) test.skip = skip;
if (isDefined(status)) test.status = status;
if (isDefined(results)) {
if (test.results) test.results.push(...results);
else test.results = results;
}
if (approved === null) test.approved = null;
else if (approved !== undefined)
Object.entries(approved).forEach(
([image, retry]) => retry !== undefined && ((test.approved = test.approved ?? {})[image] = retry),
);
} else {
const subSuite = suiteOrTest;
updateTestStatus(subSuite, path, update);
}
suite.skip = Object.values(suite.children)
.filter(isDefined)
.map(({ skip }) => skip)
.every(Boolean);
suite.status = Object.values(suite.children)
.filter(isDefined)
.map(({ status }) => status)
.reduce(calcStatus);
}
export function removeTests(suite: CreeveySuite, path: string[]): void {
const title = path.shift();
if (!title) return;
const suiteOrTest = suite.children[title];
if (suiteOrTest && !isTest(suiteOrTest)) removeTests(suiteOrTest, path);
if (isTest(suiteOrTest) || Object.keys(suiteOrTest?.children ?? {}).length == 0) {
// TODO Use Map instead
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete suite.children[title];
}
if (Object.keys(suite.children).length == 0) return;
updateChecked(suite);
suite.skip = Object.values(suite.children)
.filter(isDefined)
.map(({ skip }) => skip)
.every(Boolean);
suite.status = Object.values(suite.children)
.filter(isDefined)
.map(({ status }) => status)
.reduce(calcStatus);
}
// TODO Include images to test suite
// TODO If only one image in test, don't include it
export function filterTests(suite: CreeveySuite, filter: CreeveyViewFilter): CreeveySuite {
const { status, subStrings } = filter;
if (!status && !subStrings.length) return suite;
const filteredSuite: CreeveySuite = { ...suite, children: {} };
Object.entries(suite.children).forEach(([title, suiteOrTest]) => {
if (!suiteOrTest || suiteOrTest.skip) return;
if (!status && subStrings.some((subString) => title.toLowerCase().includes(subString))) {
filteredSuite.children[title] = suiteOrTest;
} else if (isTest(suiteOrTest)) {
if (status && suiteOrTest.status && ['pending', 'running', status].includes(suiteOrTest.status))
filteredSuite.children[title] = suiteOrTest;
} else {
const filteredSubSuite = filterTests(suiteOrTest, filter);
if (Object.keys(filteredSubSuite.children).length == 0) return;
filteredSuite.children[title] = filteredSubSuite;
}
});
return filteredSuite;
}
export function openSuite(suite: CreeveySuite, path: string[], opened: boolean): void {
const subSuite = path.reduce((suiteOrTest: CreeveySuite | CreeveyTest | undefined, pathToken) => {
if (suiteOrTest && !isTest(suiteOrTest)) {
if (opened) suiteOrTest.opened = opened;
return suiteOrTest.children[pathToken];
}
}, suite);
if (subSuite && !isTest(subSuite)) subSuite.opened = opened;
}
export function flattenSuite(suite: CreeveySuite): { title: string; suite: CreeveySuite | CreeveyTest }[] {
if (!suite.opened) return [];
return Object.entries(suite.children).flatMap(([title, subSuite]) =>
subSuite ? [{ title, suite: subSuite }, ...(isTest(subSuite) ? [] : flattenSuite(subSuite))] : [],
);
}
export function countTestsStatus(suite: CreeveySuite): CreeveyTestsStatus {
let successCount = 0;
let failedCount = 0;
let approvedCount = 0;
let pendingCount = 0;
const cases: (CreeveySuite | CreeveyTest)[] = Object.values(suite.children).filter(isDefined);
let suiteOrTest;
while ((suiteOrTest = cases.pop())) {
if (isTest(suiteOrTest)) {
if (suiteOrTest.status === 'approved') approvedCount++;
if (suiteOrTest.status === 'success') successCount++;
if (suiteOrTest.status === 'failed') failedCount++;
if (suiteOrTest.status === 'pending') pendingCount++;
} else {
cases.push(...Object.values(suiteOrTest.children).filter(isDefined));
}
}
return { approvedCount, successCount, failedCount, pendingCount };
}
export function getConnectionUrl(): string {
return [
typeof __CREEVEY_SERVER_HOST__ == 'undefined' ? window.location.hostname : __CREEVEY_SERVER_HOST__,
typeof __CREEVEY_SERVER_PORT__ == 'undefined' ? window.location.port : __CREEVEY_SERVER_PORT__,
]
.filter(Boolean)
.join(':');
}
export function getImageUrl(path: string[], imageName: string, isReport?: boolean): string {
// path => [title, story, test, browser]
const browser = path.slice(-1)[0];
const imagesUrl = window.location.host
? `${window.location.protocol}//${getConnectionUrl()}${
window.location.pathname == '/' && !isReport
? '/report'
: window.location.pathname.split('/').slice(0, -1).join('/')
}/${encodeURI(path.slice(0, -1).join('/'))}`
: encodeURI(path.slice(0, -1).join('/'));
return imageName == browser ? imagesUrl : `${imagesUrl}/${encodeURI(browser)}`;
}
export function getBorderSize(element: HTMLElement): number {
// NOTE Firefox returns empty string for `borderWidth` prop
const borderSize = parseFloat(getComputedStyle(element).borderTopWidth);
return Number.isNaN(borderSize) ? 0 : borderSize;
}
export function useLoadImages(s1: string, s2: string, s3: string): boolean {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(false);
void Promise.all(
[s1, s2, s3].map(
(url) =>
new Promise((resolve) => {
const image = document.createElement('img');
image.src = url;
image.onload = resolve;
image.onerror = resolve;
}),
),
).then(() => {
setLoaded(true);
});
}, [s1, s2, s3]);
return loaded;
}
/**
* Uses the ResizeObserver API to observe changes within the given HTML Element DOM Rect.
*
* @returns dimensions of element's content box (which means without paddings and border width)
*/
export function useResizeObserver<T extends Element>(
elementRef: RefObject<T>,
onResize: () => void,
debounceTimeout = 16,
): void {
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!elementRef.current) return;
observerRef.current = new ResizeObserver(onResize);
observerRef.current.observe(elementRef.current);
return () => observerRef.current?.disconnect();
}, [debounceTimeout, elementRef, onResize]);
}
export function useApplyScale(imageRef: RefObject<HTMLImageElement>, scale: number, dependency?: unknown): void {
useLayoutEffect(() => {
if (!imageRef.current) return;
const image = imageRef.current;
const borderSize = getBorderSize(image);
image.style.height = `${image.naturalHeight * scale + borderSize * 2}px`;
}, [imageRef, scale, dependency]);
}
export function useCalcScale(diffImageRef: RefObject<HTMLImageElement>, loaded: boolean): number {
const [scale, setScale] = useState(1);
const calcScale = useCallback(() => {
const diffImage = diffImageRef.current;
if (!diffImage || !loaded) {
setScale(1);
return;
}
const borderSize = getBorderSize(diffImage);
const ratio = (diffImage.getBoundingClientRect().width - borderSize * 2) / diffImage.naturalWidth;
setScale(Math.min(1, ratio));
}, [diffImageRef, loaded]);
useResizeObserver(diffImageRef, calcScale);
useLayoutEffect(calcScale, [calcScale]);
return scale;
}
export function setSearchParams(testPath: string[]): void {
const pageUrl = `?${stringify({ testPath })}`;
window.history.pushState({ testPath }, '', pageUrl);
}
export function getTestPathFromSearch(): string[] {
const { testPath } = parse(window.location.search.slice(1));
if (Array.isArray(testPath) && testPath.every((token) => typeof token == 'string')) {
return testPath;
}
return [];
}
export function useForceUpdate(): () => void {
const [, update] = useState({});
return useCallback(() => {
update({});
}, []);
}