All files / src record.ts

100% Statements 48/48
100% Branches 30/30
100% Functions 9/9
100% Lines 43/43

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                                                                                                        25x 23x 22x 22x 20x                   23x 2x   21x         21x 21x                 21x 21x       4x 17x     15x             20x 20x 20x   20x 20x       20x 20x 20x 1x     3x     20x 20x 20x 20x 20x   20x                 20x 20x 20x 20x 20x                     5x 5x 18x       1x 1x   17x   5x    
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
 
import type {
  BodyMatcher,
  HttpScenario,
  Scenario,
} from '@ethercalc/shared/oracle-scenarios';
 
import { encodeBase64 } from './matchers.ts';
import { headersToRecord, normalizeHeaders } from './headers.ts';
import { applyNormalizer } from './normalize.ts';
 
/**
 * The JSON artifact format. A recorded scenario is just a cloned
 * scenario with `expect` filled in from the oracle's actual response.
 * Keeping it this way means replay.ts doesn't need a separate loader
 * schema — it just parses the same `HttpScenario` type.
 */
export interface RecordedFile {
  readonly scenario: HttpScenario;
}
 
export interface RecordOptions {
  readonly targetUrl: string;
  readonly outDir: string;
  /** Defaults to `fetch`; tests pass a stub. */
  readonly fetcher?: typeof fetch;
  /** Defaults to `writeFile` from `node:fs/promises`; tests pass a stub. */
  readonly writer?: (path: string, contents: string) => Promise<void>;
  /**
   * Override the default matcher resolver. By default we infer one
   * from the response `Content-Type`: JSON bodies → `json`, empty
   * bodies / explicit 404s / redirects → `ignore` (with the regex
   * header assertion carrying the semantic weight), everything else
   * → `exact`.
   */
  readonly matcherForResponse?: (status: number, headers: Record<string, string>) => BodyMatcher;
  /** Injected logger so tests can assert progress output. */
  readonly log?: (line: string) => void;
}
 
export interface RecordResult {
  readonly scenario: HttpScenario;
  readonly path: string;
}
 
/** Default matcher selector — see RecordOptions above. */
export function defaultMatcherForResponse(
  status: number,
  headers: Record<string, string>,
): BodyMatcher {
  if (status >= 300 && status < 400) return 'ignore';
  if (status === 204 || status === 304) return 'ignore';
  const ct = (headers['content-type'] ?? '').toLowerCase();
  if (ct.includes('application/json')) return 'json';
  return 'exact';
}
 
/**
 * Sanitize a scenario name into a safe relative path fragment. The
 * scenario naming convention uses `/` as a family separator
 * (`static/get-root-index`), which happens to line up with fs path
 * separators — we just need to guard against `..` and absolute paths.
 */
export function scenarioPath(outDir: string, name: string): string {
  if (name.includes('..') || name.startsWith('/')) {
    throw new Error(`unsafe scenario name: ${JSON.stringify(name)}`);
  }
  return join(outDir, `${name}.json`);
}
 
/** Base64-encode the body of a fetch Response safely in all sizes. */
export async function encodeResponseBody(response: Response): Promise<string> {
  const buffer = await response.arrayBuffer();
  return encodeBase64(new Uint8Array(buffer));
}
 
/** Write a recorded artifact to disk, creating parent directories. */
export async function persistRecording(
  path: string,
  file: RecordedFile,
  writer: (path: string, contents: string) => Promise<void>,
): Promise<void> {
  await mkdir(dirname(path), { recursive: true });
  await writer(path, `${JSON.stringify(file, null, 2)}\n`);
}
 
/** Default file writer used when `opts.writer` is omitted. Split out so tests can exercise the fallback path directly. */
export const defaultWriter = (path: string, contents: string): Promise<void> =>
  writeFile(path, contents, 'utf8');
 
/** Default HTTP fetcher. Wrap the global so tests can stub it via the shared helper. */
export const defaultFetcher: typeof fetch = (input, init) => fetch(input, init);
 
/** Record a single HTTP scenario against the oracle. */
export async function recordOne(
  scenario: HttpScenario,
  opts: RecordOptions,
): Promise<RecordResult> {
  const fetcher = opts.fetcher ?? defaultFetcher;
  const writer = opts.writer ?? defaultWriter;
  const matcherFor = opts.matcherForResponse ?? defaultMatcherForResponse;
 
  const requestHeaders = scenario.request.headers ?? {};
  const url = new URL(scenario.request.path, opts.targetUrl).toString();
  // `redirect: 'manual'` is essential: the oracle often replies with a
  // 302 (`/_new`, `/:room/edit`, ...), and we want to capture that
  // verbatim — not silently follow the redirect and record a 200.
  const init: RequestInit = { method: scenario.request.method, redirect: 'manual' };
  if (Object.keys(requestHeaders).length > 0) init.headers = { ...requestHeaders };
  if (scenario.request.bodyBase64 !== undefined) {
    init.body = new Uint8Array(
      atob(scenario.request.bodyBase64)
        .split('')
        .map((ch) => ch.charCodeAt(0)),
    );
  }
  const response = await fetcher(url, init);
  const rawHeaders = headersToRecord(response.headers);
  const headers = normalizeHeaders(rawHeaders);
  const bodyBase64 = await encodeResponseBody(response);
  const matcher = matcherFor(response.status, headers);
 
  const raw: HttpScenario = {
    ...scenario,
    expect: {
      status: response.status,
      headers,
      bodyBase64,
      bodyMatcher: matcher,
    },
  };
  const recorded = applyNormalizer(raw);
  const path = scenarioPath(opts.outDir, scenario.name);
  await persistRecording(path, { scenario: recorded }, writer);
  opts.log?.(`recorded ${scenario.name} → ${response.status}`);
  return { scenario: recorded, path };
}
 
/**
 * Record an iterable of scenarios in order. WebSocket scenarios are
 * skipped with a TODO — see Phase 7 in the plan.
 */
export async function recordAll(
  scenarios: Iterable<Scenario>,
  opts: RecordOptions,
): Promise<readonly RecordResult[]> {
  const results: RecordResult[] = [];
  for (const scenario of scenarios) {
    if (scenario.kind === 'ws') {
      // TODO(Phase 7): record WebSocket transcripts. For now the
      // harness only handles stateless HTTP scenarios; WS scenarios
      // sit in scenarios/ws/ but the recorder will skip them.
      opts.log?.(`skipping ws scenario ${scenario.name} (Phase 7)`);
      continue;
    }
    results.push(await recordOne(scenario, opts));
  }
  return results;
}