/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local */ 'use strict'; const assert = require('assert'); const childProcess = require('child_process'); const fs = require('fs'); const http = require('http'); const net = require('net'); const os = require('os'); const path = require('path'); // eslint-disable-next-line no-undef type ChildProcess = child_process$ChildProcess; type JSONSchemaObject = { type?: string, properties?: {[string]: JSONSchemaObject, ...}, required?: Array, ... }; type CommandResult = { stdout: string, stderr: string, }; type SpawnOptions = { cwd: string, detached?: boolean, env: {[string]: string | void}, logFile: string, }; type CommandOptions = { cwd: string, env: {[string]: string | void}, logFile: string, timeout?: number, }; type Chrome = { run: (args: Array) => Promise, json: (args: Array) => Promise, }; type TreeNode = { uid: string, type: string, name: string, key?: string | null, firstChild?: string | null, nextSibling?: string | null, }; type SnapshotNode = { id?: string, role?: string, name?: string, children?: Array, }; type ToolDefinition = { name: string, description: string, inputSchema: JSONSchemaObject, }; type ToolGroup = { name: string, description: string, tools: Array, }; type ToolDiscovery = { thirdPartyDeveloperTools?: ToolGroup | Array, ... }; type SourceResult = { source: null | { name: string, fileName: string, line: number, column: number, ... }, ... }; type PageReadiness = { hasApp: boolean, hasHook: boolean, }; type ComponentDetails = { name: string, type: string, hooks: Array<{name: string, ...}>, ... }; type SearchResult = { page: number, pageSize: number, totalCount: number, totalPages: number, results: Array<{name: string, ...}>, ... }; type DomLookupResult = { type: string, name: string, ... }; type OwnersStackResult = { stack: string, ... }; type ComponentBranchEntry = { uid: string, name: string, type: string, ... }; type ErrorPayload = { error: string, ... }; type StartProfilingResult = { status: string, traceName: string, ... }; type StopProfilingResult = { status: string, traceName: string, commits: number, ... }; type TraceOverviewCommit = { commit: number, componentsChanged: number, ... }; type CommitReport = { components: Array<{name: string, ...}>, ... }; const TOOL_NAMES = [ 'react_get_component_tree', 'react_get_component_by_uid', 'react_get_component_by_dom_element', 'react_find_components', 'react_get_component_source', 'react_get_owner_stack_trace', 'react_get_parent_stack', 'react_get_owner_stack', 'react_start_profiling', 'react_stop_profiling', 'react_get_trace_overview', 'react_get_commit_report', ]; const PACKAGE_DIR = path.resolve(__dirname, '..'); const REPO_ROOT = path.resolve(PACKAGE_DIR, '..', '..'); const FIXTURE_DIR = path.join(PACKAGE_DIR, 'fixtures', 'app'); const BUILT_MODULES_DIR = path.join(REPO_ROOT, 'build', 'oss-experimental'); const LOG_DIR = process.env.E2E_LOG_DIR || path.join(REPO_ROOT, 'tmp', 'react-devtools-cdt-mcp-e2e'); const SESSION_ID = `react-devtools-cdt-mcp-${process.pid}-${Date.now()}`; function log(message: string): void { process.stdout.write(`${message}\n`); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } function createError(message: string): Error { // eslint-disable-next-line react-internal/prod-error-codes return new Error(message); } function ensureBuiltModules(): void { if (!fs.existsSync(BUILT_MODULES_DIR)) { throw createError( 'Missing build/oss-experimental. Run `yarn build-for-devtools` from ' + 'the repo root before running react-devtools-cdt-mcp E2E tests.' ); } } function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', undefined, () => { const address = server.address(); if (address == null || typeof address === 'string') { reject(createError('Failed to allocate a TCP port')); return; } server.close(() => resolve(address.port)); }); }); } function appendLog(logFile: string, text: string | Buffer): void { fs.appendFileSync(logFile, text); } function formatCommand(command: string, args: Array): string { return `$ ${[command, ...args].map(arg => JSON.stringify(arg)).join(' ')}\n`; } function spawnLogged( command: string, args: Array, options: SpawnOptions ): ChildProcess { const child = childProcess.spawn(command, args, { cwd: options.cwd, detached: options.detached === true, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], }); appendLog(options.logFile, formatCommand(command, args)); child.on('error', error => { appendLog( options.logFile, `Failed to spawn ${command}: ${ error.stack || error.message || String(error) }\n` ); }); child.stdout.on('data', chunk => appendLog(options.logFile, chunk)); child.stderr.on('data', chunk => appendLog(options.logFile, chunk)); return child; } function waitForExit(child: ChildProcess, timeout: number): Promise { return new Promise(resolve => { let done = false; const finish = () => { if (done) { return; } done = true; clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { if (child.exitCode == null) { child.kill('SIGKILL'); } finish(); }, timeout); if (child.exitCode != null) { finish(); return; } child.once('exit', finish); child.once('error', finish); }); } async function removePathWithRetries( targetPath: string, logFile: string ): Promise { for (let attempt = 0; attempt < 5; attempt++) { try { fs.rmSync(targetPath, {force: true, recursive: true}); return; } catch (error) { if (attempt === 4) { appendLog( logFile, `Failed to remove ${targetPath}: ${ error.stack || error.message || String(error) }\n` ); return; } await sleep(250); } } } function runCommand( command: string, args: Array, options: CommandOptions ): Promise { const timeout = options.timeout || 120000; const logFile = options.logFile; return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; appendLog(logFile, formatCommand(command, args)); const child = childProcess.spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], }); const timer = setTimeout(() => { child.kill('SIGTERM'); reject(createError(`Command timed out after ${timeout}ms: ${command}`)); }, timeout); child.stdout.on('data', chunk => { stdout += chunk.toString(); appendLog(logFile, chunk); }); child.stderr.on('data', chunk => { stderr += chunk.toString(); appendLog(logFile, chunk); }); child.on('error', error => { clearTimeout(timer); reject(error); }); child.on('close', code => { clearTimeout(timer); if (code === 0) { resolve({stdout, stderr}); } else { reject( createError( `Command failed with exit code ${code}: ${command} ${args.join( ' ' )}\n${stderr || stdout}` ) ); } }); }); } function startDebuggableChrome( chromeExecutablePath: string, remoteDebuggingPort: number, logFile: string ): {profileDir: string, process: ChildProcess} { const profileDir = fs.mkdtempSync( path.join(os.tmpdir(), 'react-devtools-cdt-mcp-chrome-') ); appendLog(logFile, `chromeUserDataDir=${profileDir}\n`); const args = [ '--headless=new', `--remote-debugging-port=${remoteDebuggingPort}`, '--remote-debugging-address=127.0.0.1', `--user-data-dir=${profileDir}`, '--no-first-run', '--no-default-browser-check', 'about:blank', ]; if (process.platform === 'linux') { args.splice(1, 0, '--no-sandbox', '--disable-setuid-sandbox'); } return { profileDir, process: spawnLogged(chromeExecutablePath, args, { cwd: PACKAGE_DIR, env: process.env, logFile, }), }; } function getChromeDevToolsBin(): string { let packageJsonPath: string; try { packageJsonPath = require.resolve('chrome-devtools-mcp/package.json', { paths: [PACKAGE_DIR], }); } catch (error) { throw createError( 'Missing chrome-devtools-mcp dependency. Run `yarn install` before ' + 'running react-devtools-cdt-mcp E2E tests.' ); } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); if ( packageJson == null || typeof packageJson !== 'object' || packageJson.bin == null || typeof packageJson.bin !== 'object' || typeof packageJson.bin['chrome-devtools'] !== 'string' ) { throw createError( 'chrome-devtools-mcp package.json is missing the chrome-devtools bin' ); } return path.resolve( path.dirname(packageJsonPath), packageJson.bin['chrome-devtools'] ); } async function waitForHttp(url: string, timeout: number): Promise { const deadline = Date.now() + timeout; let lastError: Error = createError('No response yet'); while (Date.now() < deadline) { try { const statusCode: number = await new Promise((resolve, reject) => { // eslint-disable-next-line no-undef const request = http.get(url, (response: http$IncomingMessage<>) => { response.resume(); response.on('end', () => resolve(response.statusCode || 0)); }); request.on('error', reject); request.setTimeout(1000, () => { request.destroy(createError('HTTP request timed out')); }); }); if (statusCode >= 200 && statusCode < 400) { return; } lastError = createError(`HTTP ${statusCode}`); } catch (error) { lastError = error; } await sleep(250); } throw createError(`Timed out waiting for ${url}: ${lastError.message}`); } async function waitForPageReady( chrome: Chrome, timeout: number ): Promise { const deadline = Date.now() + timeout; let lastResult: mixed = null; while (Date.now() < deadline) { const result = await evaluatePageReadiness( chrome, `() => ({ hasApp: document.querySelector('main.app') !== null, hasHook: window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null, hasDiscoveryListener: window.__dtmcp != null || window.__REACT_DEVTOOLS_GLOBAL_HOOK__ != null, })` ); lastResult = result; if (result.hasApp === true && result.hasHook === true) { return; } await sleep(250); } throw createError( `Timed out waiting for fixture readiness: ${ JSON.stringify(lastResult) || String(lastResult) }` ); } function parseJsonOutput(stdout: string): mixed { const text = stdout.trim(); assert.notStrictEqual(text, '', 'Expected command to print JSON'); const parsed = JSON.parse(text); if ( Array.isArray(parsed) && parsed.length === 1 && parsed[0] && parsed[0].type === 'text' && typeof parsed[0].text === 'string' ) { throw createError(parsed[0].text); } return parsed; } function parseJsonFromText(text: string): mixed { const trimmed = text.trim(); const fenced = trimmed.match(/```json\n([\s\S]*?)\n```/); if (fenced) { return JSON.parse(fenced[1]); } return JSON.parse(trimmed); } function unwrapTextResponse(response: mixed): string { if (Array.isArray(response)) { return response.join('\n'); } if (typeof response === 'string') { return response; } if (response != null && typeof response === 'object') { const message = response.message; if (typeof message === 'string') { return message; } } return JSON.stringify(response) || String(response); } function formatValue(value: mixed): string { return JSON.stringify(value) || String(value); } function expectObject(value: mixed, message: string): {+[string]: mixed, ...} { if (value == null || typeof value !== 'object' || Array.isArray(value)) { throw createError(`${message}. Saw: ${formatValue(value)}`); } return value; } function expectString(value: mixed, message: string): string { if (typeof value !== 'string') { throw createError(`${message}. Saw: ${formatValue(value)}`); } return value; } function expectNumber(value: mixed, message: string): number { if (typeof value !== 'number') { throw createError(`${message}. Saw: ${formatValue(value)}`); } return value; } function expectArray(value: mixed, message: string): $ReadOnlyArray { if (!Array.isArray(value)) { throw createError(`${message}. Saw: ${formatValue(value)}`); } return value; } function expectOptionalString(value: mixed, message: string): string | null { if (value == null) { return null; } return expectString(value, message); } function parseJSONSchemaObject( value: mixed, message: string ): JSONSchemaObject { const object = expectObject(value, message); const schema: JSONSchemaObject = {}; if (object.type != null) { schema.type = expectString(object.type, `${message}.type must be a string`); } if (object.properties != null) { const rawProperties = expectObject( object.properties, `${message}.properties must be an object` ); const properties: {[string]: JSONSchemaObject, ...} = {}; // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const key of Object.keys(rawProperties)) { properties[key] = parseJSONSchemaObject( rawProperties[key], `${message}.properties.${key}` ); } schema.properties = properties; } if (object.required != null) { schema.required = expectArray( object.required, `${message}.required must be an array` ).map((item, index) => expectString(item, `${message}.required[${index}] must be a string`) ); } return schema; } function parseToolDefinition(value: mixed): ToolDefinition { const object = expectObject(value, 'Expected tool definition object'); return { name: expectString(object.name, 'Expected tool definition name'), description: expectString( object.description, 'Expected tool definition description' ), inputSchema: parseJSONSchemaObject( object.inputSchema, 'Expected tool definition inputSchema' ), }; } function parseToolGroup(value: mixed): ToolGroup { const object = expectObject(value, 'Expected tool group object'); return { name: expectString(object.name, 'Expected tool group name'), description: expectString( object.description, 'Expected tool group description' ), tools: expectArray(object.tools, 'Expected tool group tools').map( parseToolDefinition ), }; } function parseToolDiscovery(value: mixed): ToolDiscovery { const object = expectObject(value, 'Expected tool discovery object'); const rawToolGroups = object.thirdPartyDeveloperTools; if (rawToolGroups == null) { return {}; } return { thirdPartyDeveloperTools: Array.isArray(rawToolGroups) ? rawToolGroups.map(parseToolGroup) : parseToolGroup(rawToolGroups), }; } function parsePageReadiness(value: mixed): PageReadiness { const object = expectObject(value, 'Expected page readiness object'); return { hasApp: object.hasApp === true, hasHook: object.hasHook === true, }; } async function evaluatePageReadiness( chrome: Chrome, fn: string ): Promise { const output = await chrome.json(['evaluate_script', fn]); return parsePageReadiness(parseJsonFromText(unwrapTextResponse(output))); } function parseToolResponse(output: mixed): mixed { return parseJsonFromText(unwrapTextResponse(output)); } function parseTreeNode(value: mixed): TreeNode { const object = expectObject(value, 'Expected tree node object'); return { uid: expectString(object.uid, 'Expected tree node uid'), type: expectString(object.type, 'Expected tree node type'), name: expectString(object.name, 'Expected tree node name'), key: expectOptionalString(object.key, 'Expected tree node key'), firstChild: expectOptionalString( object.firstChild, 'Expected tree node firstChild' ), nextSibling: expectOptionalString( object.nextSibling, 'Expected tree node nextSibling' ), }; } function parseTree(value: mixed): Array { const object = expectObject(value, 'Expected component tree response object'); return expectArray(object.nodes, 'Expected component tree nodes array').map( parseTreeNode ); } function parseNamedObject(value: mixed, message: string): {name: string, ...} { const object = expectObject(value, message); return { name: expectString(object.name, `${message} name`), }; } function parseComponentBranchEntry( value: mixed, message: string ): ComponentBranchEntry { const object = expectObject(value, message); return { uid: expectString(object.uid, `${message} uid`), name: expectString(object.name, `${message} name`), type: expectString(object.type, `${message} type`), }; } function parseComponentDetails(value: mixed): ComponentDetails { const object = expectObject(value, 'Expected component details object'); const details: ComponentDetails = { name: expectString(object.name, 'Expected component details name'), type: expectString(object.type, 'Expected component details type'), hooks: expectArray(object.hooks, 'Expected component details hooks').map( (hook, index) => parseNamedObject(hook, `Expected component hook ${index}`) ), }; return details; } function parseComponentType(value: mixed): string { return expectString( expectObject(value, 'Expected component details object').type, 'Expected component details type' ); } function parseSearchResult(value: mixed): SearchResult { const object = expectObject(value, 'Expected search result object'); return { ...object, page: expectNumber(object.page, 'Expected search result page'), pageSize: expectNumber(object.pageSize, 'Expected search result pageSize'), totalCount: expectNumber( object.totalCount, 'Expected search result totalCount' ), totalPages: expectNumber( object.totalPages, 'Expected search result totalPages' ), results: expectArray(object.results, 'Expected search result results').map( (result, index) => parseNamedObject(result, `Expected search result ${index}`) ), }; } function parseSnapshotNode(value: mixed): SnapshotNode { const object = expectObject(value, 'Expected snapshot node object'); const snapshotNode: SnapshotNode = {}; if (object.id != null) { snapshotNode.id = expectString(object.id, 'Expected snapshot node id'); } if (object.role != null) { snapshotNode.role = expectString( object.role, 'Expected snapshot node role' ); } if (object.name != null) { snapshotNode.name = expectString( object.name, 'Expected snapshot node name' ); } if (object.children != null) { snapshotNode.children = expectArray( object.children, 'Expected snapshot node children' ).map(parseSnapshotNode); } return snapshotNode; } function parseSnapshotResponse(value: mixed): {snapshot: SnapshotNode, ...} { const object = expectObject(value, 'Expected snapshot response object'); return { snapshot: parseSnapshotNode(object.snapshot), }; } function parseDomLookupResult(value: mixed): DomLookupResult { const object = expectObject(value, 'Expected DOM lookup result object'); return { ...object, type: expectString(object.type, 'Expected DOM lookup result type'), name: expectString(object.name, 'Expected DOM lookup result name'), }; } function parseSourceResult(value: mixed): SourceResult { const object = expectObject(value, 'Expected source result object'); if (object.source == null) { return {source: null}; } const source = expectObject(object.source, 'Expected source object'); return { ...object, source: { ...source, name: expectString(source.name, 'Expected source name'), fileName: expectString(source.fileName, 'Expected source fileName'), line: expectNumber(source.line, 'Expected source line'), column: expectNumber(source.column, 'Expected source column'), }, }; } function parseOwnersStack(value: mixed): OwnersStackResult { const object = expectObject(value, 'Expected owners stack object'); return { ...object, stack: expectString(object.stack, 'Expected owners stack string'), }; } function parseComponentBranch( value: mixed, label: string ): Array { return expectArray(value, `Expected ${label} branch array`).map( (entry, index) => parseComponentBranchEntry(entry, `Expected ${label} ${index}`) ); } function parseErrorPayload(value: mixed): ErrorPayload { const object = expectObject(value, 'Expected error payload object'); return { ...object, error: expectString(object.error, 'Expected error payload error'), }; } function parseStartProfilingResult(value: mixed): StartProfilingResult { const object = expectObject(value, 'Expected start profiling result object'); return { ...object, status: expectString(object.status, 'Expected start profiling status'), traceName: expectString( object.traceName, 'Expected start profiling traceName' ), }; } function parseStopProfilingResult(value: mixed): StopProfilingResult { const object = expectObject(value, 'Expected stop profiling result object'); return { ...object, status: expectString(object.status, 'Expected stop profiling status'), traceName: expectString( object.traceName, 'Expected stop profiling traceName' ), commits: expectNumber(object.commits, 'Expected stop profiling commits'), }; } function parseTraceOverview(value: mixed): Array { return expectArray(value, 'Expected trace overview array').map( (commit, index) => { const object = expectObject(commit, `Expected trace overview ${index}`); return { ...object, commit: expectNumber( object.commit, `Expected trace overview ${index} commit` ), componentsChanged: expectNumber( object.componentsChanged, `Expected trace overview ${index} componentsChanged` ), }; } ); } function parseCommitReport(value: mixed): CommitReport { const object = expectObject(value, 'Expected commit report object'); return { ...object, components: expectArray( object.components, 'Expected commit report components' ).map((component, index) => parseNamedObject(component, `Expected commit report component ${index}`) ), }; } function findNode( tree: Array, predicate: (node: TreeNode) => boolean, message: string ): TreeNode { const node = tree.find(predicate); if (node == null) { throw createError( `${message}. Saw: ${tree .map(item => `${item.name}:${item.type}`) .join(', ')}` ); } return node; } function flattenSnapshot( node: SnapshotNode, result: Array ): Array { result.push(node); // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const child of node.children || []) { flattenSnapshot(child, result); } return result; } function findSnapshotNode( snapshot: SnapshotNode, predicate: (node: SnapshotNode) => boolean, message: string ): SnapshotNode { const node = flattenSnapshot(snapshot, []).find(predicate); if (node == null) { throw createError(message); } return node; } function assertHasSchema(tool: ToolDefinition): void { assert.strictEqual(typeof tool.description, 'string'); assert(tool.description.length > 0); assert.strictEqual(tool.inputSchema.type, 'object'); } function getReactToolGroup(discovery: ToolDiscovery): ToolGroup | null { const toolGroups = discovery.thirdPartyDeveloperTools; if (Array.isArray(toolGroups)) { return toolGroups.find(group => group.name === 'react') || null; } if ( toolGroups != null && typeof toolGroups === 'object' && toolGroups.name === 'react' ) { return toolGroups; } return null; } function assertSourceReference(sourceResult: SourceResult): void { if (sourceResult.source === null) { return; } const source = sourceResult.source; assert.strictEqual(typeof source.name, 'string'); assert.strictEqual(typeof source.fileName, 'string'); assert( /App\.js|bundle\.js|webpack/.test(source.fileName), `Expected source file to reference App.js, bundle.js, or webpack. Saw: ${source.fileName}` ); assert.strictEqual(typeof source.line, 'number'); assert.strictEqual(typeof source.column, 'number'); } async function runE2E(chrome: Chrome, appUrl: string): Promise { await chrome.json(['navigate_page', '--type', 'url', '--url', appUrl]); await waitForPageReady(chrome, 30000); log('Checking third-party tool discovery...'); const discovery = parseToolDiscovery( await chrome.json(['list_3p_developer_tools']) ); const toolGroup = getReactToolGroup(discovery); if (toolGroup == null) { throw createError('Expected a third-party tool group'); } assert.strictEqual(toolGroup.name, 'react'); assert.deepStrictEqual( toolGroup.tools.map(tool => tool.name), TOOL_NAMES ); // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const tool of toolGroup.tools) { assertHasSchema(tool); } const domTool = toolGroup.tools.find( tool => tool.name === 'react_get_component_by_dom_element' ); if (domTool == null) { throw createError( 'Expected react_get_component_by_dom_element in discovery' ); } const inputSchemaProperties = domTool.inputSchema.properties; if (inputSchemaProperties == null) { throw createError('Expected DOM lookup tool schema properties'); } const domElementSchema = inputSchemaProperties.element; if (domElementSchema == null) { throw createError('Expected DOM lookup tool element schema'); } const domElementSchemaProperties = domElementSchema.properties; if (domElementSchemaProperties == null) { throw createError('Expected DOM lookup tool element schema properties'); } assert.strictEqual(domElementSchema.type, 'object'); assert.deepStrictEqual(domElementSchemaProperties, {uid: {type: 'string'}}); assert.deepStrictEqual(domElementSchema.required, ['uid']); log('Checking tree, details, search, and DOM lookup...'); const callTool = (toolName: string, params?: {...}): Promise => chrome .json([ 'execute_3p_developer_tool', toolName, '--params', JSON.stringify(params || {}), ]) .then(parseToolResponse); const tree = parseTree(await callTool('react_get_component_tree')); const counter = findNode( tree, node => node.name === 'Counter' && node.type === 'function', 'Expected function component Counter' ); const todo = findNode( tree, node => node.name === 'Todo' && node.type === 'function', 'Expected function component Todo' ); const todoList = findNode( tree, node => node.name === 'TodoList' && node.type === 'function', 'Expected function component TodoList' ); const todoListHost = findNode( tree, node => node.name === 'ul' && node.type === 'host', 'Expected host ul for TodoList' ); const mainNode = findNode( tree, node => node.name === 'main' && node.type === 'host', 'Expected host main' ); const app = findNode( tree, node => node.name === 'App' && node.type === 'function', 'Expected function component App' ); const root = findNode( tree, node => node.type === 'root', 'Expected root node' ); const memoBox = findNode( tree, node => node.name.includes('MemoBox') && node.type === 'memo', 'Expected memo component MemoBox' ); const fancyInput = findNode( tree, node => node.name.includes('FancyInput') && node.type === 'forwardRef', 'Expected forwardRef component FancyInput' ); const input = findNode( tree, node => node.name === 'input' && node.type === 'host', 'Expected host input' ); const counterDetails = parseComponentDetails( await callTool('react_get_component_by_uid', { uid: counter.uid, includeHooks: true, }) ); assert.strictEqual(counterDetails.name, 'Counter'); assert( counterDetails.hooks.some(hook => hook.name === 'State'), 'Expected Counter details to include a State hook' ); assert.strictEqual( parseComponentType( await callTool('react_get_component_by_uid', {uid: memoBox.uid}) ), 'memo' ); assert.strictEqual( parseComponentType( await callTool('react_get_component_by_uid', {uid: fancyInput.uid}) ), 'forwardRef' ); assert.strictEqual( parseComponentType( await callTool('react_get_component_by_uid', {uid: input.uid}) ), 'host' ); const todoSearch = parseSearchResult( await callTool('react_find_components', { name: 'Todo', pageSize: 2, }) ); assert.strictEqual(todoSearch.page, 1); assert.strictEqual(todoSearch.pageSize, 2); assert.strictEqual(todoSearch.totalCount, 4); assert.strictEqual(todoSearch.totalPages, 2); assert.deepStrictEqual( todoSearch.results.map(result => result.name), ['TodoList', 'Todo'] ); const snapshot = parseSnapshotResponse(await chrome.json(['take_snapshot'])); const buttonNode = findSnapshotNode( snapshot.snapshot, node => node.role === 'button' && node.name === '+1', 'Expected +1 button in snapshot' ); const buttonUid = buttonNode.id; if (buttonUid == null) { throw createError('Expected +1 button uid'); } const domLookup = parseDomLookupResult( await callTool('react_get_component_by_dom_element', { element: {uid: buttonUid}, }) ); assert.strictEqual(domLookup.type, 'host'); assert.strictEqual(domLookup.name, 'button'); log('Checking source, parents, owners, and error payloads...'); const source = parseSourceResult( await callTool('react_get_component_source', { uid: counter.uid, }) ); assertSourceReference(source); const ownersStack = parseOwnersStack( await callTool('react_get_owner_stack_trace', {uid: todo.uid}) ); assert.strictEqual(typeof ownersStack.stack, 'string'); if (ownersStack.stack.length > 0) { assert( /Todo|TodoList|App\.js/.test(ownersStack.stack), `Expected owners stack to reference Todo, TodoList, or App.js. Saw: ${ownersStack.stack}` ); } const parentsBranch = parseComponentBranch( await callTool('react_get_parent_stack', { uid: todo.uid, }), 'parents' ); assert.deepStrictEqual(parentsBranch, [ { uid: todoListHost.uid, name: todoListHost.name, type: todoListHost.type, }, { uid: todoList.uid, name: todoList.name, type: todoList.type, }, { uid: mainNode.uid, name: mainNode.name, type: mainNode.type, }, { uid: app.uid, name: app.name, type: app.type, }, { uid: root.uid, name: root.name, type: root.type, }, ]); const ownersBranch = parseComponentBranch( await callTool('react_get_owner_stack', { uid: todo.uid, }), 'owners' ); assert( ownersBranch.some(owner => owner.name === 'TodoList'), 'Expected Todo owners branch to include TodoList' ); const invalidUid = parseErrorPayload( await callTool('react_get_component_by_uid', { uid: 'r999999', }) ); assert.deepStrictEqual(invalidUid, { error: 'Component not found: "r999999"', }); log('Checking profiling through a real CLI click...'); const traceName = `e2e-${Date.now()}`; assert.deepStrictEqual( parseStartProfilingResult( await callTool('react_start_profiling', {traceName}) ), { status: 'started', traceName, } ); await chrome.json(['click', buttonUid]); const stopResult = parseStopProfilingResult( await callTool('react_stop_profiling') ); assert.strictEqual(stopResult.status, 'stopped'); assert.strictEqual(stopResult.traceName, traceName); assert( stopResult.commits >= 1, 'Expected profiling to record at least one commit' ); const overview = parseTraceOverview( await callTool('react_get_trace_overview', {traceName}) ); assert(overview.length >= 1, 'Expected at least one profiling commit'); assert( overview.some(commit => commit.componentsChanged >= 1), 'Expected at least one changed component in trace overview' ); let foundCounterCommit = false; // eslint-disable-next-line no-for-of-loops/no-for-of-loops for (const commit of overview) { const report = parseCommitReport( await callTool('react_get_commit_report', { traceName, commitIndex: commit.commit, }) ); if (report.components.some(component => component.name === 'Counter')) { foundCounterCommit = true; break; } } assert(foundCounterCommit, 'Expected a profiling commit involving Counter'); } async function main(): Promise { ensureBuiltModules(); fs.mkdirSync(LOG_DIR, {recursive: true}); const fixtureLog = path.join(LOG_DIR, 'fixture-server.log'); const chromeLog = path.join(LOG_DIR, 'chrome-devtools.log'); const cliLog = path.join(LOG_DIR, 'chrome-devtools-cli.log'); fs.writeFileSync(fixtureLog, ''); fs.writeFileSync(chromeLog, ''); fs.writeFileSync(cliLog, ''); const chromeDevToolsBin = getChromeDevToolsBin(); const port = await getFreePort(); const appUrl = `http://127.0.0.1:${port}/`; let fixture: ?ChildProcess; let debuggableChrome: ?ChildProcess; let debuggableChromeProfile: ?string; const runChrome = (args: Array): Promise => runCommand( process.execPath, [chromeDevToolsBin, ...args, '--sessionId', SESSION_ID], { cwd: PACKAGE_DIR, env: process.env, logFile: cliLog, timeout: 120000, } ); const chrome: Chrome = { run: runChrome, async json(args: Array): Promise { const result = await runChrome([...args, '--output-format', 'json']); return parseJsonOutput(result.stdout); }, }; try { log(`Starting fixture at ${appUrl}`); fixture = spawnLogged('yarn', ['start'], { cwd: FIXTURE_DIR, detached: true, env: { ...process.env, BROWSER: 'none', CI: 'true', E2E: 'true', HOST: '127.0.0.1', PORT: String(port), }, logFile: fixtureLog, }); await waitForHttp(appUrl, 60000); const startArgs = [ 'start', '--categoryExperimentalThirdParty=true', '--usageStatistics=false', '--logFile', chromeLog, ]; const chromeExecutablePath = process.env.CHROME_EXECUTABLE_PATH; if (chromeExecutablePath != null) { const remoteDebuggingPort = await getFreePort(); const launchedChrome = startDebuggableChrome( chromeExecutablePath, remoteDebuggingPort, chromeLog ); debuggableChrome = launchedChrome.process; debuggableChromeProfile = launchedChrome.profileDir; const browserUrl = `http://127.0.0.1:${remoteDebuggingPort}`; await waitForHttp(`${browserUrl}/json/version`, 30000); startArgs.push('--browserUrl', browserUrl); } else { startArgs.push('--headless=true', '--isolated=true'); } log('Starting chrome-devtools daemon...'); await chrome.run(startArgs); await runE2E(chrome, appUrl); log('react-devtools-cdt-mcp E2E passed.'); } finally { try { await chrome.run(['stop']); } catch (error) { appendLog(cliLog, `Failed to stop chrome-devtools: ${error.stack}\n`); } if (debuggableChrome) { try { debuggableChrome.kill('SIGTERM'); await waitForExit(debuggableChrome, 5000); } catch (error) { appendLog(chromeLog, `Failed to stop Chrome: ${error.stack}\n`); } } if (debuggableChromeProfile) { await removePathWithRetries(debuggableChromeProfile, chromeLog); } if (fixture && fixture.pid) { try { process.kill(-fixture.pid, 'SIGTERM'); } catch (error) { try { fixture.kill('SIGTERM'); } catch (innerError) { appendLog( fixtureLog, `Failed to stop fixture server: ${innerError.stack}\n` ); } } } } } module.exports = {main};