Skip to content

Commit 3e6681c

Browse files
authored
Merge pull request storybookjs#403 from storybookjs/refactor/extract-setup-page-scripts
Refactor: Extract the setup page scripts into a separate file
2 parents 40aaa6e + aaf882b commit 3e6681c

File tree

2 files changed

+343
-287
lines changed

2 files changed

+343
-287
lines changed

src/setup-page-script.ts

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
/**
2+
* PLEASE READ THIS BEFORE EDITING THIS FILE:
3+
*
4+
* This file is a template to the content which is injected to the Playwright page via the ./setup-page.ts file.
5+
* setup-page.ts will read the contents of this file and replace values that use {{x}} pattern, and they should be put right below:
6+
*/
7+
8+
// All of these variables will be replaced once this file is processed.
9+
const referenceURL: string | undefined = '{{referenceURL}}';
10+
const targetURL: string = '{{targetURL}}';
11+
const testRunnerVersion: string = '{{testRunnerVersion}}';
12+
const failOnConsole: string = '{{failOnConsole}}';
13+
const renderedEvent: string = '{{renderedEvent}}';
14+
const viewMode: string = '{{viewMode}}';
15+
const debugPrintLimit = parseInt('{{debugPrintLimit}}', 10);
16+
17+
// Type definitions for function parameters and return types
18+
type Colorizer = (message: string) => string;
19+
20+
const bold: Colorizer = (message: string) => `\u001b[1m${message}\u001b[22m`;
21+
const magenta: Colorizer = (message: string) => `\u001b[35m${message}\u001b[39m`;
22+
const blue: Colorizer = (message: string) => `\u001b[34m${message}\u001b[39m`;
23+
const red: Colorizer = (message: string) => `\u001b[31m${message}\u001b[39m`;
24+
const yellow: Colorizer = (message: string) => `\u001b[33m${message}\u001b[39m`;
25+
26+
// Constants
27+
var LIMIT_REPLACE_NODE = '[...]';
28+
var CIRCULAR_REPLACE_NODE = '[Circular]';
29+
30+
// Arrays for tracking replacements
31+
var arr: any[] = [];
32+
var replacerStack: any[] = [];
33+
34+
// Default options for stringification
35+
function defaultOptions(): { depthLimit: number; edgesLimit: number } {
36+
return {
37+
depthLimit: Number.MAX_SAFE_INTEGER,
38+
edgesLimit: Number.MAX_SAFE_INTEGER,
39+
};
40+
}
41+
42+
// Stringify function
43+
function stringify(
44+
obj: any,
45+
replacer: ((key: string, value: any) => any) | null,
46+
spacer: string | number | null,
47+
options?: { depthLimit: number; edgesLimit: number }
48+
): string {
49+
if (typeof options === 'undefined') {
50+
options = defaultOptions();
51+
}
52+
53+
decirc(obj, '', 0, [], undefined, 0, options);
54+
var res: string;
55+
try {
56+
if (replacerStack.length === 0) {
57+
// @ts-expect-error TODO: check why TS complains about this
58+
res = JSON.stringify(obj, replacer, spacer);
59+
} else {
60+
// @ts-expect-error TODO: check why TS complains about this
61+
res = JSON.stringify(obj, replaceGetterValues(replacer), spacer);
62+
}
63+
} catch (_) {
64+
return JSON.stringify('[unable to serialize, circular reference is too complex to analyze]');
65+
} finally {
66+
while (arr.length !== 0) {
67+
var part = arr.pop();
68+
if (part && part.length === 4) {
69+
Object.defineProperty(part[0], part[1], part[3]);
70+
} else if (part) {
71+
part[0][part[1]] = part[2];
72+
}
73+
}
74+
}
75+
return res;
76+
}
77+
78+
// Handle circular references and limits
79+
function decirc(
80+
val: any,
81+
k: string,
82+
edgeIndex: number,
83+
stack: any[],
84+
parent: any | undefined,
85+
depth: number,
86+
options: { depthLimit: number; edgesLimit: number }
87+
): void {
88+
depth += 1;
89+
var i: number;
90+
if (typeof val === 'object' && val !== null) {
91+
for (i = 0; i < stack.length; i++) {
92+
if (stack[i] === val) {
93+
setReplace(CIRCULAR_REPLACE_NODE, val, k, parent);
94+
return;
95+
}
96+
}
97+
98+
if (depth > options.depthLimit || edgeIndex + 1 > options.edgesLimit) {
99+
setReplace(LIMIT_REPLACE_NODE, val, k, parent);
100+
return;
101+
}
102+
103+
stack.push(val);
104+
if (Array.isArray(val)) {
105+
for (i = 0; i < val.length; i++) {
106+
decirc(val[i], i.toString(), i, stack, val, depth, options);
107+
}
108+
} else {
109+
var keys = Object.keys(val);
110+
for (i = 0; i < keys.length; i++) {
111+
var key = keys[i];
112+
decirc(val[key], key, i, stack, val, depth, options);
113+
}
114+
}
115+
stack.pop();
116+
}
117+
}
118+
119+
// Set replacement values in objects
120+
function setReplace(replace: any, val: any, k: string, parent: any | undefined): void {
121+
if (!parent) return;
122+
var propertyDescriptor = Object.getOwnPropertyDescriptor(parent, k);
123+
if (propertyDescriptor && propertyDescriptor.get !== undefined) {
124+
if (propertyDescriptor.configurable) {
125+
Object.defineProperty(parent, k, { value: replace });
126+
arr.push([parent, k, val, propertyDescriptor]);
127+
} else {
128+
replacerStack.push([val, k, replace]);
129+
}
130+
} else {
131+
parent[k] = replace;
132+
arr.push([parent, k, val]);
133+
}
134+
}
135+
136+
// Replace getter values
137+
function replaceGetterValues(
138+
replacer?: (this: any, key: string, value: any) => any
139+
): (this: any, key: string, value: any) => any {
140+
const effectiveReplacer = replacer ?? ((_k: string, v: any) => v);
141+
return function (this: any, key: string, val: any): any {
142+
if (replacerStack.length > 0) {
143+
for (var i = 0; i < replacerStack.length; i++) {
144+
var part = replacerStack[i];
145+
if (part[1] === key && part[0] === val) {
146+
val = part[2];
147+
replacerStack.splice(i, 1);
148+
break;
149+
}
150+
}
151+
}
152+
return effectiveReplacer.call(this, key, val);
153+
};
154+
}
155+
156+
// Compose message function
157+
function composeMessage(args: any): string {
158+
if (args instanceof Error) {
159+
return `${args.name}: ${args.message}\n${args.stack}`;
160+
}
161+
if (typeof args === 'undefined') return 'undefined';
162+
if (typeof args === 'string') return args;
163+
return stringify(args, null, null, { depthLimit: 5, edgesLimit: 100 });
164+
}
165+
166+
// Truncate long strings
167+
function truncate(input: string, limit: number): string {
168+
if (input.length > limit) {
169+
return input.substring(0, limit) + '…';
170+
}
171+
return input;
172+
}
173+
174+
// Add extra information to the user agent
175+
function addToUserAgent(extra: string): void {
176+
const originalUserAgent = globalThis.navigator.userAgent;
177+
if (!originalUserAgent.includes(extra)) {
178+
Object.defineProperty(globalThis.navigator, 'userAgent', {
179+
get: function () {
180+
return [originalUserAgent, extra].join(' ');
181+
},
182+
configurable: true,
183+
});
184+
}
185+
}
186+
187+
// Custom error class
188+
class StorybookTestRunnerError extends Error {
189+
constructor(storyId: string, errorMessage: string, logs: string[] = []) {
190+
super(errorMessage);
191+
this.name = 'StorybookTestRunnerError';
192+
const storyUrl = `${referenceURL ?? targetURL}?path=/story/${storyId}`;
193+
const finalStoryUrl = `${storyUrl}&addonPanel=storybook/interactions/panel`;
194+
const separator = '\n\n--------------------------------------------------';
195+
const extraLogs =
196+
logs.length > 0 ? separator + '\n\nBrowser logs:\n\n' + logs.join('\n\n') : '';
197+
198+
this.message = `\nAn error occurred in the following story. Access the link for full output:\n${finalStoryUrl}\n\nMessage:\n ${truncate(
199+
errorMessage,
200+
debugPrintLimit
201+
)}\n${extraLogs}`;
202+
}
203+
}
204+
205+
// @ts-expect-error Global function to throw custom error, used by the test runner or user
206+
async function __throwError(storyId: string, errorMessage: string, logs: string[]): Promise<void> {
207+
throw new StorybookTestRunnerError(storyId, errorMessage, logs);
208+
}
209+
210+
// Wait for Storybook to load
211+
async function __waitForStorybook(): Promise<void> {
212+
return new Promise((resolve, reject) => {
213+
const timeout = setTimeout(() => {
214+
reject();
215+
}, 10000);
216+
217+
if (document.querySelector('#root') || document.querySelector('#storybook-root')) {
218+
clearTimeout(timeout);
219+
return resolve();
220+
}
221+
222+
const observer = new MutationObserver(() => {
223+
if (document.querySelector('#root') || document.querySelector('#storybook-root')) {
224+
clearTimeout(timeout);
225+
resolve();
226+
observer.disconnect();
227+
}
228+
});
229+
230+
observer.observe(document.body, {
231+
childList: true,
232+
subtree: true,
233+
});
234+
});
235+
}
236+
237+
// Get context from Storybook
238+
// @ts-expect-error Global function to get context, used by the test runner or user
239+
async function __getContext(storyId: string): Promise<any> {
240+
// @ts-expect-error globally defined via Storybook
241+
return globalThis.__STORYBOOK_PREVIEW__.storyStore.loadStory({ storyId });
242+
}
243+
244+
// @ts-expect-error Global main test function, used by the test runner
245+
async function __test(storyId: string): Promise<any> {
246+
try {
247+
await __waitForStorybook();
248+
} catch (err) {
249+
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}`;
250+
throw new StorybookTestRunnerError(storyId, message);
251+
}
252+
253+
// @ts-expect-error globally defined via Storybook
254+
const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__;
255+
if (!channel) {
256+
throw new StorybookTestRunnerError(
257+
storyId,
258+
'The test runner could not access the Storybook channel. Are you sure the Storybook is running correctly in that URL?'
259+
);
260+
}
261+
262+
addToUserAgent(`(StorybookTestRunner@${testRunnerVersion})`);
263+
264+
// Collect logs to show upon test error
265+
let logs: string[] = [];
266+
let hasErrors = false;
267+
268+
type ConsoleMethod = 'log' | 'group' | 'warn' | 'error' | 'trace' | 'groupCollapsed';
269+
270+
const spyOnConsole = (method: ConsoleMethod, name: string): void => {
271+
const originalFn = console[method].bind(console);
272+
console[method] = function () {
273+
if (failOnConsole === 'true' && method === 'error') {
274+
hasErrors = true;
275+
}
276+
const message = Array.from(arguments).map(composeMessage).join(', ');
277+
const prefix = `${bold(name)}: `;
278+
logs.push(prefix + message);
279+
originalFn(...arguments);
280+
};
281+
};
282+
283+
// Console methods + color function for their prefix
284+
const spiedMethods: { [key: string]: Colorizer } = {
285+
log: blue,
286+
warn: yellow,
287+
error: red,
288+
trace: magenta,
289+
group: magenta,
290+
groupCollapsed: magenta,
291+
};
292+
293+
Object.entries(spiedMethods).forEach(([method, color]) => {
294+
spyOnConsole(method as ConsoleMethod, color(method));
295+
});
296+
297+
return new Promise((resolve, reject) => {
298+
channel.on(renderedEvent, () => {
299+
if (hasErrors) {
300+
return reject(new StorybookTestRunnerError(storyId, 'Browser console errors', logs));
301+
}
302+
return resolve(document.getElementById('root'));
303+
});
304+
channel.on('storyUnchanged', () => resolve(document.getElementById('root')));
305+
channel.on('storyErrored', ({ description }: { description: string }) =>
306+
reject(new StorybookTestRunnerError(storyId, description, logs))
307+
);
308+
channel.on('storyThrewException', (error: Error) =>
309+
reject(new StorybookTestRunnerError(storyId, error.message, logs))
310+
);
311+
channel.on('playFunctionThrewException', (error: Error) =>
312+
reject(new StorybookTestRunnerError(storyId, error.message, logs))
313+
);
314+
channel.on('storyMissing', (id: string) => {
315+
if (id === storyId) {
316+
reject(
317+
new StorybookTestRunnerError(
318+
storyId,
319+
'The story was missing when trying to access it.',
320+
logs
321+
)
322+
);
323+
}
324+
});
325+
326+
channel.emit('setCurrentStory', { storyId, viewMode });
327+
});
328+
}
329+
330+
export {};

0 commit comments

Comments
 (0)