DEV Community

Cover image for How I leveraged Claude Code skills to help me migrate Cypress 16 deprecations in time
Marcelo C. for Cypress

Posted on

How I leveraged Claude Code skills to help me migrate Cypress 16 deprecations in time

Have you ever stared at a deprecation warning scrolling by in your terminal and thought "eh, future me will deal with that"? We all have! But here's the thing about future you: he eventually shows up. And in my case, he showed up wearing a shirt that said Cypress v16, holding a changelog, and pointing at the line that says Cypress.env() will be fully removed.

Cypress terminal warning before migration

Warning in 15.10. Gone in 16. And sprinkled across every config, page object, and spec file in the massive E2E repository I maintain at my work monorepo. Configs, support files, fluent page-object chains — everywhere.

So let's get to the grain here: even though Cypress has a great documentation about it, I didn't want to execute a find-and-replace job manually, and I also wanted to move fast. So instead of pasting a prompt into a chat window forty times, I did something better: I turned the migration playbook itself into a Claude Code Skill — a reusable, self-triggering unit of expertise that lives in my machine and outlives this one migration.

Why Cypress.env() had to die

If you've written E2E tests, you know Cypress.env(). Synchronous, available everywhere, dead simple.

And that simplicity was exactly the problem: it hydrated the entire env object straight into the browser runtime. Every value. Including the ones a given test never reads. Your admin password, your API tokens — all of it, sitting in browser-accessible memory, one window inspection away, in every single spec.

Easy to use? Absolutely. A credential-leaking surface baked into your test runner? Also absolutely.

The v16 API splits the concern into two deliberately asymmetric halves:

  • cy.env(['key']).then(...) — asynchronous, resolved Node-side, strictly read-only. Values never touch the browser context until you explicitly consume them inside the callback. This is where credentials and tokens live now.
  • Cypress.expose('key') — synchronous, a drop-in replacement, but only for values you've explicitly opted in to exposing via a new top-level expose: block in your config (a sibling to env:, not nested inside it).

Notice the design: the safe path is ergonomic, the sensitive path is deliberately awkward. That asymmetry is the whole point.

The strategy: teaching the AI a classification heuristic

Doing a mindless regex sweep across thousands of files would have been fast — and catastrophically wrong, because every single call site needed a classification decision first. So instead of doing the work 3,000 times, I did it once: I encoded a strict heuristic and let Claude Code apply it mechanically.

The heuristic:

  1. Does the value contain a password, a username paired with a password, or any secret? → It's sensitive. It stays in the env: block and every read site gets rewritten to the async cy.env([...]).then(...) form.
  2. Is it a feature flag (our idp_active, xbrl_active), a locale, a timeout, a base URL fragment? → It's public. It moves to the expose: block and the read becomes a one-line swap to Cypress.expose().

Rule 2 is boring. Rule 1 is where the migration earns its war stories.

From prompt to Skill: making the knowledge permanent

The Cypress team actually ships an official starting point for this — the remove-cypress-env-usage prompt from the Cypress AI Toolkit:

Real prompt from Cypress team

It's good. But a prompt in a library has two problems: you have to remember it exists, and you have to re-paste it every session. A Claude Code Skill solves both. A Skill is just a folder with a SKILL.md — a markdown file with a small YAML frontmatter (name + description) followed by the instructions. Claude Code scans the descriptions on every task and auto-loads the skill when the context matches, so the moment I say "migrate this spec off Cypress.env", the whole playbook is already in its head. No copy-paste, no drift between sessions.

Prompt set as Claude skill

~/
└── .claude/
    └── skills/
        └── cypress-v16-migration/
            └── SKILL.md    # frontmatter + the full migration playbook
Enter fullscreen mode Exit fullscreen mode

But here's the important part — I didn't just copy the official prompt into a file. I transformed it, layering in everything the generic version can't know about my repo:

  • The classification heuristic above, with our real flag names (idp_active, xbrl_active) as worked examples, so it never has to guess what "sensitive" means in our codebase.
  • The command-queue traps you're about to read below, written as explicit "never do this / always do this" rules — because I hit them once and never wanted the AI to reproduce them.
  • Our repo topology: all four active config files that need the expose: block and the final flag flip, so nothing gets a partial migration.
  • The verification sequence (grep → flag → CI) as a mandatory checklist the skill must run before declaring victory.

The generic prompt is the textbook; the Skill is the textbook annotated by someone who took the exam. And since it's a personal skill (kept local, not committed to the repo), I can keep sharpening it without a PR review debate about my prompt-engineering style. 😄

Where the plot thickens 🕵️

You might be thinking: "Ok, this is still just a glorified find-and-replace with two output templates. What's the big deal?"

The big deal is the Cypress command queue.

Swapping a synchronous read for an asynchronous one inside a fluent page-object chain changes the execution semantics of everything around it. Take our global cy.login() command — the one every spec in the codebase depends on:

// BEFORE — synchronous, credentials resolved at enqueue time
Cypress.Commands.add('login', () => {
  const user = Cypress.env('username');
  const pass = Cypress.env('password');

  cy.session([user], () => {
    loginPage.visit().typeUser(user).typePass(pass).submit();
  });
  cy.goToHome();
});
Enter fullscreen mode Exit fullscreen mode

Naive migration attempt:

// BROKEN — do you see it?
Cypress.Commands.add('login', () => {
  cy.env(['username', 'password']).then(({ username, password }) => {
    cy.session([username], () => {
      loginPage.visit().typeUser(username).typePass(password).submit();
    });
  });
  cy.goToHome(); // 💥
});
Enter fullscreen mode Exit fullscreen mode

Looks reasonable. It's a time bomb. Here's why:

Trap #1 — silent reordering. Cypress enqueues commands synchronously as JavaScript executes, but runs them later. That trailing cy.goToHome() gets enqueued before the .then() callback ever fires — and any non-Cypress code after the block (variable assignments, page-object state mutations) executes immediately, before the credentials even exist. No error. No warning. Just your test quietly doing things in the wrong order. This is, by far, the easiest mistake to make in the entire migration, and grep will never find it for you.

Trap #2 — cy.session() needs a resolved ID. The session ID argument is evaluated at call time, not at run time. You can't hand it a promise or a pending value. So the entire cy.session(...) invocation — not just its setup callback — has to move inside the cy.env().then() callback:

// CORRECT — everything downstream of the credentials lives inside the callback
Cypress.Commands.add('login', () => {
  cy.env(['username', 'password']).then(({ username, password }) => {
    cy.session([username], () => {
      loginPage.visit().typeUser(username).typePass(password).submit();
    });
    cy.goToHome();
  });
});
Enter fullscreen mode Exit fullscreen mode

And here's the payoff of years of discipline: because every login flow was registered through Cypress.Commands.add, this entire architectural shift happened behind the command boundary. Thousands of cy.login() call sites across the specs didn't change by a single character. The abstraction held. This, kids, is why you wrap your login logic in a custom command instead of copy-pasting it into 5k tests (ask me how I know 😉).

The final lockdown 🔒

A migration you can't verify is just a migration you hope worked. Cypress v16 gives you a kill switch for exactly this: allowCypressEnv: false. With it set, any surviving Cypress.env() call becomes a hard runtime failure — not a warning, a crash.

My verification sequence:

  1. grep the branch for live Cypress.env( calls → zero hits (comments and the migration doc don't count).
  2. Flip allowCypressEnv: false across all four active config files — no partial lockdowns, no "we'll enable it on that config later."
  3. Ship it to CI and let the full E2E stages be the judge.

Green across the board. And here's the subtle part that made it safe: we only changed the read API, never the injection mechanism. Every CI secret, every --env CLI override, every pipeline variable kept flowing exactly as before — they just land in a place the browser can no longer see by default.

The commit log tells the real story 📜

Want proof that the Skill approach actually moved fast? Read the git history like a detective:

First PR:
Tue 11:27  Migration to v16
Tue 12:11  [E2E] Migration to Cypress v16 architecture
Tue 14:44  Updating Cypress env flag
Wed 16:04  Updating Typescript types

Fixing login issue:
Thu 10:23  [E2E] Fixing idp override with v16 Cy migration 
Enter fullscreen mode Exit fullscreen mode

Look at Tuesday morning: the bulk rewrite of the entire architecture landed in about 45 minutes, between the first pass and the structured [E2E] commit. That's thousands of call sites, classified and rewritten, in less time than a sprint planning meeting. Two hours later, allowCypressEnv: false was already flipped — the kill switch armed on day one.

Then comes the honest part, the part most migration posts conveniently forget: the long tail. Wednesday was TypeScript's turn — cy.env() returning a promise-like of a keyed object meant the old string-based typings had to be reshaped, so the compiler could catch a mistyped key at build time instead of CI catching it at run time.

And Thursday? Thursday delivered the one trap the Skill didn't know about yet — Trap #3, the runtime override. One special spec family runs against a different server with IDP/SSO enabled, and it used to flip that on with a runtime Cypress.env('idp_active', true) in a beforeEach. In v16, idp_active now lives in the expose: block — and exposed values are resolved from config, read-only at runtime. The old setter didn't crash; it just silently did nothing, and the spec calmly logged into the wrong server. The fix was moving the override where v16 wants it: into the spec-level config override, resolved before the test runs, not mutated during it.

Guess what happened next? That trap went straight into the SKILL.md. The next migration Claude Code runs already knows about runtime overrides of exposed values. That's the whole point of a Skill over a prompt: it compounds.

The takeaway

A breaking-change migration at this scale isn't a refactor problem, it's a classification + execution-semantics problem. The AI didn't make the hard decisions — the heuristic did. The AI just made applying that heuristic across thousands of files not cost me a month of my life. And by encoding it as a Skill instead of a throwaway prompt, every trap I hit made the next migration cheaper.

What about you? Have you ever had to architect a massive, breaking-change migration across thousands of tests? What was the trap that almost got you — and did your abstractions hold when it mattered? Leave me a comment, I'd love to hear!

Top comments (0)