← Blog

The GUI and the code are the same program: round-tripping a visual editor through Playwright scripts

playwrightastcompilerstypescript

Most visual automation tools have an Export Code button. It is usually a goodbye.

You click it, you get a file, and the relationship ends there. The code is a souvenir of a flow that actually lives somewhere else, in a format only the tool understands. Edit the file and the tool has no idea. Edit the flow and your file is a fossil.

Orchestra has that button too. It also has another one: Load visual. Change the code, click it, and the flow rearranges itself to match — steps appear, loops nest, conditions fold back up. The code isn't an export of your flow. It's the same thing, seen from the other side.

This was the feature I was least confident I could actually finish, so here's how it works and which bits fought back.

One flow, two windows

Say you build three steps by clicking around: go to $store, loop over every .product on the page, and inside the loop grab the heading, click Add (twice, but only if we got a name), and print it.

Open the code window and you get this. It's the real output, I've only cut two comment lines off the top:

const { chromium } = require('playwright')

const inputs = {
  "store": "https://example.com"
}

async function run() {
  const browser = await chromium.launch({ headless: false })
  const context = await browser.newContext()
  let page = await context.newPage()

  await page.goto(inputs["store"], { timeout: 30000 })
  for (const _el of await page.locator(".product").all()) { const _savedPage = page; page = _el
    inputs["name"] = (await page.locator("h3").first().textContent())?.trim() ?? null
    if (inputs.name) {
      for (let _i = 0; _i < 2; _i++) {
        await page.locator(".add").first().click()
      }
    }
    console.log("Product", inputs["name"])
  page = _savedPage; }

  await browser.close()
}

run().catch(console.error)

Now the fun part. Tweak that code, change .add to .buy, bump the 2 to a 5, wrap something in a try/catch, hit Load visual. The steps come back. The loop still knows it's iterating over elements rather than a list, and the click still shows its little "Run if" and "×2" badges, because that if (inputs.name) and that for (let _i = 0; ...) wrapper get folded back into the modifiers they came from.

You can also paste in a Playwright script you wrote years ago and watch it turn into a flow. It usually works better than it has any right to.

The rule we hold ourselves to

Round-tripping is easy to demo and miserable to guarantee. So the whole thing rests on one line that runs in our test suite over and over:

expect(regen(workspace)).toBe(genFullScript(workspace))

In English: generate code, parse it back into steps, generate code from those steps, and the two files must be byte-identical. Not "equivalent". Not "close enough". Identical. A fixed point.

That's a brutal little assertion, and it's the reason the feature is trustworthy instead of a party trick. It fails on a stray space. It fails if a parameter quietly evaporates. Right now 104 round-trip tests hold that line — one for basically every instrument and every shape it can take, from screenshot to nested each inside condition inside tryCatch.

Every step reads its own handwriting

The trick that keeps this maintainable: each instrument is defined in exactly one file, and that file carries both directions. Here's Click, trimmed for the blog:

genCode(p) {
  return `await page.locator(${jsStr(p.selector)}).first().click()`
},
parseLine(line) {
  const m = line.match(/^await page\.locator\((".*")\)\.first\(\)\.click\(\)$/)
  return m ? { selector: jsStr(m[1]) } : null
}

Write the code, read the code. Same file, same author, same afternoon.

There's a test that keeps us honest about this. It invents a completely fake instrument called Beep, whose entire job is to emit await page.beep("..."), registers it at runtime, and checks it survives a full round trip. If adding an instrument ever required touching the parser itself, that test would go red. The parser doesn't know what Click is. It just knows how to ask.

Nobody types the way a generator does

Here's where it got interesting. Our generator emits one exact style. Humans emit... whatever. Single quotes. Line breaks in the middle of a chain. Parentheses that a formatter added. Semicolons, or the absence of a religious position on them.

Matching all that with regexes would have taken until roughly the heat death of the universe. So instead each statement gets parsed into a syntax tree and printed back out in one canonical style, and then the instruments get asked.. Your await page.locator('#go').first().click() and our double-quoted version become the same string, and Click claims it.

The canonicalizer is where the real dragons were. Printing an expression back out as text sounds easy and mostly is, right up until parentheses. You're re-deciding every one of them, and when you get it wrong nothing throws, the script just quietly computes something else. There's a comment sitting in that file purely so that nobody (me, later) undoes the fix:

** is the only right-associative operator in the language, so at equal precedence it's the left operand that needs the parens, the opposite of every other operator. Get it backwards and nothing complains, the numbers just change: (2 ** 3) ** 2 is 64, 2 ** 3 ** 2 is 512.

Nobody automating a checkout page is exponentiating anything. It's still wrong, and wrong things spread.

Putting the sugar back

One more nicety. You typed $store into the Navigate box; the code says inputs["store"]. On the way back, the printer re-sugars it — inputs["store"] becomes "$store" again, so the field shows what you actually typed instead of the machinery underneath. Same for `Hello ${inputs["name"]}`"Hello $name". It's a small thing that makes the round trip feel like it remembers you.

When it doesn't recognize a line, it shrugs

Obviously you can write Playwright that maps to no instrument at all. Some gnarly page.evaluate with a closure and a reduce in it. That's fine, it becomes a Script step holding your exact code, verbatim, and it runs verbatim.

This is the part that quietly makes the whole feature safe: the parser is not allowed to be clever at your expense. The canonicalizer's contract is never throw, it returns null when it can't print something, and null means "keep the original text". Worst case, your flow has a step that looks like a little code block. Nothing is ever silently lost in translation, because when translation is uncertain, we don't translate.

The bug that taught us about names

A favorite. Cues, the background watchers that sit there thinking "if a cookie banner shows up, dismiss it", compile into a small pile of support machinery with generated variable names. We named those variables after the cue's internal id. Very sensible.

Except ids are minted fresh every time you parse a script. So: export, load, export again, same flow, same behavior, completely different variable names, and a git diff full of noise. The fixed-point test caught it immediately, which is exactly what it's for.

The fix was to name them by position in the flow instead of by identity: _cue_c0, _cue_c1. Same tree, same order, same names, forever. Turns out "stable across a round trip" is a real property that has to be designed in, not hoped for.

Why any of this matters

Mostly so nothing you build here can be held hostage.

Your flow is a Playwright script, and Playwright doesn't care whether Orchestra exists. Hand it to a developer, get their version back, load it, carry on clicking. Diff it in git like anything else. Read it to find out what a Wait Network step actually is, which is a nicer way to learn Playwright than most tutorials.

And on the days the visual editor doesn't have the exact knob you want, you go into the code, type the thing, and come back. That's really all this is for.

That's the whole idea: the GUI and the code aren't two products bolted together. They're two views of the same flow, and either one can be the source of truth.

Try Orchestra

A desktop studio for browser automation — build flows visually, watch them run live, and export plain Playwright code you own forever. 14-day free trial, then a one-time $149 license.

Try it free for 14 days