Skip to main content
{anchor}`: the text node is the title, ::after the pitch, the div the button. */ a[href*='utm_campaign=docs_sidebar_banner'] { display: none; justify-items: start; gap: 0.375rem; margin-block: 0.5rem; padding: 1.75rem 0 0.875rem; position: relative; color: var(--e2e-ink) !important; border-top: 1px solid var(--e2e-hairline); background: transparent; font-size: 0.875rem; font-weight: 500; line-height: 1.3; text-decoration: none; overflow-wrap: anywhere; &::after { content: 'TesterArmy runs your critical flows with AI agents and reports back after every deploy.'; margin-top: 0.25rem; color: var(--e2e-muted); font-size: 0.875rem; font-weight: 400; line-height: 1.5; } & > div { --e2e-pixel-unit: 12px; --e2e-pixel-fill: linear-gradient(var(--e2e-ta-orange), var(--e2e-ta-orange)); all: unset; order: 1; display: inline-flex; align-items: center; height: calc(var(--e2e-pixel-unit) * 3); margin-top: 0.375rem; padding: 0 calc(var(--e2e-pixel-unit) + 0.75rem); box-sizing: border-box; /* !important beats the inline background Mintlify sets on hover. */ background: var(--e2e-pixel-fill) center / calc(100% - var(--e2e-pixel-unit) * 2) 100% no-repeat, var(--e2e-pixel-fill) left bottom / var(--e2e-pixel-unit) calc(var(--e2e-pixel-unit) * 2) no-repeat, var(--e2e-pixel-fill) right top / var(--e2e-pixel-unit) var(--e2e-pixel-unit) no-repeat, var(--e2e-pixel-fill) right bottom / var(--e2e-pixel-unit) var(--e2e-pixel-unit) no-repeat !important; color: var(--e2e-ta-orange-ink); font-family: 'DM Mono', ui-monospace, monospace; font-size: 0.875rem; font-weight: 500; line-height: 1rem; letter-spacing: 0.07em; text-transform: uppercase; white-space: nowrap; & > svg { display: none !important; } &::before { content: 'Start testing'; } } &:hover > div, &:focus-visible > div { background: var(--e2e-pixel-fill) center / calc(100% - var(--e2e-pixel-unit) * 2) 100% no-repeat, var(--e2e-pixel-fill) left top / var(--e2e-pixel-unit) var(--e2e-pixel-unit) no-repeat, var(--e2e-pixel-fill) left bottom / var(--e2e-pixel-unit) var(--e2e-pixel-unit) no-repeat, var(--e2e-pixel-fill) right top / var(--e2e-pixel-unit) calc(var(--e2e-pixel-unit) * 2) no-repeat !important; } &:focus-visible { outline: 2px solid var(--e2e-ink); outline-offset: 2px; } @media (min-width: 1280px) { display: grid; position: fixed; top: 17rem; right: 2rem; z-index: 1; box-sizing: border-box; width: 16.5rem; margin: 0; @supports (anchor-name: --e2e-table-of-contents) and (position-anchor: --e2e-table-of-contents) { position-anchor: --e2e-table-of-contents; top: calc(anchor(bottom) + 0.5rem); right: auto; left: anchor(left); } } } @media (min-width: 1280px) { /* Above the z-21 TOC column so the card is clickable, below the z-30 navbar. */ #sidebar { z-index: 22; } @supports (anchor-name: --e2e-table-of-contents) and (position-anchor: --e2e-table-of-contents) { #table-of-contents { anchor-name: --e2e-table-of-contents; } } html:not(:has(#table-of-contents)) a[href*='utm_campaign=docs_sidebar_banner'] { display: none; } } ul:has(> li > a[href*='utm_campaign=docs_sidebar_banner']) + * { margin-top: 0 !important; } #content h2, #content h3 { letter-spacing: -0.01em; } /* The page title sits under a rule in the foreground color. */ #page-title { padding-top: 1rem; border-top: 2px solid var(--e2e-ink); } /* A hairline above every h2 separates sections. */ #content h2 { padding-top: 1rem; border-top: 1px solid var(--e2e-hairline); } /* Code renders in Geist Mono; the GitHub light/dark themes follow the color scheme (styling.codeblocks). */ code, pre, kbd, samp { font-family: 'Geist Mono', ui-monospace, monospace; } /* The code surface is the page's own grey, not the GitHub theme's blue-grey, and one flat fill: Mintlify frames the code in a tinted 2px border around a panel of another color (white in light mode), which square corners turn into a box in a box. In dark mode it paints the panel from an !important `html.dark .code-block-background`; the `body` in the dark selector is what outranks that rule. */ body .code-block, body .code-group, body .code-block-background, html.dark body .code-block-background { background-color: var(--e2e-surface) !important; } /* The fade behind a code block's floating copy button ends in that same grey. */ body .code-block-fade-overlay, html.dark body .code-block-fade-overlay { background-image: linear-gradient(to right, transparent, var(--e2e-surface) 50%) !important; } /* Callouts are a hairline box in the foreground color; the icon tells a note from a warning, not a hue. */ #content .callout { background-color: transparent !important; border-color: var(--e2e-hairline) !important; } #content .callout, #content .callout * { color: var(--e2e-ink) !important; } /* The assistant panel takes the table-of-contents column. Without a cap the content column grows into that space (816px to 1120px at 1920 wide) and every line rewraps; with it, only the panel moves. */ #content-area { max-width: 816px; margin-right: auto; } #content .setup-prompt { position: relative; margin-block: 1.25rem; border: 1px solid var(--e2e-hairline); border-radius: 0; background: transparent; } #content .setup-prompt-header { display: flex; align-items: center; height: 44px; padding-inline: 1rem; border-bottom: 1px solid var(--e2e-hairline); color: var(--e2e-muted); font-size: 0.8125rem; } #content .setup-prompt .prompt { display: block; margin: 0; padding: 1rem 1rem 0; border: 0; background: transparent; color: var(--e2e-ink); font-size: 0.875rem; line-height: 1.7; } #content .setup-prompt [data-component-part='prompt-description'] { max-height: 132px; overflow: hidden; mask-image: linear-gradient(to bottom, black 45%, transparent); } #content .setup-prompt[data-expanded='true'] [data-component-part='prompt-description'] { max-height: none; mask-image: none; } #content .setup-prompt [data-component-part='prompt-action-copy-button'] { width: 24px; height: 24px; padding: 0; border: 1px solid var(--e2e-hairline); border-radius: 0; background: transparent; color: var(--e2e-muted); } #content .setup-prompt [data-component-part='prompt-action-copy-button'] > [aria-hidden] { font-size: 0; } #content .setup-prompt [data-component-part='prompt-action-copy-button'] > [aria-hidden] > span { gap: 0; } #content .setup-prompt [data-component-part='prompt-action-copy-button'] svg { width: 16px; height: 16px; color: currentColor; } #content .setup-prompt [data-component-part='prompt-action-copy-button'] > [aria-hidden] > span:last-child::before { content: ''; width: 16px; height: 16px; background: currentColor; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.5'%3E%3Crect x='8' y='8' width='12' height='12' rx='2'/%3E%3Cpath d='M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3'/%3E%3C/svg%3E") center / contain no-repeat; } #content .setup-prompt [data-component-part='prompt-actions-wrapper'] { position: absolute; top: 10px; right: 10px; justify-content: flex-end; } #content .setup-prompt [data-component-part='prompt-action-cursor-button'] { display: inline-flex; height: 24px; border-color: var(--e2e-hairline); border-radius: 0; background: transparent; } #content .setup-prompt-toggle { display: block; width: 100%; padding: 0.5rem 1rem 0.75rem; color: var(--e2e-muted); font-size: 0.8125rem; text-align: center; cursor: pointer; } #content .setup-prompt-toggle:hover { color: var(--e2e-ink); } #content .setup-prompt button:focus-visible { outline: 2px solid var(--e2e-ink); outline-offset: 3px; } /* The provider walkthrough on the Models page: separate boxes sharing one selection (snippets/model-provider.jsx). */ #content .provider-part { margin-block: 1.25rem; border: 1px solid var(--e2e-hairline); color: var(--e2e-ink); } #content .provider-part-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; min-height: 44px; padding: 0.375rem 1rem; color: var(--e2e-muted); font-size: 0.8125rem; } #content .provider-part:not(.provider-part-select) .provider-part-header { border-bottom: 1px solid var(--e2e-hairline); } #content .provider-part-select .provider-part-header { padding-block: 0.75rem; } #content .provider-part-provider { color: var(--e2e-ink); } #content .provider-part-header select { flex: 1; min-width: 12rem; height: 2.25rem; padding: 0 2rem 0 0.75rem; border: 1px solid var(--e2e-hairline); border-radius: 0; background: var(--e2e-surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='rgb(115 115 115)' stroke-width='1.5'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E") right 0.625rem center / 14px no-repeat; color: var(--e2e-ink); font: inherit; font-size: 0.875rem; appearance: none; cursor: pointer; } #content .provider-part-header select:hover { border-color: var(--e2e-ink); } #content .provider-part-header option, #content .provider-part-header optgroup { background: var(--e2e-surface); color: var(--e2e-ink); } #content .provider-part-header a { color: var(--e2e-muted); text-decoration: underline; text-underline-offset: 3px; } #content .provider-part-header a:hover { color: var(--e2e-ink); } #content .provider-part-tabs { display: flex; } #content .provider-part-tabs button { padding: 0.125rem 0.5rem; border: 1px solid var(--e2e-hairline); color: var(--e2e-muted); font-size: 0.75rem; cursor: pointer; } #content .provider-part-tabs button + button { border-left: 0; } #content .provider-part-tabs button[aria-pressed='true'] { background: var(--e2e-ink); border-color: var(--e2e-ink); color: var(--e2e-surface); } /* Mintlify's CodeBlock inside a part: the part draws the frame and the header, so the block drops its own margins and border. */ #content .provider-part .code-block { margin: 0 !important; padding: 0 !important; border: 0 !important; } #content .provider-part-text { margin: 0; padding: 0.75rem 1rem; font-size: 0.875rem; } #content .provider-part-note { color: var(--e2e-muted); } #content .provider-part .code-block + .provider-part-note { border-top: 1px solid var(--e2e-hairline); } #content .provider-part button:focus-visible, #content .provider-part select:focus-visible, #content .provider-part a:focus-visible { outline: 2px solid var(--e2e-ink); outline-offset: 2px; } #content .video-card { display: flex; align-items: stretch; gap: 1.25rem; margin-block: 1.25rem; padding-right: 1.25rem; border: 1px solid var(--e2e-hairline); border-radius: 0; color: var(--e2e-ink); text-decoration: none; transition: border-color 200ms ease-out; } #content .video-card:hover, #content .video-card:focus-visible { border-color: var(--e2e-ink); } #content .video-card:focus-visible { outline: 2px solid var(--e2e-ink); outline-offset: 2px; } #content .video-card-thumbnail { position: relative; flex-shrink: 0; width: 200px; /* 16:9 at least, taller when the text wraps, so the image always meets the border. */ min-height: 112.5px; overflow: hidden; background: var(--e2e-surface); } /* hqdefault.jpg is 4:3 with letterbox bars; cover crops them away. */ #content .video-card-thumbnail img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; border-radius: 0; object-fit: cover; } /* Bottom right, clear of the title the thumbnail carries on its left. */ #content .video-card-play { position: absolute; right: 8px; bottom: 8px; z-index: 1; width: 28px; height: 28px; } #content .video-card-text { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 0.25rem; min-width: 0; padding-block: 0.75rem; } #content .video-card-title { font-size: 0.9375rem; font-weight: 500; line-height: 1.4; } #content .video-card-subtitle { color: var(--e2e-muted); font-size: 0.875rem; font-weight: 400; line-height: 1.5; } #content .video-card-arrow { flex-shrink: 0; align-self: center; width: 14px; height: 14px; color: var(--e2e-muted); transition: color 200ms ease-out; } #content .video-card:hover .video-card-arrow, #content .video-card:focus-visible .video-card-arrow { color: var(--e2e-ink); } #content .video-card-new-tab { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } @media (prefers-reduced-motion: reduce) { #content .video-card, #content .video-card-arrow { transition: none; } } @media (max-width: 640px) { #content .video-card-thumbnail { width: 128px; min-height: 72px; } #content .video-card-subtitle { display: none; } } /* The "Copy page" menu shows titles only. docs.json requires a description on every custom option and Mintlify renders one under each title, so the second line is hidden here, on the built-in items too. The menu has no id; the custom icons identify it. */ [role='menu']:has(img[src*='/images/contextual/']) [role='menuitem'] { align-items: center; } [role='menu']:has(img[src*='/images/contextual/']) [role='menuitem'] > div:first-child { margin-top: 0; } [role='menu']:has(img[src*='/images/contextual/']) [role='menuitem'] > div.flex-col > div:nth-child(2) { display: none; } /* The custom icons load as , so currentColor inside the SVG resolves to black. Match the built-in icons: the item's text color at half opacity, three quarters on hover. */ [role='menuitem'] img[src*='/images/contextual/'] { opacity: 0.5; } .dark [role='menuitem'] img[src*='/images/contextual/'] { filter: invert(1); } [role='menuitem']:hover img[src*='/images/contextual/'] { opacity: 0.75; } /* Card icons from a file load as too. The Expo mark is drawn black for light mode and inverted to white for dark; Kernel's sits on its own green square and reads on both. */ .dark img[src*='/images/integrations/expo.svg'] { filter: invert(1); }
Test code and configuration run with your operating-system permissions. This includes engines, tools, reporters, stores, secret providers, and app commands. e2e does not sandbox them. Run untrusted pull request code in an external sandbox without secrets or write tokens. The model receives app content as untrusted evidence. It requests actions through tools; the runner validates and performs those actions. Cached actions also run with your permission, so review shared cache entries as you would test code.

What the model sees

Model requests separate runner policy and trusted project context from app content. App content cannot grant tools, credentials, or a larger budget. How agent steps work explains what a step shows the model and what it may do; this page lists the trust boundary. Secure fields appear as value=<secure>. Known secret values in model input and reports become <secret:name>. This redaction has limits for transformed values, images, and app logs.

What the model may do

Built-in tools have closed schemas and are offered only when the engine supports them. Project tools must use defineTool and cannot replace a built-in name. Only read-only project tools may request an observation. A call to a tool the step does not offer, or with an argument its schema does not declare, never runs: the model reads the refusal and tries again. A node id that is not on the current screen fails the action with LOCATOR_NOT_FOUND. The runner never evaluates model text as code, selectors, shell commands, or config. A tool call that breaks a rule fails with POLICY_DENIED: Navigation and secret fills have no origin allowlist. The agent can follow the app to other sites and use the secrets supplied to the current step there. Password fills still require a password field. The browser engine uses a hostname-based site check for two purposes:
  • Configured headers are added to requests on the target’s site.
  • Child frames outside that site, and data: frames, are omitted from observations.
The site check approximates the registrable domain without a public suffix list. Shared hosting domains such as vercel.app count as one site, so headers can reach other hosts under that domain. basicAuth is broader: it answers a 401 challenge from any origin. The runner checks the scheme on explicit navigation. app.open, browser.goto, and the agent’s navigate verb admit http:, https:, and the exact about:blank. A device link may use an app’s custom scheme, so device.openLink refuses a list instead: file:, data:, javascript:, view-source:, blob:, and filesystem:. device.openApp and the open_app tool take an app id and refuse a link, since the device would open one as a URL. The runner does not re-check redirects or attach to popup pages. Register browser.onDialog to handle browser dialogs.

Secrets

A Secret is an opaque handle with no plaintext accessor. Three sinks accept one: a locator’s fill, the params of an agent step, and an engine option that declares it (web({ basicAuth: { password } })). The Signing in guide covers usage; the rules are here. Before a model-directed fill, the runner checks that the step declared the secret, the secret is configured, and the target is an enabled editable input. Passwords require a password field. It then resolves the value on the host and passes it to the engine. Replay repeats these checks. A deterministic fill(secret) uses the field chosen by test code without those model-directed field checks. It still registers the value for redaction and disables pixels for the rest of the attempt.

Screenshots after a secret fill

After any secret fill, for the rest of the attempt, screenshot and pixel tools are unavailable, app.screenshot() is denied, and the runner omits assertion and failure screenshots. The app could display the value anywhere, beyond the field’s masked rectangle. A test that restores a session whose setup filled a secret keeps this protection. A setup that signs in without filling a secret, for example by setting a session cookie with browser.setCookies, leaves screenshots available to the tests that restore it. A value it uses that is not a configured secret is not redacted.

Secrets in engine options

A secret an engine option holds (web({ basicAuth: { password: secrets.get(name) } })) is resolved on the host when each attempt starts and registered for redaction then, together with what the engine derives from it (for basic auth, the base64 user:password the Authorization header carries). It is not a fill and is protected as text only: reports, screen.txt, failure pages, what the model and an e2e mcp session read, text downloads, and the Playwright trace’s text (the options the browser opened with, and the Authorization: Basic header of every request) are redacted, while screenshots, the trace’s screencast frames, and the model’s pixels are kept. A page that renders the password on screen is not masked in pixels.

Redaction

The runner replaces every occurrence of a registered secret value with <secret:name> in model input, reports, a worker process’s console output, trace text, and text downloads. That includes a value the test passes as a plain string rather than a secrets.get() handle: test and describe titles, step labels (an app.open URL, a locator’s text), a fill, an agent.act or agent.assert instruction, the strings in agent.act params, agentContext, and an e2e explore goal. A plain string is not a secret fill: it is protected as text only, and screenshots stay available. Titles are redacted as the test file is collected, so the test id, --grep, the reporters, and the artifact and failure-page file names all see <secret:name>, and two titles in one file that differ only in a secret value are a duplicate title path. A custom executor’s ctx.step and the built-in agent’s prompt carry the marker, so only a handle can type the real value. Matching covers the value as written, JSON-escaped, HTML-escaped, and percent-encoded, in any letter case (so a CSS text-transform does not hide it), and with its whitespace collapsed. Text cut at a length limit is masked too when it ends with the first 8 or more characters of a value (half of a value shorter than 16), as written or with its whitespace collapsed, the way an engine collapses text before it cuts it. Every string of the observed screen, names, text, values, test ids, attributes, selectors, and frame paths alike, is redacted once, before the model, an executor’s tree, a replay cache descriptor, or an end anchor reads it. Redaction rewrites only what the runner reports; assertions still compare the text the page shows. Secret values and their cut parts never enter cache entries. A static value shorter than 6 characters is INVALID_CONFIG, and a provider returning one fails the fill: every occurrence of so short a value would take ordinary text with it.

Where redaction stops

  • A transformed form of the value, such as its last four characters, is not the secret and passes through.
  • A value encoded as a whole rather than character by character, base64 or a hash, is a different string and passes through.
  • A value the app renders in another Unicode normalization form, decomposed accents where the secret has composed ones, passes through.
  • Text that drops the whitespace inside the value passes through. Text that widens it passes through too, except on the observed screen: a field whose collapsed form holds the value is kept collapsed and masked.
  • A value spread over several nodes, one character per cell as a PIN pad shows it, is not one string in the tree and passes through.
  • A case mapping that changes length (ß shown as SS) escapes the prefix rule for text cut at a length limit.
  • A title is redacted when its file is collected, before any provider runs, so a value a secret provider returns is not redacted from titles.
  • When tests run in the runner’s own process, with the config or tests passed in memory by an embedding host, their console output is not redacted.
  • A Playwright trace is rewritten before it is kept whenever its session knows a secret value, filled or not: every static one, and a provider’s once the session resolved it. Any run of 8 or more characters of a value, its whitespace collapsed or not, becomes <secret:name>, so unrelated text that shares 8 characters with a value is masked too. A base64 or base64url run that decodes to a value or such a fragment (a basic-auth header, a cookie, a token segment) is replaced whole. After a fill, screencast frames are dropped; otherwise they are kept. An image the app served that draws the secret survives. A trace the runner cannot rewrite is deleted and the attempt records TRACE_WITHHELD.
  • A video masks nothing and is labeled redaction: "incomplete". It is never recorded unless you ask. A recording a hosted browser or device service keeps stays with that service; the report holds only its URL.
  • A download is what the app served. When the session knows a secret value, every whole occurrence of it in a text download (text/* or JSON by file extension) is replaced, as in reports, and the download is labeled redaction: "complete". Fragments and base64 runs are matched in traces only. Any other download, a binary, one that is not valid UTF-8 or cannot be rewritten, or one from a session that knows no secret value, is kept as served and labeled redaction: "incomplete".
  • The headers a protected preview needs are recorded in the trace as request headers. Share that trace as you would the bypass secret.
  • command.log is captured as the process writes it. The runner does not redact them. Keep secrets out of your app’s stdout.

Sessions

Saved sessions contain authentication state. They are encrypted and last for one run. A session also carries the secret values the setup test resolved from a provider before saving it, inside the same ciphertext, and whether a secret was filled on it. The test that restores the session redacts those values and keeps its pixels withheld, so a stored token the app echoes back never reaches a failure message, a report, or the model. The envelope names the secrets in the clear, authenticated; the values are never written unencrypted. Invalid, expired, missing, or mismatched sessions are refused. See the session errors for the corresponding codes.

Cache trust

Shared cache entries deserve the same review as test code. They can replay actions without consulting a model and are not signed. The runner validates entries before use. Invalid entries become misses. Files are named by a hash of the expected key and refused if larger than 1 MiB. The key holds the agent’s context only as a hash of its redacted text, so no secret value enters it. Entries store actions and a redacted summary; replay executes only the actions. Secret fills store a name and authorize the fill again. An entry recorded by one agent never replays for another, and one recorded on the app’s origin never replays on a page from another origin. e2e init ignores .e2e/cache/ in Git. If you choose to commit recordings, review their actions and typed values. CI defaults to read-only when no cache mode is configured.

Artifacts and logs

By default everything a run writes lands under .e2e/. The output option or --output moves it, the replay cache stays at cache.dir, and command.log is whatever project-relative path you configure. An artifacts.store receives each artifact’s bytes as they are produced, after trace and download redaction, with the redaction label the report records, so a store can export only what the runner vouches for. A cache.store receives every recorded entry and supplies every replayed one; what comes back is validated again before use.

Outbound connections

Built-in features may connect to:
  • one telemetry request per CLI invocation to eu.i.posthog.com, plus at most one per session that e2e mcp serves, unless opted out; see Telemetry
  • one request to eu.i.posthog.com per e2e feedback you run; see Feedback
  • the provider endpoint used by your configured model. Deterministic tests and fully replayed action steps make no model call.
  • readiness probes against the readyUrl of the app the runner starts
  • a Playwright browser download, once, when the browser is missing
  • the DevTools endpoint you name in web({ connect })
  • the GitHub API, only from the opt-in @e2e-dev/github reporter
  • the Kernel API and its hosted browsers, only from kernel()
  • the Expo API and the simulators’ agent-device daemons, only from easSimulators(). It authenticates with EXPO_TOKEN, else with the eas-cli login in ~/.expo/state.json: on a CI runner, set EXPO_TOKEN or keep that login out of the runner’s home
  • the app under test, and whatever that app itself loads
There is no crash reporting or update check. --ai-trace writes a local file without uploading it. Your test code, plugins, and app can make additional connections.

MCP and explore

e2e mcp uses stdio and the same action authorization as a test run. It allows four open sessions at once by default (--max-sessions, 1 through 16) and closes idle sessions. e2e explore offers every configured credential to every step; see Exploring without a test.

Reporting a vulnerability

Report privately through GitHub security advisories or by mail to [email protected]. The security policy states response times and supported versions.