browser-commander 0.16.0

Universal browser automation library that supports multiple browser engines with a unified API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "note": "Generated by scripts/generate-trace-assets.mjs from js/src/traces; do not edit.",
  "recorderGlobal": "__browserCommanderTrace__",
  "redacted": "[redacted]",
  "capture": {
    "captureSnapshot": "function captureSnapshotInPage(options) {\n  const {\n    redactSelectors = [],\n    redactAttributes = [],\n    redacted = '[redacted]',\n    html: wantHtml = true,\n    liveControlState = true,\n    openShadowRoots = true,\n    maxHtmlBytes = 0,\n  } = options || {};\n\n  const isSecret = (element) =>\n    redactSelectors.some((selector) => {\n      try {\n        return element.matches(selector);\n      } catch {\n        return false;\n      }\n    });\n\n  const cssPath = (element) => {\n    const parts = [];\n    let node = element;\n    while (node && node.nodeType === 1 && parts.length < 10) {\n      let part = node.localName;\n      if (node.id) {\n        parts.unshift(`#${node.id}`);\n        break;\n      }\n      const parent = node.parentNode;\n      if (parent && parent.children) {\n        const siblings = [...parent.children].filter(\n          (child) => child.localName === node.localName\n        );\n        if (siblings.length > 1) {\n          part += `:nth-of-type(${siblings.indexOf(node) + 1})`;\n        }\n      }\n      parts.unshift(part);\n      node =\n        node.parentNode && node.parentNode.host ? node.parentNode.host : parent;\n    }\n    return parts.join(' > ');\n  };\n\n  const controls = [];\n  const collectControls = (root) => {\n    const found = root.querySelectorAll\n      ? root.querySelectorAll('input, textarea, select, [contenteditable]')\n      : [];\n    for (const element of found) {\n      const secret = isSecret(element);\n      const entry = {\n        path: cssPath(element),\n        tag: element.localName,\n        type: element.type || null,\n        name: element.name || null,\n        id: element.id || null,\n        focused: element === document.activeElement,\n      };\n      if (element.localName === 'select') {\n        entry.value = secret ? redacted : element.value;\n        entry.selected = [...element.selectedOptions].map((option) =>\n          secret ? redacted : option.value\n        );\n      } else if (element.type === 'checkbox' || element.type === 'radio') {\n        entry.checked = element.checked;\n        entry.value = secret ? redacted : element.value;\n      } else if (element.isContentEditable) {\n        entry.value = secret ? redacted : element.textContent;\n      } else {\n        entry.value = secret ? redacted : element.value;\n      }\n      if (element.scrollTop || element.scrollLeft) {\n        entry.scroll = { top: element.scrollTop, left: element.scrollLeft };\n      }\n      entry.redacted = secret;\n      controls.push(entry);\n    }\n    if (openShadowRoots && root.querySelectorAll) {\n      for (const element of root.querySelectorAll('*')) {\n        if (element.shadowRoot) {\n          collectControls(element.shadowRoot);\n        }\n      }\n    }\n  };\n\n  if (liveControlState) {\n    collectControls(document);\n  }\n\n  // The clone carries the *live* state into the markup: a serialized\n  // `<input>` otherwise shows the value the server sent, not the one the user\n  // typed.\n  const carryLiveState = (original, copy, secret) => {\n    for (const attribute of redactAttributes) {\n      if (copy.hasAttribute && copy.hasAttribute(attribute)) {\n        copy.setAttribute(attribute, redacted);\n      }\n    }\n    if (original.localName === 'input' || original.localName === 'textarea') {\n      const value = secret ? redacted : original.value;\n      copy.setAttribute('value', value);\n      if (original.localName === 'textarea') {\n        copy.textContent = value;\n      }\n      if (original.type === 'checkbox' || original.type === 'radio') {\n        if (original.checked) {\n          copy.setAttribute('checked', '');\n        } else {\n          copy.removeAttribute('checked');\n        }\n      }\n    } else if (original.localName === 'option') {\n      if (original.selected) {\n        copy.setAttribute('selected', '');\n      } else {\n        copy.removeAttribute('selected');\n      }\n    } else if (secret) {\n      copy.textContent = redacted;\n    }\n    if (original.shadowRoot && openShadowRoots) {\n      // Shadow content is not serialized by `outerHTML`; a declarative\n      // template keeps it in the same document the viewer renders.\n      const template = document.createElement('template');\n      template.setAttribute('shadowrootmode', 'open');\n      template.innerHTML = original.shadowRoot.innerHTML;\n      copy.appendChild(template);\n    }\n  };\n\n  let html = null;\n  let truncated = false;\n  if (wantHtml) {\n    const clone = document.documentElement.cloneNode(true);\n    const originals = document.documentElement.querySelectorAll('*');\n    const copies = clone.querySelectorAll('*');\n    for (let index = 0; index < originals.length; index++) {\n      const copy = copies[index];\n      if (!copy) {\n        break;\n      }\n      carryLiveState(originals[index], copy, isSecret(originals[index]));\n    }\n\n    html = `<!DOCTYPE html>\\n${clone.outerHTML}`;\n    if (maxHtmlBytes > 0 && html.length > maxHtmlBytes) {\n      html = html.slice(0, maxHtmlBytes);\n      truncated = true;\n    }\n  }\n\n  return {\n    html,\n    state: {\n      url: location.href,\n      title: document.title,\n      readyState: document.readyState,\n      scroll: { x: window.scrollX, y: window.scrollY },\n      viewport: { width: window.innerWidth, height: window.innerHeight },\n      activeElement: document.activeElement\n        ? cssPath(document.activeElement)\n        : null,\n      controls,\n      frames: [...document.querySelectorAll('iframe, frame')].map((frame) => ({\n        path: cssPath(frame),\n        src: frame.getAttribute('src'),\n        name: frame.getAttribute('name'),\n      })),\n    },\n    truncated,\n  };\n}",
    "installMutationRecorder": "function installMutationRecorderInPage(options) {\n  const {\n    globalName = '__browserCommanderTrace__',\n    redactSelectors = [],\n    redacted = '[redacted]',\n    maxQueued = 5000,\n    liveState = true,\n  } = options || {};\n\n  if (window[globalName] && window[globalName].observing) {\n    return true;\n  }\n\n  const state = {\n    observing: true,\n    queue: [],\n    dropped: 0,\n    sequence: 0,\n    // Every document gets its own name, so a batch drained from an iframe is\n    // never mistaken for one drained from the page that holds it.\n    frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,\n    frameName: window.name || null,\n    mainFrame: window.top === window,\n  };\n\n  const isSecret = (node) => {\n    const element =\n      node && node.nodeType === 1 ? node : node && node.parentElement;\n    if (!element || !element.matches) {\n      return false;\n    }\n    return redactSelectors.some((selector) => {\n      try {\n        return element.matches(selector) || element.closest(selector) !== null;\n      } catch {\n        return false;\n      }\n    });\n  };\n\n  // A path the viewer can look the node up by: replay needs to find the same\n  // element again in a parsed copy of the checkpoint.\n  const pathOf = (node) => {\n    const steps = [];\n    for (let at = node; at && at.nodeType === 1; at = at.parentElement) {\n      if (at.id) {\n        steps.unshift(`#${at.id}`);\n        break;\n      }\n      const siblings = at.parentElement\n        ? [...at.parentElement.children].filter(\n            (c) => c.localName === at.localName\n          )\n        : [];\n      const nth =\n        siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';\n      steps.unshift(at.localName + nth);\n    }\n    return steps.join(' > ');\n  };\n\n  const childIndexOf = (parent, node) => {\n    if (!parent || !node || !parent.childNodes) {\n      return -1;\n    }\n    return Array.prototype.indexOf.call(parent.childNodes, node);\n  };\n\n  const describe = (node) => {\n    if (!node) {\n      return null;\n    }\n    if (node.nodeType === 3) {\n      return {\n        type: 'text',\n        path: pathOf(node.parentElement),\n        text: isSecret(node) ? redacted : String(node.nodeValue).slice(0, 2000),\n      };\n    }\n    if (node.nodeType !== 1) {\n      return { type: 'node', nodeType: node.nodeType };\n    }\n    return {\n      type: 'element',\n      tag: node.localName,\n      id: node.id || null,\n      path: pathOf(node),\n      html: isSecret(node) ? redacted : String(node.outerHTML).slice(0, 4000),\n    };\n  };\n\n  /**\n   * Describe a node that is no longer where it was.\n   *\n   * A removed node has no `parentElement`, so `pathOf` can only name the node\n   * itself. Its position in the parent it left is what a replay needs, and\n   * that is read from the siblings the record still points at.\n   *\n   * @param {Node} node - The node that moved or was removed\n   * @param {MutationRecord} record - The record it came from\n   * @returns {Object|null} The description, with `index` when one is knowable\n   */\n  const describeAt = (node, record) => {\n    const described = describe(node);\n    if (!described) {\n      return null;\n    }\n    const parent = record.target;\n    const own = childIndexOf(parent, node);\n    if (own >= 0) {\n      described.index = own;\n      return described;\n    }\n    const after = childIndexOf(parent, record.previousSibling);\n    if (after >= 0) {\n      described.index = after + 1;\n      return described;\n    }\n    const before = childIndexOf(parent, record.nextSibling);\n    described.index = before >= 0 ? before : null;\n    return described;\n  };\n\n  const push = (batch) => {\n    if (state.queue.length >= maxQueued) {\n      state.dropped += 1;\n      return false;\n    }\n    state.queue.push({\n      sequence: ++state.sequence,\n      at: Date.now(),\n      url: location.href,\n      frameId: state.frameId,\n      mainFrame: state.mainFrame,\n      ...batch,\n    });\n    return true;\n  };\n\n  const observer = new MutationObserver((records) => {\n    if (state.queue.length >= maxQueued) {\n      state.dropped += records.length;\n      return;\n    }\n    push({\n      records: records.map((record) => {\n        const entry = {\n          kind: record.type,\n          target: describe(record.target),\n        };\n        if (record.type === 'attributes') {\n          entry.attribute = record.attributeName;\n          const secret =\n            isSecret(record.target) ||\n            redactSelectors.includes(record.attributeName);\n          entry.before = secret ? redacted : record.oldValue;\n          entry.after =\n            secret || !record.target.getAttribute\n              ? redacted\n              : record.target.getAttribute(record.attributeName);\n        } else if (record.type === 'characterData') {\n          entry.before = isSecret(record.target) ? redacted : record.oldValue;\n          entry.after = isSecret(record.target)\n            ? redacted\n            : record.target.nodeValue;\n        } else {\n          // Where a node went is as much of the change as what it was:\n          // appending every addition to the end replays an insertion before a\n          // sibling in the wrong place, and drops moves and removals entirely.\n          entry.added = [...record.addedNodes].map((node) =>\n            describeAt(node, record)\n          );\n          entry.removed = [...record.removedNodes].map((node) =>\n            describeAt(node, record)\n          );\n          entry.previous = describe(record.previousSibling);\n          entry.next = describe(record.nextSibling);\n        }\n        return entry;\n      }),\n    });\n  });\n\n  observer.observe(document, {\n    subtree: true,\n    childList: true,\n    attributes: true,\n    attributeOldValue: true,\n    characterData: true,\n    characterDataOldValue: true,\n  });\n\n  const liveDetachers = [];\n  if (liveState) {\n    const lastValues = new WeakMap();\n    const lastLive = new WeakMap();\n\n    // One interaction fires more than one event for the same state: typing\n    // into a field and then leaving it fires `input` for each keystroke and\n    // `change` for the value it ended at, and a checkbox fires both for the\n    // one click. Replaying a state that is already on screen shows nothing, so\n    // a repeat of what this element last reported is not recorded.\n    const repeats = (element, property, after) => {\n      const seen = lastLive.get(element) || {};\n      const asText = JSON.stringify(after === undefined ? null : after);\n      if (seen[property] === asText) {\n        return true;\n      }\n      seen[property] = asText;\n      lastLive.set(element, seen);\n      return false;\n    };\n\n    const liveEntry = (target, property, after, before) => ({\n      kind: 'live-state',\n      property,\n      target: describe(target),\n      before: before === undefined ? null : before,\n      after,\n    });\n\n    const pushLive = (entry) => {\n      // Scrolling fires far more often than it changes anything worth\n      // replaying, so consecutive scrolls of one element collapse into the\n      // position it ended at. Every other property keeps each step, because\n      // \"stepwise replay must show each state\" (issue #93).\n      const last = state.queue[state.queue.length - 1];\n      const only = last && last.records.length === 1 ? last.records[0] : null;\n      if (\n        entry.property === 'scroll' &&\n        only &&\n        only.kind === 'live-state' &&\n        only.property === 'scroll' &&\n        only.target?.path === entry.target?.path\n      ) {\n        only.after = entry.after;\n        last.at = Date.now();\n        return;\n      }\n      push({ records: [entry] });\n    };\n\n    const valueOf = (element) =>\n      isSecret(element)\n        ? redacted\n        : element.isContentEditable\n          ? element.textContent\n          : element.value;\n\n    const onValue = (event) => {\n      const element = event.target;\n      if (!element || element.nodeType !== 1) {\n        return;\n      }\n      if (element.type === 'checkbox' || element.type === 'radio') {\n        if (!repeats(element, 'checked', element.checked)) {\n          pushLive(liveEntry(element, 'checked', element.checked));\n        }\n        return;\n      }\n      if (element.localName === 'select') {\n        const chosen = [...element.selectedOptions].map((option) =>\n          isSecret(element) ? redacted : option.value\n        );\n        if (!repeats(element, 'selected', chosen)) {\n          pushLive(liveEntry(element, 'selected', chosen));\n        }\n        return;\n      }\n      if (\n        element.localName !== 'input' &&\n        element.localName !== 'textarea' &&\n        !element.isContentEditable\n      ) {\n        return;\n      }\n      const after = valueOf(element);\n      if (repeats(element, 'value', after)) {\n        return;\n      }\n      const before = lastValues.get(element);\n      lastValues.set(element, after);\n      pushLive(liveEntry(element, 'value', after, before));\n    };\n\n    const onFocus = (event) => {\n      pushLive(liveEntry(event.target, 'focus', event.type === 'focusin'));\n    };\n\n    const onScroll = (event) => {\n      const target = event.target;\n      const scrolling =\n        target === document || target === document.documentElement || !target\n          ? null\n          : target;\n      pushLive({\n        kind: 'live-state',\n        property: 'scroll',\n        target: scrolling ? describe(scrolling) : null,\n        before: null,\n        after: scrolling\n          ? { top: scrolling.scrollTop, left: scrolling.scrollLeft }\n          : { top: window.scrollY, left: window.scrollX },\n      });\n    };\n\n    const listen = (type, handler) => {\n      document.addEventListener(type, handler, true);\n      liveDetachers.push(() =>\n        document.removeEventListener(type, handler, true)\n      );\n    };\n\n    listen('input', onValue);\n    listen('change', onValue);\n    listen('focusin', onFocus);\n    listen('focusout', onFocus);\n    listen('scroll', onScroll);\n  }\n\n  state.stop = () => {\n    observer.disconnect();\n    for (const detach of liveDetachers) {\n      detach();\n    }\n    state.observing = false;\n  };\n\n  window[globalName] = state;\n  return true;\n}",
    "drainMutations": "function drainMutationsInPage(globalName) {\n  const state = window[globalName || '__browserCommanderTrace__'];\n  if (!state) {\n    return {\n      batches: [],\n      dropped: 0,\n      installed: false,\n      frameId: null,\n      mainFrame: window.top === window,\n      url: location.href,\n    };\n  }\n  const batches = state.queue;\n  const dropped = state.dropped;\n  state.queue = [];\n  state.dropped = 0;\n  return {\n    batches,\n    dropped,\n    installed: true,\n    frameId: state.frameId,\n    mainFrame: state.mainFrame,\n    url: location.href,\n  };\n}",
    "stopMutationRecorder": "function stopMutationRecorderInPage(globalName) {\n  const state = window[globalName || '__browserCommanderTrace__'];\n  if (!state || !state.observing) {\n    return false;\n  }\n  state.stop();\n  return true;\n}"
  },
  "viewer": {
    "style": "\n  :root { color-scheme: light dark; font-family: system-ui, sans-serif; }\n  body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;\n         grid-template-rows: auto 1fr; height: 100vh; }\n  header { grid-column: 1 / -1; padding: 0.5rem 1rem; border-bottom: 1px solid #8884; }\n  h1 { font-size: 1rem; margin: 0 0 0.25rem; }\n  .meta { font-size: 0.75rem; opacity: 0.75; }\n  .meta.replay { opacity: 0.9; font-style: italic; }\n  aside { overflow: auto; border-right: 1px solid #8884; }\n  main { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }\n  ol { list-style: none; margin: 0; padding: 0; font-size: 0.8rem; }\n  li { padding: 0.35rem 0.75rem; border-bottom: 1px solid #8882; cursor: pointer; }\n  li.selected { background: #3b82f633; }\n  li .kind { font-weight: 600; }\n  li .at { opacity: 0.6; float: right; }\n  iframe { width: 100%; height: 100%; border: 0; background: #fff; }\n  .panel { padding: 0.5rem 1rem; border-top: 1px solid #8884; max-height: 14rem;\n           overflow: auto; font-size: 0.8rem; }\n  table { border-collapse: collapse; width: 100%; }\n  td, th { text-align: left; padding: 0.15rem 0.5rem; border-bottom: 1px solid #8882;\n           font-family: ui-monospace, monospace; font-size: 0.75rem; }\n  .controls { padding: 0.5rem 1rem; display: flex; gap: 0.5rem; align-items: center; }\n  .dropped { color: #b91c1c; }\n  button { font: inherit; }\n",
    "script": "\nconst trace = JSON.parse(document.getElementById('trace-data').textContent);\nconst timeline = document.getElementById('timeline');\nconst frame = document.getElementById('stage');\nconst details = document.getElementById('details');\nconst diffPanel = document.getElementById('diff');\nconst stepLabel = document.getElementById('step');\nlet selectedCheckpoint = null;\nlet mutationStep = 0;\n\n// Everything in the trace is what the recorded page made it, and the policy\n// allows inline handlers for this script, so recorded text is escaped before\n// it is put into the viewer's markup.\nfunction escapeHtml(value) {\n  return String(value).replace(/[&<>\"']/g, (c) => '&#' + c.charCodeAt(0) + ';');\n}\n\nfunction checkpointFor(event) {\n  if (event.kind === 'checkpoint') return event.index;\n  let latest = null;\n  for (const other of trace.events) {\n    if (other.sequence > event.sequence) break;\n    if (other.kind === 'checkpoint') latest = other.index;\n  }\n  return latest;\n}\n\nfunction renderHtml(index, html) {\n  // The captured document is handed to a sandboxed frame with no\n  // allow-scripts and no allow-forms: it is shown, never run.\n  frame.srcdoc = html == null ? '<p style=\"font:1rem system-ui\">no HTML captured for this checkpoint</p>' : html;\n}\n\nfunction nodeOf(parsed, described) {\n  if (!described) return null;\n  if (described.type === 'text') {\n    return parsed.createTextNode(described.text == null ? '' : described.text);\n  }\n  if (described.type === 'element' && described.html) {\n    const holder = parsed.createElement('template');\n    holder.innerHTML = described.html;\n    return holder.content.firstChild;\n  }\n  return null;\n}\n\nfunction looksLike(node, described) {\n  if (!node || !described) return false;\n  if (described.type === 'text') return node.nodeType === 3;\n  if (described.type !== 'element') return false;\n  if (node.nodeType !== 1 || node.localName !== described.tag) return false;\n  return described.id ? node.id === described.id : true;\n}\n\nfunction childLike(parent, described) {\n  if (!described) return null;\n  // An id is identity; an index is only a hint. A node that moved is reported\n  // at the position it moved *to*, so the index alone would find the wrong one.\n  if (described.id) {\n    for (const child of parent.childNodes) {\n      if (child.nodeType === 1 && child.id === described.id) return child;\n    }\n  }\n  const at = described.index == null ? null : parent.childNodes[described.index];\n  if (looksLike(at, described)) return at;\n  for (const child of parent.childNodes) {\n    if (looksLike(child, described)) return child;\n  }\n  return null;\n}\n\nfunction insertAt(parent, node, index) {\n  if (!node) return false;\n  const reference = index == null ? null : parent.childNodes[index] || null;\n  parent.insertBefore(node, reference);\n  return true;\n}\n\nfunction applyLiveState(parsed, target, record) {\n  const value = record.after;\n  const text = value == null ? '' : String(value);\n  if (record.property === 'scroll') {\n    // A static copy of a document cannot be scrolled, so where it was\n    // scrolled to is written onto the element rather than quietly lost.\n    const element = target || parsed.documentElement;\n    const at = value || {};\n    element.setAttribute('data-bc-scroll', at.top + ',' + at.left);\n    return true;\n  }\n  if (!target) return false;\n  if (record.property === 'value') {\n    // Serialized HTML carries attributes, not properties, so what was typed\n    // is written where a re-parse will still find it.\n    if (target.localName === 'textarea' || target.hasAttribute('contenteditable')) {\n      target.textContent = text;\n    } else {\n      target.setAttribute('value', text);\n    }\n    return true;\n  }\n  if (record.property === 'checked') {\n    if (value) target.setAttribute('checked', '');\n    else target.removeAttribute('checked');\n    return true;\n  }\n  if (record.property === 'selected') {\n    const chosen = (Array.isArray(value) ? value : []).map(String);\n    for (const option of target.querySelectorAll('option')) {\n      if (chosen.indexOf(option.value) !== -1) option.setAttribute('selected', '');\n      else option.removeAttribute('selected');\n    }\n    return true;\n  }\n  if (record.property === 'focus') {\n    for (const had of parsed.querySelectorAll('[data-bc-focus]')) {\n      had.removeAttribute('data-bc-focus');\n    }\n    if (value) target.setAttribute('data-bc-focus', '');\n    return true;\n  }\n  return false;\n}\n\nfunction applyMutations(index, upTo) {\n  const html = trace.html[index];\n  if (html == null) return null;\n  const parsed = new DOMParser().parseFromString(html, 'text/html');\n  const batches = trace.mutations[index] || [];\n  let applied = 0;\n  let skipped = 0;\n  for (const batch of batches.slice(0, upTo)) {\n    // The stage holds one document. A batch from an iframe belongs to a\n    // different one, so it is counted rather than applied to the wrong page.\n    if (batch.mainFrame === false) { skipped += (batch.records || []).length; continue; }\n    for (const record of batch.records) {\n      const target = record.target && record.target.path\n        ? parsed.querySelector(record.target.path)\n        : null;\n      if (record.kind === 'live-state') {\n        if (applyLiveState(parsed, target, record)) applied++; else skipped++;\n      } else if (!target) {\n        skipped++;\n        continue;\n      } else if (record.kind === 'attributes' && record.attribute) {\n        if (record.after == null) target.removeAttribute(record.attribute);\n        else target.setAttribute(record.attribute, record.after);\n        applied++;\n      } else if (record.kind === 'characterData') {\n        target.textContent = record.after == null ? '' : record.after;\n        applied++;\n      } else if (record.kind === 'childList') {\n        // Where a node went is as much of the change as what it was: appending\n        // every addition to the end replays an insertion before a sibling in\n        // the wrong place, and drops removals entirely (issue #93).\n        for (const removed of record.removed || []) {\n          const node = childLike(target, removed);\n          if (node) { target.removeChild(node); applied++; } else { skipped++; }\n        }\n        const added = (record.added || []).filter(Boolean)\n          .slice().sort((a, b) => (a.index == null ? 1e9 : a.index) - (b.index == null ? 1e9 : b.index));\n        for (const entry of added) {\n          if (insertAt(target, nodeOf(parsed, entry), entry.index)) applied++;\n          else skipped++;\n        }\n      } else {\n        skipped++;\n      }\n      if (target && target.style) target.style.outline = '2px solid #f59e0b';\n    }\n  }\n  return { html: '<!DOCTYPE html>' + parsed.documentElement.outerHTML, applied, skipped };\n}\n\nfunction stepText(step, total, result) {\n  const base = 'batch ' + step + ' of ' + total;\n  if (!result || !result.skipped) return base;\n  return base + ' ยท ' + result.skipped + ' not replayable here';\n}\n\nfunction showCheckpoint(index) {\n  selectedCheckpoint = index;\n  mutationStep = 0;\n  renderHtml(index, trace.html[index]);\n  const state = trace.state[index];\n  const previous = trace.state[index - 1];\n  details.innerHTML = state\n    ? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +\n      '<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +\n      '<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'\n    : '<p>no state captured</p>';\n  const changes = trace.diffs[index] || [];\n  diffPanel.innerHTML = previous && changes.length\n    ? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +\n      changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +\n        escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +\n        '</td></tr>').join('') + '</table>'\n    : '<p>no control changes against the previous checkpoint</p>';\n  stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';\n}\n\nfunction select(event, element) {\n  for (const li of timeline.children) li.classList.remove('selected');\n  element.classList.add('selected');\n  const index = checkpointFor(event);\n  if (index != null && index !== selectedCheckpoint) showCheckpoint(index);\n  if (event.kind !== 'checkpoint') {\n    details.innerHTML = '<table>' + Object.entries(event)\n      .map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(\n        typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')\n      .join('') + '</table>';\n  }\n}\n\nfor (const event of trace.events) {\n  const li = document.createElement('li');\n  if (event.kind === 'dropped') li.className = 'dropped';\n  li.innerHTML = '<span class=\"kind\"></span> <span class=\"at\"></span><div class=\"what\"></div>';\n  li.querySelector('.kind').textContent = event.kind;\n  li.querySelector('.at').textContent = (event.at || '').slice(11, 23);\n  li.querySelector('.what').textContent =\n    event.name || event.action || event.text || event.message || event.url || event.reason || '';\n  li.addEventListener('click', () => select(event, li));\n  timeline.append(li);\n}\n\ndocument.getElementById('play').addEventListener('click', async () => {\n  if (selectedCheckpoint == null) return;\n  const batches = trace.mutations[selectedCheckpoint] || [];\n  for (let step = 1; step <= batches.length; step++) {\n    const result = applyMutations(selectedCheckpoint, step);\n    if (result) frame.srcdoc = result.html;\n    stepLabel.textContent = stepText(step, batches.length, result);\n    await new Promise((resolve) => setTimeout(resolve, 250));\n  }\n});\n\ndocument.getElementById('step-forward').addEventListener('click', () => {\n  if (selectedCheckpoint == null) return;\n  const batches = trace.mutations[selectedCheckpoint] || [];\n  mutationStep = Math.min(mutationStep + 1, batches.length);\n  const result = applyMutations(selectedCheckpoint, mutationStep);\n  if (result) frame.srcdoc = result.html;\n  stepLabel.textContent = stepText(mutationStep, batches.length, result);\n});\n\ndocument.getElementById('reset').addEventListener('click', () => {\n  if (selectedCheckpoint != null) showCheckpoint(selectedCheckpoint);\n});\n\nif (trace.checkpoints.length) showCheckpoint(trace.checkpoints[0].index);\n"
  }
}