main
js 572 lines 19.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import {
11 getDisplayName,
12 getDisplayNameForReactElement,
13 isPlainObject,
14 printOperationsArray,
15 } from 'react-devtools-shared/src/utils';
16 import {TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE} from 'react-devtools-shared/src/constants';
17 import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils';
18 import {
19 formatConsoleArguments,
20 formatConsoleArgumentsToSingleString,
21 formatWithStyles,
22 gt,
23 gte,
24 } from 'react-devtools-shared/src/backend/utils';
25 import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
26 import {
27 REACT_SUSPENSE_LIST_TYPE as SuspenseList,
28 REACT_STRICT_MODE_TYPE as StrictMode,
29 } from 'shared/ReactSymbols';
30 import {createElement} from 'react';
31 import {symbolicateSource} from '../symbolicateSource';
32
33 describe('utils', () => {
34 describe('getDisplayName', () => {
35 // @reactVersion >= 16.0
36 it('should return a function name', () => {
37 function FauxComponent() {}
38 expect(getDisplayName(FauxComponent)).toEqual('FauxComponent');
39 });
40
41 // @reactVersion >= 16.0
42 it('should return a displayName name if specified', () => {
43 function FauxComponent() {}
44 FauxComponent.displayName = 'OverrideDisplayName';
45 expect(getDisplayName(FauxComponent)).toEqual('OverrideDisplayName');
46 });
47
48 // @reactVersion >= 16.0
49 it('should return the fallback for anonymous functions', () => {
50 expect(getDisplayName(() => {}, 'Fallback')).toEqual('Fallback');
51 });
52
53 // @reactVersion >= 16.0
54 it('should return Anonymous for anonymous functions without a fallback', () => {
55 expect(getDisplayName(() => {})).toEqual('Anonymous');
56 });
57
58 // Simulate a reported bug:
59 // https://github.com/facebook/react/issues/16685
60 // @reactVersion >= 16.0
61 it('should return a fallback when the name prop is not a string', () => {
62 const FauxComponent = {name: {}};
63 expect(getDisplayName(FauxComponent, 'Fallback')).toEqual('Fallback');
64 });
65
66 it('should parse a component stack trace', () => {
67 expect(
68 stackToComponentLocations(`
69 at Foobar (http://localhost:3000/static/js/bundle.js:103:74)
70 at a
71 at header
72 at div
73 at App`),
74 ).toEqual([
75 [
76 'Foobar',
77 ['Foobar', 'http://localhost:3000/static/js/bundle.js', 103, 74],
78 ],
79 ['a', null],
80 ['header', null],
81 ['div', null],
82 ['App', null],
83 ]);
84 });
85 });
86
87 describe('getDisplayNameForReactElement', () => {
88 // @reactVersion >= 16.0
89 it('should return correct display name for an element with function type', () => {
90 function FauxComponent() {}
91 FauxComponent.displayName = 'OverrideDisplayName';
92 const element = createElement(FauxComponent);
93 expect(getDisplayNameForReactElement(element)).toEqual(
94 'OverrideDisplayName',
95 );
96 });
97
98 // @reactVersion >= 16.0
99 it('should return correct display name for an element with a type of StrictMode', () => {
100 const element = createElement(StrictMode);
101 expect(getDisplayNameForReactElement(element)).toEqual('StrictMode');
102 });
103
104 // @reactVersion >= 16.0
105 it('should return correct display name for an element with a type of SuspenseList', () => {
106 const element = createElement(SuspenseList);
107 expect(getDisplayNameForReactElement(element)).toEqual('SuspenseList');
108 });
109
110 // @reactVersion >= 16.0
111 it('should return NotImplementedInDevtools for an element with invalid symbol type', () => {
112 const element = createElement(Symbol('foo'));
113 expect(getDisplayNameForReactElement(element)).toEqual(
114 'NotImplementedInDevtools',
115 );
116 });
117
118 // @reactVersion >= 16.0
119 it('should return NotImplementedInDevtools for an element with invalid type', () => {
120 const element = createElement(true);
121 expect(getDisplayNameForReactElement(element)).toEqual(
122 'NotImplementedInDevtools',
123 );
124 });
125
126 // @reactVersion >= 16.0
127 it('should return Element for null type', () => {
128 const element = createElement();
129 expect(getDisplayNameForReactElement(element)).toEqual('Element');
130 });
131 });
132
133 describe('formatConsoleArgumentsToSingleString', () => {
134 it('should format simple strings', () => {
135 expect(formatConsoleArgumentsToSingleString('a', 'b', 'c')).toEqual(
136 'a b c',
137 );
138 });
139
140 it('should format multiple argument types', () => {
141 expect(formatConsoleArgumentsToSingleString('abc', 123, true)).toEqual(
142 'abc 123 true',
143 );
144 });
145
146 it('should support string substitutions', () => {
147 expect(
148 formatConsoleArgumentsToSingleString('a %s b %s c', 123, true),
149 ).toEqual('a 123 b true c');
150 });
151
152 it('should support integer substitutions', () => {
153 expect(formatConsoleArgumentsToSingleString('%i', 3.14)).toEqual('3');
154 });
155
156 it('should support float substitutions', () => {
157 expect(formatConsoleArgumentsToSingleString('%f', 3.5)).toEqual('3.5');
158 });
159
160 it('should keep argument alignment across mixed substitutions', () => {
161 expect(formatConsoleArgumentsToSingleString('a %i b %s', 7, 'x')).toEqual(
162 'a 7 b x',
163 );
164 });
165
166 it('should gracefully handle Symbol types', () => {
167 expect(
168 formatConsoleArgumentsToSingleString(Symbol('a'), 'b', Symbol('c')),
169 ).toEqual('Symbol(a) b Symbol(c)');
170 });
171
172 it('should gracefully handle Symbol type for the first argument', () => {
173 expect(formatConsoleArgumentsToSingleString(Symbol('abc'), 123)).toEqual(
174 'Symbol(abc) 123',
175 );
176 });
177
178 it('should gracefully handle objects with no prototype', () => {
179 expect(
180 formatConsoleArgumentsToSingleString('%o', Object.create(null)),
181 ).toEqual('%o [object Object]');
182 });
183 });
184
185 describe('formatWithStyles', () => {
186 it('should format empty arrays', () => {
187 expect(formatWithStyles([])).toEqual([]);
188 expect(formatWithStyles([], 'gray')).toEqual([]);
189 expect(formatWithStyles(undefined)).toEqual(undefined);
190 });
191
192 it('should bail out of strings with styles', () => {
193 expect(
194 formatWithStyles(['%ca', 'color: green', 'b', 'c'], 'color: gray'),
195 ).toEqual(['%ca', 'color: green', 'b', 'c']);
196 });
197
198 it('should format simple strings', () => {
199 expect(formatWithStyles(['a'])).toEqual(['a']);
200
201 expect(formatWithStyles(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
202 expect(formatWithStyles(['a'], 'color: gray')).toEqual([
203 '%c%s',
204 'color: gray',
205 'a',
206 ]);
207 expect(formatWithStyles(['a', 'b', 'c'], 'color: gray')).toEqual([
208 '%c%s %s %s',
209 'color: gray',
210 'a',
211 'b',
212 'c',
213 ]);
214 });
215
216 it('should format string substituions', () => {
217 expect(
218 formatWithStyles(['%s %s %s', 'a', 'b', 'c'], 'color: gray'),
219 ).toEqual(['%c%s %s %s', 'color: gray', 'a', 'b', 'c']);
220
221 // The last letter isn't gray here but I think it's not a big
222 // deal, since there is a string substituion but it's incorrect
223 expect(formatWithStyles(['%s %s', 'a', 'b', 'c'], 'color: gray')).toEqual(
224 ['%c%s %s', 'color: gray', 'a', 'b', 'c'],
225 );
226 });
227
228 it('should support multiple argument types', () => {
229 const symbol = Symbol('a');
230 expect(
231 formatWithStyles(
232 ['abc', 123, 12.3, true, {hello: 'world'}, symbol],
233 'color: gray',
234 ),
235 ).toEqual([
236 '%c%s %i %f %s %o %s',
237 'color: gray',
238 'abc',
239 123,
240 12.3,
241 true,
242 {hello: 'world'},
243 symbol,
244 ]);
245 });
246
247 it('should properly format escaped string substituions', () => {
248 expect(formatWithStyles(['%%s'], 'color: gray')).toEqual([
249 '%c%s',
250 'color: gray',
251 '%%s',
252 ]);
253 expect(formatWithStyles(['%%c'], 'color: gray')).toEqual([
254 '%c%s',
255 'color: gray',
256 '%%c',
257 ]);
258 expect(formatWithStyles(['%%c%c'], 'color: gray')).toEqual(['%%c%c']);
259 });
260
261 it('should format non string inputs as the first argument', () => {
262 expect(formatWithStyles([{foo: 'bar'}])).toEqual([{foo: 'bar'}]);
263 expect(formatWithStyles([[1, 2, 3]])).toEqual([[1, 2, 3]]);
264 expect(formatWithStyles([{foo: 'bar'}], 'color: gray')).toEqual([
265 '%c%o',
266 'color: gray',
267 {foo: 'bar'},
268 ]);
269 expect(formatWithStyles([[1, 2, 3]], 'color: gray')).toEqual([
270 '%c%o',
271 'color: gray',
272 [1, 2, 3],
273 ]);
274 expect(formatWithStyles([{foo: 'bar'}, 'hi'], 'color: gray')).toEqual([
275 '%c%o %s',
276 'color: gray',
277 {foo: 'bar'},
278 'hi',
279 ]);
280 });
281 });
282
283 describe('semver comparisons', () => {
284 it('gte should compare versions correctly', () => {
285 expect(gte('1.2.3', '1.2.1')).toBe(true);
286 expect(gte('1.2.1', '1.2.1')).toBe(true);
287 expect(gte('1.2.1', '1.2.2')).toBe(false);
288 expect(gte('10.0.0', '9.0.0')).toBe(true);
289 });
290
291 it('gt should compare versions correctly', () => {
292 expect(gt('1.2.3', '1.2.1')).toBe(true);
293 expect(gt('1.2.1', '1.2.1')).toBe(false);
294 expect(gt('1.2.1', '1.2.2')).toBe(false);
295 expect(gte('10.0.0', '9.0.0')).toBe(true);
296 });
297 });
298
299 describe('isPlainObject', () => {
300 it('should return true for plain objects', () => {
301 expect(isPlainObject({})).toBe(true);
302 expect(isPlainObject({a: 1})).toBe(true);
303 expect(isPlainObject({a: {b: {c: 123}}})).toBe(true);
304 });
305
306 it('should return false if object is a class instance', () => {
307 expect(isPlainObject(new (class C {})())).toBe(false);
308 });
309
310 it('should return false for objects, which have not only Object in its prototype chain', () => {
311 expect(isPlainObject([])).toBe(false);
312 expect(isPlainObject(Symbol())).toBe(false);
313 });
314
315 it('should return false for primitives', () => {
316 expect(isPlainObject(5)).toBe(false);
317 expect(isPlainObject(true)).toBe(false);
318 });
319
320 it('should return true for objects with no prototype', () => {
321 expect(isPlainObject(Object.create(null))).toBe(true);
322 });
323 });
324
325 describe('extractLocationFromComponentStack', () => {
326 it('should return null if passed empty string', () => {
327 expect(extractLocationFromComponentStack('')).toEqual(null);
328 });
329
330 it('should construct the source from the first frame if available', () => {
331 expect(
332 extractLocationFromComponentStack(
333 'at l (https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js:1:10389)\n' +
334 'at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)\n' +
335 'at r (https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:498)\n',
336 ),
337 ).toEqual([
338 'l',
339 'https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js',
340 1,
341 10389,
342 ]);
343 });
344
345 it('should construct the source from highest available frame', () => {
346 expect(
347 extractLocationFromComponentStack(
348 ' at Q\n' +
349 ' at a\n' +
350 ' at m (https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236)\n' +
351 ' at div\n' +
352 ' at div\n' +
353 ' at div\n' +
354 ' at nav\n' +
355 ' at div\n' +
356 ' at te (https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:158857)\n' +
357 ' at tt (https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165520)\n' +
358 ' at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)',
359 ),
360 ).toEqual([
361 'm',
362 'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
363 5,
364 9236,
365 ]);
366 });
367
368 it('should construct the source from frame, which has only url specified', () => {
369 expect(
370 extractLocationFromComponentStack(
371 ' at Q\n' +
372 ' at a\n' +
373 ' at https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236\n',
374 ),
375 ).toEqual([
376 '',
377 'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
378 5,
379 9236,
380 ]);
381 });
382
383 it('should parse sourceURL correctly if it includes parentheses', () => {
384 expect(
385 extractLocationFromComponentStack(
386 'at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:307:11)\n' +
387 ' at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:181:11)\n' +
388 ' at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:114:9)',
389 ),
390 ).toEqual([
391 'HotReload',
392 'webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js',
393 307,
394 11,
395 ]);
396 });
397
398 it('should support Firefox stack', () => {
399 expect(
400 extractLocationFromComponentStack(
401 'tt@https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165558\n' +
402 'f@https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8535\n' +
403 'r@https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:513',
404 ),
405 ).toEqual([
406 'tt',
407 'https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js',
408 1,
409 165558,
410 ]);
411 });
412 });
413
414 describe('symbolicateSource', () => {
415 const source = `"use strict";
416 Object.defineProperty(exports, "__esModule", { value: true });
417 exports.f = f;
418 function f() { }
419 //# sourceMappingURL=`;
420 const result = {
421 location: ['', 'http://test/a.mts', 1, 17],
422 ignored: false,
423 };
424 const fs = {
425 'http://test/a.mts': `export function f() {}`,
426 'http://test/a.mjs.map': `{"version":3,"file":"a.mjs","sourceRoot":"","sources":["a.mts"],"names":[],"mappings":";;AAAA,cAAsB;AAAtB,SAAgB,CAAC,KAAI,CAAC"}`,
427 'http://test/a.mjs': `${source}a.mjs.map`,
428 'http://test/b.mjs': `${source}./a.mjs.map`,
429 'http://test/c.mjs': `${source}http://test/a.mjs.map`,
430 'http://test/d.mjs': `${source}/a.mjs.map`,
431 };
432 const fetchFileWithCaching = async (url: string) => fs[url] || null;
433 it('should parse source map urls', async () => {
434 const run = url => symbolicateSource(fetchFileWithCaching, url, 4, 10);
435 await expect(run('http://test/a.mjs')).resolves.toStrictEqual(result);
436 await expect(run('http://test/b.mjs')).resolves.toStrictEqual(result);
437 await expect(run('http://test/c.mjs')).resolves.toStrictEqual(result);
438 await expect(run('http://test/d.mjs')).resolves.toStrictEqual(result);
439 });
440
441 it('should not throw for invalid base URL with relative source map', async () => {
442 const fs2 = {
443 'bundle.js': `${source}bundle.js.map`,
444 };
445 const fetch2 = async url => fs2[url] || null;
446 const run = url => symbolicateSource(fetch2, url, 1, 1);
447 await expect(run('bundle.js')).resolves.toBe(null);
448 });
449
450 it('should resolve absolute source map even if base URL is invalid', async () => {
451 const fs3 = {
452 'invalid-base.js': `${source}http://test/a.mjs.map`,
453 'http://test/a.mts': `export function f() {}`,
454 'http://test/a.mjs.map': `{"version":3,"file":"a.mjs","sourceRoot":"","sources":["a.mts"],"names":[],"mappings":";;AAAA,cAAsB;AAAtB,SAAgB,CAAC,KAAI,CAAC"}`,
455 };
456 const fetch3 = async url => fs3[url] || null;
457 const run = url => symbolicateSource(fetch3, url, 4, 10);
458 await expect(run('invalid-base.js')).resolves.toStrictEqual(result);
459 });
460 });
461
462 describe('formatConsoleArguments', () => {
463 it('works with empty arguments list', () => {
464 expect(formatConsoleArguments(...[])).toEqual([]);
465 });
466
467 it('works for string without escape sequences', () => {
468 expect(
469 formatConsoleArguments('This is the template', 'And another string'),
470 ).toEqual(['This is the template', 'And another string']);
471 });
472
473 it('works with strings templates', () => {
474 expect(formatConsoleArguments('This is %s template', 'the')).toEqual([
475 'This is the template',
476 ]);
477 });
478
479 it('skips %%s', () => {
480 expect(formatConsoleArguments('This %%s is %s template', 'the')).toEqual([
481 'This %%s is the template',
482 ]);
483 });
484
485 it('works with %%%s', () => {
486 expect(
487 formatConsoleArguments('This %%%s is %s template', 'test', 'the'),
488 ).toEqual(['This %%test is the template']);
489 });
490
491 it("doesn't inline objects", () => {
492 expect(
493 formatConsoleArguments('This is %s template with object %o', 'the', {}),
494 ).toEqual(['This is the template with object %o', {}]);
495 });
496
497 it("doesn't inline css", () => {
498 expect(
499 formatConsoleArguments(
500 'This is template with %c %s object %o',
501 'color: rgba(...)',
502 'the',
503 {},
504 ),
505 ).toEqual([
506 'This is template with %c the object %o',
507 'color: rgba(...)',
508 {},
509 ]);
510 });
511
512 it('formats nullish values', () => {
513 expect(formatConsoleArguments('This is the %s template', null)).toEqual([
514 'This is the null template',
515 ]);
516 expect(
517 formatConsoleArguments('This is the %s template', undefined),
518 ).toEqual(['This is the undefined template']);
519 });
520
521 it('keeps a trailing percent sign', () => {
522 expect(formatConsoleArguments('Progress 100%', 'extra')).toEqual([
523 'Progress 100%',
524 'extra',
525 ]);
526 expect(formatConsoleArguments('%s 100%', 'done')).toEqual(['done 100%']);
527 });
528
529 it('keeps specifiers literal when no argument is supplied', () => {
530 expect(formatConsoleArguments('%s %s', 'the')).toEqual(['the %s']);
531 expect(formatConsoleArguments('%s %d', 'value')).toEqual(['value %d']);
532 expect(formatConsoleArguments('%s %i', 'value')).toEqual(['value %i']);
533 expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']);
534 });
535 });
536
537 describe('printOperationsArray', () => {
538 let log;
539 beforeEach(() => {
540 log = jest.spyOn(console, 'log').mockImplementation(() => {});
541 });
542 afterEach(() => {
543 log.mockRestore();
544 });
545
546 // The operation is [opcode, activitySliceID] (2 slots). A trailing operation
547 // after it verifies that the reader advances past the value slot instead of
548 // re-reading it as the next opcode.
549 it('should log an applied activity slice change and advance past its value', () => {
550 const rendererID = 1;
551 const rootID = 1;
552 const stringTableSize = 0;
553 const operations = [
554 rendererID,
555 rootID,
556 stringTableSize,
557 TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
558 42,
559 TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
560 0,
561 ];
562
563 expect(() => printOperationsArray(operations)).not.toThrow();
564
565 expect(log).toHaveBeenCalledTimes(1);
566 expect(log.mock.calls[0][0]).toContain(
567 'Applied activity slice change to 42',
568 );
569 expect(log.mock.calls[0][0]).toContain('Reset applied activity slice');
570 });
571 });
572 });