| 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 strict-local |
| 8 | */ |
| 9 | |
| 10 | 'use strict'; |
| 11 | |
| 12 | const assert = require('assert'); |
| 13 | const childProcess = require('child_process'); |
| 14 | const fs = require('fs'); |
| 15 | const http = require('http'); |
| 16 | const net = require('net'); |
| 17 | const os = require('os'); |
| 18 | const path = require('path'); |
| 19 | |
| 20 | // eslint-disable-next-line no-undef |
| 21 | type ChildProcess = child_process$ChildProcess; |
| 22 | type JSONSchemaObject = { |
| 23 | type?: string, |
| 24 | properties?: {[string]: JSONSchemaObject, ...}, |
| 25 | required?: Array<string>, |
| 26 | ... |
| 27 | }; |
| 28 | type CommandResult = { |
| 29 | stdout: string, |
| 30 | stderr: string, |
| 31 | }; |
| 32 | type SpawnOptions = { |
| 33 | cwd: string, |
| 34 | detached?: boolean, |
| 35 | env: {[string]: string | void}, |
| 36 | logFile: string, |
| 37 | }; |
| 38 | type CommandOptions = { |
| 39 | cwd: string, |
| 40 | env: {[string]: string | void}, |
| 41 | logFile: string, |
| 42 | timeout?: number, |
| 43 | }; |
| 44 | type Chrome = { |
| 45 | run: (args: Array<string>) => Promise<CommandResult>, |
| 46 | json: (args: Array<string>) => Promise<mixed>, |
| 47 | }; |
| 48 | type TreeNode = { |
| 49 | uid: string, |
| 50 | type: string, |
| 51 | name: string, |
| 52 | key?: string | null, |
| 53 | firstChild?: string | null, |
| 54 | nextSibling?: string | null, |
| 55 | }; |
| 56 | type SnapshotNode = { |
| 57 | id?: string, |
| 58 | role?: string, |
| 59 | name?: string, |
| 60 | children?: Array<SnapshotNode>, |
| 61 | }; |
| 62 | type ToolDefinition = { |
| 63 | name: string, |
| 64 | description: string, |
| 65 | inputSchema: JSONSchemaObject, |
| 66 | }; |
| 67 | type ToolGroup = { |
| 68 | name: string, |
| 69 | description: string, |
| 70 | tools: Array<ToolDefinition>, |
| 71 | }; |
| 72 | type ToolDiscovery = { |
| 73 | thirdPartyDeveloperTools?: ToolGroup | Array<ToolGroup>, |
| 74 | ... |
| 75 | }; |
| 76 | type SourceResult = { |
| 77 | source: null | { |
| 78 | name: string, |
| 79 | fileName: string, |
| 80 | line: number, |
| 81 | column: number, |
| 82 | ... |
| 83 | }, |
| 84 | ... |
| 85 | }; |
| 86 | type PageReadiness = { |
| 87 | hasApp: boolean, |
| 88 | hasHook: boolean, |
| 89 | }; |
| 90 | type ComponentDetails = { |
| 91 | name: string, |
| 92 | type: string, |
| 93 | hooks: Array<{name: string, ...}>, |
| 94 | ... |
| 95 | }; |
| 96 | type SearchResult = { |
| 97 | page: number, |
| 98 | pageSize: number, |
| 99 | totalCount: number, |
| 100 | totalPages: number, |
| 101 | results: Array<{name: string, ...}>, |
| 102 | ... |
| 103 | }; |
| 104 | type DomLookupResult = { |
| 105 | type: string, |
| 106 | name: string, |
| 107 | ... |
| 108 | }; |
| 109 | type OwnersStackResult = { |
| 110 | stack: string, |
| 111 | ... |
| 112 | }; |
| 113 | type ComponentBranchEntry = { |
| 114 | uid: string, |
| 115 | name: string, |
| 116 | type: string, |
| 117 | ... |
| 118 | }; |
| 119 | type ErrorPayload = { |
| 120 | error: string, |
| 121 | ... |
| 122 | }; |
| 123 | type StartProfilingResult = { |
| 124 | status: string, |
| 125 | traceName: string, |
| 126 | ... |
| 127 | }; |
| 128 | type StopProfilingResult = { |
| 129 | status: string, |
| 130 | traceName: string, |
| 131 | commits: number, |
| 132 | ... |
| 133 | }; |
| 134 | type TraceOverviewCommit = { |
| 135 | commit: number, |
| 136 | componentsChanged: number, |
| 137 | ... |
| 138 | }; |
| 139 | type CommitReport = { |
| 140 | components: Array<{name: string, ...}>, |
| 141 | ... |
| 142 | }; |
| 143 | |
| 144 | const TOOL_NAMES = [ |
| 145 | 'react_get_component_tree', |
| 146 | 'react_get_component_by_uid', |
| 147 | 'react_get_component_by_dom_element', |
| 148 | 'react_find_components', |
| 149 | 'react_get_component_source', |
| 150 | 'react_get_owner_stack_trace', |
| 151 | 'react_get_parent_stack', |
| 152 | 'react_get_owner_stack', |
| 153 | 'react_start_profiling', |
| 154 | 'react_stop_profiling', |
| 155 | 'react_get_trace_overview', |
| 156 | 'react_get_commit_report', |
| 157 | ]; |
| 158 | |
| 159 | const PACKAGE_DIR = path.resolve(__dirname, '..'); |
| 160 | const REPO_ROOT = path.resolve(PACKAGE_DIR, '..', '..'); |
| 161 | const FIXTURE_DIR = path.join(PACKAGE_DIR, 'fixtures', 'app'); |
| 162 | const BUILT_MODULES_DIR = path.join(REPO_ROOT, 'build', 'oss-experimental'); |
| 163 | const LOG_DIR = |
| 164 | process.env.E2E_LOG_DIR || |
| 165 | path.join(REPO_ROOT, 'tmp', 'react-devtools-cdt-mcp-e2e'); |
| 166 | |
| 167 | const SESSION_ID = `react-devtools-cdt-mcp-${process.pid}-${Date.now()}`; |
| 168 | |
| 169 | function log(message: string): void { |
| 170 | process.stdout.write(`${message}\n`); |
| 171 | } |
| 172 | |
| 173 | function sleep(ms: number): Promise<void> { |
| 174 | return new Promise(resolve => setTimeout(resolve, ms)); |
| 175 | } |
| 176 | |
| 177 | function createError(message: string): Error { |
| 178 | // eslint-disable-next-line react-internal/prod-error-codes |
| 179 | return new Error(message); |
| 180 | } |
| 181 | |
| 182 | function ensureBuiltModules(): void { |
| 183 | if (!fs.existsSync(BUILT_MODULES_DIR)) { |
| 184 | throw createError( |
| 185 | 'Missing build/oss-experimental. Run `yarn build-for-devtools` from ' + |
| 186 | 'the repo root before running react-devtools-cdt-mcp E2E tests.' |
| 187 | ); |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | function getFreePort(): Promise<number> { |
| 192 | return new Promise((resolve, reject) => { |
| 193 | const server = net.createServer(); |
| 194 | server.unref(); |
| 195 | server.on('error', reject); |
| 196 | server.listen(0, '127.0.0.1', undefined, () => { |
| 197 | const address = server.address(); |
| 198 | if (address == null || typeof address === 'string') { |
| 199 | reject(createError('Failed to allocate a TCP port')); |
| 200 | return; |
| 201 | } |
| 202 | server.close(() => resolve(address.port)); |
| 203 | }); |
| 204 | }); |
| 205 | } |
| 206 | |
| 207 | function appendLog(logFile: string, text: string | Buffer): void { |
| 208 | fs.appendFileSync(logFile, text); |
| 209 | } |
| 210 | |
| 211 | function formatCommand(command: string, args: Array<string>): string { |
| 212 | return `$ ${[command, ...args].map(arg => JSON.stringify(arg)).join(' ')}\n`; |
| 213 | } |
| 214 | |
| 215 | function spawnLogged( |
| 216 | command: string, |
| 217 | args: Array<string>, |
| 218 | options: SpawnOptions |
| 219 | ): ChildProcess { |
| 220 | const child = childProcess.spawn(command, args, { |
| 221 | cwd: options.cwd, |
| 222 | detached: options.detached === true, |
| 223 | env: options.env, |
| 224 | stdio: ['ignore', 'pipe', 'pipe'], |
| 225 | }); |
| 226 | appendLog(options.logFile, formatCommand(command, args)); |
| 227 | child.on('error', error => { |
| 228 | appendLog( |
| 229 | options.logFile, |
| 230 | `Failed to spawn ${command}: ${ |
| 231 | error.stack || error.message || String(error) |
| 232 | }\n` |
| 233 | ); |
| 234 | }); |
| 235 | child.stdout.on('data', chunk => appendLog(options.logFile, chunk)); |
| 236 | child.stderr.on('data', chunk => appendLog(options.logFile, chunk)); |
| 237 | return child; |
| 238 | } |
| 239 | |
| 240 | function waitForExit(child: ChildProcess, timeout: number): Promise<void> { |
| 241 | return new Promise(resolve => { |
| 242 | let done = false; |
| 243 | const finish = () => { |
| 244 | if (done) { |
| 245 | return; |
| 246 | } |
| 247 | done = true; |
| 248 | clearTimeout(timer); |
| 249 | resolve(); |
| 250 | }; |
| 251 | const timer = setTimeout(() => { |
| 252 | if (child.exitCode == null) { |
| 253 | child.kill('SIGKILL'); |
| 254 | } |
| 255 | finish(); |
| 256 | }, timeout); |
| 257 | |
| 258 | if (child.exitCode != null) { |
| 259 | finish(); |
| 260 | return; |
| 261 | } |
| 262 | child.once('exit', finish); |
| 263 | child.once('error', finish); |
| 264 | }); |
| 265 | } |
| 266 | |
| 267 | async function removePathWithRetries( |
| 268 | targetPath: string, |
| 269 | logFile: string |
| 270 | ): Promise<void> { |
| 271 | for (let attempt = 0; attempt < 5; attempt++) { |
| 272 | try { |
| 273 | fs.rmSync(targetPath, {force: true, recursive: true}); |
| 274 | return; |
| 275 | } catch (error) { |
| 276 | if (attempt === 4) { |
| 277 | appendLog( |
| 278 | logFile, |
| 279 | `Failed to remove ${targetPath}: ${ |
| 280 | error.stack || error.message || String(error) |
| 281 | }\n` |
| 282 | ); |
| 283 | return; |
| 284 | } |
| 285 | await sleep(250); |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | function runCommand( |
| 291 | command: string, |
| 292 | args: Array<string>, |
| 293 | options: CommandOptions |
| 294 | ): Promise<CommandResult> { |
| 295 | const timeout = options.timeout || 120000; |
| 296 | const logFile = options.logFile; |
| 297 | return new Promise((resolve, reject) => { |
| 298 | let stdout = ''; |
| 299 | let stderr = ''; |
| 300 | appendLog(logFile, formatCommand(command, args)); |
| 301 | const child = childProcess.spawn(command, args, { |
| 302 | cwd: options.cwd, |
| 303 | env: options.env, |
| 304 | stdio: ['ignore', 'pipe', 'pipe'], |
| 305 | }); |
| 306 | const timer = setTimeout(() => { |
| 307 | child.kill('SIGTERM'); |
| 308 | reject(createError(`Command timed out after ${timeout}ms: ${command}`)); |
| 309 | }, timeout); |
| 310 | |
| 311 | child.stdout.on('data', chunk => { |
| 312 | stdout += chunk.toString(); |
| 313 | appendLog(logFile, chunk); |
| 314 | }); |
| 315 | child.stderr.on('data', chunk => { |
| 316 | stderr += chunk.toString(); |
| 317 | appendLog(logFile, chunk); |
| 318 | }); |
| 319 | child.on('error', error => { |
| 320 | clearTimeout(timer); |
| 321 | reject(error); |
| 322 | }); |
| 323 | child.on('close', code => { |
| 324 | clearTimeout(timer); |
| 325 | if (code === 0) { |
| 326 | resolve({stdout, stderr}); |
| 327 | } else { |
| 328 | reject( |
| 329 | createError( |
| 330 | `Command failed with exit code ${code}: ${command} ${args.join( |
| 331 | ' ' |
| 332 | )}\n${stderr || stdout}` |
| 333 | ) |
| 334 | ); |
| 335 | } |
| 336 | }); |
| 337 | }); |
| 338 | } |
| 339 | |
| 340 | function startDebuggableChrome( |
| 341 | chromeExecutablePath: string, |
| 342 | remoteDebuggingPort: number, |
| 343 | logFile: string |
| 344 | ): {profileDir: string, process: ChildProcess} { |
| 345 | const profileDir = fs.mkdtempSync( |
| 346 | path.join(os.tmpdir(), 'react-devtools-cdt-mcp-chrome-') |
| 347 | ); |
| 348 | appendLog(logFile, `chromeUserDataDir=${profileDir}\n`); |
| 349 | |
| 350 | const args = [ |
| 351 | '--headless=new', |
| 352 | `--remote-debugging-port=${remoteDebuggingPort}`, |
| 353 | '--remote-debugging-address=127.0.0.1', |
| 354 | `--user-data-dir=${profileDir}`, |
| 355 | '--no-first-run', |
| 356 | '--no-default-browser-check', |
| 357 | 'about:blank', |
| 358 | ]; |
| 359 | |
| 360 | if (process.platform === 'linux') { |
| 361 | args.splice(1, 0, '--no-sandbox', '--disable-setuid-sandbox'); |
| 362 | } |
| 363 | |
| 364 | return { |
| 365 | profileDir, |
| 366 | process: spawnLogged(chromeExecutablePath, args, { |
| 367 | cwd: PACKAGE_DIR, |
| 368 | env: process.env, |
| 369 | logFile, |
| 370 | }), |
| 371 | }; |
| 372 | } |
| 373 | |
| 374 | function getChromeDevToolsBin(): string { |
| 375 | let packageJsonPath: string; |
| 376 | try { |
| 377 | packageJsonPath = require.resolve('chrome-devtools-mcp/package.json', { |
| 378 | paths: [PACKAGE_DIR], |
| 379 | }); |
| 380 | } catch (error) { |
| 381 | throw createError( |
| 382 | 'Missing chrome-devtools-mcp dependency. Run `yarn install` before ' + |
| 383 | 'running react-devtools-cdt-mcp E2E tests.' |
| 384 | ); |
| 385 | } |
| 386 | |
| 387 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); |
| 388 | if ( |
| 389 | packageJson == null || |
| 390 | typeof packageJson !== 'object' || |
| 391 | packageJson.bin == null || |
| 392 | typeof packageJson.bin !== 'object' || |
| 393 | typeof packageJson.bin['chrome-devtools'] !== 'string' |
| 394 | ) { |
| 395 | throw createError( |
| 396 | 'chrome-devtools-mcp package.json is missing the chrome-devtools bin' |
| 397 | ); |
| 398 | } |
| 399 | return path.resolve( |
| 400 | path.dirname(packageJsonPath), |
| 401 | packageJson.bin['chrome-devtools'] |
| 402 | ); |
| 403 | } |
| 404 | |
| 405 | async function waitForHttp(url: string, timeout: number): Promise<void> { |
| 406 | const deadline = Date.now() + timeout; |
| 407 | let lastError: Error = createError('No response yet'); |
| 408 | while (Date.now() < deadline) { |
| 409 | try { |
| 410 | const statusCode: number = await new Promise((resolve, reject) => { |
| 411 | // eslint-disable-next-line no-undef |
| 412 | const request = http.get(url, (response: http$IncomingMessage<>) => { |
| 413 | response.resume(); |
| 414 | response.on('end', () => resolve(response.statusCode || 0)); |
| 415 | }); |
| 416 | request.on('error', reject); |
| 417 | request.setTimeout(1000, () => { |
| 418 | request.destroy(createError('HTTP request timed out')); |
| 419 | }); |
| 420 | }); |
| 421 | if (statusCode >= 200 && statusCode < 400) { |
| 422 | return; |
| 423 | } |
| 424 | lastError = createError(`HTTP ${statusCode}`); |
| 425 | } catch (error) { |
| 426 | lastError = error; |
| 427 | } |
| 428 | await sleep(250); |
| 429 | } |
| 430 | throw createError(`Timed out waiting for ${url}: ${lastError.message}`); |
| 431 | } |
| 432 | |
| 433 | async function waitForPageReady( |
| 434 | chrome: Chrome, |
| 435 | timeout: number |
| 436 | ): Promise<void> { |
| 437 | const deadline = Date.now() + timeout; |
| 438 | let lastResult: mixed = null; |
| 439 | while (Date.now() < deadline) { |
| 440 | const result = await evaluatePageReadiness( |
| 441 | chrome, |
| 442 | `() => ({ |
| 443 | hasApp: document.querySelector('main.app') !== null, |
| 444 | hasHook: window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null, |
| 445 | hasDiscoveryListener: window.__dtmcp != null || |
| 446 | window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null, |
| 447 | })` |
| 448 | ); |
| 449 | lastResult = result; |
| 450 | if (result.hasApp === true && result.hasHook === true) { |
| 451 | return; |
| 452 | } |
| 453 | await sleep(250); |
| 454 | } |
| 455 | throw createError( |
| 456 | `Timed out waiting for fixture readiness: ${ |
| 457 | JSON.stringify(lastResult) || String(lastResult) |
| 458 | }` |
| 459 | ); |
| 460 | } |
| 461 | |
| 462 | function parseJsonOutput(stdout: string): mixed { |
| 463 | const text = stdout.trim(); |
| 464 | assert.notStrictEqual(text, '', 'Expected command to print JSON'); |
| 465 | const parsed = JSON.parse(text); |
| 466 | if ( |
| 467 | Array.isArray(parsed) && |
| 468 | parsed.length === 1 && |
| 469 | parsed[0] && |
| 470 | parsed[0].type === 'text' && |
| 471 | typeof parsed[0].text === 'string' |
| 472 | ) { |
| 473 | throw createError(parsed[0].text); |
| 474 | } |
| 475 | return parsed; |
| 476 | } |
| 477 | |
| 478 | function parseJsonFromText(text: string): mixed { |
| 479 | const trimmed = text.trim(); |
| 480 | const fenced = trimmed.match(/```json\n([\s\S]*?)\n```/); |
| 481 | if (fenced) { |
| 482 | return JSON.parse(fenced[1]); |
| 483 | } |
| 484 | return JSON.parse(trimmed); |
| 485 | } |
| 486 | |
| 487 | function unwrapTextResponse(response: mixed): string { |
| 488 | if (Array.isArray(response)) { |
| 489 | return response.join('\n'); |
| 490 | } |
| 491 | if (typeof response === 'string') { |
| 492 | return response; |
| 493 | } |
| 494 | if (response != null && typeof response === 'object') { |
| 495 | const message = response.message; |
| 496 | if (typeof message === 'string') { |
| 497 | return message; |
| 498 | } |
| 499 | } |
| 500 | return JSON.stringify(response) || String(response); |
| 501 | } |
| 502 | |
| 503 | function formatValue(value: mixed): string { |
| 504 | return JSON.stringify(value) || String(value); |
| 505 | } |
| 506 | |
| 507 | function expectObject(value: mixed, message: string): {+[string]: mixed, ...} { |
| 508 | if (value == null || typeof value !== 'object' || Array.isArray(value)) { |
| 509 | throw createError(`${message}. Saw: ${formatValue(value)}`); |
| 510 | } |
| 511 | return value; |
| 512 | } |
| 513 | |
| 514 | function expectString(value: mixed, message: string): string { |
| 515 | if (typeof value !== 'string') { |
| 516 | throw createError(`${message}. Saw: ${formatValue(value)}`); |
| 517 | } |
| 518 | return value; |
| 519 | } |
| 520 | |
| 521 | function expectNumber(value: mixed, message: string): number { |
| 522 | if (typeof value !== 'number') { |
| 523 | throw createError(`${message}. Saw: ${formatValue(value)}`); |
| 524 | } |
| 525 | return value; |
| 526 | } |
| 527 | |
| 528 | function expectArray(value: mixed, message: string): $ReadOnlyArray<mixed> { |
| 529 | if (!Array.isArray(value)) { |
| 530 | throw createError(`${message}. Saw: ${formatValue(value)}`); |
| 531 | } |
| 532 | return value; |
| 533 | } |
| 534 | |
| 535 | function expectOptionalString(value: mixed, message: string): string | null { |
| 536 | if (value == null) { |
| 537 | return null; |
| 538 | } |
| 539 | return expectString(value, message); |
| 540 | } |
| 541 | |
| 542 | function parseJSONSchemaObject( |
| 543 | value: mixed, |
| 544 | message: string |
| 545 | ): JSONSchemaObject { |
| 546 | const object = expectObject(value, message); |
| 547 | const schema: JSONSchemaObject = {}; |
| 548 | if (object.type != null) { |
| 549 | schema.type = expectString(object.type, `${message}.type must be a string`); |
| 550 | } |
| 551 | if (object.properties != null) { |
| 552 | const rawProperties = expectObject( |
| 553 | object.properties, |
| 554 | `${message}.properties must be an object` |
| 555 | ); |
| 556 | const properties: {[string]: JSONSchemaObject, ...} = {}; |
| 557 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 558 | for (const key of Object.keys(rawProperties)) { |
| 559 | properties[key] = parseJSONSchemaObject( |
| 560 | rawProperties[key], |
| 561 | `${message}.properties.${key}` |
| 562 | ); |
| 563 | } |
| 564 | schema.properties = properties; |
| 565 | } |
| 566 | if (object.required != null) { |
| 567 | schema.required = expectArray( |
| 568 | object.required, |
| 569 | `${message}.required must be an array` |
| 570 | ).map((item, index) => |
| 571 | expectString(item, `${message}.required[${index}] must be a string`) |
| 572 | ); |
| 573 | } |
| 574 | return schema; |
| 575 | } |
| 576 | |
| 577 | function parseToolDefinition(value: mixed): ToolDefinition { |
| 578 | const object = expectObject(value, 'Expected tool definition object'); |
| 579 | return { |
| 580 | name: expectString(object.name, 'Expected tool definition name'), |
| 581 | description: expectString( |
| 582 | object.description, |
| 583 | 'Expected tool definition description' |
| 584 | ), |
| 585 | inputSchema: parseJSONSchemaObject( |
| 586 | object.inputSchema, |
| 587 | 'Expected tool definition inputSchema' |
| 588 | ), |
| 589 | }; |
| 590 | } |
| 591 | |
| 592 | function parseToolGroup(value: mixed): ToolGroup { |
| 593 | const object = expectObject(value, 'Expected tool group object'); |
| 594 | return { |
| 595 | name: expectString(object.name, 'Expected tool group name'), |
| 596 | description: expectString( |
| 597 | object.description, |
| 598 | 'Expected tool group description' |
| 599 | ), |
| 600 | tools: expectArray(object.tools, 'Expected tool group tools').map( |
| 601 | parseToolDefinition |
| 602 | ), |
| 603 | }; |
| 604 | } |
| 605 | |
| 606 | function parseToolDiscovery(value: mixed): ToolDiscovery { |
| 607 | const object = expectObject(value, 'Expected tool discovery object'); |
| 608 | const rawToolGroups = object.thirdPartyDeveloperTools; |
| 609 | if (rawToolGroups == null) { |
| 610 | return {}; |
| 611 | } |
| 612 | return { |
| 613 | thirdPartyDeveloperTools: Array.isArray(rawToolGroups) |
| 614 | ? rawToolGroups.map(parseToolGroup) |
| 615 | : parseToolGroup(rawToolGroups), |
| 616 | }; |
| 617 | } |
| 618 | |
| 619 | function parsePageReadiness(value: mixed): PageReadiness { |
| 620 | const object = expectObject(value, 'Expected page readiness object'); |
| 621 | return { |
| 622 | hasApp: object.hasApp === true, |
| 623 | hasHook: object.hasHook === true, |
| 624 | }; |
| 625 | } |
| 626 | |
| 627 | async function evaluatePageReadiness( |
| 628 | chrome: Chrome, |
| 629 | fn: string |
| 630 | ): Promise<PageReadiness> { |
| 631 | const output = await chrome.json(['evaluate_script', fn]); |
| 632 | return parsePageReadiness(parseJsonFromText(unwrapTextResponse(output))); |
| 633 | } |
| 634 | |
| 635 | function parseToolResponse(output: mixed): mixed { |
| 636 | return parseJsonFromText(unwrapTextResponse(output)); |
| 637 | } |
| 638 | |
| 639 | function parseTreeNode(value: mixed): TreeNode { |
| 640 | const object = expectObject(value, 'Expected tree node object'); |
| 641 | return { |
| 642 | uid: expectString(object.uid, 'Expected tree node uid'), |
| 643 | type: expectString(object.type, 'Expected tree node type'), |
| 644 | name: expectString(object.name, 'Expected tree node name'), |
| 645 | key: expectOptionalString(object.key, 'Expected tree node key'), |
| 646 | firstChild: expectOptionalString( |
| 647 | object.firstChild, |
| 648 | 'Expected tree node firstChild' |
| 649 | ), |
| 650 | nextSibling: expectOptionalString( |
| 651 | object.nextSibling, |
| 652 | 'Expected tree node nextSibling' |
| 653 | ), |
| 654 | }; |
| 655 | } |
| 656 | |
| 657 | function parseTree(value: mixed): Array<TreeNode> { |
| 658 | const object = expectObject(value, 'Expected component tree response object'); |
| 659 | return expectArray(object.nodes, 'Expected component tree nodes array').map( |
| 660 | parseTreeNode |
| 661 | ); |
| 662 | } |
| 663 | |
| 664 | function parseNamedObject(value: mixed, message: string): {name: string, ...} { |
| 665 | const object = expectObject(value, message); |
| 666 | return { |
| 667 | name: expectString(object.name, `${message} name`), |
| 668 | }; |
| 669 | } |
| 670 | |
| 671 | function parseComponentBranchEntry( |
| 672 | value: mixed, |
| 673 | message: string |
| 674 | ): ComponentBranchEntry { |
| 675 | const object = expectObject(value, message); |
| 676 | return { |
| 677 | uid: expectString(object.uid, `${message} uid`), |
| 678 | name: expectString(object.name, `${message} name`), |
| 679 | type: expectString(object.type, `${message} type`), |
| 680 | }; |
| 681 | } |
| 682 | |
| 683 | function parseComponentDetails(value: mixed): ComponentDetails { |
| 684 | const object = expectObject(value, 'Expected component details object'); |
| 685 | const details: ComponentDetails = { |
| 686 | name: expectString(object.name, 'Expected component details name'), |
| 687 | type: expectString(object.type, 'Expected component details type'), |
| 688 | hooks: expectArray(object.hooks, 'Expected component details hooks').map( |
| 689 | (hook, index) => |
| 690 | parseNamedObject(hook, `Expected component hook ${index}`) |
| 691 | ), |
| 692 | }; |
| 693 | return details; |
| 694 | } |
| 695 | |
| 696 | function parseComponentType(value: mixed): string { |
| 697 | return expectString( |
| 698 | expectObject(value, 'Expected component details object').type, |
| 699 | 'Expected component details type' |
| 700 | ); |
| 701 | } |
| 702 | |
| 703 | function parseSearchResult(value: mixed): SearchResult { |
| 704 | const object = expectObject(value, 'Expected search result object'); |
| 705 | return { |
| 706 | ...object, |
| 707 | page: expectNumber(object.page, 'Expected search result page'), |
| 708 | pageSize: expectNumber(object.pageSize, 'Expected search result pageSize'), |
| 709 | totalCount: expectNumber( |
| 710 | object.totalCount, |
| 711 | 'Expected search result totalCount' |
| 712 | ), |
| 713 | totalPages: expectNumber( |
| 714 | object.totalPages, |
| 715 | 'Expected search result totalPages' |
| 716 | ), |
| 717 | results: expectArray(object.results, 'Expected search result results').map( |
| 718 | (result, index) => |
| 719 | parseNamedObject(result, `Expected search result ${index}`) |
| 720 | ), |
| 721 | }; |
| 722 | } |
| 723 | |
| 724 | function parseSnapshotNode(value: mixed): SnapshotNode { |
| 725 | const object = expectObject(value, 'Expected snapshot node object'); |
| 726 | const snapshotNode: SnapshotNode = {}; |
| 727 | if (object.id != null) { |
| 728 | snapshotNode.id = expectString(object.id, 'Expected snapshot node id'); |
| 729 | } |
| 730 | if (object.role != null) { |
| 731 | snapshotNode.role = expectString( |
| 732 | object.role, |
| 733 | 'Expected snapshot node role' |
| 734 | ); |
| 735 | } |
| 736 | if (object.name != null) { |
| 737 | snapshotNode.name = expectString( |
| 738 | object.name, |
| 739 | 'Expected snapshot node name' |
| 740 | ); |
| 741 | } |
| 742 | if (object.children != null) { |
| 743 | snapshotNode.children = expectArray( |
| 744 | object.children, |
| 745 | 'Expected snapshot node children' |
| 746 | ).map(parseSnapshotNode); |
| 747 | } |
| 748 | return snapshotNode; |
| 749 | } |
| 750 | |
| 751 | function parseSnapshotResponse(value: mixed): {snapshot: SnapshotNode, ...} { |
| 752 | const object = expectObject(value, 'Expected snapshot response object'); |
| 753 | return { |
| 754 | snapshot: parseSnapshotNode(object.snapshot), |
| 755 | }; |
| 756 | } |
| 757 | |
| 758 | function parseDomLookupResult(value: mixed): DomLookupResult { |
| 759 | const object = expectObject(value, 'Expected DOM lookup result object'); |
| 760 | return { |
| 761 | ...object, |
| 762 | type: expectString(object.type, 'Expected DOM lookup result type'), |
| 763 | name: expectString(object.name, 'Expected DOM lookup result name'), |
| 764 | }; |
| 765 | } |
| 766 | |
| 767 | function parseSourceResult(value: mixed): SourceResult { |
| 768 | const object = expectObject(value, 'Expected source result object'); |
| 769 | if (object.source == null) { |
| 770 | return {source: null}; |
| 771 | } |
| 772 | const source = expectObject(object.source, 'Expected source object'); |
| 773 | return { |
| 774 | ...object, |
| 775 | source: { |
| 776 | ...source, |
| 777 | name: expectString(source.name, 'Expected source name'), |
| 778 | fileName: expectString(source.fileName, 'Expected source fileName'), |
| 779 | line: expectNumber(source.line, 'Expected source line'), |
| 780 | column: expectNumber(source.column, 'Expected source column'), |
| 781 | }, |
| 782 | }; |
| 783 | } |
| 784 | |
| 785 | function parseOwnersStack(value: mixed): OwnersStackResult { |
| 786 | const object = expectObject(value, 'Expected owners stack object'); |
| 787 | return { |
| 788 | ...object, |
| 789 | stack: expectString(object.stack, 'Expected owners stack string'), |
| 790 | }; |
| 791 | } |
| 792 | |
| 793 | function parseComponentBranch( |
| 794 | value: mixed, |
| 795 | label: string |
| 796 | ): Array<ComponentBranchEntry> { |
| 797 | return expectArray(value, `Expected ${label} branch array`).map( |
| 798 | (entry, index) => |
| 799 | parseComponentBranchEntry(entry, `Expected ${label} ${index}`) |
| 800 | ); |
| 801 | } |
| 802 | |
| 803 | function parseErrorPayload(value: mixed): ErrorPayload { |
| 804 | const object = expectObject(value, 'Expected error payload object'); |
| 805 | return { |
| 806 | ...object, |
| 807 | error: expectString(object.error, 'Expected error payload error'), |
| 808 | }; |
| 809 | } |
| 810 | |
| 811 | function parseStartProfilingResult(value: mixed): StartProfilingResult { |
| 812 | const object = expectObject(value, 'Expected start profiling result object'); |
| 813 | return { |
| 814 | ...object, |
| 815 | status: expectString(object.status, 'Expected start profiling status'), |
| 816 | traceName: expectString( |
| 817 | object.traceName, |
| 818 | 'Expected start profiling traceName' |
| 819 | ), |
| 820 | }; |
| 821 | } |
| 822 | |
| 823 | function parseStopProfilingResult(value: mixed): StopProfilingResult { |
| 824 | const object = expectObject(value, 'Expected stop profiling result object'); |
| 825 | return { |
| 826 | ...object, |
| 827 | status: expectString(object.status, 'Expected stop profiling status'), |
| 828 | traceName: expectString( |
| 829 | object.traceName, |
| 830 | 'Expected stop profiling traceName' |
| 831 | ), |
| 832 | commits: expectNumber(object.commits, 'Expected stop profiling commits'), |
| 833 | }; |
| 834 | } |
| 835 | |
| 836 | function parseTraceOverview(value: mixed): Array<TraceOverviewCommit> { |
| 837 | return expectArray(value, 'Expected trace overview array').map( |
| 838 | (commit, index) => { |
| 839 | const object = expectObject(commit, `Expected trace overview ${index}`); |
| 840 | return { |
| 841 | ...object, |
| 842 | commit: expectNumber( |
| 843 | object.commit, |
| 844 | `Expected trace overview ${index} commit` |
| 845 | ), |
| 846 | componentsChanged: expectNumber( |
| 847 | object.componentsChanged, |
| 848 | `Expected trace overview ${index} componentsChanged` |
| 849 | ), |
| 850 | }; |
| 851 | } |
| 852 | ); |
| 853 | } |
| 854 | |
| 855 | function parseCommitReport(value: mixed): CommitReport { |
| 856 | const object = expectObject(value, 'Expected commit report object'); |
| 857 | return { |
| 858 | ...object, |
| 859 | components: expectArray( |
| 860 | object.components, |
| 861 | 'Expected commit report components' |
| 862 | ).map((component, index) => |
| 863 | parseNamedObject(component, `Expected commit report component ${index}`) |
| 864 | ), |
| 865 | }; |
| 866 | } |
| 867 | |
| 868 | function findNode( |
| 869 | tree: Array<TreeNode>, |
| 870 | predicate: (node: TreeNode) => boolean, |
| 871 | message: string |
| 872 | ): TreeNode { |
| 873 | const node = tree.find(predicate); |
| 874 | if (node == null) { |
| 875 | throw createError( |
| 876 | `${message}. Saw: ${tree |
| 877 | .map(item => `${item.name}:${item.type}`) |
| 878 | .join(', ')}` |
| 879 | ); |
| 880 | } |
| 881 | return node; |
| 882 | } |
| 883 | |
| 884 | function flattenSnapshot( |
| 885 | node: SnapshotNode, |
| 886 | result: Array<SnapshotNode> |
| 887 | ): Array<SnapshotNode> { |
| 888 | result.push(node); |
| 889 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 890 | for (const child of node.children || []) { |
| 891 | flattenSnapshot(child, result); |
| 892 | } |
| 893 | return result; |
| 894 | } |
| 895 | |
| 896 | function findSnapshotNode( |
| 897 | snapshot: SnapshotNode, |
| 898 | predicate: (node: SnapshotNode) => boolean, |
| 899 | message: string |
| 900 | ): SnapshotNode { |
| 901 | const node = flattenSnapshot(snapshot, []).find(predicate); |
| 902 | if (node == null) { |
| 903 | throw createError(message); |
| 904 | } |
| 905 | return node; |
| 906 | } |
| 907 | |
| 908 | function assertHasSchema(tool: ToolDefinition): void { |
| 909 | assert.strictEqual(typeof tool.description, 'string'); |
| 910 | assert(tool.description.length > 0); |
| 911 | assert.strictEqual(tool.inputSchema.type, 'object'); |
| 912 | } |
| 913 | |
| 914 | function getReactToolGroup(discovery: ToolDiscovery): ToolGroup | null { |
| 915 | const toolGroups = discovery.thirdPartyDeveloperTools; |
| 916 | if (Array.isArray(toolGroups)) { |
| 917 | return toolGroups.find(group => group.name === 'react') || null; |
| 918 | } |
| 919 | if ( |
| 920 | toolGroups != null && |
| 921 | typeof toolGroups === 'object' && |
| 922 | toolGroups.name === 'react' |
| 923 | ) { |
| 924 | return toolGroups; |
| 925 | } |
| 926 | return null; |
| 927 | } |
| 928 | |
| 929 | function assertSourceReference(sourceResult: SourceResult): void { |
| 930 | if (sourceResult.source === null) { |
| 931 | return; |
| 932 | } |
| 933 | const source = sourceResult.source; |
| 934 | assert.strictEqual(typeof source.name, 'string'); |
| 935 | assert.strictEqual(typeof source.fileName, 'string'); |
| 936 | assert( |
| 937 | /App\.js|bundle\.js|webpack/.test(source.fileName), |
| 938 | `Expected source file to reference App.js, bundle.js, or webpack. Saw: ${source.fileName}` |
| 939 | ); |
| 940 | assert.strictEqual(typeof source.line, 'number'); |
| 941 | assert.strictEqual(typeof source.column, 'number'); |
| 942 | } |
| 943 | |
| 944 | async function runE2E(chrome: Chrome, appUrl: string): Promise<void> { |
| 945 | await chrome.json(['navigate_page', '--type', 'url', '--url', appUrl]); |
| 946 | await waitForPageReady(chrome, 30000); |
| 947 | |
| 948 | log('Checking third-party tool discovery...'); |
| 949 | const discovery = parseToolDiscovery( |
| 950 | await chrome.json(['list_3p_developer_tools']) |
| 951 | ); |
| 952 | const toolGroup = getReactToolGroup(discovery); |
| 953 | if (toolGroup == null) { |
| 954 | throw createError('Expected a third-party tool group'); |
| 955 | } |
| 956 | assert.strictEqual(toolGroup.name, 'react'); |
| 957 | assert.deepStrictEqual( |
| 958 | toolGroup.tools.map(tool => tool.name), |
| 959 | TOOL_NAMES |
| 960 | ); |
| 961 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 962 | for (const tool of toolGroup.tools) { |
| 963 | assertHasSchema(tool); |
| 964 | } |
| 965 | const domTool = toolGroup.tools.find( |
| 966 | tool => tool.name === 'react_get_component_by_dom_element' |
| 967 | ); |
| 968 | if (domTool == null) { |
| 969 | throw createError( |
| 970 | 'Expected react_get_component_by_dom_element in discovery' |
| 971 | ); |
| 972 | } |
| 973 | const inputSchemaProperties = domTool.inputSchema.properties; |
| 974 | if (inputSchemaProperties == null) { |
| 975 | throw createError('Expected DOM lookup tool schema properties'); |
| 976 | } |
| 977 | const domElementSchema = inputSchemaProperties.element; |
| 978 | if (domElementSchema == null) { |
| 979 | throw createError('Expected DOM lookup tool element schema'); |
| 980 | } |
| 981 | const domElementSchemaProperties = domElementSchema.properties; |
| 982 | if (domElementSchemaProperties == null) { |
| 983 | throw createError('Expected DOM lookup tool element schema properties'); |
| 984 | } |
| 985 | assert.strictEqual(domElementSchema.type, 'object'); |
| 986 | assert.deepStrictEqual(domElementSchemaProperties, {uid: {type: 'string'}}); |
| 987 | assert.deepStrictEqual(domElementSchema.required, ['uid']); |
| 988 | |
| 989 | log('Checking tree, details, search, and DOM lookup...'); |
| 990 | const callTool = (toolName: string, params?: {...}): Promise<mixed> => |
| 991 | chrome |
| 992 | .json([ |
| 993 | 'execute_3p_developer_tool', |
| 994 | toolName, |
| 995 | '--params', |
| 996 | JSON.stringify(params || {}), |
| 997 | ]) |
| 998 | .then(parseToolResponse); |
| 999 | |
| 1000 | const tree = parseTree(await callTool('react_get_component_tree')); |
| 1001 | const counter = findNode( |
| 1002 | tree, |
| 1003 | node => node.name === 'Counter' && node.type === 'function', |
| 1004 | 'Expected function component Counter' |
| 1005 | ); |
| 1006 | const todo = findNode( |
| 1007 | tree, |
| 1008 | node => node.name === 'Todo' && node.type === 'function', |
| 1009 | 'Expected function component Todo' |
| 1010 | ); |
| 1011 | const todoList = findNode( |
| 1012 | tree, |
| 1013 | node => node.name === 'TodoList' && node.type === 'function', |
| 1014 | 'Expected function component TodoList' |
| 1015 | ); |
| 1016 | const todoListHost = findNode( |
| 1017 | tree, |
| 1018 | node => node.name === 'ul' && node.type === 'host', |
| 1019 | 'Expected host ul for TodoList' |
| 1020 | ); |
| 1021 | const mainNode = findNode( |
| 1022 | tree, |
| 1023 | node => node.name === 'main' && node.type === 'host', |
| 1024 | 'Expected host main' |
| 1025 | ); |
| 1026 | const app = findNode( |
| 1027 | tree, |
| 1028 | node => node.name === 'App' && node.type === 'function', |
| 1029 | 'Expected function component App' |
| 1030 | ); |
| 1031 | const root = findNode( |
| 1032 | tree, |
| 1033 | node => node.type === 'root', |
| 1034 | 'Expected root node' |
| 1035 | ); |
| 1036 | const memoBox = findNode( |
| 1037 | tree, |
| 1038 | node => node.name.includes('MemoBox') && node.type === 'memo', |
| 1039 | 'Expected memo component MemoBox' |
| 1040 | ); |
| 1041 | const fancyInput = findNode( |
| 1042 | tree, |
| 1043 | node => node.name.includes('FancyInput') && node.type === 'forwardRef', |
| 1044 | 'Expected forwardRef component FancyInput' |
| 1045 | ); |
| 1046 | const input = findNode( |
| 1047 | tree, |
| 1048 | node => node.name === 'input' && node.type === 'host', |
| 1049 | 'Expected host input' |
| 1050 | ); |
| 1051 | |
| 1052 | const counterDetails = parseComponentDetails( |
| 1053 | await callTool('react_get_component_by_uid', { |
| 1054 | uid: counter.uid, |
| 1055 | includeHooks: true, |
| 1056 | }) |
| 1057 | ); |
| 1058 | assert.strictEqual(counterDetails.name, 'Counter'); |
| 1059 | assert( |
| 1060 | counterDetails.hooks.some(hook => hook.name === 'State'), |
| 1061 | 'Expected Counter details to include a State hook' |
| 1062 | ); |
| 1063 | |
| 1064 | assert.strictEqual( |
| 1065 | parseComponentType( |
| 1066 | await callTool('react_get_component_by_uid', {uid: memoBox.uid}) |
| 1067 | ), |
| 1068 | 'memo' |
| 1069 | ); |
| 1070 | assert.strictEqual( |
| 1071 | parseComponentType( |
| 1072 | await callTool('react_get_component_by_uid', {uid: fancyInput.uid}) |
| 1073 | ), |
| 1074 | 'forwardRef' |
| 1075 | ); |
| 1076 | assert.strictEqual( |
| 1077 | parseComponentType( |
| 1078 | await callTool('react_get_component_by_uid', {uid: input.uid}) |
| 1079 | ), |
| 1080 | 'host' |
| 1081 | ); |
| 1082 | |
| 1083 | const todoSearch = parseSearchResult( |
| 1084 | await callTool('react_find_components', { |
| 1085 | name: 'Todo', |
| 1086 | pageSize: 2, |
| 1087 | }) |
| 1088 | ); |
| 1089 | assert.strictEqual(todoSearch.page, 1); |
| 1090 | assert.strictEqual(todoSearch.pageSize, 2); |
| 1091 | assert.strictEqual(todoSearch.totalCount, 4); |
| 1092 | assert.strictEqual(todoSearch.totalPages, 2); |
| 1093 | assert.deepStrictEqual( |
| 1094 | todoSearch.results.map(result => result.name), |
| 1095 | ['TodoList', 'Todo'] |
| 1096 | ); |
| 1097 | |
| 1098 | const snapshot = parseSnapshotResponse(await chrome.json(['take_snapshot'])); |
| 1099 | const buttonNode = findSnapshotNode( |
| 1100 | snapshot.snapshot, |
| 1101 | node => node.role === 'button' && node.name === '+1', |
| 1102 | 'Expected +1 button in snapshot' |
| 1103 | ); |
| 1104 | const buttonUid = buttonNode.id; |
| 1105 | if (buttonUid == null) { |
| 1106 | throw createError('Expected +1 button uid'); |
| 1107 | } |
| 1108 | const domLookup = parseDomLookupResult( |
| 1109 | await callTool('react_get_component_by_dom_element', { |
| 1110 | element: {uid: buttonUid}, |
| 1111 | }) |
| 1112 | ); |
| 1113 | assert.strictEqual(domLookup.type, 'host'); |
| 1114 | assert.strictEqual(domLookup.name, 'button'); |
| 1115 | |
| 1116 | log('Checking source, parents, owners, and error payloads...'); |
| 1117 | const source = parseSourceResult( |
| 1118 | await callTool('react_get_component_source', { |
| 1119 | uid: counter.uid, |
| 1120 | }) |
| 1121 | ); |
| 1122 | assertSourceReference(source); |
| 1123 | |
| 1124 | const ownersStack = parseOwnersStack( |
| 1125 | await callTool('react_get_owner_stack_trace', {uid: todo.uid}) |
| 1126 | ); |
| 1127 | assert.strictEqual(typeof ownersStack.stack, 'string'); |
| 1128 | if (ownersStack.stack.length > 0) { |
| 1129 | assert( |
| 1130 | /Todo|TodoList|App\.js/.test(ownersStack.stack), |
| 1131 | `Expected owners stack to reference Todo, TodoList, or App.js. Saw: ${ownersStack.stack}` |
| 1132 | ); |
| 1133 | } |
| 1134 | |
| 1135 | const parentsBranch = parseComponentBranch( |
| 1136 | await callTool('react_get_parent_stack', { |
| 1137 | uid: todo.uid, |
| 1138 | }), |
| 1139 | 'parents' |
| 1140 | ); |
| 1141 | assert.deepStrictEqual(parentsBranch, [ |
| 1142 | { |
| 1143 | uid: todoListHost.uid, |
| 1144 | name: todoListHost.name, |
| 1145 | type: todoListHost.type, |
| 1146 | }, |
| 1147 | { |
| 1148 | uid: todoList.uid, |
| 1149 | name: todoList.name, |
| 1150 | type: todoList.type, |
| 1151 | }, |
| 1152 | { |
| 1153 | uid: mainNode.uid, |
| 1154 | name: mainNode.name, |
| 1155 | type: mainNode.type, |
| 1156 | }, |
| 1157 | { |
| 1158 | uid: app.uid, |
| 1159 | name: app.name, |
| 1160 | type: app.type, |
| 1161 | }, |
| 1162 | { |
| 1163 | uid: root.uid, |
| 1164 | name: root.name, |
| 1165 | type: root.type, |
| 1166 | }, |
| 1167 | ]); |
| 1168 | |
| 1169 | const ownersBranch = parseComponentBranch( |
| 1170 | await callTool('react_get_owner_stack', { |
| 1171 | uid: todo.uid, |
| 1172 | }), |
| 1173 | 'owners' |
| 1174 | ); |
| 1175 | assert( |
| 1176 | ownersBranch.some(owner => owner.name === 'TodoList'), |
| 1177 | 'Expected Todo owners branch to include TodoList' |
| 1178 | ); |
| 1179 | |
| 1180 | const invalidUid = parseErrorPayload( |
| 1181 | await callTool('react_get_component_by_uid', { |
| 1182 | uid: 'r999999', |
| 1183 | }) |
| 1184 | ); |
| 1185 | assert.deepStrictEqual(invalidUid, { |
| 1186 | error: 'Component not found: "r999999"', |
| 1187 | }); |
| 1188 | |
| 1189 | log('Checking profiling through a real CLI click...'); |
| 1190 | const traceName = `e2e-${Date.now()}`; |
| 1191 | assert.deepStrictEqual( |
| 1192 | parseStartProfilingResult( |
| 1193 | await callTool('react_start_profiling', {traceName}) |
| 1194 | ), |
| 1195 | { |
| 1196 | status: 'started', |
| 1197 | traceName, |
| 1198 | } |
| 1199 | ); |
| 1200 | await chrome.json(['click', buttonUid]); |
| 1201 | const stopResult = parseStopProfilingResult( |
| 1202 | await callTool('react_stop_profiling') |
| 1203 | ); |
| 1204 | assert.strictEqual(stopResult.status, 'stopped'); |
| 1205 | assert.strictEqual(stopResult.traceName, traceName); |
| 1206 | assert( |
| 1207 | stopResult.commits >= 1, |
| 1208 | 'Expected profiling to record at least one commit' |
| 1209 | ); |
| 1210 | |
| 1211 | const overview = parseTraceOverview( |
| 1212 | await callTool('react_get_trace_overview', {traceName}) |
| 1213 | ); |
| 1214 | assert(overview.length >= 1, 'Expected at least one profiling commit'); |
| 1215 | assert( |
| 1216 | overview.some(commit => commit.componentsChanged >= 1), |
| 1217 | 'Expected at least one changed component in trace overview' |
| 1218 | ); |
| 1219 | |
| 1220 | let foundCounterCommit = false; |
| 1221 | // eslint-disable-next-line no-for-of-loops/no-for-of-loops |
| 1222 | for (const commit of overview) { |
| 1223 | const report = parseCommitReport( |
| 1224 | await callTool('react_get_commit_report', { |
| 1225 | traceName, |
| 1226 | commitIndex: commit.commit, |
| 1227 | }) |
| 1228 | ); |
| 1229 | if (report.components.some(component => component.name === 'Counter')) { |
| 1230 | foundCounterCommit = true; |
| 1231 | break; |
| 1232 | } |
| 1233 | } |
| 1234 | assert(foundCounterCommit, 'Expected a profiling commit involving Counter'); |
| 1235 | } |
| 1236 | |
| 1237 | async function main(): Promise<void> { |
| 1238 | ensureBuiltModules(); |
| 1239 | fs.mkdirSync(LOG_DIR, {recursive: true}); |
| 1240 | |
| 1241 | const fixtureLog = path.join(LOG_DIR, 'fixture-server.log'); |
| 1242 | const chromeLog = path.join(LOG_DIR, 'chrome-devtools.log'); |
| 1243 | const cliLog = path.join(LOG_DIR, 'chrome-devtools-cli.log'); |
| 1244 | fs.writeFileSync(fixtureLog, ''); |
| 1245 | fs.writeFileSync(chromeLog, ''); |
| 1246 | fs.writeFileSync(cliLog, ''); |
| 1247 | |
| 1248 | const chromeDevToolsBin = getChromeDevToolsBin(); |
| 1249 | const port = await getFreePort(); |
| 1250 | const appUrl = `http://127.0.0.1:${port}/`; |
| 1251 | |
| 1252 | let fixture: ?ChildProcess; |
| 1253 | let debuggableChrome: ?ChildProcess; |
| 1254 | let debuggableChromeProfile: ?string; |
| 1255 | const runChrome = (args: Array<string>): Promise<CommandResult> => |
| 1256 | runCommand( |
| 1257 | process.execPath, |
| 1258 | [chromeDevToolsBin, ...args, '--sessionId', SESSION_ID], |
| 1259 | { |
| 1260 | cwd: PACKAGE_DIR, |
| 1261 | env: process.env, |
| 1262 | logFile: cliLog, |
| 1263 | timeout: 120000, |
| 1264 | } |
| 1265 | ); |
| 1266 | const chrome: Chrome = { |
| 1267 | run: runChrome, |
| 1268 | async json(args: Array<string>): Promise<mixed> { |
| 1269 | const result = await runChrome([...args, '--output-format', 'json']); |
| 1270 | return parseJsonOutput(result.stdout); |
| 1271 | }, |
| 1272 | }; |
| 1273 | |
| 1274 | try { |
| 1275 | log(`Starting fixture at ${appUrl}`); |
| 1276 | fixture = spawnLogged('yarn', ['start'], { |
| 1277 | cwd: FIXTURE_DIR, |
| 1278 | detached: true, |
| 1279 | env: { |
| 1280 | ...process.env, |
| 1281 | BROWSER: 'none', |
| 1282 | CI: 'true', |
| 1283 | E2E: 'true', |
| 1284 | HOST: '127.0.0.1', |
| 1285 | PORT: String(port), |
| 1286 | }, |
| 1287 | logFile: fixtureLog, |
| 1288 | }); |
| 1289 | await waitForHttp(appUrl, 60000); |
| 1290 | |
| 1291 | const startArgs = [ |
| 1292 | 'start', |
| 1293 | '--categoryExperimentalThirdParty=true', |
| 1294 | '--usageStatistics=false', |
| 1295 | '--logFile', |
| 1296 | chromeLog, |
| 1297 | ]; |
| 1298 | const chromeExecutablePath = process.env.CHROME_EXECUTABLE_PATH; |
| 1299 | if (chromeExecutablePath != null) { |
| 1300 | const remoteDebuggingPort = await getFreePort(); |
| 1301 | const launchedChrome = startDebuggableChrome( |
| 1302 | chromeExecutablePath, |
| 1303 | remoteDebuggingPort, |
| 1304 | chromeLog |
| 1305 | ); |
| 1306 | debuggableChrome = launchedChrome.process; |
| 1307 | debuggableChromeProfile = launchedChrome.profileDir; |
| 1308 | const browserUrl = `http://127.0.0.1:${remoteDebuggingPort}`; |
| 1309 | await waitForHttp(`${browserUrl}/json/version`, 30000); |
| 1310 | startArgs.push('--browserUrl', browserUrl); |
| 1311 | } else { |
| 1312 | startArgs.push('--headless=true', '--isolated=true'); |
| 1313 | } |
| 1314 | log('Starting chrome-devtools daemon...'); |
| 1315 | await chrome.run(startArgs); |
| 1316 | |
| 1317 | await runE2E(chrome, appUrl); |
| 1318 | log('react-devtools-cdt-mcp E2E passed.'); |
| 1319 | } finally { |
| 1320 | try { |
| 1321 | await chrome.run(['stop']); |
| 1322 | } catch (error) { |
| 1323 | appendLog(cliLog, `Failed to stop chrome-devtools: ${error.stack}\n`); |
| 1324 | } |
| 1325 | if (debuggableChrome) { |
| 1326 | try { |
| 1327 | debuggableChrome.kill('SIGTERM'); |
| 1328 | await waitForExit(debuggableChrome, 5000); |
| 1329 | } catch (error) { |
| 1330 | appendLog(chromeLog, `Failed to stop Chrome: ${error.stack}\n`); |
| 1331 | } |
| 1332 | } |
| 1333 | if (debuggableChromeProfile) { |
| 1334 | await removePathWithRetries(debuggableChromeProfile, chromeLog); |
| 1335 | } |
| 1336 | if (fixture && fixture.pid) { |
| 1337 | try { |
| 1338 | process.kill(-fixture.pid, 'SIGTERM'); |
| 1339 | } catch (error) { |
| 1340 | try { |
| 1341 | fixture.kill('SIGTERM'); |
| 1342 | } catch (innerError) { |
| 1343 | appendLog( |
| 1344 | fixtureLog, |
| 1345 | `Failed to stop fixture server: ${innerError.stack}\n` |
| 1346 | ); |
| 1347 | } |
| 1348 | } |
| 1349 | } |
| 1350 | } |
| 1351 | } |
| 1352 | |
| 1353 | module.exports = {main}; |