[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"