Skip to content

Instantly share code, notes, and snippets.

@BLamy
Last active May 1, 2025 06:36
Show Gist options
  • Save BLamy/94e813e22d39764ceab8035fcee901b6 to your computer and use it in GitHub Desktop.
Save BLamy/94e813e22d39764ceab8035fcee901b6 to your computer and use it in GitHub Desktop.
export default function debuggerInstrumentation(babel) {
const { types: t } = babel;
/* -------------------------------------------------- *
* Helpers *
* -------------------------------------------------- */
const DEFAULT_MAX_VARS = 10;
/** Build { get a() { … }, … } — TDZ-safe getters */
function buildVarsObjectAst(names, max) {
const props = [];
const validNames = Array.from(names || []).filter(n => typeof n === 'string');
const sorted = validNames.sort();
for (let i = 0; i < sorted.length && i < max; i++) {
const name = sorted[i];
// Keep arguments if present in the names set
// if (name === 'arguments') continue; // Removed this line to allow capturing 'arguments'
props.push(
t.objectMethod(
'get',
t.identifier(name),
[],
t.blockStatement([
t.tryStatement(
t.blockStatement([t.returnStatement(t.identifier(name))]),
t.catchClause(
t.identifier('e'),
t.blockStatement([t.returnStatement(t.identifier('undefined'))])
)
),
]),
false, false, false, false // Static=false, generator=false, async=false, computed=false
// Note: We don't explicitly mark these generated getters,
// rely on skip logic in Function visitor based on parent structure/markers.
)
);
}
return t.objectExpression(props);
}
/** Sanitize string for use in file paths */
function sanitizeForPath(str) {
if (typeof str !== 'string') return '_invalid_';
// Replace common problematic characters with underscores
// Added '.' to the regex to avoid issues with file extensions in names
return str.replace(/[\s\\/?:*|"<>.]/g, '_').replace(/_+/g, '_'); // Collapse multiple underscores
}
/** Generate runtime stub AST for file system logging */
function createRuntimeStubAst() {
// --- AST for code to be generated --- (comments kept for clarity)
/*
if (!globalThis.__recordStep) {
const fs = require('fs');
const path = require('path');
// Function to ensure directory exists (avoids race conditions)
const ensureDirSync = (dirPath) => {
const fullPath = path.join(process.cwd(), '.timetravel', dirPath);
try {
fs.mkdirSync(fullPath, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') {
console.error(`[TimeTravelPlugin] Error creating directory ${fullPath}:`, err);
throw err; // Rethrow other errors
}
}
return fullPath; // Return the created/existing full path
};
let stepNumber = 0;
globalThis.__recordStep = (f, l, v, sName, tName) => {
stepNumber++; // Increment step number first
const clonedVars = {};
try {
// Use Object.getOwnPropertyNames for potentially non-enumerable properties
for (const k of Object.getOwnPropertyNames(v)) {
// Access via getter to handle potential TDZ or errors gracefully
try {
clonedVars[k] = v[k];
} catch (_getterErr) {
clonedVars[k] = undefined; // Or some indicator of error
}
}
} catch (_cloneErr) {
console.error("[TimeTravelPlugin] Error cloning variables for step:", _cloneErr);
}
const stepData = {
stepNumber: stepNumber, // Use the incremented step number
file: f,
line: l,
vars: clonedVars,
ts: Date.now(),
suite: sName,
test: tName
};
let filePathForError = 'unknown path'; // Initialize for catch block
try {
// Construct path: [process.cwd()]/.timetravel/sanitized_suite/sanitized_test/stepNumber.json
const sanitizedTestName = tName.replace(/[\s\\/?:*|"<>.]/g, '_').replace(/_+/g, '_');
const dirPath = path.join(sName, sanitizedTestName);
const fullDirPath = ensureDirSync(dirPath); // Returns full path with process.cwd()/.timetravel
const filePath = path.join(fullDirPath, `${stepNumber}.json`); // Use stepNumber for filename
filePathForError = filePath; // Update for potential use in catch
// Write the file synchronously
fs.writeFileSync(filePath, JSON.stringify(stepData, null, 2)); // Pretty print JSON
} catch (writeErr) {
console.error(`[TimeTravelPlugin] Error writing step file ${filePathForError}:`, writeErr);
// Decide if you want to swallow the error or stop execution
}
};
// Mark the function itself to avoid instrumenting *its* creation
globalThis.__recordStep._generated_by_plugin_ = true;
}
*/
// --- Build the AST ---
// require('fs')
const requireFs_ = t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('fs'),
t.callExpression(t.identifier('require'), [t.stringLiteral('fs')])
),
]);
// require('path')
const requirePath_ = t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('path'),
t.callExpression(t.identifier('require'), [t.stringLiteral('path')])
),
]);
// ensureDirSync function AST
const ensureDirSyncFunc_ = t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('ensureDirSync'),
t.arrowFunctionExpression(
[t.identifier('dirPath')], // param
t.blockStatement([ // body
// Use process.cwd() as base directory instead of absolute paths
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('fullPath'),
t.callExpression(
t.memberExpression(t.identifier('path'), t.identifier('join')),
[
t.callExpression(
t.memberExpression(t.identifier('process'), t.identifier('cwd')),
[]
),
t.stringLiteral('.timetravel'),
t.identifier('dirPath')
]
)
)
]),
t.tryStatement(
// try block
t.blockStatement([
t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('fs'), t.identifier('mkdirSync')),
[
t.identifier('fullPath'),
t.objectExpression([
t.objectProperty(t.identifier('recursive'), t.booleanLiteral(true))
])
]
)
)
]),
// catch block
t.catchClause(
t.identifier('err'), // catch param
t.blockStatement([ // catch body
t.ifStatement(
t.binaryExpression('!==',
t.memberExpression(t.identifier('err'), t.identifier('code')),
t.stringLiteral('EEXIST')
),
t.blockStatement([ // if body
t.expressionStatement(t.callExpression( // console.error(...)
t.memberExpression(t.identifier('console'), t.identifier('error')),
[
t.templateLiteral(
[
t.templateElement({ raw: '[TimeTravelPlugin] Error creating directory '}),
t.templateElement({ raw: ':'}, true) // tail=true
],
[t.identifier('fullPath')] // expression placeholder
),
t.identifier('err')
]
)),
t.throwStatement(t.identifier('err')) // Rethrow
])
// no else block
)
])
)
// no finally block
),
// Return the full path so it can be used later
t.returnStatement(t.identifier('fullPath'))
])
)
)
]);
// Body of the __recordStep function AST
const recordStepBody_ = t.blockStatement([
// ++stepNumber; (Prefix increment)
t.expressionStatement(
t.updateExpression('++', t.identifier('stepNumber'), /* prefix */ true)
),
// const clonedVars = {};
t.variableDeclaration('const', [
t.variableDeclarator(t.identifier('clonedVars'), t.objectExpression([])),
]),
// try { for (...) { try { clonedVars[k] = v[k]; } catch {}}} catch {}
t.tryStatement(
t.blockStatement([
t.forOfStatement(
t.variableDeclaration('const', [t.variableDeclarator(t.identifier('k'))]),
t.callExpression(
t.memberExpression(t.identifier('Object'), t.identifier('getOwnPropertyNames')),
[t.identifier('v')]
),
// Inner try-catch for getter access
t.blockStatement([
t.tryStatement(
t.blockStatement([ // try: clonedVars[k] = v[k]
t.expressionStatement(
t.assignmentExpression('=',
t.memberExpression(t.identifier('clonedVars'), t.identifier('k'), /* computed */ true),
t.memberExpression(t.identifier('v'), t.identifier('k'), /* computed */ true)
)
)
]),
t.catchClause( // catch(_getterErr) { clonedVars[k] = undefined }
t.identifier('_getterErr'),
t.blockStatement([
t.expressionStatement(
t.assignmentExpression('=',
t.memberExpression(t.identifier('clonedVars'), t.identifier('k'), /* computed */ true),
t.identifier('undefined')
)
)
])
)
)
])
)
]),
t.catchClause(t.identifier('_cloneErr'), t.blockStatement([
t.expressionStatement(t.callExpression( // console.error(...)
t.memberExpression(t.identifier('console'), t.identifier('error')),
[ t.stringLiteral('[TimeTravelPlugin] Error cloning variables for step:'), t.identifier('_cloneErr') ]
))
]))
),
// const stepData = { ... };
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('stepData'),
t.objectExpression([
t.objectProperty(t.identifier('stepNumber'), t.identifier('stepNumber')), // Use updated stepNumber
t.objectProperty(t.identifier('file'), t.identifier('f')),
t.objectProperty(t.identifier('line'), t.identifier('l')),
t.objectProperty(t.identifier('vars'), t.identifier('clonedVars')),
t.objectProperty(
t.identifier('ts'),
t.callExpression(t.memberExpression(t.identifier('Date'), t.identifier('now')), [])
),
// Add suite and test names
t.objectProperty(t.identifier('suite'), t.identifier('sName')),
t.objectProperty(t.identifier('test'), t.identifier('tName')),
])
),
]),
// Define filePathForError outside the try block so it's accessible in the catch block
t.variableDeclaration('let', [
t.variableDeclarator(t.identifier('filePathForError'), t.stringLiteral('unknown path'))
]),
// try { ... fs operations ... } catch { ... }
t.tryStatement(
// try block
t.blockStatement([
// const sanitizedTestName = tName.replace(...)
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('sanitizedTestName'),
t.callExpression(
t.memberExpression(
t.callExpression( // tName.replace(/[\s\\/?:*|"<>.]/g, '_') <-- added '.'
t.memberExpression(t.identifier('tName'), t.identifier('replace')),
[t.regExpLiteral('[\\s\\\\/?:\*\|"<>.]', 'g'), t.stringLiteral('_')] // Escaped regex
),
t.identifier('replace')
),
[t.regExpLiteral('_+', 'g'), t.stringLiteral('_')] // .replace(/_+/g, '_')
)
)
]),
// const dirPath = path.join(sName, sanitizedTestName);
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('dirPath'),
t.callExpression(
t.memberExpression(t.identifier('path'), t.identifier('join')),
[t.identifier('sName'), t.identifier('sanitizedTestName')] // Use sanitized name
)
)
]),
// const fullDirPath = ensureDirSync(dirPath);
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('fullDirPath'),
t.callExpression(t.identifier('ensureDirSync'), [t.identifier('dirPath')])
)
]),
// const filePath = path.join(fullDirPath, `${stepNumber}.json`); <-- Use stepNumber
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier('filePath'),
t.callExpression(
t.memberExpression(t.identifier('path'), t.identifier('join')),
[
t.identifier('fullDirPath'),
t.templateLiteral( // `${stepNumber}.json`
[ t.templateElement({ raw: ''}), t.templateElement({ raw: '.json'}, true) ], // quasis
[ t.identifier('stepNumber') ] // expression placeholder changed to stepNumber
)
]
)
)
]),
// Update filePathForError for use in catch block if needed
t.expressionStatement(
t.assignmentExpression('=', t.identifier('filePathForError'), t.identifier('filePath'))
),
// fs.writeFileSync(filePath, JSON.stringify(stepData, null, 2));
t.expressionStatement(
t.callExpression(
t.memberExpression(t.identifier('fs'), t.identifier('writeFileSync')),
[
t.identifier('filePath'),
t.callExpression( // JSON.stringify(stepData, null, 2)
t.memberExpression(t.identifier('JSON'), t.identifier('stringify')),
[t.identifier('stepData'), t.nullLiteral(), t.numericLiteral(2)]
)
]
)
)
]),
// catch(writeErr) { ... }
t.catchClause(
t.identifier('writeErr'),
t.blockStatement([
t.expressionStatement(t.callExpression( // console.error(...)
t.memberExpression(t.identifier('console'), t.identifier('error')),
[
t.templateLiteral(
[
t.templateElement({ raw: '[TimeTravelPlugin] Error writing step file ' }),
t.templateElement({ raw: ':' }, true)
],
[t.identifier('filePathForError')] // Use the potentially updated path
),
t.identifier('writeErr')
]
))
])
)
) // end try-catch for file writing
]);
// globalThis.__recordStep = (f, l, v, sName, tName) => { ... recordStepBody ... };
const assignFn_ = t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(t.identifier('globalThis'), t.identifier('__recordStep')),
t.arrowFunctionExpression(
[ // Params: f, l, v, sName, tName
t.identifier('f'), t.identifier('l'), t.identifier('v'),
t.identifier('sName'), t.identifier('tName')
],
recordStepBody_ // Body AST defined above
)
)
);
// Add marker to the assignment expression's node itself to prevent instrumentation later
assignFn_.expression.right._generated_by_plugin_ = true; // Mark the ArrowFunctionExpression
// if (!globalThis.__recordStep) { requireFs; requirePath; ensureDirSyncFunc; assignFn; }
const ifStmt = t.ifStatement(
t.unaryExpression('!', t.memberExpression(t.identifier('globalThis'), t.identifier('__recordStep'))),
// Consequent block
t.blockStatement([
requireFs_,
requirePath_,
ensureDirSyncFunc_,
// Add: let stepNumber = 0;
t.variableDeclaration('let', [
t.variableDeclarator(t.identifier('stepNumber'), t.numericLiteral(0))
]),
assignFn_ // Assign the function
])
// No Alternate
);
ifStmt._generated_by_babel_plugin_time_travel_stub = true; // Mark the IfStatement itself
return [ifStmt];
}
/* -------------------------------------------------- *
* Nodes we never instrument *
* -------------------------------------------------- */
const STATEMENTS_TO_SKIP = new Set([
'FunctionDeclaration', 'ClassDeclaration', 'IfStatement', 'SwitchStatement',
'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ForInStatement',
'ForOfStatement', 'TryStatement', 'CatchClause', 'LabeledStatement',
'ReturnStatement', 'BreakStatement', 'ContinueStatement', 'ThrowStatement',
'DebuggerStatement', 'EmptyStatement', 'BlockStatement', 'WithStatement',
'SwitchCase',
'ImportDeclaration', 'ExportNamedDeclaration', 'ExportDefaultDeclaration', 'ExportAllDeclaration',
// Be careful with these, rely on the parent marker or specific checks
// 'VariableDeclaration', // Allow for potential instrumentation *after* declaration
// 'ExpressionStatement', // Allow for instrumentation *after* expression
]);
// Helper to create the recorder call expression statement
function createRecorderStatement(path, state, lineOverride = null, varNamesOverride = null) {
// Should instrumentation be skipped for this context?
if (path.node?._is_recorder_call || path.findParent(p => p.node?._generated_by_babel_plugin_time_travel_stub || p.node?._generated_by_plugin_)) {
return null; // Skip instrumentation
}
// Ensure path.node exists and has loc
if (!path?.node?.loc) {
// console.warn(`[timeTravelPlugin] Skipping instrumentation: Node or location missing.`); // Optional warning
return null;
}
const line = lineOverride ?? path.node.loc.start.line;
const file = state.file.opts.filename || 'unknown';
const suiteName = sanitizeForPath(state.opts?.suiteName || 'DefaultSuite');
const maxVars = state.opts?.maxVars ?? DEFAULT_MAX_VARS;
if (maxVars <= 0) return null; // No variables to capture
let allNames;
if (varNamesOverride) {
allNames = varNamesOverride;
} else {
// Collect bindings and referenced identifiers for standard statements
const bindingMap = path.scope?.getAllBindings?.() ?? {};
allNames = new Set(Object.keys(bindingMap));
try {
// Only traverse if path.traverse exists and is a function
if (typeof path.traverse === 'function') {
path.traverse({
Identifier(idPath) {
// Don't traverse into nested scopes defined within this statement
if (idPath.scope.parent !== path.scope && idPath.scope !== path.scope) {
idPath.skip();
return;
}
const { node, parent } = idPath;
const name = node.name;
if ( allNames.has(name) || name === 'undefined') return;
if (!idPath.isReferencedIdentifier()) return;
if (
((t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && parent.property === node && !parent.computed) ||
((t.isObjectProperty(parent) || t.isClassProperty(parent) || t.isObjectMethod(parent) || t.isClassMethod(parent)) && parent.key === node && !parent.computed) ||
(t.isLabeledStatement(parent) && parent.label === node) ||
(t.isBreakStatement(parent) && parent.label === node) ||
(t.isContinueStatement(parent) && parent.label === node)
) { return; }
allNames.add(name);
},
});
} else {
// console.warn(`[timeTravelPlugin] path.traverse not available for node type ${path.node.type} at ${file}:${line}. Variable collection might be incomplete.`);
}
} catch (traversalError) {
console.error(`[timeTravelPlugin] Error traversing identifiers for ${file}:${line}:`, traversalError);
return null; // Prevent proceeding with potentially incorrect vars
}
}
const varsObjectAst = buildVarsObjectAst(allNames, maxVars);
// *** MODIFIED CHECK ***
// Ensure varsObjectAst is not just any node, but specifically an Expression,
// as it needs to be an argument to t.callExpression.
if (!varsObjectAst || !t.isExpression(varsObjectAst)) {
console.error(`[timeTravelPlugin] Failed to build a valid vars object *expression* for ${file}:${line}. Got:`, varsObjectAst);
return null; // Return null if it's not a valid expression
}
let recorderCall;
try {
// Call __recordStep(file, line, varsObj, suiteName, testName)
recorderCall = t.callExpression(
t.identifier('__recordStep'),
[
t.stringLiteral(file),
t.numericLiteral(line),
varsObjectAst, // Now we're more confident this is a valid Expression
t.stringLiteral(suiteName),
t.logicalExpression( // globalThis.__testName ?? 'UnknownTest'
'??',
t.memberExpression(t.identifier('globalThis'), t.identifier('__testName')),
t.stringLiteral('UnknownTest')
)
]
);
} catch (callExprError) {
// Catch errors during t.callExpression creation itself
console.error(`[timeTravelPlugin] Error creating recorder CallExpression for ${file}:${line}:`, callExprError);
return null;
}
// If recorderCall is somehow still not valid (highly unlikely now), prevent the error
if (!recorderCall || !t.isExpression(recorderCall)) {
console.error(`[timeTravelPlugin] Internal error: recorderCall is not a valid expression for ${file}:${line}.`);
return null;
}
// This line should now be safe
const recorderStatement = t.expressionStatement(recorderCall);
recorderStatement._is_recorder_call = true; // Mark the statement itself
return recorderStatement;
}
/* -------------------------------------------------- *
* Plugin visitor *
* -------------------------------------------------- */
return {
name: 'time-travel-instrumentation-fs',
visitor: {
Program: {
enter(path, state) {
// --- Stub injection logic ---
let alreadyInjected = false;
path.get('body').forEach(statementPath => {
if (statementPath.isIfStatement() && statementPath.node?._generated_by_babel_plugin_time_travel_stub) {
alreadyInjected = true;
} else if (statementPath.isIfStatement()) { // Fallback check
const test = statementPath.get('test');
if (test.isUnaryExpression({ operator: '!' })) {
const argument = test.get('argument');
if (argument.isMemberExpression() &&
argument.get('object').isIdentifier({ name: 'globalThis' }) &&
argument.get('property').isIdentifier({ name: '__recordStep' }))
{ alreadyInjected = true; }
}
}
});
if (!alreadyInjected) {
try {
const stubNodes = createRuntimeStubAst();
if (Array.isArray(stubNodes) && stubNodes.length > 0 && stubNodes[0]) {
path.unshiftContainer('body', stubNodes);
} else {
console.error("[timeTravelPlugin] Internal error: createRuntimeStubAst did not return a valid node array.");
}
} catch (stubError) {
console.error("[timeTravelPlugin] Error creating or injecting runtime stub:", stubError);
}
}
// --- Initialize __testName ---
try {
const initTestNameAssignment = t.assignmentExpression(
'=',
t.memberExpression(t.identifier('globalThis'), t.identifier('__testName')),
t.logicalExpression( // globalThis.__testName = globalThis.__testName ?? 'NoTestContext'
'??',
t.memberExpression(t.identifier('globalThis'), t.identifier('__testName')),
t.stringLiteral('NoTestContext')
)
);
// Check if the expression statement already exists at the top to prevent duplicates
const firstNode = path.node.body[0];
let shouldInjectInit = true;
// Check if the first node is the `if(!globalThis.__recordStep)` stub block
if (firstNode && firstNode._generated_by_babel_plugin_time_travel_stub) {
// If stub exists, check the node *after* the stub
const secondNode = path.node.body[1];
if (secondNode && t.isExpressionStatement(secondNode) && secondNode._generated_by_plugin_) {
shouldInjectInit = false; // Assume it's our initializer
}
} else if (firstNode && t.isExpressionStatement(firstNode) && firstNode._generated_by_plugin_) {
// If no stub, check the first node directly
shouldInjectInit = false;
}
if (shouldInjectInit) {
const initTestNameStmt = t.expressionStatement(initTestNameAssignment);
initTestNameStmt._generated_by_plugin_ = true; // Mark as generated
// Inject *after* the runtime stub if it exists, otherwise at the top
if (!alreadyInjected) { // If stub was just injected or already existed
path.insertAfter(initTestNameStmt); // Insert after the stub
} else {
path.unshiftContainer('body', initTestNameStmt); // Insert at the very top if no stub
}
}
} catch (initError) {
console.error("[timeTravelPlugin] Error creating or injecting __testName initializer:", initError);
}
},
},
// Visitor for instrumenting *after* executable statements
Statement: {
enter(path, state) {
// Skip conditions checked within createRecorderStatement now
// Skip nodes specifically handled by the Function visitor or structure nodes
if (t.isFunctionDeclaration(path.node) || t.isClassDeclaration(path.node) ||
STATEMENTS_TO_SKIP.has(path.node.type)) {
return;
}
// Skip ExpressionStatements containing only function/class definitions
if (t.isExpressionStatement(path.node) &&
(t.isFunctionExpression(path.node.expression) ||
t.isArrowFunctionExpression(path.node.expression) ||
t.isClassExpression(path.node.expression))) {
return;
}
// Build the recorder call statement using the helper
const recorderStatement = createRecorderStatement(path, state);
if (!recorderStatement) return; // Skip if helper decided not to instrument
/* Insert AFTER current statement */
if (path.isStatement() && path.node.loc && (path.parentPath.isBlockStatement() || path.parentPath.isProgram())) {
try {
// Check if the *next* sibling is already a recorder call. Avoid double insertion.
const nextSibling = path.getSibling(path.key + 1);
if (!nextSibling.node || !nextSibling.node._is_recorder_call) {
path.insertAfter(recorderStatement);
}
} catch (e) {
const file = state.file.opts.filename || 'unknown';
const line = path.node.loc?.start?.line || '?';
console.error(`[timeTravelPlugin] Error inserting recorder call after ${file}:${line}: ${e.message}`);
}
} else {
// Attempt to wrap if not in a block (e.g., single statement in if/loop)
if (path.isStatement() && !path.isBlockStatement() && (path.parentPath.isIfStatement() || path.parentPath.isLoop() || path.parentPath.isWithStatement())) {
if (!t.isBlockStatement(path.node)) { // Avoid wrapping an already-wrapped block
try {
const block = t.blockStatement([path.node, recorderStatement]);
path.replaceWith(block);
} catch (e) {
const file = state.file.opts.filename || 'unknown';
const line = path.node.loc?.start?.line || '?';
console.error(`[timeTravelPlugin] Error wrapping statement in block for ${file}:${line}: ${e.message}`);
}
}
// If it's already a block (e.g., due to Function visitor), the first `if` condition above handles insertion.
}
}
},
},
// Visitor for instrumenting Function Entry
Function(path, state) {
try { // Wrap function visitor logic in try/catch
// Skip functions generated by this plugin
if (path.node._generated_by_plugin_ || path.findParent(p => p.node?._generated_by_babel_plugin_time_travel_stub || p.node?._generated_by_plugin_)) {
return;
}
// Skip getter/setter methods potentially created by buildVarsObjectAst
if (path.isObjectMethod() && (path.node.kind === 'get' || path.node.kind === 'set')) {
const parentObject = path.parentPath;
const grandParentObject = parentObject?.parentPath;
if (parentObject?.isObjectProperty() && grandParentObject?.isObjectExpression()) {
// Likely one of our generated getters/setters, check if grandparent is argument to __recordStep? Could be complex.
// Let's rely on the _generated_by_plugin_ check for now, assuming buildVarsObjectAst doesn't add it.
// If issues persist, add marker in buildVarsObjectAst.
}
}
// Skip functions without a body or location
if (!path.node.body || !path.node.loc) {
return;
}
// --- Ensure body is a BlockStatement ---
let bodyPath = path.get('body');
if (!bodyPath.isBlockStatement()) {
const node = path.node;
// Handle implicit return for arrow functions
if (t.isExpression(node.body)) {
bodyPath.replaceWith(t.blockStatement([t.returnStatement(node.body)]));
bodyPath = path.get('body'); // Re-fetch path after replacement
} else {
// If body is not an expression and not a block, something is wrong.
console.error(`[timeTravelPlugin] Function body is neither BlockStatement nor Expression at ${state.file.opts.filename || 'unknown'}:${path.node.loc.start.line}. Skipping entry instrumentation.`);
return;
}
}
// --- Create the recorder call for function entry ---
const varNames = new Set(Object.keys(path.scope?.getAllBindings?.() ?? {}));
if (!path.isArrowFunctionExpression()) {
varNames.add('arguments');
}
// Consider adding 'this' if needed, carefully. For now, omitted.
const functionStartLine = path.node.loc.start.line;
const recorderStatement = createRecorderStatement(path, state, functionStartLine, varNames);
if (!recorderStatement) return; // Skip if helper decided not to instrument
// --- Insert recorder call at the beginning of the body ---
// Check if the first statement is *already* our recorder call
const firstStmt = bodyPath.get('body.0');
if (!firstStmt?.node?._is_recorder_call) {
bodyPath.unshiftContainer('body', recorderStatement);
}
} catch (e) {
const file = state.file.opts.filename || 'unknown';
const line = path.node?.loc?.start?.line || '?';
console.error(`[timeTravelPlugin] Error processing Function visitor near ${file}:${line}: ${e.message}`, e);
}
},
// Visitor for setting test name from 'it'/'test' calls
CallExpression(path, state) {
try { // Wrap call expression visitor logic
const callee = path.get('callee');
if (callee.isIdentifier({ name: 'it' }) || callee.isIdentifier({ name: 'test' })) {
const args = path.get('arguments');
if (args.length > 0 && args[0].isStringLiteral()) {
const testName = args[0].node.value;
const testFunctionPath = args[args.length - 1];
if (testFunctionPath && (testFunctionPath.isFunctionExpression() || testFunctionPath.isArrowFunctionExpression())) {
// Check if function path has a valid body property
if (!testFunctionPath.node.body) return;
const assignmentAst = t.expressionStatement(
t.assignmentExpression(
'=',
t.memberExpression(t.identifier('globalThis'), t.identifier('__testName')),
t.stringLiteral(testName)
)
);
assignmentAst._generated_by_plugin_ = true;
const bodyPath = testFunctionPath.get('body');
if (bodyPath.isBlockStatement()) {
const firstStmt = bodyPath.get('body.0');
if (!firstStmt?.node?._generated_by_plugin_) { // Check marker
bodyPath.unshiftContainer('body', assignmentAst);
}
} else if (t.isExpression(testFunctionPath.node.body)) { // Handle implicit return
const currentBody = testFunctionPath.node.body;
const newBody = t.blockStatement([
assignmentAst,
t.returnStatement(currentBody)
]);
testFunctionPath.node.body = newBody; // Replace the body node directly
}
}
}
}
} catch (e) {
const file = state.file.opts.filename || 'unknown';
const line = path.node?.loc?.start?.line || '?';
console.error(`[timeTravelPlugin] Error processing CallExpression visitor near ${file}:${line}: ${e.message}`, e);
}
},
},
};
}

Understanding a Babel Time Travel Debugging Plugin

This Babel plugin implements a sophisticated time-travel debugging system by instrumenting code to capture variables at specific execution points. Let's explore how it works and what makes it powerful.

What Does This Plugin Do?

The plugin tracks program execution by:

  1. Capturing variable state at key points in the code
  2. Recording this data to JSON files for later playback
  3. Creating a "time-travel" experience for debugging

Core Components Breakdown

1. The Runtime Stub

The plugin injects a global __recordStep function that:

if (!globalThis.__recordStep) {
  const fs = require('fs');
  const path = require('path');
  
  // Sets up directory management
  const ensureDirSync = (dirPath) => {
    // Creates directory structure
  };

  let stepNumber = 0;
  globalThis.__recordStep = (file, line, vars, suiteName, testName) => {
    stepNumber++;
    // Captures variables safely
    // Writes state to JSON file in .timetravel/[suite]/[test]/[step].json
  };
}

2. Variable Capture

The buildVarsObjectAst() function creates TDZ-safe getters:

// Builds a special object with getters for each variable
// { get varName() { try { return varName } catch { return undefined } } }

This safely captures all in-scope variables without throwing errors for uninitialized or inaccessible variables.

3. Strategic Instrumentation

The plugin adds recorder calls at key points:

// After meaningful statements
someStatement();
__recordStep("file.js", 42, { get x() {...}, get y() {...} }, "TestSuite", "TestName");

// At function entries
function myFunc() {
  __recordStep("file.js", 10, { get arguments() {...}, get param1() {...} }, "TestSuite", "TestName");
  // function body...
}

4. Test Context Tracking

It automatically detects test frameworks:

it("should do something", () => {
  globalThis.__testName = "should do something"; // Injected by plugin
  // test code...
});

How the AST Manipulation Works

The plugin's most complex part is AST manipulation:

  1. createRuntimeStubAst() - Builds the AST for the __recordStep function
  2. createRecorderStatement() - Creates AST nodes for recorder calls
  3. Visitor patterns - Handle different code structures:
    • Program visitor - Injects the runtime stub
    • Statement visitor - Instruments after statements
    • Function visitor - Instruments function entries
    • CallExpression visitor - Detects test functions

The File Output Structure

.timetravel/
├── TestSuite1/
│   ├── TestCase1/
│   │   ├── 1.json
│   │   ├── 2.json
│   │   └── ...
│   └── TestCase2/
│       └── ...
└── TestSuite2/
    └── ...

Each JSON file contains:

  • stepNumber - Sequential execution step
  • file & line - Source location
  • vars - Current variable values
  • ts - Timestamp
  • suite & test - Test context

Error Handling and Edge Cases

The plugin includes extensive error handling for:

  • Invalid AST structures
  • Missing file locations
  • TDZ variable access
  • File system errors
  • Generated code detection (to prevent infinite loops)

Practical Applications

This is ideal for:

  1. Debugging complex async code - See state changes over time
  2. Test case analysis - Understand what happened during test execution
  3. Educational tools - Show code execution step by step
  4. Reproducing race conditions - Capture state sequence for hard-to-reproduce bugs

How It Differs from Standard Debuggers

Unlike traditional debuggers:

  • Records the entire execution without stopping
  • Captures test context automatically
  • Provides a complete "time travel" experience
  • Creates persistent execution records for later analysis

This plugin demonstrates the power of Babel for more than just syntax transformation - it can be used for sophisticated development tools that enhance the debugging experience beyond what browsers natively provide.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment