diff --git a/src/setup-page.ts b/src/setup-page.ts index e4e30cc5..1d951a46 100644 --- a/src/setup-page.ts +++ b/src/setup-page.ts @@ -50,26 +50,70 @@ export const setupPage = async (page) => { await page.addScriptTag({ content: ` + // colorizes the console output + const bold = (message) => \`\\u001b[1m\${message}\\u001b[22m\`; + const magenta = (message) => \`\\u001b[35m\${message}\\u001b[39m\`; + const blue = (message) => \`\\u001b[34m\${message}\\u001b[39m\`; + const red = (message) => \`\\u001b[31m\${message}\\u001b[39m\`; + const yellow = (message) => \`\\u001b[33m\${message}\\u001b[39m\`; + + // removes circular references from the object + function serializer(replacer, cycleReplacer) { + let stack = [], + keys = []; + + if (cycleReplacer == null) + cycleReplacer = function (_key, value) { + if (stack[0] === value) return '[Circular]'; + return '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'; + }; + + return function (key, value) { + if (stack.length > 0) { + let thisPos = stack.indexOf(this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value); + } else { + stack.push(value); + } + + return replacer == null ? value : replacer.call(this, key, value); + }; + } + + function safeStringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces); + } + + function composeMessage(args) { + if (typeof args === 'undefined') return "undefined"; + if (typeof args === 'string') return args; + return safeStringify(args); + } + function truncate(input, limit) { if (input.length > limit) { return input.substring(0, limit) + '…'; } return input; } - + class StorybookTestRunnerError extends Error { - constructor(storyId, errorMessage) { + constructor(storyId, errorMessage, logs) { super(errorMessage); this.name = 'StorybookTestRunnerError'; const storyUrl = \`${referenceURL || targetURL}?path=/story/\${storyId}\`; const finalStoryUrl = \`\${storyUrl}&addonPanel=storybook/interactions/panel\`; + const separator = '\\n\\n--------------------------------------------------'; + const extraLogs = logs.length > 0 ? separator + "\\n\\nBrowser logs:\\n\\n"+ logs.join('\\n\\n') : ''; - this.message = \`\nAn error occurred in the following story. Access the link for full output:\n\${finalStoryUrl}\n\nMessage:\n \${truncate(errorMessage,${debugPrintLimit})}\`; + this.message = \`\nAn error occurred in the following story. Access the link for full output:\n\${finalStoryUrl}\n\nMessage:\n \${truncate(errorMessage,${debugPrintLimit})}\n\${extraLogs}\`; } } - async function __throwError(storyId, errorMessage) { - throw new StorybookTestRunnerError(storyId, errorMessage); + async function __throwError(storyId, errorMessage, logs) { + throw new StorybookTestRunnerError(storyId, errorMessage, logs); } async function __waitForElement(selector) { @@ -118,18 +162,45 @@ export const setupPage = async (page) => { 'The test runner could not access the Storybook channel. Are you sure the Storybook is running correctly in that URL?' ); } + + // collect logs to show upon test error + let logs = []; + + const spyOnConsole = (method, name) => { + const originalFn = console[method]; + return function () { + const message = [...arguments].map(composeMessage).join(', '); + const prefix = \`\${bold(name)}: \`; + logs.push(prefix + message); + originalFn.apply(console, arguments); + }; + }; + + // console methods + color function for their prefix + const spiedMethods = { + log: blue, + warn: yellow, + error: red, + trace: magenta, + group: magenta, + groupCollapsed: magenta, + } + + Object.entries(spiedMethods).forEach(([method, color]) => { + console[method] = spyOnConsole(method, color(method)) + }) return new Promise((resolve, reject) => { channel.on('${renderedEvent}', () => resolve(document.getElementById('root'))); channel.on('storyUnchanged', () => resolve(document.getElementById('root'))); channel.on('storyErrored', ({ description }) => reject( - new StorybookTestRunnerError(storyId, description)) + new StorybookTestRunnerError(storyId, description, logs)) ); channel.on('storyThrewException', (error) => reject( - new StorybookTestRunnerError(storyId, error.message)) + new StorybookTestRunnerError(storyId, error.message, logs)) ); channel.on('storyMissing', (id) => id === storyId && reject( - new StorybookTestRunnerError(storyId, 'The story was missing when trying to access it.')) + new StorybookTestRunnerError(storyId, 'The story was missing when trying to access it.', logs)) ); channel.emit('setCurrentStory', { storyId, viewMode: '${viewMode}' });