diff --git a/src/setup-page-script.ts b/src/setup-page-script.ts new file mode 100644 index 00000000..bba39fe0 --- /dev/null +++ b/src/setup-page-script.ts @@ -0,0 +1,330 @@ +/** + * PLEASE READ THIS BEFORE EDITING THIS FILE: + * + * This file is a template to the content which is injected to the Playwright page via the ./setup-page.ts file. + * setup-page.ts will read the contents of this file and replace values that use {{x}} pattern, and they should be put right below: + */ + +// All of these variables will be replaced once this file is processed. +const referenceURL: string | undefined = '{{referenceURL}}'; +const targetURL: string = '{{targetURL}}'; +const testRunnerVersion: string = '{{testRunnerVersion}}'; +const failOnConsole: string = '{{failOnConsole}}'; +const renderedEvent: string = '{{renderedEvent}}'; +const viewMode: string = '{{viewMode}}'; +const debugPrintLimit = parseInt('{{debugPrintLimit}}', 10); + +// Type definitions for function parameters and return types +type Colorizer = (message: string) => string; + +const bold: Colorizer = (message: string) => `\u001b[1m${message}\u001b[22m`; +const magenta: Colorizer = (message: string) => `\u001b[35m${message}\u001b[39m`; +const blue: Colorizer = (message: string) => `\u001b[34m${message}\u001b[39m`; +const red: Colorizer = (message: string) => `\u001b[31m${message}\u001b[39m`; +const yellow: Colorizer = (message: string) => `\u001b[33m${message}\u001b[39m`; + +// Constants +var LIMIT_REPLACE_NODE = '[...]'; +var CIRCULAR_REPLACE_NODE = '[Circular]'; + +// Arrays for tracking replacements +var arr: any[] = []; +var replacerStack: any[] = []; + +// Default options for stringification +function defaultOptions(): { depthLimit: number; edgesLimit: number } { + return { + depthLimit: Number.MAX_SAFE_INTEGER, + edgesLimit: Number.MAX_SAFE_INTEGER, + }; +} + +// Stringify function +function stringify( + obj: any, + replacer: ((key: string, value: any) => any) | null, + spacer: string | number | null, + options?: { depthLimit: number; edgesLimit: number } +): string { + if (typeof options === 'undefined') { + options = defaultOptions(); + } + + decirc(obj, '', 0, [], undefined, 0, options); + var res: string; + try { + if (replacerStack.length === 0) { + // @ts-expect-error TODO: check why TS complains about this + res = JSON.stringify(obj, replacer, spacer); + } else { + // @ts-expect-error TODO: check why TS complains about this + 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; +} + +// Handle circular references and limits +function decirc( + val: any, + k: string, + edgeIndex: number, + stack: any[], + parent: any | undefined, + depth: number, + options: { depthLimit: number; edgesLimit: number } +): void { + depth += 1; + var i: number; + 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(); + } +} + +// Set replacement values in objects +function setReplace(replace: any, val: any, k: string, parent: any | undefined): void { + if (!parent) return; + var propertyDescriptor = Object.getOwnPropertyDescriptor(parent, k); + if (propertyDescriptor && propertyDescriptor.get !== undefined) { + 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]); + } +} + +// Replace getter values +function replaceGetterValues( + replacer?: (this: any, key: string, value: any) => any +): (this: any, key: string, value: any) => any { + const effectiveReplacer = replacer ?? ((_k: string, v: any) => v); + return function (this: any, key: string, val: any): any { + 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); + }; +} + +// Compose message function +function composeMessage(args: any): string { + if (args instanceof Error) { + return `${args.name}: ${args.message}\n${args.stack}`; + } + if (typeof args === 'undefined') return 'undefined'; + if (typeof args === 'string') return args; + return stringify(args, null, null, { depthLimit: 5, edgesLimit: 100 }); +} + +// Truncate long strings +function truncate(input: string, limit: number): string { + if (input.length > limit) { + return input.substring(0, limit) + '…'; + } + return input; +} + +// Add extra information to the user agent +function addToUserAgent(extra: string): void { + const originalUserAgent = globalThis.navigator.userAgent; + if (!originalUserAgent.includes(extra)) { + Object.defineProperty(globalThis.navigator, 'userAgent', { + get: function () { + return [originalUserAgent, extra].join(' '); + }, + configurable: true, + }); + } +} + +// Custom error class +class StorybookTestRunnerError extends Error { + constructor(storyId: string, errorMessage: string, logs: string[] = []) { + 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 + )}\n${extraLogs}`; + } +} + +// @ts-expect-error Global function to throw custom error, used by the test runner or user +async function __throwError(storyId: string, errorMessage: string, logs: string[]): Promise { + throw new StorybookTestRunnerError(storyId, errorMessage, logs); +} + +// Wait for Storybook to load +async function __waitForStorybook(): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(); + }, 10000); + + 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, + }); + }); +} + +// Get context from Storybook +// @ts-expect-error Global function to get context, used by the test runner or user +async function __getContext(storyId: string): Promise { + // @ts-expect-error globally defined via Storybook + return globalThis.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId }); +} + +// @ts-expect-error Global main test function, used by the test runner +async function __test(storyId: string): Promise { + 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)?\n\n\nHTML: ${document.body.innerHTML}`; + throw new StorybookTestRunnerError(storyId, message); + } + + // @ts-expect-error globally defined via Storybook + const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; + if (!channel) { + throw new StorybookTestRunnerError( + storyId, + 'The test runner could not access the Storybook channel. Are you sure the Storybook is running correctly in that URL?' + ); + } + + addToUserAgent(`(StorybookTestRunner@${testRunnerVersion})`); + + // Collect logs to show upon test error + let logs: string[] = []; + let hasErrors = false; + + type ConsoleMethod = 'log' | 'group' | 'warn' | 'error' | 'trace' | 'groupCollapsed'; + + const spyOnConsole = (method: ConsoleMethod, name: string): void => { + const originalFn = console[method].bind(console); + console[method] = function () { + if (failOnConsole === 'true' && method === 'error') { + hasErrors = true; + } + const message = Array.from(arguments).map(composeMessage).join(', '); + const prefix = `${bold(name)}: `; + logs.push(prefix + message); + originalFn(...arguments); + }; + }; + + // Console methods + color function for their prefix + const spiedMethods: { [key: string]: Colorizer } = { + log: blue, + warn: yellow, + error: red, + trace: magenta, + group: magenta, + groupCollapsed: magenta, + }; + + Object.entries(spiedMethods).forEach(([method, color]) => { + spyOnConsole(method as ConsoleMethod, color(method)); + }); + + return new Promise((resolve, reject) => { + channel.on(renderedEvent, () => { + if (hasErrors) { + return reject(new StorybookTestRunnerError(storyId, 'Browser console errors', logs)); + } + return resolve(document.getElementById('root')); + }); + channel.on('storyUnchanged', () => resolve(document.getElementById('root'))); + channel.on('storyErrored', ({ description }: { description: string }) => + reject(new StorybookTestRunnerError(storyId, description, logs)) + ); + channel.on('storyThrewException', (error: Error) => + reject(new StorybookTestRunnerError(storyId, error.message, logs)) + ); + channel.on('playFunctionThrewException', (error: Error) => + reject(new StorybookTestRunnerError(storyId, error.message, logs)) + ); + channel.on('storyMissing', (id: string) => { + if (id === storyId) { + reject( + new StorybookTestRunnerError( + storyId, + 'The story was missing when trying to access it.', + logs + ) + ); + } + }); + + channel.emit('setCurrentStory', { storyId, viewMode }); + }); +} + +export {}; diff --git a/src/setup-page.ts b/src/setup-page.ts index 239febcd..c73a5c79 100644 --- a/src/setup-page.ts +++ b/src/setup-page.ts @@ -2,6 +2,8 @@ import type { Page, BrowserContext } from 'playwright'; import readPackageUp, { NormalizedReadResult } from 'read-pkg-up'; import { PrepareContext } from './playwright/hooks'; import { getTestRunnerConfig } from './util/getTestRunnerConfig'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; /** * This is a default prepare function which can be overridden by the user. @@ -58,291 +60,15 @@ export const setupPage = async (page: Page, browserContext: BrowserContext) => { // if we ever want to log something from the browser to node await page.exposeBinding('logToPage', (_, message) => console.log(message)); - 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\`; - - // Code taken and adjusted from https://github.com/davidmarkclements/fast-safe-stringify - 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 - } - } - - // Regular stringify - function stringify (obj, replacer, spacer, options) { - if (typeof options === 'undefined') { - options = defaultOptions() - } - - decirc(obj, '', 0, [], undefined, 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.length === 4) { - Object.defineProperty(part[0], part[1], part[3]) - } else { - part[0][part[1]] = part[2] - } - } - } - return res - } - - function setReplace (replace, val, k, parent) { - var propertyDescriptor = Object.getOwnPropertyDescriptor(parent, k) - if (propertyDescriptor.get !== undefined) { - 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]) - } - } - - 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 ( - typeof options.depthLimit !== 'undefined' && - depth > options.depthLimit - ) { - setReplace(LIMIT_REPLACE_NODE, val, k, parent) - return - } - - if ( - typeof options.edgesLimit !== 'undefined' && - edgeIndex + 1 > options.edgesLimit - ) { - setReplace(LIMIT_REPLACE_NODE, val, k, parent) - return - } - - stack.push(val) - // Optimize for Arrays. Big arrays could kill the performance otherwise! - if (Array.isArray(val)) { - for (i = 0; i < val.length; i++) { - decirc(val[i], i, 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() - } - } - - // wraps replacer function to handle values we couldn't replace - // and mark them as replaced value - function replaceGetterValues (replacer) { - replacer = - typeof replacer !== 'undefined' - ? replacer - : function (k, v) { - return 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 replacer.call(this, key, val) - } - } - // end of fast-safe-stringify code - - function composeMessage(args) { - if (args instanceof Error) { - return \`\${args.name}: \${args.message}\\n\${args.stack}\`; - } - if (typeof args === 'undefined') return "undefined"; - if (typeof args === 'string') return args; - return stringify(args, null, null, { depthLimit: 5, edgesLimit: 100 }); - } - - function truncate(input, limit) { - if (input.length > limit) { - return input.substring(0, limit) + '…'; - } - return input; - } - - function addToUserAgent(extra) { - const originalUserAgent = globalThis.navigator.userAgent; - if (!originalUserAgent.includes(extra)) { - Object.defineProperty(globalThis.navigator, 'userAgent', { - get: function () { - return [originalUserAgent, extra].join(' '); - }, - configurable: true, - }); - } - }; - - class StorybookTestRunnerError extends Error { - 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})}\n\${extraLogs}\`; - } - } - - async function __throwError(storyId, errorMessage, logs) { - throw new StorybookTestRunnerError(storyId, errorMessage, logs); - } - - async function __waitForStorybook() { - return new Promise((resolve, reject) => { - - const timeout = setTimeout(() => { - reject(); - }, 10000); - - if (document.querySelector('#root') || document.querySelector('#storybook-root')) { - clearTimeout(timeout); - return resolve(); - } - - const observer = new MutationObserver(mutations => { - if (document.querySelector('#root') || document.querySelector('#storybook-root')) { - clearTimeout(timeout); - resolve(); - observer.disconnect(); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); - }); - } - - async function __getContext(storyId) { - return globalThis.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId }); - } - - 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)?\n\n\nHTML: \${document.body.innerHTML}\`; - throw new StorybookTestRunnerError(storyId, message); - } - - const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; - if(!channel) { - throw new StorybookTestRunnerError( - storyId, - 'The test runner could not access the Storybook channel. Are you sure the Storybook is running correctly in that URL?' - ); - } - - addToUserAgent(\`(StorybookTestRunner@${testRunnerVersion})\`); - - // collect logs to show upon test error - let logs = []; - let hasErrors = false; - - const spyOnConsole = (method, name) => { - const originalFn = console[method]; - return function () { - if (\`${failOnConsole}\`==='true' && method==='error') { - hasErrors = true; - } - 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}', () => { - if (hasErrors) { - return reject(new StorybookTestRunnerError(storyId, 'Browser console errors', logs)); - } - return resolve(document.getElementById('root')); - }); - channel.on('storyUnchanged', () => resolve(document.getElementById('root'))); - channel.on('storyErrored', ({ description }) => reject( - new StorybookTestRunnerError(storyId, description, logs)) - ); - channel.on('storyThrewException', (error) => reject( - new StorybookTestRunnerError(storyId, error.message, logs)) - ); - channel.on('playFunctionThrewException', (error) => reject( - 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.', logs)) - ); - - channel.emit('setCurrentStory', { storyId, viewMode: '${viewMode}' }); - }); - }; - `, - }); + const scriptLocation = require.resolve(path.join(__dirname, 'setup-page-script.mjs')); + // read the contents of setup-page-scripts.ts and replace the referenceUrl + const content = (await readFile(scriptLocation, 'utf-8')) + .replace('{{targetURL}}', targetURL ?? '') + .replace('{{referenceUrl}}', referenceURL ?? '') + .replace('{{failOnConsole}}', failOnConsole ?? 'false') + .replace('{{renderedEvent}}', renderedEvent) + .replace('{{testRunnerVersion}}', testRunnerVersion) + .replace('{{debugPrintLimit}}', debugPrintLimit.toString()); + + await page.addScriptTag({ content }); };