ワンクリックで
e2e-test-review
// Review Cypress E2E spec files for Metabase conventions, common gotchas, and flakiness/performance issues. Use when reviewing pull requests or diffs containing Cypress spec files in e2e/test/scenarios/.
// Review Cypress E2E spec files for Metabase conventions, common gotchas, and flakiness/performance issues. Use when reviewing pull requests or diffs containing Cypress spec files in e2e/test/scenarios/.
| name | e2e-test-review |
| description | Review Cypress E2E spec files for Metabase conventions, common gotchas, and flakiness/performance issues. Use when reviewing pull requests or diffs containing Cypress spec files in e2e/test/scenarios/. |
| allowed-tools | Read, Grep, Bash, Glob |
@./../_shared/cypress-conventions.md
Before starting, determine which mode to use:
mcp__github__create_pending_pull_request_review is available, post issues as one cohesive pending review.If the spec references an issue (metabase#NNNNN) and the user wants to fix flakiness or assess whether the test still reproduces the original bug, that's outside the review skill's scope. Point them at /fix-flakey-test (or the dedicated flake-fixing workflow), which knows to fetch the issue and the resolving PR's diff. The review skill stays focused on "is this test well-written and conformant" — it doesn't fetch external issue context by default.
The checklist mirrors the order of the conventions file. Items marked (lint) are also caught by ESLint — flag only when you see them slip through (helper-wrapped, lint-disabled, etc.).
e2e/test/scenarios/<area>/.cy.spec.ts (preferred) or .cy.spec.js — don't flag .js on existing filesdescribe block names the area when relevant: "area > sub-area > feature (#issue-number)".only or .skip (pre-commit hook should catch, but flag it if it slips through)const { H } = cy; — no direct imports from e2e/support/helpers (lint)cypress_sample_databasecypress_sample_instance_dataH.openOrdersTable, H.visitDashboard(id), etc.) instead of raw cy.visit() chainsfindByRole, findByLabelText) over findByTextfindByText only when an a11y query doesn't fitfindByTestId for data-testid attributes (never raw cy.get("[data-testid='...']")).css-1abc2d)e2e/support/helpers/e2e-visual-tests-helpers.js are the only intentional exception — see "What NOT to flag").eq(), .first(), .last(), :nth-child) only when the order is the assertion itself, or guarded by a length assertion immediately before. (lint catches .last() and .eq(<negative); the rest is on the reviewer)cy.findByText(...) / cy.contains(...) in it/before/beforeEach is forbidden. Must be scoped via cy.contains(selector, text), cy.someQuery().findByText(...), or someQuery().within(...). (lint catches the top-level case ONLY — it does NOT catch helper-wrapped queries; manually scan helper bodies)cy.request / API helpers, not the UIH.restore() and sign-in are in beforeEach, not beforeit() is independently runnable — no it() depends on prior it() stateH.restore() precedes H.resetTestTable() (lint)cy.wait(ms) — even small onescy.intercept() defined BEFORE the action that triggers the requestsetTimeout, Cypress.Promise.delay, or other manual sleeps{ timeout: 30000 }) papering over a race.should("be.visible"), not .should("exist"). Reserve exist for hidden inputs / off-screen / portal-detached cases.cy.* commandsconst x = cy.someCommand(...) — x is a one-shot chainer, not the resolved value (lint catches simple cases)const foo = () => cy.findByText("Foo")), not assigned to a const.then() or aliased with .as() + cy.get("@alias")should("not.exist") / should("not.be.visible") passes by accident if the page hasn't rendered yet. Anchor on a positive signal first..should("contain", ...).and("contain", ...).and("not.contain", ...) chain rather than three separate findByText().should(...) queries. Single retry budget, atomic against one DOM snapshot.expect() only used inside cy.then / cy.wrap callbacks.should("not.exist") vs .should("not.be.visible") used correctly for intent.then(el => if (...))) — .then() runs once, .should() retriescy.within (always chained)cy.within(...) is chained off a previous selector. Standalone cy.within(...) has no scope and is wrong by construction — flag on sight.within callbacks — if the callback has only one inner command, chain directly off the parent instead. Reserve within for two or more commands sharing the scope.within callbacks — within((modal) => ...) is redundant; the subject is inherited automatically by inner commands. If you actually need the jQuery subject, use .then($el => ...) instead..within() callback is properly closed; assertions outside the callback don't accidentally rely on the within scopecy.log("..."), not // comments (visible in command panel, screenshots, videos)cy.log lines aren't redundant paraphrases of the next command — they mark phases or non-obvious intentLook up actual CI duration before flagging slowness.
e2e/support/timings.jsonholds the latest CI run's per-spec wall time in ms (entries keyed as../test/scenarios/...). Read it instead of running the spec — running adds minutes; reading is instant. Compare the spec to its siblings in the same directory to ground the "this is expensive" claim. Per-it()granularity is not in this file.
it() blocks for the same flow with the same beforeEach setup are a smell. Each it() costs 5–10s of Cypress runner overhead + the beforeEach cost; merge into a single flow when possible.it() that shares 80–90% of its setup and steps with a sibling test should extend the existing test, not spawn a clone.cy.visit() calls — each is a cold app boot (multi-second). After the first visit, navigate via the UI (click a link, breadcrumb, sidebar item) instead of issuing a second cy.visit().metabase#12345) in the test name or describe, grep the codebase for the bare #NNNNN (no metabase prefix — backend tests don't always include it). If a Jest spec or backend _test.clj already cites the same issue, the e2e test is already redundant; recommend deletion. Recipe:
bash rg "#12345" -g '*.spec.{ts,tsx,js,jsx}' -g '!*.cy.spec.*' -g '*_test.clj' -g '*_test.cljc'
The -g '!*.cy.spec.*' exclusion drops the e2e spec being reviewed (which will obviously match its own reference).forEach over cy queries — use cy.each() if iteration is neededasync-await with cy chainscy.contains("text") (single-arg, top-level) — same scoping rule as findByTextQuick scan for common issues. (lint) marks patterns ESLint already catches in the simple form — flag when you see them slip through.
| Pattern | Issue |
|---|---|
cy.wait(2000) | Numeric wait — use intercept alias or .should("be.visible") |
cy.get(".css-1abc2d") | Generated CSS class — use a11y query / findByText / findByTestId |
cy.get("[data-testid='foo']") | Always use cy.findByTestId('foo') instead |
cy.get("path[fill='#abc']") in a spec | Ad-hoc chart selector — use e2e-visual-tests-helpers or add a new helper there |
cy.get("li:nth-child(3)") | Positional in a CSS selector — anchor on text or role |
.last() / .eq(-1) without a prior length assertion | Risk of off-by-one when collection size shifts (lint) |
cy.visit("/dashboard/10") | Hardcoded numeric ID — capture from create response or import from cypress_sample_instance_data |
import { restore } from "e2e/support/helpers" | Direct helper import — use const { H } = cy; (lint) |
cy.findByText("Save") at top of it() / beforeEach | Unscoped text selector — wrap in cy.contains(selector, text) or chain off a scoping query (lint, BUT misses helper-wrapped) |
function clickSave() { cy.findByText("Save").click() } | Helper-wrapped unscoped text — lint blind spot, scan helpers manually |
Standalone cy.within(() => ...) | cy.within must be chained off an existing selector — wrong by construction |
cy.intercept placed AFTER the triggering call | Intercept must precede the action |
cy.get(...).then(el => { if (...) }) | JS conditional on cy chain — use .should() for retry semantics |
await cy.something() | Cypress chains aren't real Promises |
els.forEach(el => cy....) | Use cy.each() instead |
{ timeout: 30000 } on a single command | Likely papering over a race — find the root cause |
const button = cy.findByRole(...) | Assigning cy.* return value — button is a one-shot chainer, not a DOM element (lint catches simple cases) |
const foo = () => cy.findByText("Foo") | This is fine — the function form re-enqueues the query on each call |
.should("not.exist") as the only assertion in a step | Negative-only — passes by accident if page hasn't rendered. Anchor on a positive assertion first |
H.resetTestTable() before H.restore() | restore must come first (lint) |
Multiple cy.visit() in one it() block | Each is a cold boot — navigate via the UI between screens |
it.only( / describe.only( | Pre-commit hook should block — flag as slip |
New it() block with same setup + 80–90% same steps as sibling | Likely a near-duplicate — extend the existing test instead |
it("...", () => { cy.findBy*().should("be.visible") }) only | Static-UI-only test — strong signal it should be a Jest unit test, not e2e |
cy.log("Visit dashboard"); H.visitDashboard(id); | Redundant log — paraphrases the next command |
// Visit dashboard followed by H.visitDashboard(id); | Use cy.log("...") instead — visible in screenshots/videos |
.cy.spec.js extension on existing files — both .js and .ts are acceptable; .ts is preferred only for new specs.path[fill='...'], [stroke-dasharray='...'], text[stroke-width='3'], etc.) inside e2e/support/helpers/e2e-visual-tests-helpers.js. ECharts renders SVG with no data-testid and minimal a11y, and that file is the intentional exception. Do flag the same patterns when they appear in spec files or in new helpers — those should funnel through the existing helpers or extend that file..first() / .eq(N) / :nth-child(N) when the order is genuinely the assertion (e.g. testing sort order) or when a .should("have.length", n) immediately precedes them.cy.findByText(...) / cy.contains(...) inside a helper body if the helper is documented or clearly intended to be called from inside an outer within(...) scope at the call site (the inherited within scope makes it safe at runtime). When in doubt, ask the author..only if you're doing a local-dev review (the user is intentionally focused). At PR / commit-time review, do flag it.Number every issue sequentially starting from Issue 1. Format: **Issue N: [Brief title]**.
## Issues
**Issue 1: [Brief title]**
File:Line — succinct description
Suggested fix
**Issue 2: [Brief title]**
...
Use the pending review workflow:
mcp__github__create_pending_pull_request_review — start a draft review.mcp__github__get_pull_request_diff — get file paths and line numbers.mcp__github__add_pull_request_review_comment_to_pending_review — post each issue as a separate comment, in parallel within a single response.mcp__github__submit_pending_pull_request_review with event "COMMENT" (not REQUEST_CHANGES), no body.Each comment body starts with **Issue N: [Brief title]**.
Guide Clojure and ClojureScript development using REPL-driven workflow, coding conventions, and best practices. Use when writing, developing, or refactoring Clojure/ClojureScript code.
Export content from a running Metabase instance, validate with checkers, edit YAML, and import back. Use when the user wants to export, import, or run the full serdes round-trip workflow.
Drive Metabase's UI with the Playwright MCP browser tools (mcp__playwright__browser_*). Covers the snapshot/act/check pattern, Mantine component pitfalls (Menu race, Select/MultiSelect, the Escape-closes-modal trap, portal scoping), and Metabase-specific login flows. Use whenever a session needs to interact with the Metabase UI through Playwright MCP.
Run Cypress E2E tests, analyze failures including screenshots, and stress test for flakiness
Analyze React component source code to understand UI structure, then generate idiomatic Cypress E2E tests following Metabase conventions. Falls back to Playwright MCP browser exploration only when code reading and screenshot debugging are insufficient.
Add product analytics events to track user interactions in the Metabase frontend