| 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 | }); |