UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

381 lines 14.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.calcStatus = calcStatus; exports.getTestPath = getTestPath; exports.getSuiteByPath = getSuiteByPath; exports.getTestByPath = getTestByPath; exports.getTestsByStoryId = getTestsByStoryId; exports.checkSuite = checkSuite; exports.treeifyTests = treeifyTests; exports.getCheckedTests = getCheckedTests; exports.getFailedTests = getFailedTests; exports.updateTestStatus = updateTestStatus; exports.removeTests = removeTests; exports.filterTests = filterTests; exports.openSuite = openSuite; exports.flattenSuite = flattenSuite; exports.countTestsStatus = countTestsStatus; exports.getConnectionUrl = getConnectionUrl; exports.getImageUrl = getImageUrl; exports.getBorderSize = getBorderSize; exports.useLoadImages = useLoadImages; exports.useResizeObserver = useResizeObserver; exports.useApplyScale = useApplyScale; exports.useCalcScale = useCalcScale; exports.setSearchParams = setSearchParams; exports.getTestPathFromSearch = getTestPathFromSearch; exports.useForceUpdate = useForceUpdate; const qs_1 = require("qs"); const react_1 = require("react"); const types_js_1 = require("../../types.js"); const statusUpdatesMap = new Map([ [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 = []) { return { path, skip: true, opened: false, checked: true, indeterminate: false, children: {}, }; } function calcStatus(oldStatus, newStatus) { return newStatus && statusUpdatesMap.get(oldStatus)?.test(newStatus) ? newStatus : oldStatus; } function getTestPath(test) { const { browser, testName, storyPath } = test; return [...storyPath, testName, browser].filter(types_js_1.isDefined); } function getSuiteByPath(suite, path) { return path.reduce((suiteOrTest, pathToken) => (0, types_js_1.isTest)(suiteOrTest) ? suiteOrTest : suiteOrTest?.children[pathToken], suite); } function getTestByPath(suite, path) { const test = getSuiteByPath(suite, path) ?? suite; return (0, types_js_1.isTest)(test) ? test : null; } function getTestsByStoryId(suite, storyId) { return Object.values(suite.children) .filter(types_js_1.isDefined) .flatMap((suiteOrTest) => { if ((0, types_js_1.isTest)(suiteOrTest)) return suiteOrTest.storyId === storyId ? suiteOrTest : []; return getTestsByStoryId(suiteOrTest, storyId); }) .filter(types_js_1.isDefined); } function checkTests(suiteOrTest, checked) { suiteOrTest.checked = checked; if (!(0, types_js_1.isTest)(suiteOrTest)) { suiteOrTest.indeterminate = false; Object.values(suiteOrTest.children) .filter(types_js_1.isDefined) .forEach((child) => { checkTests(child, checked); }); } } function updateChecked(suite) { const children = Object.values(suite.children) .filter(types_js_1.isDefined) .filter((child) => !child.skip); const checkedEvery = children.every((test) => test.checked); const checkedSome = children.some((test) => test.checked); const indeterminate = children.some((test) => ((0, types_js_1.isTest)(test) ? false : test.indeterminate)) || (!checkedEvery && checkedSome); const checked = indeterminate || suite.checked == checkedEvery ? suite.checked : checkedEvery; suite.checked = checked; suite.indeterminate = indeterminate; } function checkSuite(suite, path, checked) { 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 ((0, types_js_1.isTest)(parentSuite)) return; if (parentSuite) updateChecked(parentSuite); }); updateChecked(suite); } function treeifyTests(testsById) { const rootSuite = 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 ((0, types_js_1.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; } function getCheckedTests(suite) { return Object.values(suite.children) .filter(types_js_1.isDefined) .flatMap((suiteOrTest) => { if ((0, types_js_1.isTest)(suiteOrTest)) return suiteOrTest.checked ? suiteOrTest : []; if (!suiteOrTest.checked && !suiteOrTest.indeterminate) return []; return getCheckedTests(suiteOrTest); }); } function getFailedTests(suite) { return Object.values(suite.children) .filter(types_js_1.isDefined) .flatMap((suiteOrTest) => { if ((0, types_js_1.isTest)(suiteOrTest)) return suiteOrTest.status === 'failed' ? suiteOrTest : []; return getFailedTests(suiteOrTest); }); } function updateTestStatus(suite, path, update) { const title = path.shift(); if (!title) return; const suiteOrTest = suite.children[title] ?? (suite.children[title] = { ...(path.length == 0 ? update : makeEmptySuiteNode([...suite.path, title])), checked: suite.checked, }); if ((0, types_js_1.isTest)(suiteOrTest)) { const test = suiteOrTest; const { skip, status, results, approved } = update; if ((0, types_js_1.isDefined)(skip)) test.skip = skip; if ((0, types_js_1.isDefined)(status)) test.status = status; if ((0, types_js_1.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(types_js_1.isDefined) .map(({ skip }) => skip) .every(Boolean); suite.status = Object.values(suite.children) .filter(types_js_1.isDefined) .map(({ status }) => status) .reduce(calcStatus); } function removeTests(suite, path) { const title = path.shift(); if (!title) return; const suiteOrTest = suite.children[title]; if (suiteOrTest && !(0, types_js_1.isTest)(suiteOrTest)) removeTests(suiteOrTest, path); if ((0, types_js_1.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(types_js_1.isDefined) .map(({ skip }) => skip) .every(Boolean); suite.status = Object.values(suite.children) .filter(types_js_1.isDefined) .map(({ status }) => status) .reduce(calcStatus); } // TODO Include images to test suite // TODO If only one image in test, don't include it function filterTests(suite, filter) { const { status, subStrings } = filter; if (!status && !subStrings.length) return suite; const filteredSuite = { ...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 ((0, types_js_1.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; } function openSuite(suite, path, opened) { const subSuite = path.reduce((suiteOrTest, pathToken) => { if (suiteOrTest && !(0, types_js_1.isTest)(suiteOrTest)) { if (opened) suiteOrTest.opened = opened; return suiteOrTest.children[pathToken]; } }, suite); if (subSuite && !(0, types_js_1.isTest)(subSuite)) subSuite.opened = opened; } function flattenSuite(suite) { if (!suite.opened) return []; return Object.entries(suite.children).flatMap(([title, subSuite]) => subSuite ? [{ title, suite: subSuite }, ...((0, types_js_1.isTest)(subSuite) ? [] : flattenSuite(subSuite))] : []); } function countTestsStatus(suite) { let successCount = 0; let failedCount = 0; let approvedCount = 0; let pendingCount = 0; const cases = Object.values(suite.children).filter(types_js_1.isDefined); let suiteOrTest; while ((suiteOrTest = cases.pop())) { if ((0, types_js_1.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(types_js_1.isDefined)); } } return { approvedCount, successCount, failedCount, pendingCount }; } function getConnectionUrl() { 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(':'); } function getImageUrl(path, imageName, isReport) { // 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)}`; } function getBorderSize(element) { // NOTE Firefox returns empty string for `borderWidth` prop const borderSize = parseFloat(getComputedStyle(element).borderTopWidth); return Number.isNaN(borderSize) ? 0 : borderSize; } function useLoadImages(s1, s2, s3) { const [loaded, setLoaded] = (0, react_1.useState)(false); (0, react_1.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) */ function useResizeObserver(elementRef, onResize, debounceTimeout = 16) { const observerRef = (0, react_1.useRef)(null); (0, react_1.useEffect)(() => { if (!elementRef.current) return; observerRef.current = new ResizeObserver(onResize); observerRef.current.observe(elementRef.current); return () => observerRef.current?.disconnect(); }, [debounceTimeout, elementRef, onResize]); } function useApplyScale(imageRef, scale, dependency) { (0, react_1.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]); } function useCalcScale(diffImageRef, loaded) { const [scale, setScale] = (0, react_1.useState)(1); const calcScale = (0, react_1.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); (0, react_1.useLayoutEffect)(calcScale, [calcScale]); return scale; } function setSearchParams(testPath) { const pageUrl = `?${(0, qs_1.stringify)({ testPath })}`; window.history.pushState({ testPath }, '', pageUrl); } function getTestPathFromSearch() { const { testPath } = (0, qs_1.parse)(window.location.search.slice(1)); if (Array.isArray(testPath) && testPath.every((token) => typeof token == 'string')) { return testPath; } return []; } function useForceUpdate() { const [, update] = (0, react_1.useState)({}); return (0, react_1.useCallback)(() => { update({}); }, []); } //# sourceMappingURL=helpers.js.map