UNPKG

@storybook/test-runner

Version:
491 lines (487 loc) • 16.7 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); const TEST_RUNNER_STORYBOOK_URL = "{{storybookUrl}}"; const TEST_RUNNER_VERSION = "{{testRunnerVersion}}"; const TEST_RUNNER_FAIL_ON_CONSOLE = "{{failOnConsole}}"; const TEST_RUNNER_RENDERED_EVENT = "{{renderedEvent}}"; const TEST_RUNNER_VIEW_MODE = "{{viewMode}}"; const TEST_RUNNER_LOG_LEVEL = "{{logLevel}}"; const TEST_RUNNER_DEBUG_PRINT_LIMIT = parseInt("{{debugPrintLimit}}", 10); const bold = /* @__PURE__ */ __name((message) => `\x1B[1m${message}\x1B[22m`, "bold"); const magenta = /* @__PURE__ */ __name((message) => `\x1B[35m${message}\x1B[39m`, "magenta"); const blue = /* @__PURE__ */ __name((message) => `\x1B[34m${message}\x1B[39m`, "blue"); const red = /* @__PURE__ */ __name((message) => `\x1B[31m${message}\x1B[39m`, "red"); const yellow = /* @__PURE__ */ __name((message) => `\x1B[33m${message}\x1B[39m`, "yellow"); const grey = /* @__PURE__ */ __name((message) => `\x1B[90m${message}\x1B[39m`, "grey"); var LIMIT_REPLACE_NODE = "[...]"; var CIRCULAR_REPLACE_NODE = "[Circular]"; var arr = []; var replacerStack = []; function defaultOptions() { return { depthLimit: Number.MAX_SAFE_INTEGER, edgesLimit: Number.MAX_SAFE_INTEGER }; } __name(defaultOptions, "defaultOptions"); function stringify(obj, replacer, spacer, options) { if (typeof options === "undefined") { options = defaultOptions(); } decirc(obj, "", 0, [], void 0, 0, options); var res; try { if (replacerStack.length === 0) { res = JSON.stringify(obj, replacer, spacer); } else { res = JSON.stringify(obj, replaceGetterValues(replacer), spacer); } } catch (_) { return JSON.stringify("[unable to serialize, circular reference is too complex to analyze]"); } finally { while (arr.length !== 0) { var part = arr.pop(); if (part && part.length === 4) { Object.defineProperty(part[0], part[1], part[3]); } else if (part) { part[0][part[1]] = part[2]; } } } return res; } __name(stringify, "stringify"); function decirc(val, k, edgeIndex, stack, parent, depth, options) { depth += 1; var i; if (typeof val === "object" && val !== null) { for (i = 0; i < stack.length; i++) { if (stack[i] === val) { setReplace(CIRCULAR_REPLACE_NODE, val, k, parent); return; } } if (depth > options.depthLimit || edgeIndex + 1 > options.edgesLimit) { setReplace(LIMIT_REPLACE_NODE, val, k, parent); return; } stack.push(val); if (Array.isArray(val)) { for (i = 0; i < val.length; i++) { decirc(val[i], i.toString(), i, stack, val, depth, options); } } else { var keys = Object.keys(val); for (i = 0; i < keys.length; i++) { var key = keys[i]; decirc(val[key], key, i, stack, val, depth, options); } } stack.pop(); } } __name(decirc, "decirc"); function setReplace(replace, val, k, parent) { if (!parent) return; var propertyDescriptor = Object.getOwnPropertyDescriptor(parent, k); if (propertyDescriptor && propertyDescriptor.get !== void 0) { if (propertyDescriptor.configurable) { Object.defineProperty(parent, k, { value: replace }); arr.push([ parent, k, val, propertyDescriptor ]); } else { replacerStack.push([ val, k, replace ]); } } else { parent[k] = replace; arr.push([ parent, k, val ]); } } __name(setReplace, "setReplace"); function replaceGetterValues(replacer) { const effectiveReplacer = replacer ?? ((_k, v) => v); return function(key, val) { if (replacerStack.length > 0) { for (var i = 0; i < replacerStack.length; i++) { var part = replacerStack[i]; if (part[1] === key && part[0] === val) { val = part[2]; replacerStack.splice(i, 1); break; } } } return effectiveReplacer.call(this, key, val); }; } __name(replaceGetterValues, "replaceGetterValues"); function composeMessage(args) { if (args instanceof Error) { return `${args.name}: ${args.message} ${args.stack}`; } if (typeof args === "undefined") return "undefined"; if (typeof args === "string") return args; return stringify(args, null, null, { depthLimit: 5, edgesLimit: 100 }); } __name(composeMessage, "composeMessage"); function truncate(input, limit) { if (input.length > limit) { return input.substring(0, limit) + "\u2026"; } return input; } __name(truncate, "truncate"); function addToUserAgent(extra) { const originalUserAgent = globalThis.navigator.userAgent; if (!originalUserAgent.includes(extra)) { Object.defineProperty(globalThis.navigator, "userAgent", { get: /* @__PURE__ */ __name(function() { return [ originalUserAgent, extra ].join(" "); }, "get"), configurable: true }); } } __name(addToUserAgent, "addToUserAgent"); function getStory() { const currentRender = globalThis.__STORYBOOK_PREVIEW__.currentRender; if (currentRender && "story" in currentRender) { return currentRender.story; } return {}; } __name(getStory, "getStory"); let StorybookTestRunnerError = class StorybookTestRunnerError2 extends Error { static { __name(this, "StorybookTestRunnerError"); } constructor(params) { const { storyId, errorMessage, logs = [], isMessageFormatted = false } = params; const message = isMessageFormatted ? errorMessage : StorybookTestRunnerError2.buildErrorMessage({ storyId, errorMessage, logs }); super(message); this.name = "StorybookTestRunnerError"; } static buildErrorMessage(params) { const { storyId, errorMessage, logs = [], panel, errorMessagePrefix = "" } = params; const storyUrl = `${TEST_RUNNER_STORYBOOK_URL}?path=/story/${storyId}`; const finalStoryUrl = panel ? `${storyUrl}&addonPanel=${panel}` : storyUrl; const separator = "\n\n--------------------------------------------------"; const finalLogs = logs.filter((err) => !err.includes(errorMessage)); const extraLogs = finalLogs.length > 0 ? separator + "\n\nBrowser logs:\n\n" + finalLogs.join("\n\n") : ""; const linkPrefix = blue(` Click to debug the error directly in Storybook: ${finalStoryUrl} `); const message = `${errorMessagePrefix}${linkPrefix}Message: ${truncate(errorMessage, TEST_RUNNER_DEBUG_PRINT_LIMIT)} ${extraLogs}`; return message; } }; async function __throwError(storyId, errorMessage, logs) { throw new StorybookTestRunnerError({ storyId, errorMessage, logs }); } __name(__throwError, "__throwError"); async function __waitForStorybook() { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(); }, 1e4); if (document.querySelector("#root") || document.querySelector("#storybook-root")) { clearTimeout(timeout); return resolve(); } const observer = new MutationObserver(() => { if (document.querySelector("#root") || document.querySelector("#storybook-root")) { clearTimeout(timeout); resolve(); observer.disconnect(); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } __name(__waitForStorybook, "__waitForStorybook"); async function __getContext(storyId) { return globalThis.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId }); } __name(__getContext, "__getContext"); function isServerComponentError(error) { return typeof error === "string" && (error.includes("Only Server Components can be async at the moment.") || error.includes("A component was suspended by an uncached promise.") || error.includes("async/await is not yet supported in Client Components")); } __name(isServerComponentError, "isServerComponentError"); async function __test(storyId) { try { await __waitForStorybook(); } catch (err) { const message = `Timed out waiting for Storybook to load after 10 seconds. Are you sure the Storybook is running correctly in that URL? Is the Storybook private (e.g. under authentication layers)? HTML: ${document.body.innerHTML}`; throw new StorybookTestRunnerError({ storyId, errorMessage: message }); } const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; if (!channel) { throw new StorybookTestRunnerError({ storyId, errorMessage: "The test runner could not access the Storybook channel. Are you sure the Storybook is running correctly in that URL?" }); } addToUserAgent(`(StorybookTestRunner@${TEST_RUNNER_VERSION})`); let logs = []; let hasErrors = false; const logLevelMapping = { log: [ "info", "verbose" ], warn: [ "info", "warn", "verbose" ], error: [ "info", "warn", "error", "verbose" ], info: [ "verbose" ], trace: [ "verbose" ], debug: [ "verbose" ], group: [ "verbose" ], groupCollapsed: [ "verbose" ], table: [ "verbose" ], dir: [ "verbose" ] }; const spyOnConsole = /* @__PURE__ */ __name((method, name) => { const originalFn = console[method].bind(console); console[method] = function() { const isConsoleError = method === "error"; if (isConsoleError && isServerComponentError(arguments?.[0])) { return; } const shouldCollectError = TEST_RUNNER_FAIL_ON_CONSOLE === "true" && method === "error"; if (shouldCollectError) { hasErrors = true; } let message = Array.from(arguments).map(composeMessage).join(", "); if (method === "trace") { const stackTrace = new Error().stack; message += ` ${stackTrace} `; } if (logLevelMapping[method].includes(TEST_RUNNER_LOG_LEVEL) || shouldCollectError) { const prefix = `${bold(name)}: `; logs.push(prefix + message); } originalFn(...arguments); }; }, "spyOnConsole"); const spiedMethods = { // info log: blue, info: blue, // warn warn: yellow, // error error: red, // verbose dir: magenta, trace: magenta, group: magenta, groupCollapsed: magenta, table: magenta, debug: magenta }; Object.entries(spiedMethods).forEach(([method, color]) => { spyOnConsole(method, color(method)); }); const cleanup = /* @__PURE__ */ __name((_listeners) => { Object.entries(_listeners).forEach(([eventName, listener]) => { channel.off(eventName, listener); }); }, "cleanup"); return new Promise((resolve, reject) => { const rejectWithFormattedError = /* @__PURE__ */ __name((storyId2, message, panel) => { const errorMessage = StorybookTestRunnerError.buildErrorMessage({ storyId: storyId2, errorMessage: message, logs, panel }); testRunner_errorMessageFormatter(errorMessage).then((formattedMessage) => { reject(new StorybookTestRunnerError({ storyId: storyId2, errorMessage: formattedMessage, logs, isMessageFormatted: true })); }).catch((error) => { reject(new StorybookTestRunnerError({ storyId: storyId2, errorMessage: "There was an error when executing the errorMessageFormatter defiend in your Storybook test-runner config file. Please fix it and rerun the tests:\n\n" + error.message })); }); }, "rejectWithFormattedError"); const INTERACTIONS_PANEL = "storybook/interactions/panel"; const A11Y_PANEL = "storybook/a11y/panel"; const listeners = { [TEST_RUNNER_RENDERED_EVENT]: (data) => { cleanup(listeners); if (hasErrors) { rejectWithFormattedError(storyId, "Browser console errors"); return; } else if (data?.reporters) { const story = getStory(); const a11yGlobals = story.globals?.a11y; const a11yParameter = story.parameters?.a11y; const a11yTestParameter = a11yParameter?.test; const a11yReport = data.reporters.find((reporter) => reporter.type === "a11y"); const shouldRunA11yTest = a11yParameter?.disable !== true && a11yParameter?.test !== "off" && a11yGlobals?.manual !== true && a11yReport?.result?.violations?.length > 0; if (shouldRunA11yTest) { const violations = expectToHaveNoViolations(a11yReport.result); if (violations && a11yTestParameter === "error") { rejectWithFormattedError(storyId, violations.long, A11Y_PANEL); return; } else if (violations && a11yTestParameter === "todo") { const warningMessage = StorybookTestRunnerError.buildErrorMessage({ storyId, errorMessagePrefix: `-------------------------- ${story.title} > ${story.name}`, errorMessage: yellow(violations.short), logs, panel: A11Y_PANEL }); logToPage(warningMessage); } } } resolve(document.getElementById("root")); }, storyUnchanged: /* @__PURE__ */ __name(() => { cleanup(listeners); resolve(document.getElementById("root")); }, "storyUnchanged"), storyErrored: /* @__PURE__ */ __name(({ description }) => { cleanup(listeners); rejectWithFormattedError(storyId, description, INTERACTIONS_PANEL); }, "storyErrored"), storyThrewException: /* @__PURE__ */ __name((error) => { cleanup(listeners); rejectWithFormattedError(storyId, error.message, INTERACTIONS_PANEL); }, "storyThrewException"), playFunctionThrewException: /* @__PURE__ */ __name((error) => { cleanup(listeners); rejectWithFormattedError(storyId, error.message, INTERACTIONS_PANEL); }, "playFunctionThrewException"), unhandledErrorsWhilePlaying: /* @__PURE__ */ __name(([error]) => { cleanup(listeners); rejectWithFormattedError(storyId, error.message, INTERACTIONS_PANEL); }, "unhandledErrorsWhilePlaying"), storyMissing: /* @__PURE__ */ __name((id) => { cleanup(listeners); if (id === storyId) { rejectWithFormattedError(storyId, "The story was missing when trying to access it."); } }, "storyMissing") }; Object.entries(listeners).forEach(([eventName, listener]) => { channel.on(eventName, listener); }); channel.emit("setCurrentStory", { storyId, viewMode: TEST_RUNNER_VIEW_MODE }); }); } __name(__test, "__test"); function expectToHaveNoViolations(results) { let violations = filterViolations( results.violations, // `impactLevels` is not a valid toolOption but one we add to the config // when calling `run`. axe just happens to pass this along. Might be a safer // way to do this since it's not documented API. results.toolOptions?.impactLevels ?? [] ); function reporter(violations2) { if (violations2.length === 0) { return null; } let lineBreak = "\n\n"; let horizontalLine = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"; return violations2.map((violation) => { let errorBody = violation.nodes.map((node) => { let selector = node.target.join(", "); let expectedText = red(`Expected the HTML found at $('${selector}') to have no violations:`) + lineBreak; return expectedText + grey(node.html) + lineBreak + red(`Received:`) + lineBreak + red(`"${violation.help} (${violation.id})"`) + lineBreak + yellow(node.failureSummary) + lineBreak + (violation.helpUrl ? red(`You can find more information on this issue here:`) + ` ${blue(violation.helpUrl)}` : ""); }).join(lineBreak); return errorBody; }).join(lineBreak + horizontalLine + lineBreak); } __name(reporter, "reporter"); let formatedViolations = reporter(violations); return { long: formatedViolations, short: `Found ${violations.length} a11y violations, run the test with 'a11y: { test: 'error' }' parameter to see the full report or debug it directly in Storybook.` }; } __name(expectToHaveNoViolations, "expectToHaveNoViolations"); function filterViolations(violations, impactLevels) { if (impactLevels && impactLevels.length > 0) { return violations.filter((v) => impactLevels.includes(v.impact)); } return violations; } __name(filterViolations, "filterViolations");