main
js 1,353 lines 36.6 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 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};