Shiftwright · how we built it Watch a replay of a real run →

How we built Shiftwright

Shiftwright migrates Selenium and Cypress suites to Playwright: an agentic workflow does the migration, and you approve the pull request. This post is the build story — the pieces, what happens to one file, a real run beat by beat, and what broke on the way. Every label names its wire, so you can check it against the code.

We created Shiftwright and this post for the purposes of entering the All Things Agentic Hackathon. Published 2026-08-30.

01 · Why we built it

Legacy Selenium suites rot: brittle XPath, arbitrary time.sleep(), CI flakiness. Teams want Playwright's auto-waiting and parallelism, and they don't migrate, because it's weeks of work that ships no features. Public GitHub holds ~1.78M Selenium and Cypress source files; of the Python ones, 0.2% also import Playwright. Migrating is a rewrite per test, so it doesn't happen.

Our starting position: migration is a runtime problem. The code has to run, the failure has to be read against live DOM, and the fix has to hold. So we built a system that executes what it writes — every migrated test runs in a container first, and what lands in your pull request is a suite that already passes.

The stack, up front: one Google ADK SequentialAgent with four agents (Planner, Modernizer, Diagnoser, Verifier), all on Gemini 3.5 Flash over Vertex AI, the Diagnoser multimodal; a private MCP server on Cloud Run that runs every test; Firestore as message bus and run record; Vertex AI Memory Bank as the cross-repository memory tier; three Cloud Run services in total.

02 · The pieces

What goes in and out, the agents that decide, the sandbox that executes, the memory both write to. No step order — that is next.

In Legacy Selenium repository public URL · cloned read-only into an isolated workspace
Out Draft pull request GitHub App · a new branch, after the human gate — never a commit on yours
the whole run happens between those two, on Google Cloud
Control plane Google Cloud ADK

ADK SequentialAgent · all four on gemini-3.5-flash over Vertex AI, the Diagnoser multimodal

Planner
scores complexity, orders the curriculum
Modernizer
rewrites Selenium into Playwright
Diagnoser
reads screenshot + DOM, patches the locator
Verifier
benchmarks, drafts the PR
Human ✓ approve → PR draft kept
Execution plane one MCP server · Cloud Run

Isolated sandbox driven over JSON-RPC — never your machine, never your branches. Every call carries an ID token.

mcp_ast_parse scan repo → files, locators, anti-patterns
mcp_selenium_baseline time the untouched Selenium suite
mcp_playwright_sandbox_exec run the rewritten test, traced
mcp_trace_inspect DOM + screenshot at the failing step
mcp_kb_query reuse a locator already learned — exact, or Gemma-similar
mcp_kb_upsert write the healed locator back
Knowledge plane Firestore · Memory Bank · GCS

What one test learns, every later test reuses — the reason healing effort trends down across a run. Verified repairs also persist to Vertex AI Memory Bank, so the next repository starts with what earlier ones worked out.

locator mappingshealing recipesflake patternstrace bundlescross-repository repairs

03 · What happens to one file

Rewrite, run it for real, and on failure diagnose from the screenshot and DOM rather than the source. At most three attempts; every file ends in one of four recorded states.

Forward path one file · plan → rewrite → run
  1. 1 Scan & order
    AST scan scores each file, then phases it — fixtures before journeys
    mcp_ast_parse
  2. 2 Recall
    every scanned locator looked up first: exact key, then Gemma similarity
    mcp_kb_query
  3. 3 Rewrite
    one LlmAgent per file, schema-constrained, KB hits supplied as evidence
    Vertex AI · gemini-3.5-flash
  4. 4 Quality gate
    output still importing selenium or cypress, or sleeping, earns one corrective rewrite
    in-process AST check
  5. 5 Execute
    the rewritten test actually runs, traced, in the sandbox container
    mcp_playwright_sandbox_exec
did it pass?
No · heal loop at most 3 attempts per file

The failure is the input, not the source that already failed. Each attempt gets a different brief, so a second pass knows what the first missed.

  1. 6
    Inspect DOM and the failure screenshot at the step that threw mcp_trace_inspect
  2. 7
    Diagnose image + text in one call: failure class, root cause, patched locator Vertex AI · multimodal
  3. 8
    Scope guard the patch is rejected if it changed what the test asserts — checked, not trusted agents/patch_guard.py
  4. 9
    Learn locator, recipe and flake pattern written back before the re-run mcp_kb_upsert
patch applied → back to step 5
Yes · verify once per run, not per file

Green is not the finish line. The suite is timed against the one it replaces, and nothing lands without a person approving the diff.

  1. 10
    Benchmark the untouched legacy suite timed on the same container for the comparison mcp_selenium_baseline
  2. 11
    Human gate diff, metrics, approve — the run stops here until a person decides Firestore approval doc
  3. 12
    Draft PR a new branch on the source repository, never a commit on yours GitHub App
Terminal states the four the run record writes
passed green on the first execution
healed green after 1–3 diagnoses
preexisting red before the migration too — the assertion is left alone
fallback did not converge — report written, file left for a human

04 · A real run, beat by beat

16 beats in the order a run plays them, timed as they actually occurred.

≤3 attemptsOrchestratorGoogle ADK SequentialAgent · Cloud RunPlannerLlmAgent · structured planorders the curriculumModernizerLlmAgent · one file at a timerewrites Selenium or Cypress into PlaywrightDiagnosermultimodal · in the heal loopreads screenshot + DOM, patches itVerifiercustom BaseAgent · HITL gatebenchmarks, drafts the PRWeb UISvelteKit · Cloud Runwizardlive dashboardHumanthe only gatestart migrationapprove the PRFirestoremessaging + statemigration_requestsmigrations/{id}· tasks· eventshitl_decisionsMCP serverJSON-RPC · Cloud Run, privateEmbeddingGemma-300MKB locator similarity · int4, CPU6 tools · ID-token authVertex AIGemini 3.5 Flash · location=globaltext + vision, one modelAgent Engine Memory BankCloud Storageartifactstraces · screenshots · recap mp4Recap rendernarration → TTS → ffmpegGemini script · Chirp3-HD voice
01/16 Someone pastes a repo URL migration_requests/{id}

The wizard writes a request document. No server call — the browser talks to Firestore.

05 · What we ran into

A migration that edits assertions has destroyed the thing the suite was for. Our first run against a repo we had not designed for came out 16/16. It should have been 13/16. The Diagnoser had made three genuinely-failing tests pass by rewriting what they asserted — each diagnosis right that the test was wrong, and each out of scope. patch_guard.py now extracts every test's expectation set before and after each patch, and refuses any change to a comparison operator, an expected literal, or the assertion count.

A verdict is only worth what the runner behind it did. Adding Java and Cypress created three ways to report an outcome nobody had measured. The markdown-fence stripper matched ```python and nothing else, so ```java survived into the file handed to Maven — every Java file failed to compile, and the benchmark timed those failures as a 5.1× "speedup". Playwright's default testMatch filtered out migrated Cypress specs named *.cy.ts, which read as "no tests found". Both are now rules: pair only tests that passed on both sides, and a runner that collected nothing reports harnessError rather than a verdict.

A knowledge base can be dead and look alive. Ours had one writer storing Playwright expressions and one reader querying By.ID=… strings — two vocabularies that could never match, so "reusing N locators" had never once fired, while counters incremented locally for documents that were never written.

Serial migration looked like a framework constraint and was not. The Modernizer is an LlmAgent driven through session state, and concurrent turns race on it. The constraint was the single session, not the agent. One ADK session per file, and the same agent parallelizes untouched.

06 · What we learned

Give an agent an oracle and generation stops being the hard part. Scope does. Knowing which failures are yours to fix is the real problem, and enforcing that boundary in code worked where prompting did not. The guard costs no legitimate heal, and it is why the third-party corpus lands at 13-of-16.

Structure beats model size. A curriculum, a typed hand-off and a scoped memory made Gemini 3.5 Flash sufficient for every role, multimodal diagnosis included. The wins came from the shape of the workflow.

MCP made the sandbox modular. Standardising execution on JSON-RPC tools decoupled reasoning from the environment. Running untrusted test code in an isolated Cloud Run container became a deployment detail rather than an agent concern.

Two memory tiers earn their keep differently. Firestore stays authoritative inside a migration, because a locator this run already learned has to come back verbatim. Vertex AI Memory Bank answers across repositories — what has ever worked for a widget like this, in any suite this project has migrated.

No API between the halves

Browser and worker never call each other. Both read and write Firestore, and the dashboard subscribes — so the UI has no backend to lose.

Execution is a separate service

Tests run in a private Cloud Run container, driven over JSON-RPC with an ID token — never a developer machine, never your branch.

Two human gates, then stop

Approve the plan, approve the draft. The most that reaches your repository is a draft pull request on a new branch.