Source: lib/core/testing/snapshot.js

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// Registry tracking used snapshots per test file to detect obsolete entries
const fileSnapshotRegistry = new Map();

/**
 * Normalizes and formats DOM nodes or raw HTML strings into stable, deterministic markup.
 * Sorts attributes alphabetically, trims whitespace, and applies mask rules for volatile data.
 * @param {Element|string|any} input
 * @param {object} [options]
 * @param {Array<{match: RegExp|string, replace: string}>} [options.masks]
 * @returns {string}
 */
export function serializeSnapshot(input, options = {}) {
  const { masks = [] } = options;

  // If input is an element node directly, format it directly
  if (input && typeof input === 'object' && input.nodeType === 1) {
    let formatted = formatNode(input, 0);
    const defaultMasks = [
      { match: /\bax-[a-f0-9]{6,}\b/g, replace: 'ax-[hash]' },
      { match: /data-ax-[a-f0-9]{6,}/g, replace: 'data-ax-[hash]' },
    ];
    for (const { match, replace } of [...defaultMasks, ...masks]) {
      formatted = formatted.replace(match, replace);
    }
    return formatted;
  }

  let raw;
  if (typeof input === 'string') {
    raw = input.trim();
  } else if (input && typeof input.outerHTML === 'string') {
    raw = input.outerHTML.trim();
  } else if (input && typeof input.innerHTML === 'string') {
    raw = input.innerHTML.trim();
  } else {
    raw = String(input ?? '');
  }

  let formatted = '';
  if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
    try {
      const template = document.createElement('template');
      template.innerHTML = raw;
      const rootNodes = template.content?.childNodes || template.childNodes || [];
      formatted = Array.from(rootNodes)
        .map((node) => formatNode(node, 0))
        .filter(Boolean)
        .join('\n');
    } catch {
      formatted = formatHtmlStringFallback(raw);
    }
  }

  if (!formatted) {
    formatted = formatHtmlStringFallback(raw);
  }

  const defaultMasks = [
    { match: /\bax-[a-f0-9]{6,}\b/g, replace: 'ax-[hash]' },
    { match: /data-ax-[a-f0-9]{6,}/g, replace: 'data-ax-[hash]' },
  ];

  let masked = formatted;
  for (const { match, replace } of [...defaultMasks, ...masks]) {
    masked = masked.replace(match, replace);
  }

  return masked;
}

/**
 * Formats a DOM node with indentation based on depth.
 * @param {Node} node
 * @param {number} depth
 * @returns {string}
 */
function formatNode(node, depth) {
  const indent = '  '.repeat(depth);

  if (node.nodeType === 3) {
    // Text node
    const text = (node.textContent || '').trim();
    return text ? `${indent}${text}` : '';
  }

  if (node.nodeType === 8) {
    // Comment
    const comment = (node.textContent || '').trim();
    return `${indent}<!-- ${comment} -->`;
  }

  if (node.nodeType === 1) {
    // Element
    const tag = node.tagName.toLowerCase();
    const attrs = Array.from(node.attributes || [])
      .sort((a, b) => a.name.localeCompare(b.name))
      .map((attr) => {
        if (attr.value === '' || attr.value === null) return attr.name;
        return `${attr.name}="${attr.value}"`;
      });

    const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
    const children = Array.from(node.childNodes)
      .map((child) => formatNode(child, depth + 1))
      .filter(Boolean);

    const selfClosingTags = new Set([
      'area',
      'base',
      'br',
      'col',
      'embed',
      'hr',
      'img',
      'input',
      'link',
      'meta',
      'param',
      'source',
      'track',
      'wbr',
    ]);

    if (children.length === 0) {
      if (selfClosingTags.has(tag)) {
        return `${indent}<${tag}${attrStr} />`;
      }
      return `${indent}<${tag}${attrStr}></${tag}>`;
    }

    if (children.length === 1 && !children[0].includes('\n') && !children[0].startsWith('  '.repeat(depth + 1) + '<')) {
      const inlineText = children[0].trim();
      return `${indent}<${tag}${attrStr}>${inlineText}</${tag}>`;
    }

    return `${indent}<${tag}${attrStr}>\n${children.join('\n')}\n${indent}</${tag}>`;
  }

  return '';
}

/**
 * Normalizes tag attribute ordering in raw HTML strings without full DOM parsing.
 * @param {string} html
 * @returns {string}
 */
function formatHtmlStringFallback(html) {
  return html.replace(/>\s*</g, '><').replace(/(<[a-zA-Z0-9-]+)(\s+[^>]+)?(\/?>)/g, (_, open, attrs, close) => {
    if (!attrs) return `${open}${close}`;
    const sortedAttrs = attrs
      .trim()
      .split(/\s+(?=[a-zA-Z_:@-])/)
      .sort((a, b) => a.localeCompare(b))
      .join(' ');
    return `${open} ${sortedAttrs}${close}`;
  });
}

/**
 * Computes a readable line-by-line diff between expected and received strings.
 * @param {string} expected
 * @param {string} received
 * @returns {string}
 */
export function generateDiff(expected, received) {
  const expLines = (expected || '').split('\n');
  const recLines = (received || '').split('\n');

  const lines = ['Snapshot difference:'];
  const max = Math.max(expLines.length, recLines.length);

  for (let i = 0; i < max; i++) {
    const exp = expLines[i];
    const rec = recLines[i];

    if (exp === rec) {
      lines.push(`  ${exp ?? ''}`);
    } else {
      if (exp !== undefined) lines.push(`- ${exp}`);
      if (rec !== undefined) lines.push(`+ ${rec}`);
    }
  }

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

/**
 * Resolves the caller test file path using stack traces.
 * @returns {string|null}
 */
function resolveCallingTestFile() {
  const originalPrepare = Error.prepareStackTrace;
  try {
    Error.prepareStackTrace = (_, stack) => stack;
    const err = new Error();
    const stack = err.stack;

    if (Array.isArray(stack)) {
      for (const frame of stack) {
        let filename = frame.getFileName();
        if (!filename) continue;
        if (filename.startsWith('file://')) {
          filename = fileURLToPath(filename);
        }
        if (filename.includes('.test.js') || filename.includes('.spec.js')) {
          return filename;
        }
      }
    }
  } finally {
    Error.prepareStackTrace = originalPrepare;
  }
  return null;
}

/**
 * Asserts that the received DOM node, wrapper, or HTML matches a persisted snapshot.
 * @param {Element|string|object} received
 * @param {string} [name]
 * @param {object} [options]
 * @param {string} [options.testFile] Explicit test file path if stack inference is unavailable
 * @param {Array<{match: RegExp|string, replace: string}>} [options.masks]
 * @returns {void}
 */
export function assertSnapshot(received, name = 'default', options = {}) {
  const testFile = options.testFile || resolveCallingTestFile();

  if (!testFile) {
    throw new Error('assertSnapshot: Could not infer calling test file. Pass options.testFile explicitly.');
  }

  const snapshotDir = path.join(path.dirname(testFile), '__snapshots__');
  const snapshotFile = path.join(snapshotDir, `${path.basename(testFile)}.snap`);

  const serialized = serializeSnapshot(received, options);
  const isCI = Boolean(process.env.CI && process.env.CI !== 'false' && process.env.CI !== '0');
  const isUpdate = Boolean(process.env.AVENX_UPDATE_SNAPSHOTS === '1' || process.env.UPDATE_SNAPSHOTS === '1');

  let snapshots = {};
  if (fs.existsSync(snapshotFile)) {
    try {
      snapshots = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
    } catch {
      snapshots = {};
    }
  }

  if (!fileSnapshotRegistry.has(snapshotFile)) {
    fileSnapshotRegistry.set(snapshotFile, new Set());
  }
  fileSnapshotRegistry.get(snapshotFile).add(name);

  if (!(name in snapshots)) {
    if (isCI && !isUpdate) {
      throw new Error(
        `Snapshot "${name}" does not exist in CI mode for "${path.basename(testFile)}". Missing snapshot must fail in CI.`,
      );
    }
    snapshots[name] = serialized;
    fs.mkdirSync(snapshotDir, { recursive: true });
    fs.writeFileSync(snapshotFile, JSON.stringify(snapshots, null, 2) + '\n', 'utf8');
    return;
  }

  const expected = snapshots[name];
  if (expected !== serialized) {
    if (isUpdate) {
      snapshots[name] = serialized;
      fs.writeFileSync(snapshotFile, JSON.stringify(snapshots, null, 2) + '\n', 'utf8');
      return;
    }

    const diff = generateDiff(expected, serialized);
    const error = new Error(`Snapshot mismatch for "${name}" in "${path.basename(testFile)}"\n\n${diff}`);
    error.expected = expected;
    error.actual = serialized;
    throw error;
  }
}

/**
 * Returns any snapshots found in the file that were not executed during the test suite.
 * @param {string} testFile
 * @returns {string[]}
 */
export function getObsoleteSnapshots(testFile) {
  const snapshotFile = path.join(path.dirname(testFile), '__snapshots__', `${path.basename(testFile)}.snap`);
  if (!fs.existsSync(snapshotFile)) return [];

  try {
    const snapshots = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
    const used = fileSnapshotRegistry.get(snapshotFile) || new Set();
    return Object.keys(snapshots).filter((key) => !used.has(key));
  } catch {
    return [];
  }
}