All files / lib/core/trace resource.js

97.54% Statements 159/163
70% Branches 21/30
100% Functions 6/6
97.54% Lines 159/163

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 11x 11x 11x 11x 121x 119x 119x 2x 2x 2x 2x 2x 2x 2x 11x 405x 405x 405x 405x 405x 10x 10x 10x 405x 405x 405x 405x 405x 405x 2x 2x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 17x 14x 14x 3x 17x 1x 1x 1x 1x 1x 2x 17x 405x 405x 405x 405x 405x 405x 405x 405x 405x 3x 3x     3x 3x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 405x 3x 3x     3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 1x 1x 3x 2x 2x 3x 3x 3x  
/**
 * @file resource.js
 * @description Recording and replay of `<resource>` requests.
 *
 * Resources are Avenx's declarative async boundary, which makes them the one
 * place the framework can see network work happening. Recording their
 * settlements — and feeding those settlements back during replay — is what
 * lets a recorded session be reproduced without a server.
 *
 * This deliberately does not intercept `fetch` or `XMLHttpRequest` globally. A
 * trace that silently swallowed every request in the page would be recording
 * work Avenx has no model of, and would report success for reproductions that
 * only appeared to work. Requests made outside a `<resource>` are not
 * reproduced, and a trace whose state depends on them diverges visibly during
 * replay rather than passing by accident.
 * @module lib/core/trace/resource
 */
 
import { tracer } from './tracer.js';
import { TraceNodeType, NonDeterminismReason } from './schema.js';
 
/**
 * Recorded settlements queued per resource name, oldest first.
 *
 * Empty outside replay, so {@link takeRecordedResponse} is a `Map.size` check
 * on the recording path.
 * @type {Map<string, object[]>}
 */
const recordedResponses = new Map();
 
/**
 * Called when replay asks for a response that was never recorded.
 * @type {function(string): void|null}
 */
let onMissingResponse = null;
 
/**
 * Loads a trace's resource settlements so replay can serve them.
 * @param {object} trace - The trace to replay.
 * @param {function(string): void} [onMissing] - Called when a resource asks for a response the trace does not have.
 */
export function installResourceResponses(trace, onMissing) {
  recordedResponses.clear();
  onMissingResponse = onMissing || null;
 
  for (const node of trace.nodes || []) {
    if (node.type !== TraceNodeType.RESOURCE || node.phase !== 'settled') {
      continue;
    }
    let queue = recordedResponses.get(node.name);
    if (!queue) {
      queue = [];
      recordedResponses.set(node.name, queue);
    }
    queue.push({ status: node.status, value: node.value, error: node.error });
  }
}
 
/**
 * Discards any loaded responses, restoring live resource behaviour.
 */
export function clearResourceResponses() {
  recordedResponses.clear();
  onMissingResponse = null;
}
 
/**
 * Whether replay has responses loaded.
 * @returns {boolean}
 */
export function hasRecordedResponses() {
  return recordedResponses.size > 0;
}
 
/**
 * Takes the next recorded settlement for a resource.
 *
 * A resource that asks for more settlements than were recorded is reported
 * rather than served a repeat: replay has gone further than the recording did,
 * which is divergence, and inventing a response would hide it.
 * @param {string} name - The resource name.
 * @returns {object|null} `{status, value, error}`, or null when nothing is queued.
 */
export function takeRecordedResponse(name) {
  if (recordedResponses.size === 0) {
    return null;
  }
  const queue = recordedResponses.get(name);
  if (!queue || queue.length === 0) {
    if (onMissingResponse) {
      onMissingResponse(name);
    }
    return null;
  }
  return queue.shift();
}
 
/**
 * Opens a node for a resource that has started work.
 * @param {string} name - The resource name.
 * @param {object|null} component - The owning component instance.
 * @param {number} pollInterval - The configured poll interval, if any.
 * @returns {object|null} The opened node, for later settlement.
 */
export function traceResourceStart(name, component, pollInterval) {
  const recorder = tracer.sink;
  if (!recorder) {
    return null;
  }
 
  if (pollInterval > 0) {
    // How many times a polling resource settled depends on wall-clock time, so
    // the sequence cannot be reproduced by replaying inputs.
    recorder.markNonDeterministic(
      NonDeterminismReason.POLLING_RESOURCE,
      `<resource name="${name}"> polls every ${pollInterval}ms`,
    );
  }
 
  return tracer.record(TraceNodeType.RESOURCE, {
    name,
    phase: 'pending',
    component: component && component.constructor && component.constructor.name,
  });
}
 
/**
 * Records how a resource settled.
 *
 * Settlements are recorded as their own node rather than by annotating the
 * pending one: a resource settles asynchronously, and a trace that mutated an
 * older node in place would lose the ordering that replay depends on.
 * @param {object|null} pending - The node returned by {@link traceResourceStart}.
 * @param {string} name - The resource name.
 * @param {'resolved'|'rejected'} status - How it settled.
 * @param {any} payload - The value, or the error.
 */
export function traceResourceSettle(pending, name, status, payload) {
  const recorder = tracer.sink;
  if (!recorder) {
    return;
  }
 
  const node = {
    name,
    phase: 'settled',
    status,
    // Parented to the request rather than to whatever happened to be running
    // when the promise settled, which is a microtask with no causal relation.
    request: pending ? pending.id : undefined,
  };
 
  if (status === 'rejected') {
    node.error = {
      name: (payload && payload.name) || 'Error',
      message: String((payload && payload.message) || payload),
    };
  } else {
    node.value = recorder.capture(payload, `resource.${name}`);
  }
 
  tracer.record(TraceNodeType.RESOURCE, node);
}