function convertNodeTimingsToTrace(nodeTimings) {
  /** @type {LH.TraceEvent[]} */
  const traceEvents = [];
  const baseTs = 1e9;
  const baseEvent = {pid: 1, tid: 1, cat: 'devtools.timeline'};
  const frame = 'A00001';
  /** @param {number} ms */
  const toMicroseconds = ms => baseTs + ms * 1000;

  traceEvents.push(createFakeTracingStartedEvent());
  traceEvents.push({...createFakeTracingStartedEvent(), name: 'TracingStartedInBrowser'});

  // Create a fake requestId counter
  let requestId = 1;
  let lastEventEndTime = 0;
  for (const [node, timing] of nodeTimings.entries()) {
    lastEventEndTime = Math.max(lastEventEndTime, timing.endTime);
    if (node.type === 'cpu') {
      // Represent all CPU work that was bundled in a task as an EvaluateScript event
      traceEvents.push(...createFakeTaskEvents(node, timing));
    } else {
      /** @type {LH.Artifacts.NetworkRequest} */
      const record = node.rawRequest;
      // Ignore data URIs as they don't really add much value
      if (/^data/.test(record.url)) continue;
      traceEvents.push(...createFakeNetworkEvents(requestId, record, timing));
      requestId++;
    }
  }

  // Create a fake task event ~1s after the trace ends for a sane default bounds in DT
  traceEvents.push(
    ...createFakeTaskEvents(
      // @ts-expect-error
      {childEvents: [], event: {}},
      {
        startTime: lastEventEndTime + 1000,
        endTime: lastEventEndTime + 1001,
      }
    )
  );

  return {traceEvents};

  /**
   * @return {LH.TraceEvent}
   */
  function createFakeTracingStartedEvent() {
    const argsData = {
      frameTreeNodeId: 1,
      sessionId: '1.1',
      page: frame,
      persistentIds: true,
      frames: [{frame, url: 'about:blank', name: '', processId: 1}],
    };

    return {
      ...baseEvent,
      ts: baseTs - 1e5,
      ph: 'I',
      s: 't',
      cat: 'disabled-by-default-devtools.timeline',
      name: 'TracingStartedInPage',
      args: {data: argsData},
      dur: 0,
    };
  }

  /**
   * @param {LH.Gatherer.Simulation.GraphCPUNode} cpuNode
   * @param {{startTime: number, endTime: number}} timing
   * @return {LH.TraceEvent[]}
   */
  function createFakeTaskEvents(cpuNode, timing) {
    const argsData = {
      url: '',
      frame,
      lineNumber: 0,
      columnNumber: 0,
    };

    const eventTs = toMicroseconds(timing.startTime);

    /** @type {LH.TraceEvent[]} */
    const events = [
      {
        ...baseEvent,
        ph: 'X',
        name: 'Task',
        ts: eventTs,
        dur: (timing.endTime - timing.startTime) * 1000,
        args: {data: argsData},
      },
    ];

    const nestedBaseTs = cpuNode.event.ts || 0;
    const multiplier = (timing.endTime - timing.startTime) * 1000 / cpuNode.event.dur;
    // https://github.com/ChromeDevTools/devtools-frontend/blob/5429ac8a61ad4fa/front_end/timeline_model/TimelineModel.js#L1129-L1130
    const netReqEvents = new Set(['ResourceSendRequest', 'ResourceFinish',
      'ResourceReceiveResponse', 'ResourceReceivedData']);
    for (const event of cpuNode.childEvents) {
      if (netReqEvents.has(event.name)) continue;
      const ts = eventTs + (event.ts - nestedBaseTs) * multiplier;
      const newEvent = {...event, ...{pid: baseEvent.pid, tid: baseEvent.tid}, ts};
      if (event.dur) newEvent.dur = event.dur * multiplier;
      events.push(/** @type {LH.TraceEvent} */(newEvent));
    }

    return events;
  }

  /**
   * @param {number} requestId
   * @param {LH.Artifacts.NetworkRequest} record
   * @param {CompleteNodeTiming} timing
   * @return {LH.TraceEvent}
   */
  function createWillSendRequestEvent(requestId, record, timing) {
    return {
      ...baseEvent,
      ph: 'I',
      s: 't',
      // No `dur` on network instant events but add to keep types happy.
      dur: 0,
      name: 'ResourceWillSendRequest',
      ts: toMicroseconds(timing.startTime),
      args: {data: {requestId: String(requestId)}},
    };
  }

  /**
   * @param {number} requestId
   * @param {LH.Artifacts.NetworkRequest} record
   * @param {CompleteNodeTiming} timing
   * @return {LH.TraceEvent[]}
   */
  function createFakeNetworkEvents(requestId, record, timing) {
    if (!('connectionTiming' in timing)) {
      throw new Error('Network node timing incomplete');
    }

    // 0ms requests get super-messed up rendering
    // Use 0.3ms instead so they're still hoverable, https://github.com/GoogleChrome/lighthouse/pull/5350#discussion_r194563201
    let {startTime, endTime} = timing;
    if (startTime === endTime) endTime += 0.3;

    const requestData = {requestId: requestId.toString(), frame};
    // No `dur` on network instant events but add to keep types happy.
    /** @type {LH.Util.StrictOmit<LH.TraceEvent, 'name'|'ts'|'args'>} */
    const baseRequestEvent = {...baseEvent, ph: 'I', s: 't', dur: 0};

    const sendRequestData = {
      ...requestData,
      requestMethod: record.requestMethod,
      url: record.url,
      priority: record.priority,
    };

    const {dnsResolutionTime, connectionTime, sslTime, timeToFirstByte} = timing.connectionTiming;
    let sslStart = -1;
    let sslEnd = -1;
    if (connectionTime !== undefined && sslTime !== undefined) {
      sslStart = connectionTime - sslTime;
      sslEnd = connectionTime;
    }
    const receiveResponseData = {
      ...requestData,
      statusCode: record.statusCode,
      mimeType: record.mimeType,
      encodedDataLength: record.transferSize,
      fromCache: record.fromDiskCache,
      fromServiceWorker: record.fetchedViaServiceWorker,
      timing: {
        // `requestTime` is in seconds.
        requestTime: toMicroseconds(startTime) / (1000 * 1000),
        // Remaining values are milliseconds after `requestTime`.
        dnsStart: dnsResolutionTime === undefined ? -1 : 0,
        dnsEnd: dnsResolutionTime ?? -1,
        connectStart: dnsResolutionTime ?? -1,
        connectEnd: connectionTime ?? -1,
        sslStart,
        sslEnd,
        sendStart: connectionTime ?? 0,
        sendEnd: connectionTime ?? 0,
        receiveHeadersEnd: timeToFirstByte,
        workerStart: -1,
        workerReady: -1,
        proxyStart: -1,
        proxyEnd: -1,
        pushStart: 0,
        pushEnd: 0,
      },
    };

    const resourceFinishData = {
      requestId: requestId.toString(),
      encodedDataLength: record.transferSize,
      decodedBodyLength: record.resourceSize,
      didFail: !!record.failed,
      finishTime: toMicroseconds(endTime) / (1000 * 1000),
    };

    /** @type {LH.TraceEvent[]} */
    const events = [];

    // Navigation request needs an additional ResourceWillSendRequest event.
    if (requestId === 1) {
      events.push(createWillSendRequestEvent(requestId, record, timing));
    }

    events.push(
      {
        ...baseRequestEvent,
        name: 'ResourceSendRequest',
        ts: toMicroseconds(startTime),
        args: {data: sendRequestData},
      },
      {
        ...baseRequestEvent,
        name: 'ResourceFinish',
        ts: toMicroseconds(endTime),
        args: {data: resourceFinishData},
      }
    );

    if (!record.failed) {
      events.push({
        ...baseRequestEvent,
        name: 'ResourceReceiveResponse',
        // Event `ts` isn't meaningful, so just pick a time.
        ts: toMicroseconds((startTime + endTime) / 2),
        args: {data: receiveResponseData},
      });
    }

    return events;
  }
}
@param {Map<LH.Gatherer.Simulation.GraphNode, CompleteNodeTiming>} nodeTimings
@return {LH.Trace}