95. Round-trip testing the front-end
The easiest program to compile is one where there is nothing to do. Viscosity must hand it back byte for byte, and running that one check over five hundred real files found a crash nobody had written a test for.
- Define an identity transform and explain why a splicing emitter makes it a byte-for-byte check.
- Say what the repository's round-trip, differential and parser-equivalence tests each catch.
- Explain why each run in the differential test gets a fresh vm context.
- Write a small differential tester that compares printed output and errors.
Nothing to do
Most code in a real project has no arrays of same-shaped objects. Viscosity will run over all of it anyway, every time you build. For those files, the only correct output is your file, unchanged. Not reformatted, not with a comment moved, not with a stray blank line at the top.
A good proofreader handed a page with no mistakes hands it back untouched. If the page comes back with a comma moved, you stop trusting the proofreader, even on pages that did need fixing.
An identity transform is a compile that must change nothing: for an input with nothing to transform, the output equals the input. For a source-to-source compiler that splices edits, “equals” can mean byte for byte. It is the simplest round-trip test: the input is its own expected output.
Chapter 88 showed the round trip for a printer: parse, print, parse again, compare trees. The trees can match while the text differs, because a printer chooses its own layout. Viscosity’s emitter splices instead of printing (chapter 94), so it can promise more: the same bytes.
The test in the repository
It sits at the end of differential.test.ts:
it('leaves programs with nothing to ' +
'transform byte-identical', () => {
const src =
'const a = [1, 2, 3];\n' +
'// a comment\n' +
'console.log(a.length);\n';
expect(compile(src, jsPlugin()).code)
.toBe(src);
});
(The real test name is one string.) It looks almost too small to
matter. But compile runs the whole pipeline: acorn parses, the
resolver walks the scopes, the lowering builds VIR, the analyses look
at a and reject it (its elements are numbers, not object literals),
the planner decides nothing, and the emitter has no prelude, so it
returns its input. A crash anywhere, or a single stray edit, fails
the test.
The program was chosen with care. It has an array, so the collection analysis really runs; a comment, which a printer would lose; and a trailing newline, the classic thing to drop.
TypeScript is the one exception: the plugin strips types first, so a
.ts file with nothing to transform comes back as the same code
without its types.
Three checks, one front-end
The front-end package has three kinds of test, and each catches something the others can’t. Step through them:
The differential test runs each version in a brand-new vm
context. Inside it, the global object is a fresh one with its own
built-ins, and the outside globals stay unchanged[1],
so neither run can see what the other left behind. (Node warns that
vm is not a security mechanism; here it is only isolation for
testing.)
Run for this chapter, the whole package:
$ npx vitest run packages/frontend-js
✓ mini-parser.test.ts (43 tests)
✓ differential.test.ts (38 tests)
Tests 81 passed (81)
The 38 are the 36 corpus files plus two round-trip tests (the tiny
program, and the export default cases you are about to meet).
The 43 are those 36 files and 5 benchmark programs through both
parsers, plus 2 tests of the mini parser itself. One more fact,
measured for this chapter: 16 of the 36 corpus files keep every collection as an array of
objects, and their compiled code is byte-identical to the source. The
identity property holds inside the differential suite too, even
though nothing there checks it.
Five hundred real files
A test with one hand-written snippet only covers what its author
thought of. The identity check needs no expected output, so it scales
for free: any JavaScript file is a test case. For this chapter, it
was run over the .js and .mjs files (minus CommonJS builds) in the engine’s
node_modules (514 files from 61 third-party packages):
same: 497 changed: 0 failed: 17
No file was changed. That is the good news: the emitter never
touched code it had no business touching. But 17 files crashed the
compiler, every one with the same error, and every one containing
export default class. The smallest program that crashes is one
line:
export default class A {}
TypeError: Cannot read properties of
undefined (reading 'owner')
The resolver declares names from export declarations, but not from
export default class, so the lowering asks for the binding of A
and gets undefined. The repository’s corpus is about arrays and
loops; one file has an export function, none a default export, so
no existing test could see it. The fix was one line: resolve.ts
now declares names from export default declarations too, and a
round-trip test for it joined the suite. (An anonymous
export default function () {} crashed the resolver too. The Rust
port’s parity work found it later, and the same test covers it now.)
Rerun on the same files:
same: 514 changed: 0 failed: 0
The fix was small; finding the bug took a test with no expected output and a pile of other people’s code. This is the corpus idea of chapter 112, and the reason fuzzers like Csmith exist: inputs that no test author would have thought of[2].
Each test here guards a different promise. The round trip guards “don’t touch what you don’t change”. The differential test guards “change it without changing what it does”. Parser equivalence guards “the tree is the tree, whichever parser built it”. A front-end that passes only one of them can still be badly wrong, and Viscosity needs all three because it is a source-to-source compiler: its output is somebody’s code.
Your turn
Programs are strings of JavaScript that print with log(...). Write outcome(code), which runs code with new Function('log', code), and returns { out, error }: out is the list of printed lines (arguments joined with a space), and error is null, or name + ': ' + message of the uncaught error. Then write diffRun(progs, transform): return the index of the first program whose outcome differs from the outcome of transform(program), comparing every line and the error, or -1 if all agree.
function outcome(code) {
const out = [];
const log = (...a) =>
out.push(a.join(' '));
new Function('log', code)(log);
return { out, error: null };
}
function diffRun(ps, transform) {
// your code here
return -1;
}When an identity test fails, “not equal” is not much help. Write firstDiff(a, b): return null if the strings are identical, otherwise { offset, line, col } for the first position where they differ (lines and columns count from 1). If one string is a prefix of the other, they differ at the end of the shorter one.
function firstDiff(a, b) {
if (a === b) return null;
return { offset: 0, line: 1, col: 1 };
}What you learned
- An identity transform must change nothing. For Viscosity it is checked byte for byte, because the emitter splices instead of printing.
- The repository’s round trip runs the whole pipeline on a tiny program with an array, a comment and a trailing newline.
- The front-end has three checks: the round trip, the
differential corpus (fresh
vmcontexts, output and errors), and acorn against the mini parser. 75 tests pass. - The identity check needs no expected output, so it scales to any
code. On 514 real files it found a crash on
export default class, since fixed: all 514 now come back byte-identical.
Check your understanding
0/6- 1.
Which output passes Viscosity’s round-trip test for
const a = [1, 2, 3];\n// a comment\nconsole.log(a.length);\n? - 2.
Why is the round trip a stronger check for Viscosity than parse → print → parse?
- 3.
The snippet has no array of objects. Does the collection analysis still run on it?
- 4.
What does a fresh
vmcontext per run give the differential test? - 5.
What did the first round trip over 514 third-party files show?
- 6.
A transform turns
ps[100].x(which throws) into code that prints0. Which test catches it?
References
- [1] Node.js contributors. VM (executing JavaScript), Node.js documentation. nodejs.org. nodejs.org/api/vm.html (checked 2026-10-31)
- [2] Xuejun Yang, Yang Chen, Eric Eide, John Regehr. Csmith: a random generator of C programs. github.com/csmith-project/csmith (README). github.com/csmith-project/csmith (checked 2026-10-31)