add-e2e-test
The e2e tier exists to run the built packages/cli/dist/cli.mjs against a real Metabase, with no mocks. Subtle violations of the harness contract corrupt shared state for every other test in the run. Reading this is required before generating any e2e file.
The tier is workspace-wide: it lives at the repo root under tests/e2e/, not inside either package, because it exercises the shipped binary rather than a package's source. Unit tests are the mirror image — they sit beside the code under packages/<p>/src/**/*.test.ts.
Step 0 — Pre-flight (mandatory, do not skip)
Before generating anything, anchor to the existing harness:
ls tests/e2e/— see the layout and existing nouns.- Read one existing e2e test end-to-end (e.g.
tests/e2e/auth.e2e.test.ts). - Read the harness modules every later step reaches for:
tests/e2e/run-cli.ts— the only sanctioned way to invoke the CLI.tests/e2e/bootstrap-data.ts— theBootstrapschema you must NOT redeclare.tests/e2e/cli-error.ts—cliErrorMessage/cliErrorCategory, how you assert on stderr.tests/e2e/defaults.ts— base URL, stack id, snapshot name.tests/e2e/server-gate.ts—requireServer/serverVersionBelow, how a suite skips.tests/e2e/seed/seeded.tsandtests/e2e/seed/ids.ts— discovered and fixed entity ids.
Skip this and you will reinvent harness pieces that already exist, or redeclare the bootstrap schema and silently drift from the writer.
When to add an e2e test
- A new command was added under
packages/cli/src/commands/<noun>/<verb>.ts. Every new command needs an e2e test. - An existing command grew a flag, output mode, or behavior that unit tests cannot exercise (real network round-trip, real auth flow, real polling).
If the command can be fully covered by colocated packages/<p>/src/**/*.test.ts unit tests, do not add an e2e test — the unit tier is faster and more deterministic.
Where the test lives
- Path:
tests/e2e/<noun>.e2e.test.ts(one file per noun, multipleits for verbs/flows). - File name suffix is
.e2e.test.ts— the vitest project glob requires this..test.tsalone will not run in the e2e tier.
The runtime contract
You must follow all of these. Each rule has bitten the harness before.
1. Invoke the CLI only via runCli.
import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli";
runCli({ args, configHome, env, stdin, timeoutMs })spawnsnode packages/cli/dist/cli.mjsviaexecawith an isolatedXDG_CONFIG_HOME,MB_CLI_DISABLE_KEYRING=1, and stripped env (no inheritedMB_*/METABASE_*).- Do not import
execa,child_process,node:child_process, orspawndirectly. - Do not call
fetchagainst the Metabase instance. Bootstrap owns network setup; tests drive the CLI. - Do not spread
process.envinto theenvparam.env: process.env,env: { ...process.env, ... }, and friends defeat the entire isolation guarantee — they let developer-shellMETABASE_*leak into the test. Pass only the explicit keys you need.
1a. No mocks in the e2e tier.
The whole point of e2e is end-to-end with real I/O. These are forbidden anywhere under tests/e2e/:
vi.mock(...)vi.spyOn(...)vi.hoisted(...)vi.fn(...)to stand in for a real dep
If you find yourself wanting a mock, you are in the wrong tier — write a unit test colocated under packages/<p>/src/.
2. Read admin credentials only via readBootstrap().
import { readBootstrap, type E2EBootstrap } from "./bootstrap-data";
let bootstrap: E2EBootstrap;
beforeAll(async () => {
bootstrap = await readBootstrap();
});
- The
Bootstrapschema lives intests/e2e/bootstrap-data.ts. Never redeclare it. If you need a new field, edit the schema there — the writer (tests/e2e/setup/bootstrap.ts) consumes the same type, so drift is mechanically prevented. - The seeded
bootstrap.adminApiKeyauthenticates as a synthetic api-key user (emailapi-key-user-…@api-key.invalid). For tests that need a real human admin, run the in-process OAuth login harness (tests/e2e/setup/oauth-harness.ts—consentingBrowserwithbootstrap.admin; OAuth-capable servers only, gate withrequireOAuthServer("<lane>")) and explain in the test name why — don't paper over it. - Never invoke the setup wizard from a test. That mutates global state. Bootstrap runs once per
bun run test:e2eviatests/e2e/setup/global-setup.ts. - Never hard-code an API key. Always read from
bootstrap.
2a. Seeded entity ids come from SEEDED, never a literal.
import { SEEDED } from "./seed/seeded";
const tableId = SEEDED.tables.orders;
tests/e2e/seed/seeded.tsexposes the ids the bootstrap discovered by name (warehouse db, collection, card, dashboard, dashcard, plus warehouse tables and fields). Ids are not stable across stacks — a literal5passes on your machine and fails in the matrix.- Constants that genuinely are fixed by Metabase itself (the
All UsersandAdministratorsgroup ids) live intests/e2e/seed/ids.ts. Import from there rather than inlining the number.
2b. Gate the suite on server capability.
import { requireServer } from "./server-gate";
const skipReason = requireServer("transform › transform e2e", { minVersion: 59 });
describe.skipIf(skipReason !== null)("transform e2e", () => {
- If the command under test declares
capabilitiesabove the baseline{ minVersion: 58 }— a higher major, or atokenFeaturelikeremote_syncorlibrary— the suite must gate itself.requireServerfeeds the persisted server probe through the productioncheckCapabilitiesand returns a skip reason (ornull). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to.gate-skips.<stack>.json, which the closing block prints so a skipped lane is reported rather than counted as a passing one. - The point is that a lane passes or skips, never fails, on a server that cannot satisfy the command. A suite that hits HTTP 402 or 404 because nobody gated it is a broken lane, not a real failure.
3. Each test gets its own config home.
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(cleanupConfigHome));
});
async function makeIsolatedConfigHome(): Promise<string> {
const dir = await mkTempConfigHome();
tempDirs.push(dir);
return dir;
}
- One
XDG_CONFIG_HOMEper test, drained inafterEach. Sharing a config home across tests leaks profile state and is a flake source.
4. Every test starts from the restored snapshot.
vitest.workspace.ts registers setupFiles: ["tests/e2e/setup/restore-each.ts"] for the e2e project, whose beforeEach restores the app-db snapshot (resetToCliDefault) and the warehouse (resetWarehouse) before every test. Two consequences:
- Never rely on state a previous
itcreated. Each test builds the entities it needs, inside itself. A test that passes only when its neighbour ran first will fail the moment either is renamed, skipped, or filtered by-t. - No manual cleanup. Don't unwind a created card or transform at the end of the
it— the restore does it. Teardown code that duplicates the harness is noise, and it hides the state leak it was written to fix.
The /api/testing/snapshot and /api/testing/restore endpoints belong to tests/e2e/setup/**. A test that calls them directly re-enters the restore path mid-run and corrupts the very isolation it was reaching for.
5. Use parseJson for --json output, not JSON.parse + Zod.parse.
The schema is the contract. Import it from the production source — never redeclare in the test. Client modules come in by package specifier; CLI modules come in by relative path from the repo root:
import { parseJson } from "@metabase/client/json";
import { LoginResult } from "../../packages/cli/src/commands/auth/login";
const result = parseJson(login.stdout, LoginResult);
expect(result).toEqual({ profile: "default", ...});
If the command emits a single domain resource, import the <Resource> / <Resource>Compact schema from @metabase/client/domain/<r>. For list commands, import the <Resource>ListEnvelope from the command file itself (packages/cli/src/commands/<noun>/list.ts exports it as a named const built from listEnvelopeSchema(<Resource>Compact)). Never redeclare a z.object({ data, returned, offset, limit, total, has_more, next_offset, truncated }) envelope inline — the envelope shape is owned by packages/cli/src/output/types.ts:listEnvelopeSchema and threaded through the command's outputSchema. Tests reuse production schemas; copying any shape into the test is silent drift the type-checker can't catch.
Which package a schema belongs to is a boundary question, not a stylistic one. A Metabase API resource is client surface and lives in packages/client/src/domain/; a shape that exists only because a command renders it that way (LoginResult, AuthStatus, <Resource>ListEnvelope) is CLI surface and lives with its command. Import from wherever it actually is — do not mirror it into the other package to shorten the specifier.
5b. Assertion strictness. Every assertion is exact:
Parsed payloads: one
toEqual({ ...full expected... })over the parsed object — never a sequence ofexpect(parsed.id).toBe(...),expect(parsed.name).toBe(...). The whole point ofparseJsonis that the structure is now in your hands; pinning each field individually leaves the rest untested and lets a regression that flips an unchecked field pass silently.Exit codes:
expect(result.exitCode).toBe(<n>)with the exact integer. Never.not.toBe(0). The taxonomy ispackages/client/src/errors.ts:ConfigError→ 2,CapabilityError(packages/client/src/version/preflight-error.ts) → 2,AbortError→ 130, everything else (HttpError,ValidationError,NetworkError,TimeoutError,UnknownError) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all".Error strings: the harness is never a TTY, so the CLI renders errors as a JSON envelope on stderr rather than plain text. Assert through
cliErrorMessage(result.stderr)from./cli-error, which unwraps that envelope and hands back the human message:import { cliErrorMessage } from "./cli-error"; expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)');A raw
expect(result.stderr).toContain("…")also works when the expected text is a plain substring, and plenty of suites use it that way. ButtoBeagainst raw stderr never matches — stderr carries the whole envelope, plus any leadingwarn()lines. And any message containing a quote, backslash, or newline requirescliErrorMessage, because the envelope JSON-escapes those characters, so the raw substring is absent from stderr. NevertoMatch(/.../i)against stderr/stdout.Look up the literal message where it is raised —
packages/client/src/http/errors.tsfor HTTP status messages (a 404 produces`Not found: ${method} ${path}.`; the bare"Not found."in that file is the legacy plain-text body older servers send, used only to classify the error kind),packages/cli/src/commands/<noun>/<verb>.tsforConfigErrorstrings (e.g."--yes required to clear credentials non-interactively","verification failed: …"),packages/cli/src/commands/parse-integer.tsfor"invalid id: …". Pinning the literal substring catches a refactor that swaps the message; a regex with\d+or.*does not.Dynamic messages: if the message contains a generated value (a byte count, a created id, a timestamp), build the expected string from the same data the production code consumed and assert with
toBe/toContain. Don't paper over the dynamic part with a regex.
5a. Command-list test parity. When you add a new leaf command, update the literal ALL_COMMANDS list in packages/cli/src/runtime/command-help.test.ts to include the new entry (and remove any path you renamed/deleted). The index is generated by walking packages/cli/src/main.ts, so the contract test will fail until the literal matches the new tree.
6. License token: opaque only.
If your test exercises EE features that need the dev license token:
- Read its existence via
process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined. Never read the value to log/assert/debug it. - Pass it to the CLI only as opaque stdin to
runCli({ stdin }). Never echo it. Neverexpect(...).toContain(token). Never include it in a snapshot. - Use
mb_dev_…dummy tokens for storage-roundtrip tests; only the EE-integration suite ever threads the real value, and only as opaque stdin.
7. Resolving the base URL.
import { resolveE2EBaseUrl } from "./defaults";
bootstrap.baseUrlis normally what you want (it's already resolved). Only callresolveE2EBaseUrl()directly when constructing a URL that does not flow through bootstrap.
Skeleton
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { parseJson } from "@metabase/client/json";
import { LoginResult } from "../../packages/cli/src/commands/auth/login";
import { readBootstrap, type E2EBootstrap } from "./bootstrap-data";
import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli";
describe("<noun> e2e", () => {
let bootstrap: E2EBootstrap;
const tempDirs: string[] = [];
beforeAll(async () => {
bootstrap = await readBootstrap();
});
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map(cleanupConfigHome));
});
async function makeIsolatedConfigHome(): Promise<string> {
const dir = await mkTempConfigHome();
tempDirs.push(dir);
return dir;
}
it("does the thing", async () => {
const configHome = await makeIsolatedConfigHome();
const result = await runCli({
args: ["<noun>", "<verb>", "--json"],
configHome,
env: {
MB_URL: bootstrap.baseUrl,
MB_API_KEY: bootstrap.adminApiKey,
},
});
expect(result.exitCode, result.stderr).toBe(0);
expect(
parseJson(result.stdout /* schema from @metabase/client/domain or the command file */),
).toEqual({
// ...
});
});
});
Running it locally
Prerequisites — once a day, ~1 minute:
bun run build
bun run e2e:up
bun run e2e:bootstrap
Per iteration:
bun run test:e2e # one-shot
bun run test:e2e:watch # watch mode; Metabase stays up between runs
Stack control: bun run e2e:up, bun run e2e:down (wipes volumes), bun run e2e:logs.
Step N − 1 — Self-grep before declaring done (mandatory)
Run each of these against the file you just wrote. Any hit must be fixed; then re-run.
# Forbidden imports / direct calls:
rg -n "from\s+\"execa\"|from\s+\"node:child_process\"|from\s+\"child_process\"" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
rg -n "\bspawn\(|\bexec\(|\bexecFile\(" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
rg -n "\bfetch\s*\(" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
# No mocks in e2e:
rg -n "vi\.(mock|spyOn|hoisted|fn)\b" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
# Schema discipline:
rg -n "JSON\.parse\(" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # use parseJson
rg -n "const\s+Bootstrap\s*=" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # never redeclare Bootstrap
# Env hygiene:
rg -n "env:\s*process\.env|\.\.\.process\.env" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
# Hard-coded creds / URLs:
rg -n "http://localhost" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # use bootstrap.baseUrl or resolveE2EBaseUrl()
rg -n "mb_api_key_|mb_dev_" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # use bootstrap.adminApiKey
# Snapshot endpoints are bootstrap-only:
rg -n "/api/testing/(snapshot|restore)" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK
# License token: existence only, never read the value:
rg -n "process\.env\[\"MB_PREMIUM_EMBEDDING_TOKEN\"\]" tests/e2e/<noun>.e2e.test.ts | rg -v "=== undefined" && echo FAIL || echo OK
# Assertion strictness:
rg -n "\.not\.toBe\(0\)" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # use exact exitCode
rg -n "\.toMatch\(/" tests/e2e/<noun>.e2e.test.ts && echo FAIL || echo OK # use toContain / toBe
Replace <noun> with your actual file name.
Step N — Runnable verification (mandatory)
The gate is required and cheap; the e2e suite itself is opt-in (slow, brings up docker).
bun run check
Must exit 0. If you ran the suite, also include:
bun run test:e2e tests/e2e/<noun>.e2e.test.ts
If you did not run the e2e suite (it requires bun run e2e:up && bun run e2e:bootstrap and starts containers), say so explicitly — do not claim "passed e2e" if you only ran tsc.
Sanity checks before declaring done
- Step 0 (read existing e2e file + harness) was actually performed.
- File path is
tests/e2e/<noun>.e2e.test.ts(note.e2e.test.ts). - Imports
runCli,mkTempConfigHome,cleanupConfigHomefrom./run-cli. Noexeca/child_process/fetchimport. - Reads creds via
readBootstrap(). No hard-coded API keys, noBootstrapschema redeclaration, no setup-wizard call. - Seeded entity ids come from
SEEDED(./seed/seeded) or./seed/ids; no literal id anywhere. - If the command's
capabilitiesare above{ minVersion: 58 }, the suite gates onrequireServer("<lane>", {...})viadescribe.skipIf, with a lane label naming what the gate guards. - No
vi.mock/vi.spyOn/vi.hoisted/vi.fnanywhere in the file. - No
process.envspread intorunCli({ env: ... }). Only the explicit keys the test needs. - Per-test
makeIsolatedConfigHome()pattern withtempDirs+afterEachcleanup. -
--jsonassertions go throughparseJson(stdout, <Schema>)(parseJsonfrom@metabase/client/json) where<Schema>is imported from@metabase/client/domain/...or../../packages/cli/src/commands/.... - Does not call
/api/testing/snapshotor/api/testing/restore. - If license-touching: token only as opaque stdin; existence check via
=== undefined; no logging/snapshotting/asserting on the value. - Self-grep step ran clean (no FAIL lines).
-
bun run checkexited 0. - If e2e suite was run, it was run after
bun run e2e:up && bun run e2e:bootstrap. If it was NOT run, that is stated explicitly.
If any box is unchecked, the test is unfinished — do not report it as done. State explicitly which box is unchecked and continue working.