@storybook/test-runner
Version:
Test runner for Storybook stories
491 lines (487 loc) • 16.7 kB
JavaScript
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");