static getMainThreadTasks(mainThreadEvents, frames, traceEndTs, traceStartTs) {
    const timers = new Map();
    const xhrs = new Map();
    const frameURLsById = new Map();
    frames.forEach(({id, url}) => frameURLsById.set(id, url));
    /** @type {Array<string>} */
    const lastTaskURLs = [];
    const priorTaskData = {timers, xhrs, frameURLsById, lastTaskURLs};
    const tasks = MainThreadTasks._createTasksFromEvents(
      mainThreadEvents,
      priorTaskData,
      traceEndTs
    );

    // Compute the recursive properties we couldn't compute earlier, starting at the toplevel tasks
    for (const task of tasks) {
      if (task.parent) continue;

      MainThreadTasks._computeRecursiveSelfTime(task, undefined);
      MainThreadTasks._computeRecursiveAttributableURLs(task, [], [], priorTaskData);
      MainThreadTasks._computeRecursiveTaskGroup(task);
      priorTaskData.lastTaskURLs = task.attributableURLs;
    }

    // Rebase all the times to be relative to start of trace and covert to ms.
    const firstTs = traceStartTs ?? tasks[0].startTime;
    for (const task of tasks) {
      task.startTime = (task.startTime - firstTs) / 1000;
      task.endTime = (task.endTime - firstTs) / 1000;
      task.duration /= 1000;
      task.selfTime /= 1000;

      // Check that we have selfTime which captures all other timing data.
      if (!Number.isFinite(task.selfTime)) {
        throw new Error('Invalid task timing data');
      }
    }

    return tasks;
  }
@param {LH.TraceEvent[]} mainThreadEvents
@param {Array<{id: string, url: string}>} frames
@param {number} traceEndTs
@param {number} [traceStartTs] Optional time-0 ts for tasks. Tasks before this point will have negative start/end times. Defaults to the first task found.
@return {TaskNode[]}