@samitouri / QOS-React-2 / commits / 7df975a0c5

[react-devtools-cdt-mcp] add chrome-devtools E2E coverage (#36823)

A node runner that bootstraps `fixtures/app`, and then uses `chrome-devtools` CLI to test third-party tools. ``` $ node e2e/run.js Starting fixture at http://127.0.0.1:60033/ Starting chrome-devtools daemon... Checking third-party tool discovery... Checking tree, details, search, and DOM lookup... Checking source, owners, and error payloads... Checking profiling through a real CLI click... react-devtools-cdt-mcp E2E passed. ✨ Done in 11.66s. ```

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