/**
* @file exportTest.js
* @description Turns a recorded trace into an executable regression test.
*
* This is the part of the feature that earns its keep. A causal view explains
* a bug once; an exported test keeps it fixed. The step from "I reproduced it"
* to "there is a test for it" is the expensive one on every team, and a trace
* already contains everything that step needs: the inputs, the expected
* observations, and the responses to replay instead of the network.
*
* The generated file is deliberately readable and deliberately editable. It is
* a starting point a developer prunes — delete the assertions you do not care
* about, keep the ones that describe the bug — not an opaque artifact.
* @module lib/core/trace/exportTest
*/
import { TraceNodeType, Determinism, INPUT_TYPES, indexNodes } from './schema.js';
import { formatCaptured } from './capture.js';
/**
* Escapes a value for embedding in generated source.
* @param {any} value - The value.
* @returns {string} A JavaScript literal.
*/
function literal(value) {
return JSON.stringify(value);
}
/**
* Collects the inputs replay will drive, with the observations each caused.
* @param {object} trace - The trace.
* @returns {object[]} One entry per input, in recorded order.
*/
function inputSteps(trace) {
const byId = indexNodes(trace);
const stepOf = new Map();
const steps = [];
for (const node of trace.nodes) {
if (!INPUT_TYPES.has(node.type) || (node.parent !== null && node.parent !== undefined)) {
continue;
}
if (node.type === TraceNodeType.RESOURCE && node.phase === 'settled') {
continue;
}
stepOf.set(node.id, steps.length);
steps.push({ index: steps.length, input: node, writes: [], dom: [], navigations: [] });
}
for (const node of trace.nodes) {
let current = node;
while (current && !stepOf.has(current.id)) {
current = current.parent === null || current.parent === undefined ? null : byId.get(current.parent);
}
if (!current) {
continue;
}
const step = steps[stepOf.get(current.id)];
if (node.type === TraceNodeType.WRITE && node.to !== undefined) {
step.writes.push(node);
} else if (node.type === TraceNodeType.DOM && (node.op === 'text' || node.op === 'attr')) {
step.dom.push(node);
} else if (node.type === TraceNodeType.NAVIGATION && node !== current) {
step.navigations.push(node);
}
}
return steps;
}
/**
* Describes an input in a comment.
* @param {object} input - The input node.
* @returns {string} A short label.
*/
function describe(input) {
if (input.type === TraceNodeType.EVENT) {
const target = input.target ? `<${input.target.selector}>` : '<unknown>';
const value = input.value !== undefined ? ` with value ${literal(input.value)}` : '';
return `${input.eventType} on ${target}${value}`;
}
if (input.type === TraceNodeType.NAVIGATION) {
return `navigate to ${input.to}`;
}
return input.type;
}
/**
* Builds the assertion lines for one step.
*
* Assertions come from the recorded DOM and state changes rather than from the
* final state alone, so a generated test fails at the step that broke rather
* than at the end.
* @param {object} step - A step from {@link inputSteps}.
* @param {boolean} redacted - Whether the trace withheld values.
* @returns {string[]} Assertion source lines, already indented.
*/
function assertionsFor(step, redacted) {
const lines = [];
for (const dom of step.dom) {
if (!dom.target || dom.to === null || dom.to === undefined) {
continue;
}
if (redacted && typeof dom.to === 'string' && dom.to.includes('[redacted]')) {
continue;
}
const selector = literal(dom.target.selector);
const nth = dom.target.nth || 0;
const finder = nth === 0 ? `find(${selector})` : `findAll(${selector})[${nth}]`;
if (dom.op === 'text') {
lines.push(
` // ${dom.target.selector} showed ${formatCaptured(dom.from)} before this step.`,
` assert.strictEqual(app.${finder}.textContent.trim(), ${literal(String(dom.to).trim())});`,
);
} else {
lines.push(
` assert.strictEqual(app.${finder}.getAttribute(${literal(dom.name)}), ${literal(String(dom.to))});`,
);
}
}
if (lines.length === 0) {
// A step with no assertable DOM change still deserves a line, so the
// developer can see it happened and decide what to check.
lines.push(' // No DOM change was recorded for this step.');
}
return lines;
}
/**
* Generates the source of a regression test from a trace.
* @param {object} trace - The trace to export.
* @param {object} options - Generation options.
* @param {string} options.tracePath - Import specifier for the trace JSON, relative to the test.
* @param {string} [options.componentPath] - Import specifier for the component to mount.
* @param {string} [options.componentName] - The component's class name, for comments.
* @param {Array<{name: string, path: string}>} [options.bridges] - Bridges the component
* imports, with import specifiers relative to the generated test.
* @param {string} [options.title] - The test's title.
* @returns {string} The generated test source.
*/
export function generateTest(trace, options) {
const steps = inputSteps(trace);
const deterministic = (trace.determinism && trace.determinism.status) === Determinism.DETERMINISTIC;
const title = options.title || `regression: ${trace.id}`;
const redacted = !!trace.redacted;
const lines = [];
lines.push(
'/**',
` * Regression test generated by \`avenx trace export ${trace.id}\`.`,
' *',
` * Recorded ${trace.createdAt || 'at an unknown time'}${trace.meta && trace.meta.url ? ` at ${trace.meta.url}` : ''}.`,
` * ${steps.length} recorded input${steps.length === 1 ? '' : 's'}.`,
' *',
' * replay() drives the recorded inputs back through the real framework and',
' * compares every state and DOM change against the recording. The assertions',
' * below are additional, and are yours to prune: keep the ones that describe',
' * the bug, delete the rest.',
);
if (!deterministic) {
lines.push(
' *',
' * ⚠ This trace was recorded as best-effort, so replay may not reproduce it:',
);
for (const reason of (trace.determinism && trace.determinism.reasons) || []) {
lines.push(` * - ${reason.reason}${reason.detail ? `: ${reason.detail}` : ''}`);
}
lines.push(
' * allowBestEffort is set below so this file runs, but the result will',
' * never be reported as verified. Fix the source of non-determinism and',
' * re-record for a test you can rely on.',
);
}
if (redacted) {
lines.push(
' *',
` * Redacted paths (${(trace.redactions || []).join(', ')}) were withheld at`,
' * record time, so no assertion below can check them.',
);
}
lines.push(' */', '');
lines.push(
"import assert from 'assert';",
"import { mountTestComponent, replay } from 'avenx-core/testing';",
);
if (options.componentPath) {
lines.push("import { loadComponent } from 'avenx-core/tooling';");
}
// A component that imports a bridge compiles to a reference the bundle
// supplies, so the test has to hand the real bridge instances in. Bridge
// modules are ordinary ES modules, so they import normally.
const bridges = options.bridges || [];
for (const entry of bridges) {
lines.push(`import ${entry.name} from ${literal(entry.path)};`);
}
lines.push(`import trace from ${literal(options.tracePath)} with { type: 'json' };`, '');
if (options.componentPath) {
const bridgeArg =
bridges.length > 0 ? `,\n { bridges: { ${bridges.map((entry) => entry.name).join(', ')} } },` : ',';
lines.push(
`const ${options.componentName || 'Component'} = loadComponent(`,
` new URL(${literal(options.componentPath)}, import.meta.url).pathname${bridgeArg}`,
');',
'',
);
} else {
lines.push(
'// The recording did not identify a single component to mount. Replace this',
'// with however your application is set up for tests.',
'// const Component = loadComponent("../src/components/…/….component.js");',
'',
);
}
lines.push(`console.log(${literal(`🧪 ${title}`)});`, '');
lines.push('let app;', '');
lines.push('const result = await replay(trace, {');
if (!deterministic) {
lines.push(' // See the note above: this trace is best-effort.');
lines.push(' allowBestEffort: true,');
}
lines.push(' async mount() {');
if (options.componentPath) {
lines.push(` app = await mountTestComponent(${options.componentName || 'Component'}, {});`);
} else {
lines.push(' app = await mountTestComponent(Component, {});');
}
lines.push(' return app;', ' },');
lines.push(' async at(step) {', ' switch (step.index) {');
for (const step of steps) {
lines.push(` // Step ${step.index + 1}: ${describe(step.input)}`);
lines.push(` case ${step.index}: {`);
for (const line of assertionsFor(step, redacted)) {
lines.push(` ${line}`);
}
lines.push(' break;', ' }');
}
lines.push(' default:', ' break;', ' }', ' },', '});', '');
lines.push(
'// replay() throws on divergence, so reaching here means every recorded state',
'// and DOM change was reproduced exactly.',
'assert.strictEqual(result.ok, true);',
);
if (deterministic) {
lines.push(
"assert.strictEqual(result.verified, true, 'a deterministic trace reproduced exactly');",
);
}
const finalWrites = steps.flatMap((step) => step.writes);
const lastWrite = finalWrites[finalWrites.length - 1];
if (lastWrite && lastWrite.path && !String(lastWrite.to).includes('[redacted]')) {
lines.push(
'',
`// Final recorded state: ${lastWrite.path} ended at ${formatCaptured(lastWrite.to)}.`,
);
}
lines.push('', 'app.unmount();', `console.log(${literal(`✅ ${title}`)});`, '');
return lines.join('\n');
}
/**
* Suggests a file name stem for an exported trace.
* @param {object} trace - The trace.
* @returns {string} A kebab-case stem such as `cart-item-click`.
*/
export function suggestName(trace) {
const nodes = trace.nodes || [];
const component = nodes.find((node) => node.component);
const input = nodes.find((node) => node.type === TraceNodeType.EVENT);
const parts = [];
if (component) {
parts.push(
String(component.component)
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase(),
);
}
if (input && input.eventType) {
parts.push(input.eventType);
}
if (parts.length === 0) {
parts.push(String(trace.id || 'trace').replace(/[^a-z0-9]+/gi, '-'));
}
return parts.join('-').replace(/-+/g, '-');
}