/**
 * 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<string>,
  ...
};
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<string>) => Promise<CommandResult>,
  json: (args: Array<string>) => Promise<mixed>,
};
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<SnapshotNode>,
};
type ToolDefinition = {
  name: string,
  description: string,
  inputSchema: JSONSchemaObject,
};
type ToolGroup = {
  name: string,
  description: string,
  tools: Array<ToolDefinition>,
};
type ToolDiscovery = {
  thirdPartyDeveloperTools?: ToolGroup | Array<ToolGroup>,
  ...
};
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<void> {
  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<number> {
  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>): string {
  return `$ ${[command, ...args].map(arg => JSON.stringify(arg)).join(' ')}\n`;
}

function spawnLogged(
  command: string,
  args: Array<string>,
  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<void> {
  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<void> {
  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<string>,
  options: CommandOptions
): Promise<CommandResult> {
  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<void> {
  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<void> {
  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<mixed> {
  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<PageReadiness> {
  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<TreeNode> {
  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<ComponentBranchEntry> {
  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<TraceOverviewCommit> {
  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<TreeNode>,
  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<SnapshotNode>
): Array<SnapshotNode> {
  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<void> {
  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<mixed> =>
    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<void> {
  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<string>): Promise<CommandResult> =>
    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<string>): Promise<mixed> {
      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};