Source: lib/core/trace/replay.js

/**
 * @file replay.js
 * @description Deterministic replay of a recorded trace.
 *
 * Replay drives the *inputs* a session received — events, navigations, resource
 * settlements, and the non-deterministic globals the sandbox handed out — back
 * into a real application, and then compares what the application did against
 * what the recording says it did the first time.
 *
 * That comparison is the whole point, and it is why replay records a trace of
 * its own rather than simply asserting at the end. A replay that drove the
 * inputs and then checked a final value could pass while every intermediate
 * step was wrong. A replay that compares the observed write and DOM sequence
 * step by step cannot.
 *
 * ## Why observations are compared rather than trusted
 *
 * A recording marks itself best-effort when it detects an escape it knows
 * about: an unattributed write, a polling resource, a value it could not
 * serialize. Those checks are real but not exhaustive — bridge modules are
 * ordinary ES modules and are not sandboxed, so a `Date.now()` inside one is
 * invisible to the recorder.
 *
 * Replay therefore never trusts the recording's own claim. It re-derives
 * determinism by running the framework for real and diffing. A trace that
 * says "deterministic" and diverges fails loudly; a trace that says
 * "best-effort" and reproduces perfectly is reported as such but still refuses
 * to call itself verified unless the caller opts in.
 * @module lib/core/trace/replay
 */

import { nextTick } from '../reactive/scheduler.js';
import { tracer } from './tracer.js';
import { TraceRecorder } from './recorder.js';
import { installReplayGlobals, clearGlobalOverrides } from './globals.js';
import { installResourceResponses, clearResourceResponses } from './resource.js';
import { resolveNode } from './dom.js';
import { findContractViolations } from './contracts.js';
import {
  TraceNodeType,
  Determinism,
  INPUT_TYPES,
  validateTrace,
  indexNodes,
} from './schema.js';
import { AvenxError, AvenxErrorCodes } from '../runtime/AvenxError.js';

/**
 * Settles pending microtasks, the scheduler queue and one macrotask turn.
 *
 * The scheduler's own `nextTick` covers batched component updates; the
 * macrotask turn is what lets a resolved resource promise land. Implemented
 * here rather than imported from the testing helpers so the replay engine does
 * not depend on the DOM mock.
 * @returns {Promise<void>}
 */
async function settle() {
  await nextTick();
  await new Promise((resolve) => {
    if (typeof globalThis.setImmediate === 'function') {
      globalThis.setImmediate(resolve);
    } else {
      setTimeout(resolve, 0);
    }
  });
  await nextTick();
}

/**
 * Reduces an observation node to the fields worth comparing.
 *
 * Ids, timestamps and sequence numbers are excluded: they differ between two
 * correct runs and comparing them would report divergence for every replay.
 * @param {object} node - A recorded node.
 * @returns {string|null} A comparable signature, or null for nodes not compared.
 */
function signature(node) {
  if (node.type === TraceNodeType.WRITE) {
    return `write ${node.path} ${JSON.stringify(node.from)} -> ${JSON.stringify(node.to)}${
      node.op ? ` (${node.op})` : ''
    }`;
  }
  if (node.type === TraceNodeType.DOM) {
    const target = node.target ? `${node.target.selector}[${node.target.nth}]` : '?';
    const name = node.name ? ` ${node.name}` : '';
    return `dom ${node.op} ${target}${name} ${JSON.stringify(node.from)} -> ${JSON.stringify(node.to)}`;
  }
  if (node.type === TraceNodeType.NAVIGATION) {
    return `nav ${node.from} -> ${node.to} (${node.page})`;
  }
  return null;
}

/**
 * Groups a trace's observations by the input that caused them.
 *
 * Every node is walked up to its causal root; nodes rooted at input *n* belong
 * to step *n*. Anything rooted elsewhere — an orphan left by truncation, an
 * unattributed write — is collected separately, because it is evidence about
 * the recording rather than about a step.
 * @param {object} trace - The trace.
 * @returns {{steps: object[], orphans: string[]}} Inputs with their observations.
 */
function groupByInput(trace) {
  const byId = indexNodes(trace);
  const stepOf = new Map();
  const steps = [];
  const orphans = [];

  for (const node of trace.nodes) {
    if (INPUT_TYPES.has(node.type) && (node.parent === null || node.parent === undefined)) {
      // A settled resource is a consequence, not a fresh input to drive.
      if (node.type === TraceNodeType.RESOURCE && node.phase === 'settled') {
        continue;
      }
      stepOf.set(node.id, steps.length);
      steps.push({ index: steps.length, input: node, observations: [] });
    }
  }

  for (const node of trace.nodes) {
    const sig = signature(node);
    if (!sig) {
      continue;
    }
    let current = node;
    while (current && !stepOf.has(current.id)) {
      current = current.parent === null || current.parent === undefined ? null : byId.get(current.parent);
    }
    if (current) {
      steps[stepOf.get(current.id)].observations.push(sig);
    } else {
      orphans.push(sig);
    }
  }

  return { steps, orphans };
}

/**
 * Builds the places an event target may be looked up, most specific first.
 *
 * A test mount is not attached to the document — `mountTestComponent` mounts
 * into a detached host — so resolving only against `document` would fail every
 * replay run from a test. The mounted subtree is therefore searched first, and
 * the document last, which also keeps a replay from reaching into unrelated
 * markup that happens to match the same selector.
 * @param {object|null} context - Whatever `mount()` returned.
 * @param {Document|Element} [explicit] - A root the caller supplied.
 * @returns {Array<Document|Element>} Candidate roots.
 */
function candidateRoots(context, explicit) {
  const roots = [];
  if (explicit) {
    roots.push(explicit);
  }
  if (context) {
    // The shape `mountTestComponent` returns, and the one an app-level mount
    // helper is most likely to mirror.
    for (const key of ['target', 'element', 'container', 'root']) {
      const candidate = context[key];
      if (candidate && typeof candidate.querySelectorAll === 'function') {
        roots.push(candidate);
      }
    }
  }
  if (typeof document !== 'undefined') {
    roots.push(document);
  }
  return roots;
}

/**
 * Dispatches a recorded event against the live application.
 * @param {object} input - The recorded event node.
 * @param {Array<Document|Element>} roots - Where to look for the target, in order.
 * @returns {{ok: boolean, error: string}} Whether the event could be delivered.
 */
function dispatchRecordedEvent(input, roots) {
  let target = null;
  for (const root of roots) {
    target = resolveNode(input.target, root);
    if (target) {
      break;
    }
  }
  if (!target) {
    return {
      ok: false,
      error: `could not find ${input.target ? `${input.target.selector}[${input.target.nth}]` : 'the event target'}`,
    };
  }

  // Field state is restored before dispatch, not asserted afterwards: for an
  // input event the typed value *is* the input, and the handler reads it off
  // the element.
  if (typeof input.value === 'string') {
    target.value = input.value;
  }
  if (typeof input.checked === 'boolean') {
    target.checked = input.checked;
  }

  const EventCtor = typeof globalThis.Event === 'function' ? globalThis.Event : null;
  if (!EventCtor) {
    return { ok: false, error: 'no DOM Event constructor is available in this environment' };
  }

  let event;
  if (input.key && typeof globalThis.KeyboardEvent === 'function') {
    event = new globalThis.KeyboardEvent(input.eventType, { bubbles: true, cancelable: true, key: input.key });
  } else {
    event = new EventCtor(input.eventType, { bubbles: true, cancelable: true });
  }

  target.dispatchEvent(event);
  return { ok: true };
}

/**
 * Replays a recorded trace against a live application.
 * @param {object} trace - A trace produced by a recording.
 * @param {object} options - Replay options.
 * @param {function(): (Promise<object>|object)} options.mount - Sets up and mounts the application.
 *   Whatever it returns is handed back to `at` as the context.
 * @param {function(object, object): (Promise<void>|void)} [options.at] - Called after each input
 *   with `(step, context)`, for the caller's own assertions.
 * @param {Document|Element} [options.root] - Where to resolve event targets. Defaults to the
 *   mounted subtree, falling back to `document`.
 * @param {object} [options.router] - A router to drive recorded navigations through.
 * @param {boolean} [options.allowBestEffort] - Accept a trace the recorder marked best-effort.
 *   Without this, replaying such a trace throws rather than reporting a pass it cannot stand behind.
 * @param {boolean} [options.strict] - Throw on divergence. Defaults to true.
 * @returns {Promise<object>} The replay result.
 * @throws {AvenxError} When the trace cannot be read, when it is best-effort and
 *   `allowBestEffort` was not set, or when replay diverged in strict mode.
 */
export async function replay(trace, options = {}) {
  const valid = validateTrace(trace);
  if (!valid.ok) {
    throw new AvenxError(AvenxErrorCodes.TRACE_UNREADABLE, valid.error);
  }
  if (typeof options.mount !== 'function') {
    throw new AvenxError(
      AvenxErrorCodes.TRACE_REPLAY_FAILED,
      'replay() needs a mount() option that sets up and mounts the application.',
    );
  }

  const recordedStatus = (trace.determinism && trace.determinism.status) || Determinism.DETERMINISTIC;
  if (recordedStatus !== Determinism.DETERMINISTIC && !options.allowBestEffort) {
    const reasons = ((trace.determinism && trace.determinism.reasons) || [])
      .map((entry) => `  - ${entry.reason}${entry.detail ? `: ${entry.detail}` : ''}`)
      .join('\n');
    throw new AvenxError(
      AvenxErrorCodes.TRACE_NOT_DETERMINISTIC,
      `${trace.id || 'this trace'}\n${reasons}`,
    );
  }

  const strict = options.strict !== false;
  const expected = groupByInput(trace);
  const problems = [];

  // Everything replay substitutes is installed together and torn down together,
  // so a thrown assertion cannot leave a fake clock behind for the next test.
  const exhausted = [];
  const missingResources = [];
  installReplayGlobals(trace.globals || {}, (source) => exhausted.push(source));
  installResourceResponses(trace, (name) => missingResources.push(name));

  const observer = new TraceRecorder({ id: `${trace.id || 'trace'}-replay` });
  const steps = [];
  /**
   * Where event targets are looked up. Assigned once `mount()` has returned the
   * context, because the mounted subtree is the most specific place to search.
   * @type {Array<Document|Element>}
   */
  let roots;

  try {
    tracer.attach(observer);
    const context = await options.mount();
    await settle();
    roots = candidateRoots(context, options.root);

    // Mount noise is not the session; recording of divergences starts here.
    observer.arm();

    for (const step of expected.steps) {
      const before = observer.nodes.length;
      const input = step.input;

      if (input.type === TraceNodeType.EVENT) {
        const delivered = dispatchRecordedEvent(input, roots);
        if (!delivered.ok) {
          problems.push({ step: step.index, kind: 'input', detail: delivered.error });
        }
      } else if (input.type === TraceNodeType.NAVIGATION) {
        const router = options.router || (context && context.router);
        if (router && typeof router.navigate === 'function') {
          router.navigate(input.to);
        } else {
          problems.push({
            step: step.index,
            kind: 'input',
            detail: `the trace navigates to ${input.to} but no router was supplied to replay()`,
          });
        }
      }

      await settle();

      const observed = observer.nodes
        .slice(before)
        .map(signature)
        .filter(Boolean);

      const divergence = diff(step.observations, observed);
      if (divergence) {
        problems.push({ step: step.index, kind: 'divergence', ...divergence });
      }

      const record = {
        index: step.index,
        type: input.type,
        label: describeInput(input),
        expected: step.observations,
        observed,
        diverged: !!divergence,
      };
      steps.push(record);

      // In strict mode the first divergence ends the replay before the
      // caller's own assertions run. Once a step has diverged, every later
      // assertion is comparing against a state the recording never reached, and
      // a bare "expected 1, got 2" from step four is a much worse explanation
      // than the causal report for step one.
      if (divergence && strict) {
        throw new AvenxError(
          AvenxErrorCodes.TRACE_REPLAY_DIVERGED,
          formatProblems({ traceId: trace.id, steps, problems }),
        );
      }

      if (typeof options.at === 'function') {
        await options.at(record, context);
      }
    }
  } finally {
    tracer.detach();
    clearGlobalOverrides();
    clearResourceResponses();
  }

  for (const source of exhausted) {
    problems.push({
      step: -1,
      kind: 'exhausted',
      detail: `replay read more ${source} values than the recording captured`,
    });
  }
  for (const name of missingResources) {
    problems.push({
      step: -1,
      kind: 'exhausted',
      detail: `<resource name="${name}"> asked for a response the trace does not contain`,
    });
  }

  const result = {
    ok: problems.length === 0,
    traceId: trace.id,
    recordedDeterminism: recordedStatus,
    // Determinism is re-derived from the run rather than copied from the trace.
    // This is the only claim in the system backed by evidence.
    verified: problems.length === 0 && recordedStatus === Determinism.DETERMINISTIC,
    steps,
    problems,
    orphans: expected.orphans,
    contractViolations: findContractViolations(trace),
  };

  if (!result.ok && strict) {
    throw new AvenxError(AvenxErrorCodes.TRACE_REPLAY_DIVERGED, formatProblems(result));
  }

  return result;
}

/**
 * Compares an expected observation sequence against what replay saw.
 * @param {string[]} expectedSigs - Signatures from the recording.
 * @param {string[]} observedSigs - Signatures from this run.
 * @returns {{expected: string[], observed: string[], at: number}|null} The first
 *   point of difference, or null when the sequences match.
 */
function diff(expectedSigs, observedSigs) {
  const limit = Math.max(expectedSigs.length, observedSigs.length);
  for (let i = 0; i < limit; i++) {
    if (expectedSigs[i] !== observedSigs[i]) {
      return { expected: expectedSigs, observed: observedSigs, at: i };
    }
  }
  return null;
}

/**
 * Describes a replayed input in one line.
 * @param {object} input - The input node.
 * @returns {string} A label such as `click <button.qty-inc>`.
 */
function describeInput(input) {
  if (input.type === TraceNodeType.EVENT) {
    const target = input.target ? `<${input.target.selector}>` : '<unknown>';
    return `${input.eventType} ${target}`;
  }
  if (input.type === TraceNodeType.NAVIGATION) {
    return `navigate ${input.to}`;
  }
  return input.type;
}

/**
 * Renders a replay's problems as a developer-readable report.
 * @param {object} result - A replay result.
 * @returns {string} The report.
 */
export function formatProblems(result) {
  const lines = [`Replay of ${result.traceId || 'trace'} diverged from the recording.`, ''];

  for (const problem of result.problems) {
    if (problem.kind === 'divergence') {
      const step = result.steps[problem.step];
      lines.push(`Step ${problem.step + 1} (${step ? step.label : 'unknown'}) diverged at position ${problem.at + 1}:`);
      lines.push(`  recorded: ${problem.expected[problem.at] ?? '(nothing more)'}`);
      lines.push(`  replayed: ${problem.observed[problem.at] ?? '(nothing more)'}`);
      lines.push('');
    } else if (problem.kind === 'input') {
      lines.push(`Step ${problem.step + 1}: ${problem.detail}`);
      lines.push('');
    } else {
      lines.push(`${problem.detail}`);
      lines.push('');
    }
  }

  lines.push(
    'A divergence means the recorded session cannot be reproduced from its inputs alone —',
    'usually because something outside the sandbox boundary (a bridge reading the clock, a',
    'timer, a request made outside a <resource>) took part in the original run.',
  );

  return lines.join('\n');
}