Compatibility
Upstream parity target: tester-army/e2e commit ee901b333ed12352418dfb07bda5eafe32e71c8d (docs(agents): show several personas running one suite (#1258)). On 2026-10-19 the 300 upstream commits from 94ddbfe to it were checked: each commit’s title and release note against this file, and against the port’s code where the note named a behavior. A commit that only changes docs, tests of code the port lacks, dependencies, or release notes needs no entry. The sections were not re-read line by line against upstream’s source, so a statement that no commit in that range touched is as it was at 94ddbfe. The packages upstream has are in Upstream packages.
Names are C# versions of the JavaScript API: agent.act is ActAsync, screen.getByRole is GetByRole, expect(locator).toContainText is Expect.That(locator).ToContainTextAsync, and unique() is Values.Unique.
Package map
Section titled “Package map”| JavaScript | .NET |
|---|---|
e2e and @e2e-dev/web |
E2E (WebEngine is Playwright, in the same package) |
NUnit [Test] |
E2E.NUnit.E2ETest. The fixture commits the replay cache from the NUnit result |
xUnit v3 [Fact] |
E2E.XUnit.V3.E2ETest. The fixture commits the replay cache from the xUnit result |
e2e.config.ts |
e2e.config.json, the same keys as JSON. E2ETest finds and applies it; fixture properties override it |
| Vercel AI SDK model | OpenAiCompatibleModel, OpenAiResponsesModel, AnthropicModel, GoogleModel, and BedrockModel, built from provider in JSON or ModelProviders in code. See Models |
e2e/oauth/chatgpt, copilot, grok, opencode-console |
E2E.OAuth.Subscriptions |
e2e login, e2e logout, e2e models, e2e guide, e2e mcp, e2e explore, e2e-web install |
E2E.Cli, a .NET tool whose command is e2e. e2e explore is ported in part: see Explore. e2e guide [topic] prints the bundled skill, as upstream; an unknown topic exits 2. e2e-web install is e2e install: see Browser install command. e2e mcp is ported apart from video recording tools, project tools, and e2e init: see MCP server |
@e2e-dev/kernel |
E2E.Kernel, KernelProvider: Kernel hosted browsers on the IBrowserProvider seam. See Kernel |
@e2e-dev/eas |
E2E.Eas, EasSimulators: see EAS Simulators |
@e2e-dev/limrun |
E2E.Limrun, LimrunSimulators: see Limrun |
@e2e-dev/decision, @e2e-dev/smol, @e2e-dev/lightpanda, @e2e-dev/github |
Not ported: see Upstream packages |
| — | E2E.TestMu.Mobile, TestMu AI mobile devices through Appium. Upstream’s @e2e-dev/testmu serves the same devices another way: see TestMu mobile |
| — | E2E.Mobile.NUnit and E2E.Mobile.XUnit.V3, MobileAppTest: a ready base class for a device test. Not in upstream: see MobileAppTest |
| — | E2E.Mobile.DevFlow, DevFlowAgents: .NET MAUI apps through the MAUI DevFlow agent. Not in upstream: see MAUI DevFlow |
| — | E2E.WinApp, WinAppEngine: Windows desktop apps through Windows UI Automation. Not in upstream: see WinApp engine |
| — | E2E.WinApp.NUnit and E2E.WinApp.XUnit.V3, WinAppTest: a ready base class for a Windows app test. Not in upstream: see WinAppTest |
@e2e-dev/testmu (testmuBrowsers(), browsers.ts) |
E2E.TestMu, TestMuBrowsers: TestMu AI hosted browsers on the IBrowserProvider seam. Ported in part: see TestMu |
| — | DocumentEngine, an in-memory page for hosts that do not want a browser |
Ported
Section titled “Ported”- NUnit
[Test],[SetUp],[TearDown],[OneTimeSetUp], and[OneTimeTearDown] Assert.Ignore,[Retry],[Timeout], and[Category]- Screen queries: role, text, label, test id, placeholder, display value,
filter(hasTextandhas),or,first,last,nth. A locator offers every query kind, scoped to its matches’ descendants. Every query takes aTextMatch: a string or a .NETRegex, tested against the whitespace-normalized text. A string matches the whole text, case-sensitively, unlessexactis false, which makes it a case-insensitive substring, as upstream.RoleOptionscarriesname,checked,disabled,selected,expanded,pressed, andlevel, andimgis read asimage. A role query never matches a hidden node; the other kinds keep hidden nodes unlessVisibleis set, as upstream.getByLabelmatches any node whose accessible name matches, as the upstream reference resolver does. Options are C# objects (RoleOptions,TextMatchOptions) instead of object literals, andfilter({ hasText, has })is the two overloadsFilter(TextMatch)andFilter(Locator). Regular expressions use .NET syntax, not ECMAScript.GetByTestIdalso takesExact, which upstream’sgetByTestIddoes not.locator.or(other)(upstream #1049) isOr(Locator): aunionexpression that matches either branch once, in document order, and stays strict (STRICT_MODEwhere upstream saysLOCATOR_AMBIGUOUS; addFirst()when both may match). Both locators must come from the same screen and attempt, elseINVALID_LOCATOR.LocatorResolver.ResolveandDeviceLocator.ResolveExpressionresolve the union. Differences: the parameter is typed, so upstream’s refusal of a non-locator has no port (nullis anArgumentNullException); and upstream’sUNSUPPORTED_CAPABILITYfor a display-value branch has no port, since the port’s engines resolve over their tree. Web branches with different frame-selector chains fail withUNSUPPORTED_CAPABILITY(locator.or requires both branches in the same document) as upstream’sframeSelectorsdoes - Actions: tap, click (an alias of tap), doubleTap (
DblClickAsyncis an alias), fill (a string or aSecret), pressSequentially, press, check, uncheck, clear, selectOption, focus, scrollIntoView. They wait up to the action timeout for exactly one enabled, visible match.selectOptiontakes a string that matches an option value or label.ActionOptions.Timeoutoverrides the wait per action (PressSequentiallyOptionsaddsDelay). The web engine’s own Playwright wait still uses the configured action timeout - Locator reads:
textContent,inputValue,getAttribute,isVisible,isHidden,isEnabled,isDisabled,isChecked,boundingBox,count,all,allTextContents. A read of one node (textContent,inputValue,getAttribute,isEnabled,isDisabled,isChecked,boundingBox) waits for its one match until the action timeout, then reads once with no retry for a value;isVisible,isHidden,count,all, andallTextContentsanswer from the current screen at once. No read verifies an earlieract.textContentandallTextContentscollapse each whitespace run to one space and trim, as upstream. A single-node read fails withNOT_FOUNDwhen nothing matches before the action timeout ends screen.tree()andscreen.debug()(commitd7ec0ec) areScreen.TreeAsyncandScreen.DebugAsync. One observation of the whole screen, rendered byFailureEvidence.Listing(the trace’s screen at failure, upstream’sprepareObservation): one node per line, children indented, secrets redacted, cut at the observation byte limit (262144). A node the page does not show is left out. A step of kindscreen.DebugAsyncwrites the text toConsole.Out, where a test framework picks it up. Differences:Timeoutbounds the read with a linked token and fails withOPERATION_TIMEOUT, where upstream passes it to the engine operation. ALocatorhas noTreeAsync, so upstream’s refusal on a locator is a compile error. On aFrameScreenthey refuse withINVALID_ARGUMENT, as upstream’s do. The same commit’s change to print test output without thelistreporter, and to keep stdout for--reporter json, is not ported: the port has no runner.screen-tree.test.tsisScreenTree/ScreenTreeTestsandScreenDebugTests(not ported: the refusal on a locator, the unknown option, andsdk-types.ts’s checks, which a typed API makes compile errors);screen-tree-secrets.test.tsisScreenTreeSecrets/ScreenTreeTests(through a session over a fake engine).run-user-output.test.ts’s two new cases are not ported with the runner change.screen.scrollUntilVisibleandlocator.scrollUntilVisible(locator/screen.ts) areScreen.ScrollUntilVisibleAsyncandLocator.ScrollUntilVisibleAsync, withScrollUntilVisibleOptions { Direction, Momentum, Timeout }(down, slow, 30 s by default).Momentum(None,Slow,Fast; upstream’sMomentum) moved fromE2E.MobiletoE2E.EnginebesideScrollDirection, andLocatorAction.Swipecarries it.IEngineSession.SwipeAsync(direction, momentum, ct)is the viewport swipe with a stride; its default drops the momentum.WebEnginestrides as upstream’sswipeDistance(none half, slow three quarters, fast one and a half of the extent; a swipe naming none keeps three quarters),MobileEnginestrides a node swipe by it and keeps the device client’s default for the whole-screen swipe, andDocumentEngineandWinAppEnginehave one stride. The port has no operation context, so a swipe’s deadline is the cancellation of the token it runs under, and a swipe the deadline ends fails with anOPERATION_TIMEOUTcause. A locator swipe’s engine error is not wrapped inACTION_FAILED, since the port has notranslateLocatorError.filter({ has })now refuses a locator of another screen withINVALID_LOCATOR, as upstream’sownLocatordoeslocator.waitForwithattached,detached,visible(default), andhidden, asWaitForAsync(new LocatorWaitForOptions { State, Timeout }). The timeout defaults to the action timeout.LocatorWaitForOptionsis named apart fromagent.waitFor’sWaitForOptions. Differences: a timeout fails withTIMEOUT(upstreamLOCATOR_NOT_FOUND,locator did not become <state>), and a passingwaitFordoes not verify an earlieract(upstream it does). The timeout carriesDetailsas upstream’s does (the locator, what it asked for, and how long it waited; see Trace page)- Locator expectations: visible, hidden, attached, enabled, disabled, checked, selected, expanded, focused, text, contained text, value, attribute, accessible name, and count. They poll until the assertion timeout, or the matcher’s own
timeout.Notinverts a matcher, which then passes after 1000 ms of continuous truth (or the whole budget when it is shorter). Text matchers take the sameTextMatchas queries,ignoreCase, and a list form. The boolean flags arevisible,attached,enabled, andisChecked(checkedis a C# keyword) toHaveScreenshotasExpect.That(Screen).ToHaveScreenshotAsync(name, options)and the same on a locator, withNotandExpect.Soft(locator); see Visual comparisonexpect.softfor every locator expectation, asExpect.Soft(locator), including.Notand the per-matchertimeout. AnASSERTION_FAILEDis kept and the body runs on; any other error still throws.E2ETestrecords each one on the NUnit result, as insideAssert.EnterMultipleScope, so the test fails when the body ends and lists every failure.E2E.XUnit.V3.E2ETestkeeps them on the session and fails the test when it is disposed. As upstream, a failed soft assertion outranks a skip:Assert.IgnoreorAssert.Inconclusiveafter one fails the test in NUnit, and the message keeps the skip reason beside the failures. In xUnit v3,Assert.Skipafter one fails the test the same way. Other hosts setE2ESessionOptions.OnSoftFailureor callE2ESession.CloseSoftFailuresexpect(browser)asExpect.That(Browser):ToHaveURLAsync(a string or aRegex, withignoreCaseandtimeout),ToHaveTitleAsync(aTextMatch, withtimeout), andToHaveClassAsync(locator, TextMatch),.Not, andExpect.Soft(Browser). Like the locator matchers, a passing one verifies the previousact, and.Notpasses after 1000 ms of the opposite. A read that outlasts the matcher’s deadline ends it, so a page that stops answering fails at the assertion timeoutexpect.pollasExpect.Poll(read, options), withTimeout,Interval, andMessage. A read that throws is retried. The matchers areToBeAsync,ToSatisfyAsync, and.Not;E2E.NUnitaddsToMatchAsyncfor any NUnit constraint, such asIs.GreaterThan(3). The default timeout is 5 seconds, not the configured assertion timeout, because the poll does not see the running test, and it does not stop at the test deadline unless a cancellation token is passedagent.act,agent.assert,agent.waitFor,agent.extract.ActOptionshasParams,Timeout,MaxSteps,MaxModelCalls, andAgent;AssertOptionshasTimeoutandAgent;WaitForOptionshasTimeout,Interval,MaxModelCalls, andAgent;ExtractOptionshasTimeoutandAgent. See Agents- Act tools
observe,scroll,scroll_to, andback. They are offered when the engine declaresEngineCapabilities.ScrollorEngineCapabilities.History.scroll_towith a target scrolls it into view. With text, it pages the viewport, or the target list, until a node reading the text is listed, then scrolls it into view. It stops when the screen stops moving - Replay cache for a verified
act: role, name, test id, and path. Modes areself-finalized,agent-concluded, andmissed Values.UniqueandSecret.Values.Uniquerejects an empty or whitespace value, and a value that contains the cache slot marker (U+0001, the port’s form of{{param:). A secret value has at least 6 code points:Secret.CreatethrowsINVALID_ARGUMENT, a config secretINVALID_CONFIG, andCredentials.UserINVALID_CONFIGfor a shortE2E_USER_*_PASSWORD. The values of the secrets anactreceived inParamsare redacted from every later prompt of the attempt, including theassert,waitFor, andextractstatement and the agent’ssystemandcontext. A secret filled through a locator, or read withSecrets.Getand never passed toact, is not added to that list. Redaction matches any case, JSON escapes, HTML character references, percent encoding, and collapsed inner whitespace. A known marker is never rewritten. The same secrets are redacted from the node names and test ids anactrecords as targets and end anchors in its replay cache entry, and a target, on replay or from the model, is matched against the screen in that redacted form. The same secrets are redacted from the failed matcher messages of locators andExpect.That(Browser), atoHaveScreenshotfailure, alocator.waitFortimeout, the soft-failure aggregate, and the messages of the inner exceptions under them. Upstream redacts error text once, at its report boundary (serializeError), which the port has not, so each of these places redacts its own text. The aggregate redacts when the test ends, so a secret the attempt learned after a failure was kept is covered.expect.pollhas no session, so a poll failure redacts the secrets of every live session in the process, as upstream’ssetErrorRedactordoes for the process. A cause with a secret in its message is rebuilt with the redacted text and keeps its type and code when it is anEngineException,AgentException, orConfigurationException, otherwise it becomes aTestExceptionwith codeERROR, and loses its stack trace. A failed secret fill inWebEnginereports the Playwright message with the value and its fragments of 8 or more characters redacted. A node name cut at 256 characters, and a text or value cut at 512, that ends with the leading part of a secret (8 characters or more, or half of a shorter secret) shows that secret’s marker in its place, as upstream’sredactCutdoes, in the model’s snapshot, the recorded targets and anchors, and thelocateresult.StreamRedactorandRedactor.HoldFrom(upstream’sSecretLedger.holdFrom, withmaxFormLengthasMaxFormLength) hold back the tail of text written in pieces so a value split across two writes is still caught. The port’sRedactorplays upstream’sSecretLedgerhere (the port’s ownSecretLedgeronly wraps one for the MCP server), soStreamRedactortakes aRedactor, and it takes bytes as aReadOnlySpan<byte>. Upstream uses them for what user code prints: a test worker’s stdout and stderr, and thee2e mcpprocess’s output. The port has no worker, ande2e mcpsendsConsole.Outto stderr as it is, so nothing in the port writes through them yet. The server’s own messages are redacted whole where they are writtenapp.open,app.back,app.restart,app.clearState, andapp.baseUrl(App.BaseUrl), and the contextplatform.app.restartandapp.clearStatereopen the base URL inside their own step, as upstream’ssteer, so noapp.openstep follows them- The
browserfixture subset:goto,reload,back,forward,url,title,waitForURL,evaluate,waitForResponse,route,unroute,unrouteAll,cookies,setCookies,setViewport,emulateMedia,addInitScript,keyboard.press,keyboard.type,keyboard.down,keyboard.up, andmouse.move,wheel,down,up - Agent budgets:
ActOptions.MaxSteps,ActOptions.MaxModelCalls, andWaitForOptions.MaxModelCalls. A per-call budget can only lower the agent’s configured one. A higher or non-positive value throwsINVALID_ARGUMENT - An
actpast its action budget endsSTEP_BUDGET_EXHAUSTED, blocked. The model is told and may still conclude: a passing verdict, or a failure without a code, becomes the budget error. Replayed actions draw on the same budget.navigate,back,scroll, andscroll_totake a slot;observedoes not. Anactthat needs one model call more than its budget also endsSTEP_BUDGET_EXHAUSTED, blocked (agent.act exhausted its model-call budget of N) - As upstream, an
actguards against a model that repeats itself. The same call with the same input 3 times in a row, or a sequence of 2 to 4 calls twice, adds a warning. 5 identical calls, or a sequence 3 times, offers the model onlydone, so it still writes its own verdict. Thedonecall never counts. The failure-streak guard is the port’s own: it counts every failed action and empty turn of the act, not only the trailing run, and only asks for a verdict, without limiting the tools. An executor’s transcript is kept only underE2E_DEBUG=1, as upstream keeps it under--debug. ActResult.ModelCalls(0 for a full replay) andActResult.Actions(replayed and live actions, counting failed attempts)AgentException.BlockedandAgentException.Explanation(the same text asMessage)- OpenAI-compatible tool calling
Replay cache
Section titled “Replay cache”E2E_CACHE_DIRreplacescache.diras upstream’s does: it resolves from the config directory, a leading~or~/is the home directory, a blank value is ignored, and the cache mode stays as configured. Not ported:--cache-dirone2e runande2e cache(the port has no such commands), the refusal besidecache.store(the port rejectscache.store), the overlap check againstoutput(the port has nooutput), and theREPLAY_STALEadvice that names the cache directory.- A passing locator expectation,
agent.assert, oragent.waitForafteractverifies it, and a verified act is written. Locator reads,locator.waitFor, andagent.extractverify nothing.agent.assert,waitFor, andextractalways run live. As upstream, verification stops when the test fails, so a check in a derived[TearDown]after a failure records nothing. - Replay runs
backand a viewport scroll as recorded, re-finds a scrolled list before each repeat, and pages again for ascroll_totext. Consecutive identical scrolls are recorded as one action with a repeat count.observeis not recorded. Upstream scrolls the viewport when a list that filled the screen cannot be re-found. Replay does not use node geometry, so a lost list stops the replay. - When a replay ends in
end-mismatchand the agent repairs it with more actions, the entry is evicted instead of rewritten; the next clean run records the flow again. - The key holds the cache schema, the replay policy version, the test, the engine (its platform, never its version, as upstream since
e77c17a), the instruction, the params, the agent’s name, a SHA-256 digest of the agent’scontextwith the known secrets redacted, and the repeat index. Another agent, or a changed context, records again. The repeat index counts per agent and context. Replay policy version 4 re-keys every entry, so the first run after the upgrade records them again: an entry recorded before it may hold a page load as its delta. - A recording checks the step’s delta, as upstream: up to eight nodes that appeared and up to eight that went away, alerts and status messages first, each with its value (never a secure field’s), secrets redacted from names and values, and its
checked,expanded,pressed, andselectedstates. A node counts as gone only when no node on the end screen still matches its anchor. A replay passes on its own only when the end route matches, every appeared node is present, every gone node is absent, at least one of those changes happened during the replay (counted from the first screen the replay saw on the route it ended on), and no alert shows that was not there before and that the recording never saw. The web engine does not observe Next.js App Router’s route announcer (thenext-route-announcerelement), so its title echo never counts as an alert. A value the replay typed counts as a change only when the step changed nothing else. An alert’s dates, times, durations, and ids are read as placeholders. A step that changed no node and stayed on its route records nothing, and an entry read for an act that then passed with nothing to record is evicted. - As upstream, each side lists alerts and status messages first, then leaves, then containers. It skips text that reads differently on every run (ids, durations, dates, clock times, bare numbers, and a count that is the data rather than the step’s result) while a stable anchor remains. An anchor is a visible node with a name, a text, a test id, or a placeholder, and records its role, name, text (when it differs from the name), test id, placeholder, input purpose, value, and states. A node the other fields identify is matched without its test id, so a test id that changed between renders is forgiven; a node with only a test id is matched by it. The screen a step passed on is read once it holds still. Upstream reads a recording’s delta from the step’s settled start screen, and measures a replay’s evidence from the replay’s start. The port reads both from the screen the step’s first action was resolved against: a page that shows a loading state for a few hundred milliseconds after
loadholds still long enough to pass a held-still look, and a delta read from it is the page load, which a replay finds again before its own effect renders. A step that resumes a replay reads its delta from the screen the replay’s first action was taken on. A node whose name or text holds aValues.Uniquevalue is not an anchor. ActResult.CachecarriesReplayedActions,TotalActions, andNotRecorded(param-collision), as upstream’s step cache detail does. It also carriesDetail, as upstream’sstep.cache.detaildoes. On a step that read an entry it carriesRecordedBy, the model that recorded it, as upstream’sstep.cache.recordedBydoes (see Trace page). For a replay that stopped at an action it names the action as the hand-off notice does, since the port records no summary (at action 2 of 3, tap button "Apply", then:and the first line of the error when the action threw). After anend-mismatchit names the recorded nodes that did not show (missing alert "Acme Notes"), the ones that did not go away (still showing ...), the alerts the recording never saw (unrecorded alert ...), or that the outcome already showed before the replay acted. A store whose read throws, or that returns an entry of another schema or with no actions, is a miss withinvalid-entry.- Only the first attempt replays. A
[Retry]attempt runs live and still records, and its acts reportmissedwith reasonretry. Each[Repeat]iteration is a first attempt. - A recording that opens with
navigatereplays from any route. Any other recording needs the start route. - At the end of an attempt that passed, failed, or was skipped, verified acts are written and the unverified acts that recorded or replayed are evicted. An act that missed the cache and then failed leaves its key alone. A model outage or a cancelled test writes and evicts nothing, unless an assertion also failed on the app: then the unverified acts are evicted. An act is reported evicted only once the store deleted its entry (see “Recording outcome” under Trace page).
- An entry that a verified replay finished, or that already holds the same flow, is not rewritten, except to write in the step fields an older entry lacks.
- As upstream, the screen is read by one settle policy per action, for live acts and replays. After a tap, a key press, a select, a check, a navigation, or a back, the next settled look waits up to 2 s for the screen to leave the shape the action was resolved against, then reads until two looks 75 ms apart agree, for at most 1 s. After a fill it waits for the change only; after a scroll, up to 500 ms; after a secret fill, for no change. Node references, focus, and clock-like values do not count as a change, and after an action an empty document does not count as a settled screen. Upstream also counts a screenshot as the shape; the port counts the scroll position instead, so a scroll that moves no node still ends the change wait. An act’s start, the screen after each action, each page of a
scroll_towith a text, and the recording’s end screen are held still; a replay settles the look before each action as the previous action’s policy asks, takes a held-still look between the repeats of a scroll, and reads the end state raw. - A replay waits up to
ReplayTimeout(15 s; a .NET-only fixture property, not a config key) for each recorded target and for the recorded end state. Upstream waits 15 s for the end route and sizes the anchor wait from the recording. FileStepCache.Writeis upstream’sFileCacheStore.write(cache/store.ts): it writes the entry throughAtomicWrite.WriteFileAtomic, a sibling temporary file moved over the entry file, so a crashed or concurrent writer leaves no torn entry, and two writers on one key leave the last write, as upstream’s store documents. Upstream’s store awaitswriteFileAtomic;IStepCache.Writeis synchronous, so the port uses the synchronous twin (upstream’swriteFileAtomicSync, which its CLI and telemetry stores use). Windows-only difference: a POSIX rename replaces the target in one step, but Windows refuses a move over a file that another move is replacing (ERROR_ACCESS_DENIED) or that a reader holds open (ERROR_SHARING_VIOLATION). Upstream’srenamedoes not retry, so there the losing writer throws and the recording is lost. The port retries such a move up to ten times with a growing pause (about 90 ms in all) before the error is the writer’s, so the write lands and the step reportsSavedwhere it would have reportedNotWritten. A denial that lasts, such as a read-only target, reports the same error after the same wait; any other error reports at once; on other systems the retry never runs. .NET-only names:AtomicWrite.MoveAttempts,AccessDenied,SharingViolation, andRetriesMove(the Windows errors and the retry),Utf8(the encoding both twins write with), andTemporaryPath(upstream’stemporaryPath).cache.moderead-onlyreplays but never writes or evicts, including theend-mismatcheviction.offneither replays nor records. Undercache.strict, a stale recording fails the act withREPLAY_STALEand stays in place; a strict run never writes or evicts, and its retries replay. Ano-entrymiss fails too when the cache directory holds a recording of the same step under another key. Entries record the step as the test, the engine, the instruction, the params digest, the repeat index, and the agent; the port stores the engine where upstream stores the target id, and the instruction text where upstream stores its digest. Only an entry that records the whole step counts, and, outsidecache.strict, a verified whole replay inread-writemode writes the missing fields into an older entry. A customIStepCacheis not checked. The message names what changed between the entry’skeyedByand this run’s key context, as upstream’skeyContextChangesdoes, and says “the runner, the engine, the app, or the agent’s context” for an entry with nokeyedBy.- The entry’s file name is upstream’s
traceCacheKeyHash(cache/identity.ts): the SHA-256 of the RFC 8785 canonical JSON of atrace-1key (buildTraceCacheKey) of the test, target, engine name and contract version, app identity, instruction digest (instructionDigest, normalized bynormalizeInstruction), params digest (paramsDigest), repeat index (createCallIndexer, counted per agent name and redacted context), agent, agent context digest, and replay policy version. The port’s key differs from upstream’s in these parts only:projectis the digest of the emptyprojectId(the port has noprojectId);appIdentityis the digest of an emptyidentityandenvironment(the port has noapp.identityorenvironment, so the app’s URL never keys an entry);testIdis the test title, not<file>::<title path>;targetIdis the target’s name andengineNamethe engine’s platform (the port has no engine manifest);policyVersionis the port’s own4. Params are projected before the digest as the port’s cache has always projected them: aValues.Uniquevalue is<unique>and aSecretis<secret:name>; a number is written by .NET’s shortest round-trip form, which matches ECMAScript’s for the values a param holds. The entry storeskeyedBy(TraceKeyContext) as upstream does; an entry with a malformedkeyedBystill reads, without it. Recordings made before this key (the port’s former text-hash key) are not read and are not migrated: they miss asno-entryand must be re-recorded. Undercache.strictthey do not fail asREPLAY_STALEeither: the check matches the step by its recorded params digest, which the old entries hold in the old form, so the step runs live.
Visual comparison
Section titled “Visual comparison”ToHaveScreenshotAsync compares the viewport, or one locator’s box, against <test file>-snapshots/<name>-<target>-<os>.png, with upstream’s names, OS suffixes (darwin, linux, win32), tolerance rules, retries until the screen matches or holds still, masks, and failure images. The pixel comparison is upstream’s (the pixelmatch threshold and anti-aliasing rules) over PNGs read and written with SkiaSharp. The ported testbed PNGs are upstream’s. Differences:
- A 16-bit PNG is scaled to 8 bits by Skia, which can differ from rounding by one level. Chromium writes 8-bit PNGs.
DocumentEnginethrowsUNSUPPORTED_CAPABILITY.MobileEngineover agent-device captures the masked screenshot withnormalizeStatusBaron, so the status bar is held fixed; the TestMu (Appium) and MAUI DevFlow clients ignore it and show the device’s real status bar.- The test file is the file the call is compiled in (
[CallerFilePath]), not a runner’s test entry. A call in a helper stores beside the helper. A build that rewrites source paths (DeterministicSourcePaths) breaks it. - The update flag is
E2E_UPDATE_SNAPSHOTS=1, or the fixture propertyUpdateSnapshots, in place of--update-snapshots(-u). There is no CLI flag or config key. - Failure images go to
.e2e/results/<result>/attempt-<n>/(the fixture propertyResultsDirectorymoves it). They are not attached to a step, because the port has no step report. A screenshot written earlier in the run is tracked per process, not per run directory. Expect.That(screen)is the only screen matcher. There is noExpect.Soft(screen).Expect.That(frameScreen).ToHaveScreenshotAsync(the scopeBrowser.FrameLocatorreturns) fails withINVALID_ARGUMENT, as upstream’s does, andTreeAsyncandDebugAsyncon it do too (screen.tree reads the whole screen; call it on screen).- Secure fields are covered by the engine (Playwright masks, in every frame, including a field inside a closed shadow root, which the engine reaches through the
e2e-closed=selector engine it registers with Playwright before it opens a browser) and by painting the observed secure nodes. A closed root made by declarative shadow DOM (<template shadowrootmode="closed">) is parsed, not attached, so the hook never sees it and the mask cannot reach it, as upstream. Upstream counts masked regions against secure nodes before it keeps the image; the port counts them (EngineScreenshot.MaskedRegionCount), and a vision judgment compares them (see Agents). - After a secret fill,
ToHaveScreenshotAsync, the assertion screenshots, and the failure screenshot stay on, as upstream since58a5cec: pixels are withheld only from a model (Exposure.WithholdsModelPixels, upstream’swithholdsModelPixels). They mask secure fields only.ToHaveScreenshotAsyncfails withPOLICY_DENIEDand keeps no screenshot when the engine masked fewer regions than the screen lists secure nodes, or does not say (EngineScreenshot.MaskedRegionCountis null), as upstream’spixelsProvenMaskeddoes. The failure screenshot applies the same condition, but leaves the image out of the report instead of failing. - The name is a
string?and the options aScreenshotOptionsobject, so an unknown option and a non-locator mask cannot be written. - Locator boxes inside an iframe are in the top-level viewport (past the frame’s border and padding), where they used to be relative to the frame, as upstream’s change for this feature does.
Defaults
Section titled “Defaults”| Setting | Default | Upstream |
|---|---|---|
Test timeout (timeout) |
120 s | Same |
launchTimeout |
60 s | Same |
actionTimeout |
30 s | Same |
assertionTimeout |
5 s | Same |
cleanupTimeout |
30 s | Same |
maxModelCalls |
25 | Same |
maxSteps (actions per act) |
25 | Same |
judgmentTimeout (assert, waitFor, extract) |
30 s | Same |
waitFor interval |
3 s. After the first judgment, the judge runs again only on a changed screen | Same |
act timeout |
30 s (StepTimeout, .NET-only) |
The test timeout |
| Replay wait | 15 s (ReplayTimeout, .NET-only) |
15 s for the end route; the anchor wait is sized from the recording |
expect.poll timeout |
5 s | The assertion timeout |
E2EDefaults holds these values, except the expect.poll timeout (PollOptions); E2EConfig, E2ESessionOptions, and E2ETest start from it.
Error codes that differ
Section titled “Error codes that differ”| Upstream | .NET | |
|---|---|---|
| A locator action, read, or expectation matched more than one node | LOCATOR_AMBIGUOUS |
STRICT_MODE |
| A locator action or read matched no node | LOCATOR_NOT_FOUND |
NOT_FOUND |
locator.waitFor timed out |
LOCATOR_NOT_FOUND |
TIMEOUT |
Not ported
Section titled “Not ported”- The
e2ecommand-line tool apart fromlogin,logout,models,guide, andmcp: the custom runner,[E2ETest]discovery,--grep,test.only, and the JSON report @e2e-dev/mobileapart from its snapshot model and engine (see Mobile snapshot model and Mobile engine),@e2e-dev/githube2e init(see Coding agents),e2e upgrade, the update notice,e2e run --watch,e2e run --max-durationandmaxDuration,e2e feedback, and upstream’s Claude and Codex plugins (plugins/e2e,.claude-plugin). The port has no runner, and ships its skill withnpx skills add- The other upstream packages and what they do: see Upstream packages
- Vision on
act(the screenshot and point tools).app.screenshot()is not ported either. Video is ported on the web engine and onMobileEnginewith agent-device’s own recording: see Video. Vision on judgments is ported: see Agents.ToHaveScreenshotAsyncis: see Visual comparison - Parallel workers and serial suites
- The full locator action set (
dragTo,swipe,hover,setInputFiles,secondaryTap,longPress, pointer points).secondaryTapandlongPresshave no counterpart in the document engine - The screen-level
screen.tapAtandscreen.swipe - Per-action options other than
timeout(anddelayforpressSequentially): clickmodifierson a locator (the agent’stapanddouble_taphold them, above), tapposition, and the{ label, value, index }form ofselectOption - Diff-only observations. Each model turn receives a full text snapshot
- Telemetry
STEP_NOT_AWAITED, the error that fails a test body, hook, or fixture teardown that returns before itsexpect.pollfinished, and cancels that poll. The step recorder hasAbandonRunningand records a step the test did not await asSTEP_NOT_AWAITED, but no fixture calls it yet, andexpect.pollis not a step. Nothing cancels a poll the test does not await, so it keeps reading until its timeout or theCancellationTokenpassed to it. The compiler warns (CS4014) only for a bare, un-awaitedTaskcall in anasyncmethod- Value expectations (
expect(value).toBe,toEqual,toMatchObject,toHaveProperty,toMatchSchema, and the rest) and theirexpect.softform. Use NUnitAssert.Thatwith constraints, andAssert.EnterMultipleScope(orAssert.Multiple) for soft value checks.expect.polltakes NUnit constraints throughToMatchAsyncin their place - Asymmetric matchers (
expect.any,anything,objectContaining,arrayContaining,stringContaining,stringMatching). Use NUnit constraints such asIs.InstanceOf,Is.Not.Null,Has.Property,Is.SupersetOf,Does.Contain, andDoes.Match - The upstream reporter and GitHub pull request comment. The trace page is ported in part: see Trace page
Upstream test-only commits
Section titled “Upstream test-only commits”The tests of upstream commits adcfc2ba, c9445ce6, a13e119c, 1731e0d8, fe2c3450, and 5b6a731e that the port does not run, and why. The tests that follow upstream are in the PRs #517 (adcfc2ba, c9445ce6), #521 (a13e119c), #516 (fe2c3450), #510 and #511 (5b6a731e). 1731e0d8 changed nothing the port has.
Ported in part (the test name carries Partial_ or a PARTIAL suffix):
keeps the first verdict: a later complete_step cannot turn a failure into a passandrefuses a blocked verdict without a blocking error code and leaves the step open for a real one: the port’s verdict tool keeps no state and sends no result text, so the tests check what the loop does instead (the step outcome, the model-call count, and the text of the rejections) and not the verdict result text upstream readsrefuses $sink before reading the plaintext: the port has no plaintext resolver to count reads and records no policy decisionsnever sends a secret value to the model: runs on a scripted model, not a real onediverges with target-not-found when relocation never matches: noobservations == 9check, because the port retries every 20 ms up toReplayTimeoutmasks a password inside an iframe in artifact screenshots and observed pixels: the port has one screenshot pathpins the id of a non-ASCII title pathandrejects an empty name, or an empty, repeated, or blank-entry agent list at registration: a .NET test has one title, and[Agent]takes one nameuses CI defaults for retries, workers, trace, and video: the port has no workers, and its trace default is deliberate (see Defaults)refuses both spellings side by side, and an explicit path that does not exist: the port has one config file name and throwsINVALID_CONFIGrefuses another screen's locator or a non-locator before any engine call(locator-union):Or(Locator)is typed, so only the other screen’s locator can be refused
Not ported, because the feature is not in the port:
- No
ListReporter, run header, or live window; no JUnit or Markdown report renderer; no serial groups, workers, shards, runner,e2e run,e2e init, ore2e list; no JavaScript config loader; notest.extend; no telemetry; no wordmark; noprojectId; norunWithRetries: thereport-list,report-junit,report-markdown,cli-run,cli-boot,config-load,parallel-workers,extended-fixtures,telemetry-*,wordmark,target-identity, andretrytests, and the threereport-statustests about the last attempt of a serial group - No value expectations (
expect-valuestruthy/falsy/null/undefined fail on the wrong value, and pass negated), or click-modifier tests; thewaitForResponse,route, andunroutepredicate tests and thean option ... does not take is refusedtests cannot happen, because C# overloads are typed - No run-wide usage accumulator (the port has
ModelUsage, but no overflow-capping total or usage events),withStallGuard,realmSlot, orverifyEndStatewithwaitMs: themodel-usage,tool-loop-stall,realm-slot, andtrace-replayend-wait tests - No
pixelFallbackobserve option,tap_atorselect_at, upload tool, orschemaandvisionoptions onact: theagent-fallback-handoff,agent-act-pixel-tools,agent-act-verbshidden-file,act-validation, andagent-screen-updatetests - No removed-key hints in
agent-config, noartifactsconfig key (refuses the removed kinds list), no module-error explainer (diagnose), nodefineEngine(binds validateApp, prepare, finish, and fixture factories to the spec) - No stalled-reader pixel fallback in
semantic-fallback - The MCP config takes one target and has no project tools: the three
mcp-serverandmcp-session-hosttests about several targets and project tools. The declared-verbs part is covered bySessionHostTests.Catalogs_only_the_verbs_the_engine_declares - No
--traceflag or retry runner, so thetrace-modestest (records retries only under on-all-retries, --trace wins) cannot run. No runner or report, so the report-schema parts oftrace-cache-replaycannot run either
Left for other work:
detectVcsand theerror stackstest: covered by #496
Upstream’s --debug prints the run’s phase timings and a table of agent steps on stderr, and keeps each planned step’s transcript. A test run has no --debug flag, so the .NET-only variable E2E_DEBUG=1 (or true) stands in for it, and a fixture can override the Debug property. e2e explore --debug is the flag itself (ExploreOptions.Debug; the variable turns it on too). E2ESessionOptions.Debug carries it to the session.
DebugTrace(internal,Internal/DebugTrace.cs) is upstream’sinternal/debug.ts:Record,Time(async, rethrows),Summary, and the staticTableandFormatMs. It hasOfffor a caller that has no trace. It has noDrainand noMerge, because the port prints per test and has no worker channel to ship a snapshot over.debug.test.tsisDebug/DebugTraceTests.- The phases are
session.launch(the engine’s start),test.body(from the start of the session untilCompleteor dispose),session.close(the engine’s disposal), and the agent’sagent.observe,agent.model, andagent.action(Phases, internal; upstreamagent/phases.ts, whereinstrumentPhasealso records step events).agent.cacheis declared upstream and used nowhere, so the port omits it. Upstream also hasconfig.load,engine.prepare,engine.init,app.start(...),collect, andscheduler: the port has no such phase, because it has no runner, no app process, and no engine prepare step. - The table is written to stderr when each session is released, once per test, where upstream prints one table for the run. The title is the same,
[e2e debug] phase timings (wall ...). - The agent step table is
DebugSteps.AgentStepTable(internal,Report/DebugSteps.cs), upstream’sagentStepTableinreport/debug-steps.ts: one row per agent step in execution order, with the step’s time, the time in its model, observe, and action events, its model calls, tokens in and out, the share the provider served from its prompt cache, and its cost. Aviacolumn appears when the steps named several models. It is written to stderr after the phase table when each session is released, from that session’s step records (upstream takes the run’s results and serial groups). Differences:StepModelInfohas no provenance (endpoint,adapterVersion,policyVersion,tokenAccounting,peakTokensPerCall), andIAgentModel.Namespells provider and model together, so a step’s label is that name alone unless an executor reports a provider. Themodelcolumn sums the step’sModelevents, one per model call (StepRecorder.AddModelCall). Theobserveandactioncolumns sum the step’sObservationandEngineevents (see Trace page).cachedandcostread-unless the provider or an executor reports them inExecutorModelCall. Every built-in provider returns the cache read tokens (and the write tokens, where it has them) inModelUsage, andToolLoopand the agent’s own loop (Agent.cs) pass them on. The AI trace still writes only the input and output tokens. Bedrock and Anthropic report the cached tokens apart from the input tokens, so the port adds them back intoInputTokens, as the AI SDK does (convertAmazonBedrockUsage:total: inputTokens + cacheReadTokens + cacheWriteTokens), and upstream’sModelUsage.recordreads that total. Google counts the cached tokens insidepromptTokenCountalready. The OpenRouter cost is the field upstream’sreadCostreads (metadata?.['openrouter']?.['usage'], thencost). A count that is negative or not whole is dropped, as upstream’stokenCountdoes. Only an OpenAI-compatible endpoint reports a cost, and only when it sendsusage.cost, as OpenRouter does. Upstream’sreadCostalso reads the AI Gateway metadata; the port has no gateway.debug-steps.test.tsisDebugSteps/AgentStepTableTests(bothit; the first is partial: the port has no serial groups, so its third step is a second attempt’s)..NET-only: the agent step table case ofSessionDebugTests,ModelCallUsageTestsandProvider_usage_carries_the_cache_split_and_the_cost_when_reported. - An executor’s
AttachTranscripttext is kept astranscript-<n>.txtonly under debug..NET-only:SessionDebugTests, and the debug-on and debug-off cases ofAttachedTranscriptTests.
AI trace
Section titled “AI trace”Upstream’s --ai-trace writes every model call of the run to <output>/ai-trace.json, in the AI SDK devtools shape ({ runs[], steps[] }) that npx unbox-ai reads. One run is one agent step, and one step is one model round trip. A test run has no --ai-trace flag, so the .NET-only variable E2E_AI_TRACE=1 (or true) stands in for it, a fixture can override the AiTrace property, e2e explore --ai-trace is the flag itself (ExploreOptions.AiTrace; the variable turns it on too), and E2ESessionOptions.AiTrace carries it to the session.
AiTraceRecorder,AiTraceCollector,AiTraceScope,AiTraceRun,AiTraceStep,AiTraceDocument,withAiTraceScopeandwithAiTraceStep(asAiTraceScopes.WithAiTraceScopeandWithAiTraceStep), andstringify(asAiTrace.Stringify) are upstream’sinternal/ai-trace.ts(all internal,Internal/AiTrace.cs). The recorder keeps upstream’s rules: a run per step, a nested run per tool, steps numbered without collisions, the prompt as sent with the system prompt in front, and inline media replaced by[binary N bytes omitted]. ItsdrainisDrain(all).- Upstream’s recorder is an AI SDK telemetry integration, so
registerAiTraceRecorderanddispose’s removal from the SDK’s list have no port. The recorder’sonStart,onStepStart,onLanguageModelCallStart,onLanguageModelCallEnd,onStepEnd,onEnd,onError, andexecuteToolare methods (OnStart, …), andAiTraceModel.CompleteAsync(.NET-only,Agent/AiTraceModel.cs) fires them around each model call. Both places that ask a model for a turn, the tool loop and the judgment path, go through it, so a custom executor with a model of its own is traced too. A custom executor that calls a provider directly is not seen.ExecuteToolis not called by any tool. Upstream’s AI SDK keeps a generation open while its tools run, so a generation a tool starts is a child of the step that called it. The port closes a generation (OnEnd) as soon as the model answers, before the tool loop runs the tools, and no tool of the port starts a model call, so there is no open generation forExecuteToolto attribute a nested one to. - The scope comes from
StepRecorder, as upstream’ssteps.tsdoes: each step enters the attempt’s scope and narrows it to its api and label. The attempt’s scope is built by the session (the test title is also itsTestId). The port has no worker, so there is nodrainover a channel: the session drains its recorder when it is released, the process adds it to oneAiTraceCollectorper file, and it rewrites the file at most every 2 seconds and when it exits. - The file is
ai-trace-<process id>.jsonnext to the results directory (.e2e/by default), notai-trace.json. A test run can use several processes (one per test project), and each rewrites only its own file. Open it withnpx unbox-ai <file>. - What the model sees is redacted by the same redactor as the request, and the trace redacts it again on the way in: text, tool arguments, the response, and a failure message. Screenshots are byte counts. The port’s
ModelUsagehas the cache read and write tokens but noinputTokenDetailsobject, sousagehas noinputTokenDetails, andoutputhas noproviderMetadata. The response has noid.provideris the model class withoutModel, such asAnthropic. - Tests:
ai-trace.test.tsisAiTrace/(13 of 14; “registers through the SDK loader and tolerates a missing SDK” is skipped, since there is no SDK; “records the status and the body of a provider error the SDK could not parse” goes throughOpenAiCompatibleModeland expects the port’sThe model provider failed: HTTP 400: ...message).ai-trace-media.test.tsisAiTraceMedia/(3 of 3).agent-ai-trace.test.tsisAgentAiTrace/(2 of 3, partial: the model is the port’sScriptedModel, so the model id isscriptedand the providerScripted, and there is no run outcome, so the test reads the file; “–ai-trace on child-process workers” is skipped, since the port has no workers)..NET-only:AiTraceModelTests(redaction, screenshots, concurrent tests).
Upstream packages
Section titled “Upstream packages”The packages in tester-army/e2e at the target commit, and what the port has:
| Upstream | Status | Notes |
|---|---|---|
e2e (packages/e2e) |
Ported in part | Everything under Ported and the sections after it. The runner, reporters, e2e run, e2e init, e2e upgrade, telemetry, and the hosted session (e2e/runner) are not ported: see Not ported |
@e2e-dev/web |
Ported | WebEngine in E2E: see Web engine |
@e2e-dev/mobile |
Ported in part | E2E.Mobile: see Mobile snapshot model and Mobile engine |
@e2e-dev/eas |
Ported | E2E.Eas: see EAS Simulators |
@e2e-dev/limrun |
Ported | E2E.Limrun: see Limrun |
@e2e-dev/kernel |
Ported | E2E.Kernel: see Kernel |
@e2e-dev/testmu |
Not ported | A mobile device provider for TestMu AI, through agent-device. E2E.TestMu.Mobile serves the same devices with Appium: see TestMu mobile, and what still differs |
@e2e-dev/decision |
Not ported | decisionFilter (see Test filters, where the filter seam it plugs into is ported) and a decision-model executor: a model that picks one action from a grammar (taps, points, vision). The port has the executor seam (IStepExecutor, see Agents) but no such executor |
@e2e-dev/smol |
Not ported | A browser provider that runs each browser and the app in a branchable smol machines microVM. The port has the provider seam (IBrowserProvider, see Web engine) but no such provider |
@e2e-dev/lightpanda |
Not ported | A browser provider for the Lightpanda headless browser. Upstream added it on 2026-10-15 (b41afa2), after the earlier target. The engine drives Chromium only |
@e2e-dev/github |
Not ported | A reporter that posts the run as a pull request comment. The port has no reporters |
A config that selects a decision model or smol cannot be written in e2e.config.json, and it fails at load with INVALID_CONFIG instead of being ignored (E2EConfig):
- Decision models are an
executor. Upstream takesexecutor: decision(...)inagents.<name>. The port rejects the key withconfig key "executor" is not supported by the .NET port: an executor is code, so set AgentOptions.Executor. A custom executor in code works, but the port ships no decision executor.provideraccepts only the names in Models, so aproviderofdecisionis rejected with the list of valid names. - smol is a
browserprovider on the target (web({ browser: smol() })). The target acceptsname,platform,app,trace, andvideo. Abrowserkey is rejected as an unknown target key, and anenginekey as not supported by the .NET port. To use a provider, setWebEngineOptions.Browserin the fixture’sCreateEngine. The port shipsKernelProviderandTestMuBrowsersthere, and none for smol.
Skill text: start near the screen, fix from the trace page
Section titled “Skill text: start near the screen, fix from the trace page”skills/e2e-dotnet ports upstream’s 74c2b73 (#1311, start each test near the screen it covers) and c5e3248 (#1309, “Workflow: fixing a failed test”). Differences:
- The port has no saved
session,test.setup, ortest.extendfixture, so “Start near the screen under test” signs in and seeds data in the framework’s per-test setup (NUnit[SetUp], xUnit v3InitializeAsync) with anHttpClient, andBrowser.SetCookiesAsynchands the cookies to the browser. On a device it isDevice.OpenLinkAsync. - The test runner prints the exception, not upstream’s failure block, so the port has no
❯ at,❯ on screen, or❯ tracelines. The skill points to the exception’sCode, message, andDetails, and to the trace page’s## Screen at failure(its URL andClosest to what the locator asked for). The trace page,## Screen at failure, andscreen-at-failure.txtexist as upstream. Expect.Soft,Values.Unique,App.OpenAsync, andDevice.OpenLinkAsyncstand in forexpect.soft,unique,app.open, anddevice.openLink. In CI the cache isread-onlyunless the config sets a mode (E2EConfig), where upstream’s default records nothing there. Upstream’score-concepts.mdxandcoding-agents.mdxhave no counterpart.
Skill text: off-site iframes (not ported)
Section titled “Skill text: off-site iframes (not ported)”Upstream’s 4982d8d (#1333) documents that the browser engine’s semantic tree leaves out the content of a frame from another site, so agent steps there need browser.frameLocator and vision, and an agent.act whose only change is in that frame records no replay. The port does not take the text, because the behavior does not exist here: the web engine reads every iframe’s document through Playwright, same-origin or cross-origin, and puts it under the iframe node (see Web engine), so the agent sees and acts on that content. Browser.FrameLocator exists, and it resolves over that same tree. The changes to skills/e2e-dotnet/references/{agent,debugging,writing-tests}.md and docs/ are skipped because the behavior they warn about does not exist here.
Verification skill
Section titled “Verification skill”skills/create-verification-skill/ ports upstream’s skill of the same name (commit d85702a2, feat(skills): create-verification-skill generates a verify-<app> skill on e2e (#981)). Like upstream, it is not bundled in E2E.Cli and e2e guide does not read it; install it with npx skills add hardkoded/e2e-dotnet --skill create-verification-skill. Differences:
- The steps use what the port has. The launch is the target’s
app.commandor the app started by hand, the doctor is a smoke test run withdotnet test --filter, and the evidence is thedotnet testresult and the test’s own checks, since the port writes no report or trace page, and a video only whenvideois on. - Not ported: the generated bug bash fan-out. It depends on
e2e.bugbash.config.ts, which the port lacks, and runs manye2e explorecommands at once, so a feature’s charters are goals for a live MCP walk, and a finding is a bug once a repro test fails withASSERTION_FAILED. - Not ported:
npx e2e initas the setup step,e2e listas a config check,--outputfor proofs, and the.gitignorelines for.e2e/bugbash/and.e2e/proof/. The repo already ignores.e2e/. - The example under
references/example/isverify-todomvcforsamples/TodoMvc, in place ofverify-playgroundfor upstream’sapps/testbed, which the port does not have. It has three features (adding, completing, filtering), not the playground’s three (todos, forms, login). - Upstream added no tests for this skill, so none are ported.
skills/migrate-to-e2e/ ports upstream’s third skill (e6885dc, feat(skills): add migrate-to-e2e (#1109), and 6609428). Install it with npx skills add hardkoded/e2e-dotnet --skill migrate-to-e2e; like upstream, it is not bundled in E2E.Cli. Differences:
- The source runners are Playwright .NET, Selenium .NET, and Appium, with upstream’s Node ones (Playwright, Cypress, Detox, Maestro) kept. The e2e side is an NUnit or xUnit v3 test project run with
dotnet test, in place ofe2e.config.ts,*.e2e.tsfiles,npx e2e init,e2e run --repeat-each 3, ande2e list. - Not ported: upstream’s migration guides (
docs/migrate/*.mdx) and the edits to them ine6885dc. The port has no such guides, so the skill reads thee2e-dotnetskill’s topics, and the hand-over offers an issue athardkoded/e2e-dotnetin place ofe2e feedback. - Not ported: the plugin copy (
plugins/e2e/skills/migrate-to-e2e), since the port has no plugin; theAGENTS.mdandCONTRIBUTING.mdlines about it map toCONTRIBUTING.mdhere. - Upstream added no tests for this skill, so none are ported.
docs-site/src/content/docs/coding-agents.mdxports upstream’sdocs/coding-agents.mdxfor the commands the port has.
Coding agents
Section titled “Coding agents”Not ported: e2e init, which scaffolds the config, installs the skill into .agents/skills and .claude/skills, registers the MCP server, and adds a test:e2e script. The port installs the skill with npx skills add hardkoded/e2e-dotnet and registers the MCP server with claude mcp add e2e -- e2e mcp. The bug-bash config (e2e.bugbash.config.ts) and its fan-out are not ported either: see Verification skill.
MobileAppTest (not in upstream)
Section titled “MobileAppTest (not in upstream)”E2E.Mobile.NUnit.MobileAppTest and E2E.Mobile.XUnit.V3.MobileAppTest derive from E2ETest and make a device test one using and one config file. Upstream declares a device in e2e.config.ts and its runner owns the engine and the hosted device. The port’s adapters do, so these classes do it. Every symbol here is .NET-only. Differences:
e2e.config.jsonaccepts"platform": "ios"and"android". A device target takesapp.bundleIdandapp.appPath, as upstream’sapp. It takesapp.commandandapp.readyUrltoo (see Config). It does not take the other upstream app keys (identity,environment,launchArguments,permissions). The port still runs one target.E2E_MOBILE_PLATFORMandE2E_MOBILE_APP_PATHwin over the config, so one project can run on iOS and Android.E2E_MOBILE_DEVICE,E2E_MOBILE_PROVIDER(local,limrun,devflow),E2E_DEVFLOW_PORT, andE2E_DEVFLOW_APPare .NET-only.- A hosted run leases one device on the first fixture and releases it when the process ends, because the adapters have no hook after the last test. A killed process is covered by the provider’s hard timeout (15 minutes for Limrun).
MobileAppHostis one source file, compiled into both packages.HostedDeviceProviders(the provider eachE2E_MOBILE_PROVIDERname makes) is a second, compiled into both packages and intoE2E.Cli.CreateMobileOptions,AppId,AppPath,Platform, andAttachesToRunningAppare the members a test project overrides or reads.
Mobile snapshot model
Section titled “Mobile snapshot model”E2E.Mobile ports the pure logic of @e2e-dev/mobile: the projection of an agent-device accessibility snapshot onto SemanticNode (SnapshotProjection), locator expressions and agent-device selectors over it (DeviceLocator, AgentDeviceSelector), error translation (DeviceErrors), links, screenshot masking, and the shared helpers (DeviceSupport). The client, pool, provider, and engine are in Mobile engine. Differences:
- agent-device is a Node package.
AgentDeviceErrorstands in for itsAppError, andAgentDeviceSelectorreads the selector grammar ofparseSelectorChainand applies the term rules oflistSelectorChainMatchesto the raw nodes, for the target’s platform (the keysid,role,text,label,value,appname,windowtitle, and the flagsvisible,hidden,editable,selected,focused,enabled,hittable). - An
AMBIGUOUS_MATCHfailure (several devices match and no selector narrows it) keeps agent-device’s message, code, details, and hint, andDeviceErrorsadds one sentence to the text:Set MobileOptions.Device to a device id, or boot exactly one device.Upstream adds nothing.AmbiguousMatchTestscovers it and has no upstream counterpart. SemanticNode.Statesholds plain booleans, so it cannot tell an unchecked switch from a node with no checked state.ProjectedNode.Stateskeeps the states the platform reported, andProjectedNode.Selectorkeeps theid=hint upstream puts on the node.- A
filterwithhasTexthonors the exactness of its text pattern, as upstream’smatchesTextdoes.Locator.Filter(TextMatch)is a substring match (upstream’stoTextPattern(hasText, { exact: false })), so a locator test sees no difference.TextMatchholds no exactness, so the expression carries it:LocatorExpression.HasTextExact(.NET-only; defaultfalse) makesHasTextequal the text, as upstream’sexact('About')does inlocate.test.ts. ARegexignores it. The core resolver (Screen) reads it too. - A role query has no
hiddenstate. Role queries skip hidden nodes, as upstream’s do. - Link parsing reads a URL with
System.Uri, after the whitespace rules a browser applies. MaskPngrefuses a PNG with no end chunk, as upstream’s parser does.
Mobile engine
Section titled “Mobile engine”MobileEngine ports engine.ts, options.ts, bindings.ts, pool.ts, provider.ts, surface.ts, actions.ts, device.ts, and tools.ts. It implements IEngine and declares every capability, video included. DeviceSession is the surface, DeviceActions (the IDeviceActions the engine uses by default) turns a node and an action into commands, Device is the device fixture, and MobileTools is the agent tool pack. The eas and limrun providers are ported (see EAS Simulators and Limrun); a provider’s own recording (DeviceProvider.record) is IDeviceProvider.RecordAsync (see Provider recording). Differences:
- agent-device is a Node program.
IDeviceClientstands in for upstream’screateAgentDeviceClient: a call isnamespace.methodwith an options object, as upstream’s client names them.AgentDeviceRpcClientimplements it over the daemon’s HTTP JSON-RPC 2.0 (POST /rpc, methodagent_device.command,GET /health). The wire format was read from agent-device 0.21.22’s code and checked against a running 0.21.22 daemon fordevices,boot,close,session_list,prepare, andsnapshot. A running 0.21.25 daemon was checked forGET /health(protocol 2) andsession_list. A running 0.21.29 daemon answeredGET /health(protocol 2, version 0.21.29),session_list, anddevicesin the same envelope, and itsdaemon.jsonstill carrieshttpPort,token, andversion(it addscapabilitiesandplugins, which the client ignores). The 0.21.26 to 0.21.29 release notes list no change to the RPC envelope or to the commands the engine sends; the changes that touch the engine are in its commands (a coordinatefillthat names no text input is refused on iOS, afindfocus match behind the visible keyboard is refused, an alertdismisspicks a listed decline, and a delayed launch confirmation is surfaced), and none of them was exercised on a device. It is not a documented protocol, so the version is pinned (AgentDeviceVersion.Pinned) and the client checksrpcProtocolVersionand the daemon version.DeviceCommandsturns each call into a command, positionals, and flags. The calls the engine makes today are covered. A call it does not know is refused. - A local daemon needs Node 22.12 or newer. The first call starts it the way agent-device’s own CLI does (
npx --yes agent-device@0.21.29 session list --json, withAGENT_DEVICE_DAEMON_TRANSPORT=http), in the state directory~/.agent-device/e2e-dotnet, and it keeps running after the command.E2E_AGENT_DEVICE_BINnames anagent-device.mjsto run in place ofnpx. A missing Node, a Node that is too old, an agent-device that does not start, and a daemon of another version fail withINVALID_CONFIGand the recovery. Starting a daemon on Windows is not verified. MobileOptionscarries the app (App: bundle id, build path, launch arguments, permissions), the target name, and the device choice as three properties (Device,Devices,Provider; at most one), because the port has no target config for a device yet. Permission names and states are enums, so an unknown one cannot be written.SettleandSettleMillisecondsstand in forsettle: number | false.- The core engine contract has no
prepare,finish,init, or worker slot.MobileEngine.PrepareAsyncandFinishAsynctake their own info records, andStartAsync(MobileStartInfo)starts the worker of one slot.StartAsync(EngineStartOptions)starts slot 0 of the target. Bindings travel from prepare to a worker throughMobilePrepareResult.Env, as upstream’s environment variable does. In one process the pool also keeps them. app.open()isOpenAsync: it launches the pinned app fresh. A deep link or any absolute URL is refused.EngineStartOptions.BaseUrlis refused as upstream refusesapp.url.DeviceSession.BackAsyncalways settles (upstream’s agent origin), since a test step has no origin here.- The core session has no attempt hooks, pixel observations, or artifact directory. So
DeviceSessionis one attempt (it registers on the engine until it closes), andScreenshotAsyncreturns the masked, status-bar-normalized PNG fortoHaveScreenshotinstead of writing numbered files. A secure field that cannot be masked still withholds the image withENGINE_FAILURE. DeviceSession.LocateAsync(LocatorExpression, TimeSpan timeout, CancellationToken)is upstream’slocate(surface.ts), so a locator orExpecton a device resolves in the engine. It snapshots withinteractiveOnly: false, runsDeviceLocator.ResolveExpression, and binds the matches in a separate map capped at 2048 (the oldest goes first; an older ref isNODE_STALE). It hands the capture it matched against toEngineStartOptions.Screen, and tellsEngineStartOptions.Environmentthe platform and device once, when the session starts. Differences:timeoutis the budget left (upstream’soperation.timeoutMs), since the core session has no operation context; the expression is one flat record (LocatorExpression) where upstream’s is a union ofquery,filter,index,union,selector, andframe; and the port’s refs carry no revision, so upstream’srejectSupersededLocatehas no port (a device mints a new id for every projection, so a later locate never reuses a ref). Core retries a retryableEngineExceptionof a locate until the wait’s deadline, asresolveOncedoes.- Upstream’s Android locate (
fix(mobile): keep locator polling through unreadable android snapshots,4fe6baf) is ported. A snapshot agent-device cannot read yet (details.androidSnapshotHelperFailureReasonofempty-helper-output,system-window-only, orcontent-poor-app-window, the messageAndroid snapshot helper returned insufficient foreground app content) matches nothing in a locate, so a locator orExpectkeeps polling until its wait runs out, and a locate whose capture leaves no budget fails withENGINE_FAILUREand that message.DeviceErrors.IsUnreadableCapturereads the reason off theAgentDeviceErrorthat the translatedEngineExceptionkeeps as itsInnerException. An observation, such as the agent’s next turn, still fails on it at once, as upstream’s. - Upstream’s iOS locate (
2b2b845, tester-army/e2e#1281) is ported. Just afterOpenAppAsync,RestartAsyncorOpenLinkAsync, a snapshot that iOS refuses withdetails.runnerErrorCodeofAPP_NOT_RUNNING(DeviceErrors.IsAppNotRunning) matches nothing in a locate (locator waits andExpectonly; an observation still fails at once), like an unreadable Android capture, until a snapshot succeeds (DeviceSession._launching, upstream’slaunching). A wait that runs out while the app is still down fails withENGINE_FAILUREand that reason. An app that exits after its first readable screen still fails at once. - Upstream’s Android test id match without the
<package>:id/prefix (feat(mobile): match Android test ids without their package prefix,2791786) is inDeviceLocator, whichDeviceSession.LocateAsyncnow calls, soScreen.GetByTestId("greet")matches<package>:id/greeton Android when nothing matches the full id. - The core does not say who calls
PerformAsync, soDeviceSession.Origindoes.ActionOrigin.Agent(the default) settles after each action.ActionOrigin.Testnever settles and waits out a transition (TransitionMilliseconds) for a control that arrived with the last action. The coreLocatorActionhas no long press, hover, drag, or swipe momentum, soLongPressAsync,HoverAsync,DragToAsync,SwipeAsync(node, direction, momentum), andPerformAtAsync(point, PointerAction)are methods ofDeviceSession.ScrollIntoView,Select, andPressSequentiallyareUNSUPPORTED_CAPABILITY. Deviceis a plain class (session.Device) with...Asyncmethods that take a cancellation token per call. The harness does not recorddevice.<method>steps or labels, andDevicehas nolocator.appearance,network,alert, and biometric results are enums. A permission name or state cannot be unknown, so theINVALID_ARGUMENTcheck for one is not needed.- The sample (
samples/MobileApp.E2ETests) derives fromMobileAppTest(see MobileAppTest). It launches the app withDevice.OpenAppAsync, because the coreApp.OpenAsyncneeds a URL, and reads theDevicefixture fromMobileEngine.ActiveSessions, because the core session does not expose the engine’s session. It was run against an iPhone 17 simulator. The Android path is not run. MobileTools.CreatereturnsAgentTools by name (the port’sdefineTool), withPlatformsas the strings the engines report (ios,android). PassMobileTools.Create(engine)as the agent’s tools (AgentToolsonE2ETest), and the agent offersopen_app,swipe, andalerton the platforms of the engines it was built from.ai-importshas no counterpart: there is noaipackage.- agent-device takes no abort signal, so a cancelled command keeps running. Upstream’s surface tracks those commands and makes the next attempt wait for them. The port does not: each attempt has its own session and client.
- The key grammar (
DeviceKeys) is ported here, since the core has none. - Video is
DeviceSession.StartVideoAsyncandStopVideoAsync(upstream’sstartVideoandstopVideoofAgentDeviceSurface), overrecording.record(commandrecord, withquality: medium,recordingScope: device, andhideToucheswhenMobileOptions.VideoTouchesis false). The file isvideo/video.mp4inMobileStartInfo.ArtifactsDir(upstream’sartifactsDir;EngineStartOptions.ArtifactsDirfills it). A recording is marked before the device is asked, so a start that outlived its budget is stopped when the session is disposed, as is one whose stop failed (upstream’sendAttemptanddispose). A recording the device finalized elsewhere is moved into place. A session is one attempt, so upstream’sstartAttemptcleanup (sessions.closeof a leftover recording) has no port, and the in-flight commands outlive the session on the engine (see In-flight commands). Closing a session does not wait for a recording start that outlived its budget before it stops the recording (upstream’sendAttemptsettles the in-flight commands first), so the daemon may see the stop while the start still runs. The device provider’s recording (LeaseRecording) is in Provider recording. The agent-device parameters were read from upstream and the 0.21.25 daemon’s command table; on an iOS 27 simulator with Xcode 27.1 the daemon accepted the start, and its stop timed out inxcrunafter 30 s, so a recorded file is not yet seen end to end. VideoTests(describe('video')ofengine.test.ts) are ported in full on a scripted client; “records and stops on the pinned $platform device after closeApp ends the previous session” runs the second attempt as a new session.ManifestTestsdeclares the video capability.- Upstream’s keyboard drag start (
fix(mobile): start the iOS keyboard-dismissing drag clear of controls,787a5d5, tester-army/e2e#1305) is ported.Keyboard.KeyboardDragStartinKeyboard.csis upstream’skeyboardDragStartinkeyboard.ts(the class name is the module’s, since C# has no free functions).DeviceSession.KeyboardTopis upstream’skeyboardTop, andRawSnapshot.KeyboardFramecarries the keyboard fact’s frame it reads.SnapshotProjection.ScreenKindsis exported as upstream exportsSCREEN_KINDS.DismissKeyboardAsyncdrags from that point, and a screen with none fails withUNSUPPORTED_CAPABILITYbefore any drag. Tests: fiveits ofdescribe('device fixture')inEngine/DeviceFixtureDismissingAKeyboardThatHasNoDismissKeyTests. Not ported: ‘dismissing the keyboard over choice controls leaves them as they were’ (apps/mobile-benchmark/tests/device.e2e.ts), which needs the mobile benchmark app, a React Native app the port lacks. - Upstream’s known-viewport fix (
fix(mobile): keep the known viewport when the app reads smaller than the screen,7674a2c, tester-army/e2e#1291) is ported.DeviceSession.Projectlearns a snapshot’s viewport only when it is plainly the screen’s: the first one the session reads, the one it knows, or that one rotated (SameScreen, upstream’ssameScreen; two sizes within a point are one screen, since rects from the tree carry fractions).ViewportForAsync(upstream’sviewportFor) checks a tree of any other size against the device’s screenshot, in either orientation, before it replaces the known one: a screenshot reports the portrait size even in landscape, so it confirms a size but cannot pick the orientation. The size it checks against isDeviceSupport.LogicalScreenSize(upstream’slogicalScreenSize): the screenshot’s logical dimensions, else its pixel dimensions over its density. A simulator’s screenshot reports those; a real device’s reports neither, so there the tree’s size is taken as it comes. A screenshot that fails during that check fails the observation, as upstream’s does.TraceScreenandMaskedScreenshotAsyncsize the trace screen and the secure-field masks by the known viewport, not the tree’s. On iOS, a swipe from a sheet’s grabber can pull down Notification Center, and the app’s tree behind it reported a third of the screen (130x281 on a 390x844 device), which misplaced every rect and tap after it. Upstream records this only in its changeset; its skill references and docs say nothing about it, soSKILL.mdanddocs/have nothing to mirror. Tests: inEngine/ObservationTests, ‘keeps the known viewport for an app drawn smaller than the screen, and follows a rotation’ (new) and ‘measures the screen in the viewport the snapshot publishes, and in its tree when it publishes none’ (split into two harnesses, as upstream’s); inEngine/DeviceFixtureDismissingAKeyboardThatHasNoDismissKeyTests, ‘drags at the centre of a screen rotated since the last observation’ (new), since the keyboard drag measures its start throughViewportForAsynctoo. - Upstream’s input budget (
fix(mobile): bound each input by its budget,14b77d3, tester-army/e2e#1073) is ported. An input (a tap or other node action, a point action, typing, a key,back, a keyboard dismissal, and the nodeLongPressAsync,HoverAsync,DragToAsync, andSwipeAsyncthe port has in place of upstream’slongPress,hover,dragTo, andswipeactions) runs its device commands underInputBudget(DeviceSupport.cs, upstream’ssupport.ts): with no budget left the input is not sent and fails withOPERATION_TIMEOUT, an input that does not answer in time fails withACTION_MAY_HAVE_COMMITTED, one sent with almost no budget left still gets 250 ms to answer, a hold (a long press or timed swipe) at least as long as the budget left is refused withNOT_ACTIONABLE, and each command of a multi-command input (a key on an unfocused field) is sent on its own. Reads, launches, artifacts, and theDevicefixture calls (includingdevice.dismissKeyboard, which passes no budget, as upstream’sdismissKeyboard(signal)does) are unbounded. Differences: upstream reads the budget per call, fromoperation.timeoutMs, which is the call’stimeoutoractionTimeout. The port’s session calls carry no timeout, so every input getsDeviceSession.ActionTimeout, whichMobileStartInfo.ActionTimeoutsets (EngineStartOptions.ActionTimeoutfills it), as the web engine’s operations get the action timeout.DeviceSession.InputBudgetis upstream’sinputBudgetandDeviceSession.SendInputAsyncitssendInput.InputBudget’strackis anAction<Task>that registers the task (upstream’s returns the promise it is given); see In-flight commands. TheOperationContext.signaldoc change indocs/reference/engine.mdxandcontract.tshas no counterpart: the port has noOperationContext. Tests: inEngine/ObservationTests, ‘ends %s, hung on %s, at its budget as possibly committed’ (a theory of the same seven entries; the screen swipe is aperformon the root), ‘sends nothing once a settling target spent the budget, and no keystroke once a focus tap used it up’, and ‘refuses a long press that holds longer than its timeout leaves, before sending it’ (also on the nodeLongPressAsync); inSupport/SupportHelpersTests, ‘sends an input with what is left of its budget, and reports one cut off as possibly committed’. These run on real timers of a few hundred milliseconds, where upstream’s fake timers do not wait. ‘lets a locate outlive its budget’ was already ported. - Upstream’s WebDriver element-type fix (
fix(mobile): read XCUIElementType class names as their XCTest types,3f9a7d8, tester-army/e2e#1133) is ported.SnapshotProjection.NormalizeKindis upstream’snormalizeKindinnodes.ts: it drops^XCUIElementType(?=[A-Z])(XcuiElementTypePrefix, a .NET[GeneratedRegex]for upstream’s inline regex) before it maps the type, so theXCUIElementTypeButtonthat agent-device’s WebDriver runtime reports on hosted Appium clouds reads asButton.DeviceSession.KeyboardShowingis upstream’skeyboardShowinginsurface.ts: it routes node types throughNormalizeKind, so anXCUIElementTypeKeyboardcounts as a shown keyboard. Upstream records this only in its changeset; its skill references and docs say nothing about it, soSKILL.mdanddocs/have nothing to mirror. Tests: inNodes/SnapshotProjectionTests, ‘reads the XCUIElementType class names a WebDriver session reports as the native XCTest types’; inEngine/DeviceFixtureDismissingAKeyboardThatHasNoDismissKeyTests, ‘reads an XCUIElementTypeKeyboard from a WebDriver session as a shown keyboard’. - Not ported: the tool-pixels test (needs the full runner and a scripted model), and the
device.openAppcheck of an unknown permission name.
EAS Simulators
Section titled “EAS Simulators”EasSimulators (the E2E.Eas package, namespace E2E.Eas) ports @e2e-dev/eas: hosted iOS simulators and Android emulators from Expo, leased through Expo’s GraphQL API over HttpClient, with no SDK. The provider is its own package, E2E.Eas, which depends on E2E.Mobile, as upstream’s @e2e-dev/eas is a package of its own. It keeps a private copy of the shared HttpClient, so E2E.Mobile exposes nothing extra for it. Pass it as MobileOptions.Provider. The sessions, the queue and startup limits, the idle backstop, the EXPO_TOKEN or eas-cli login, and the project id from the app config follow upstream. Differences:
- Options are the
EasSimulatorsOptionsrecord, so an unknown option key is a compile error and upstream’srejectUnknownKeyshas no counterpart. A bad combination (BuildIdwithApplicationArchiveUrl, an idle limit not below the duration) isINVALID_CONFIGfrom the constructor. - A dynamic app config (
app.config.tsand its siblings) is evaluated by running the project’s ownexpo configwithnodefrom the path of the process, so it needs Node. A staticapp.jsonneeds nothing. - The request’s cancellation is a
CancellationToken. A cancelled read of the app config is anOperationCanceledException; upstream wraps it as “could not read the Expo app config”. - A device request always carries the project root, so upstream’s check for an engine that passes none has no counterpart.
Limrun
Section titled “Limrun”LimrunSimulators (the E2E.Limrun package, namespace E2E.Limrun) ports @e2e-dev/limrun: one hosted iOS simulator or Android emulator per worker slot, created, polled to ready, driven through an agent-device daemon the provider starts for it, and deleted on release. Pass it as MobileOptions.Provider. The key and base URL (LIMRUN_API_KEY, LIMRUN_BASE_URL), the instance spec, the daemon environment, the retained lease, the 20-second heartbeat, and the cleanup follow upstream. The client has the lease commands the provider needs: leases.allocate, leases.heartbeat, and leases.release (DeviceMethods), sent as agent-device’s agent_device.lease.* JSON-RPC methods. Differences:
- The provider is its own package,
E2E.Limrun, which depends onE2E.Mobile. Upstream’s@e2e-dev/limrunis a package of its own too. The package keeps private copies of two smallE2E.Mobileinternals (the argument builder and the sharedHttpClient), soE2E.Mobileexposes nothing extra for it. - Limrun’s REST API (
/v1/ios_instancesand/v1/android_instances: create, get, delete) is called overHttpClient, with no@limrun/apiSDK. Like upstream’s configuration of the SDK, it retries nothing and gives each request 60 seconds. - Upstream forks a Node process per device, so the daemon and its lease client get the instance’s credentials without changing the runner’s environment. The port starts the daemon itself, with exactly the run’s environment plus the instance’s (
AgentDeviceDaemon.LocalAsyncwith an environment), and runs the lease and install calls in the runner.AgentDeviceDaemon.StopAsyncrunsagent-device daemon stop --clean. - Upstream pins agent-device as a dependency of
@e2e-dev/limrun, at the engine’s version (0.21.29), and checks that the installed one matches. The port always starts the pinned release (AgentDeviceVersion.Pinned), so nothing is installed by hand. It only checks that the engine asked for that release, and the daemon’s own version check runs when it starts. - The lease request leaves out
providerCredentialFingerprint. The daemon only compares it with the credentials of a daemon that was already running, and the port starts a fresh daemon with the right credentials for every device. - Options are the
LimrunSimulatorsOptionsrecord, with enums for the device models, so an unknown option is a compile error and upstream’srejectUnknownKeyshas no counterpart. - Upstream’s Limrun page (
docs/integrations/limrun.mdx) is ported asdocs-site/src/content/docs/integrations/limrun.mdx, with the port’s C# names. Itsexamples/with-limrunapp-flow example is not ported:samples/MobileAppis the port’s mobile example. - Not verified against a live service: the lease wire format was read from agent-device’s code (the
agent_device.lease.*methods and their params in the daemon, and the client that writes them), and Limrun’s REST calls from the@limrun/api0.59.0 source. No test reaches Limrun or a daemon.
In-flight commands
Section titled “In-flight commands”agent-device takes no abort signal, so a command a cancel or a timeout abandoned keeps running on the device. Upstream’s surface keeps them in inflight (track, settleInflight) and its startAttempt waits for them before it opens anything, so a ghost tap never lands in a retry. The port has the same behavior:
DeviceSession.Trackis upstream’strack:CommandAsync,ActAsync(upstream’sperformregistration), and everyInputBudgetregister the device command until it settles.DeviceSession.SettleInflightAsyncissettleInflight, andDeviceSession.StartAsynccalls it first, with the caller’s token, asstartAttemptdoes: a command that never settles fails the start asCANCELLEDonce the token ends, and the boot never goes out.- Differences: upstream’s surface is the worker, so it holds the set. The port’s session is one attempt, so
MobileEngine.InflightOf(target, slot)holds the set of a worker slot (InflightCommands, .NET-only) across the attempts that drive it. A command that fails after its caller left is read once and never reported. The set is keyed by target, slot, and the lease or device the binding names, so an engine reused for another run does not wait on a released device’s commands. The wait is also bounded byE2EDefaults.LaunchTimeoutwhen the caller’s token never ends (ENGINE_FAILURE). Upstream’s client takes no abort signal, so an abandoned command is still running when it is tracked; the port passes the caller’sCancellationTokentoIDeviceClient.CallAsync, and the HTTP client aborts its request on a cancel, so a command the caller cancelled (not one an input budget timed out) is tracked only until that call returns, while the daemon may still be running it. Closing a session (upstream’sendAttemptordispose) does not settle the in-flight commands. - Tests:
Engine/ObservationTests, ‘keeps a timed-out input in flight: the next attempt waits for the device command to settle’, andEngine/SessionHooksViewportSwipeLocationArtifactsTests, ‘waits for an abandoned command to settle before the next attempt starts and its launch goes out’. Both wait on a command the test releases, not on a timer, apart from the 200 ms input budget of the first.
Provider recording
Section titled “Provider recording”IDeviceProvider.RecordAsync(lease, context, ct) is upstream’s DeviceProvider.record. When an attempt records video and the slot rides a provider’s lease, DeviceSession.StartVideoAsync calls it in place of agent-device’s recording, with the lease as it traveled to the worker (DeviceProviders.TravelledLease, upstream’s travelledLease: the id and the declared fields, never what a derived lease class kept) and a ProviderRecordContext of RunId, TargetName, AttemptId, and Env. DeviceProviders.RecordLeaseAsync is recordLease: a failure is ENGINE_FAILURE naming the provider and the lease, a failure met once the token was cancelled is CANCELLED, and a token already cancelled never reaches the provider. StopVideoAsync ends the recording with ProviderRecordings.StopProviderRecordingAsync (upstream’s stopProviderRecording) into a VideoFile or a VideoLink; the recording is marked at once and cleared when the provider confirmed the stop, before what it wrote is checked; a stop shared by concurrent callers runs once. Closing the session stops a recording still marked, best effort. DevicePool.Recorder is pool.recorder, and LeasedDevices.Recorder is recorder. Differences:
- Upstream’s
recordis optional, and a provider without one records with agent-device. A C# interface member has no absence to test, so the defaultRecordAsyncreturns null and a provider that returns null records with agent-device, asIBrowserProvider.RecordAsyncdoes.LeasedDevices.Recorderis always the provider. Upstream’sasDeviceProvidercheck thatrecordis a function andisProviderRecording’s check for astartedAtand astophave no port: the types say it. The context’ssignalis theCancellationTokenparameter. MobileStartInfo.AttemptId(upstream’sEngineAttemptContext.attemptId) names the recording’s attempt.EngineStartOptions.AttemptIdfills it, and a new id is made when it is absent. A run through the test host sets noRunIdyet, soProviderRecordContext.RunIdis empty there.- Upstream’s worker outlives its attempts and keeps what an attempt could not stop: a provider recording whose stop failed is retried at the next
startAttempt(which then blocks), a native recording that could not be confirmed stopped resets the leased session before the next attempt, and a worker disposed while the lease is reused keeps its session. The port’s session is one attempt that always ends its agent-device session: a recording whose stop failed is retried once, when the session closes, and then abandoned, and nothing is retained for the next attempt. Providers must clean up their recordings when they release a lease. - Tests:
Provider/DeviceProviderRecordingTestsports 10 of the 22itofdescribe('device provider recording'). Eight follow upstream: ‘records through the provider instead of agent-device, from the worker, with the lease id and the declared lease fields’, ‘reports a recording the service keeps as a link’, ‘keeps the agent-device recording for a provider without record’, ‘keeps the agent-device recording for a worker without a lease, when no prepare ran’, ‘names the provider and the lease when record fails, and has nothing to stop at attempt end’, ‘cancels a start whose budget ran out, and stops the recording that came up anyway at attempt end’, ‘never asks the provider to record for an operation already cancelled’, and ‘keeps a recording whose stop failed, so endAttempt stops it once more’. Two are partial (Partial_prefix): ‘stops a provider recording without closing its leased session when the worker is disposed mid-attempt’ (the port closes the session, so it checks the stop only) and ‘clears a confirmed provider stop before rejecting its invalid artifact’ (it checks the stops, not the reuse of the session). Skipped: ‘refuses a provider whose record is not a function’ (the type says it), and 11 that need a worker that survives attempts and retains a recording or its session: ‘keeps a reusable leased session when the provider recording still cannot stop during disposal’, ‘finishes a retained provider recording before the next attempt records in the same worker’, ‘blocks the next attempt while its retained provider recording cannot stop and preserves its owner’, ‘does not begin another attempt or record when a retained provider stop wait is cancelled’, ‘resets a leased session whose native recording still cannot stop after endAttempt’, ‘resets an unconfirmed native recording before another attempt in the same worker’, ‘fails the next attempt when its native recording reset cannot close the session’, ‘keeps an unconfirmed native recording after its stop wait was cancelled’, ‘preserves a session after native stop succeeded even when moving its artifact fails’, ‘retries an unconfirmed provider stop after endAttempt during disposal’, and ‘shares a pending provider stop and closes on reuse revocation before the stop resolves’. .NET-only: ‘keeps the agent-device recording for a provider that records nothing of this device’ (a provider whoseRecordAsyncreturns null).
device.locator
Section titled “device.locator”Device.Locator(string selector) is upstream’s device.locator: a Locator over a selector expression, which DeviceLocator answers with the agent-device selector grammar (see Mobile snapshot model). Upstream builds it with EngineFixtureContext.locator. The port has EngineFixtureContext (public, Locator(LocatorExpression) only) and IEngineFixtureHost (.NET-only): E2ESession hands the context to an engine session that implements the interface, once the attempt’s Screen exists, and DeviceSession keeps it for its Device. Device.Locator on a session no test harness started (an engine used on its own) is INVALID_STATE. The other members of upstream’s EngineFixtureContext (fixture, app, timeouts, signal, operation, attachArtifact, attachViewport, screen, expectable) have no port: the port’s fixtures take their own tokens and record no steps. Upstream’s engine test has no it for locator; Engine/DeviceFixtureTests has a .NET-only test, ‘mints a locator from an agent-device selector on the attempt’s screen’.
Kernel
Section titled “Kernel”KernelProvider (the E2E.Kernel package, namespace E2E.Kernel) ports @e2e-dev/kernel at the port’s parity commit: kernel() from provider.ts, the Kernel client from client.ts (KernelBrowsers, kernelBrowsers, KernelBrowserParams, KernelReplayParams), and envValue from env.ts. It is a WebEngineOptions.Browser provider: Name is kernel, AcquireAsync creates a browser, ReleaseAsync deletes it, SweepAsync deletes the run’s active browsers, Downloads reads a download back through the browser filesystem, and RecordAsync records a Kernel replay. Lease tags, the 600 s idle timeout, the cancelled-request delete, the sweep message, and the replay.mp4 replay with its retried download follow upstream. Differences:
- Upstream’s function
kernel(options)is the classKernelProvider(a type namedKernelwould clash with the namespace).KernelOptionsis a record that extendsKernelBrowserParams, as upstream’s interface does. - There is no Kernel .NET SDK (no package from Kernel on NuGet; Kernel publishes Node, Python, and Go SDKs). The client calls Kernel’s REST API with
HttpClient, the API key as a bearer token, and replaces@onkernel/sdk:POST /browsers,DELETE /browsers/{id}(404 counts as deleted),GET /browsers?status=active&tags[k]=v(paged byX-Has-MoreandX-Next-Offset),POST /browsers/{id}/replays,POST .../replays/{replayId}/stop,GET .../replays/{replayId}(Accept: video/mp4), andGET /browsers/{id}/fs/read_file?path=. The base URL ishttps://api.onkernel.com/, orKERNEL_BASE_URLfrom the run’s environment (the SDK reads it from the process environment). The SDK retries a failed request twice; the client retries nothing, since a retried create could allocate a second browser. The SDK keeps one client per API key; the client here is stateless, so the provider builds one for each request. An error answer becomes aKernelApiExceptionwith the SDK’s message, trimmed (401 Invalid or disabled API key). - Upstream types the create-browser body as the SDK’s
BrowserCreateParams.KernelBrowserParamshas properties forstealth,headless,gpu,timeout_seconds,profile,proxy_id,viewport, andtags(Stealth,Headless,Gpu,TimeoutSeconds,Profile,ProxyId,Viewport,Tags), andExtra, a JSON object for any other field by its wire name (proxy,extensions,region, …), which the properties win over, so the run’s tags and timeout stay. Upstream’sreplayis a boolean or the start-replay body; the port hasReplay(default true) andReplayParams.Scopeisnullfor the default,BrowserProviderScope.Worker. - The web engine calls
RecordAsyncfor an attempt that records video (see Video): the replay is the attempt’svideo/replay.mp4, and a headless browser, orReplay = false, gets the engine’s screencast. It does not callDownloadsyet: the port has nowaitForDownload. The provider tests both against a fake Kernel; the recording is not checked against a live Kernel. - Not verified against a live Kernel, because no
KERNEL_API_KEYwas available. The endpoints, field names, and paging headers were read from the@onkernel/sdk0.113.0 source (browsers.ts,replays.ts,fs.ts,core/pagination.ts,client.ts), the version upstream pins. The wire format may differ from what is written here; a first run against a real account is the check. tests/E2E.Kernel.Testsportspackages/kernel/tests/unit/kernel.test.ts:Kernel/KernelTests.csfordescribe('kernel()')andKernel/KernelRecordTests.csfordescribe('record'), 15 of the 16it. The 16th, “keeps the idle timeout backstop for an undefined option from a JavaScript config”, has no C# counterpart: an unsetTimeoutSecondsisnull, and “creates a browser with the run tags and the idle timeout backstop” checks it. Two have the suffixPARTIAL_in the name: “fails without an API key in the run environment and reuses one SDK client per key” (no SDK client to reuse; the test checks that each request carries the key of its run) and “records a Kernel replay of the browser …” (a call has no signal of its own to compare with the step’s). The mocked-SDK harness isFakeKernel, a fakeHttpMessageHandler.RestApi/KernelRestApiTests.cshas six .NET-only tests of the REST requests (base URL, create body fields, a JSON error body, paging), which no upstreamitcovers.
TestMu mobile
Section titled “TestMu mobile”E2E.TestMu.Mobile drives TestMu AI real and virtual mobile devices through Appium. Upstream has @e2e-dev/testmu (commit 88ed717), a testmu() device provider that leases a device for each worker slot and drives it through an agent-device daemon (daemon.ts, driver.ts, heartbeat.ts, sessions.ts). The port does not port it. It talks to TestMu’s hub with Appium instead, so every type here is .NET-only. The package depends on E2E.Mobile and holds an IDeviceClient over a W3C WebDriver session on TestMu’s hub (https://mobile-hub.lambdatest.com/wd/hub), with the flat LT:Options capabilities TestMu documents. It is a preview: no provider leases a device yet, and the page source is not converted to a snapshot yet, so capture.snapshot fails with NOT_IMPLEMENTED (UNSUPPORTED_CAPABILITY in a test). What it does:
E2E.Mobilegets one new public interface,IDeviceClientSource, which has no upstream counterpart. AMobileOptions.Providerthat implements it makes theIDeviceClientfor each session, in place of the agent-device client. A worker in another process builds its engine from the same options, so its provider object makes the client there. What the client needs to find the leased device travels inDeviceLease.Client, which reaches the worker asDeviceConnection.Client. Any other provider is unchanged.- Supported: launch and terminate an app, deep links, tap, double tap, long press, fill, type, swipe, pan, drag, scroll, screenshot, home, back, native alerts, the keyboard (Enter and dismiss), orientation (portrait and landscape), and the clipboard. A
reffinds the node’s rect in the newest snapshot, and a tap lands on its centre. A ref of an older snapshot is stale (NODE_STALE). - Refused as
UNSUPPORTED_OPERATION, which a test sees asUNSUPPORTED_CAPABILITY: permissions, appearance, biometrics, location, wifi and airplane mode, clearing app state, the keychain, hover, folding, the foreground app, installing a build from a test, and launch arguments except on an iOS relaunch. The provider will install the app when it leases the device. sessions.closedoes nothing. The engine closes at the end of every attempt, and the next attempt drives the same device, so only the provider’s release will end the session.filltaps the field and writes to the focused element, because a snapshot ref is not an Appium element id.backon iOS taps the first button in the navigation bar, as agent-device does, and fails when the screen has none; Android uses the system back.settleoptions are ignored: the engine settles on observe.normalizeStatusBaris sent on for the masked screenshot an observation takes, but the Appium client ignores it (Appium cannot normalize the status bar), so a screenshot shows the device’s real status bar.- The package is in CI’s pack step but not in the release workflow until the provider lands. Not verified against a live account: every wire detail was read from TestMu’s and Appium’s documentation, and the
mobile:script names and arguments were not sent to a real hub. The unit tests use a fake hub. Among them: whether the hub accepts theLT:Options-only capabilities without a top-levelplatformName, the session id field, and whichmobile:commands it forwards.
What still differs from upstream’s @e2e-dev/testmu
Section titled “What still differs from upstream’s @e2e-dev/testmu”E2E.TestMu.Mobile serves the same TestMu AI devices another way, so it is a client, not a port. Compared with upstream’s device provider (provider.ts, daemon.ts, driver.ts, heartbeat.ts, sessions.ts):
- No
IDeviceProviderleases a device: upstream’stestmu(options)(device,osVersion,app,deviceType,project,build,sessionName,orientation,geoLocation,timezone,language,locale,appiumVersion) has no counterpart. Nothing uploads the build fromapp, names the dashboard session (e2e-<run id>-<target>-<slot>, with the project and the build), or releases the session at the end of a run. The package opens its session from aTestMuSessionRequesta caller builds. - No
RecordAsync: upstream’srecordfinds the lease’s session on the dashboard (findSession) and returns its video URL (sessionVideoUrl) as avideo/mp4link, with the session’s start time. The seam is ported (see Provider recording), and nothing on TestMu’s side feeds it. The client refusesrecording.record, so a TestMu device has no video. - No agent-device daemon or plugin: upstream starts one daemon per slot with
@agent-device/testmu, checks its version (assertDriverVersion), keeps the lease alive with a heartbeat (LEASE_TTL_MS), and allocates withretainOnClose. The port has no heartbeat. - The page source is not converted to a snapshot, so a test cannot observe the screen (
capture.snapshotisNOT_IMPLEMENTED). - Credentials: upstream reads
LT_USERNAMEandLT_ACCESS_KEYfrom the run’s environment (testmuCredentials); this package takes the hub credentials from its request.
MAUI DevFlow (not in upstream)
Section titled “MAUI DevFlow (not in upstream)”E2E.Mobile.DevFlow drives a .NET MAUI app through the MAUI DevFlow agent, the HTTP server Microsoft.Maui.DevFlow.Agent runs inside the app in Debug builds (builder.AddMauiDevFlowAgent()). Upstream has no counterpart, so every type here is .NET-only. The package depends on E2E.Mobile and holds an IDeviceClient over the agent’s /api/v1 API plus the provider, DevFlowAgents, an IDeviceProvider and IDeviceClientSource to pass as MobileOptions.Provider. It is a preview: it attaches to an app that is already running, and the device-side verbs are not there yet. What it does:
DevFlowAgents.AcquireAsyncfinds the agent (DevFlowAgentsOptions.Port, else the one agent of the platform andAppNamea running DevFlow broker lists at port 19223, else port 9223), waitsConnectTimeout(60 s) for its status, checks the platform (status.device.platform, where the agent writes it) and the app’s package id against the target, and claims the agent’s mutation lease as holdere2e, so the DevFlow Inspector and the MCP server see who drives the app. The agent answers a claim with 200 and says who holds the lease; one another host holds fails the run naming the holder. The lease travels to the worker inDeviceLease.Client(host,port,leaseId,label). Release gives the lease back and leaves the app running. A worker without a binding finds the agent on its first call, with the platformdevices.bootnames, and claims a lease of its own there. The broker is never started from here. One agent is one running app, so a target with more than one worker slot isINVALID_CONFIG.- The agent lets a lease lapse ten seconds after the holder was last heard from, and refuses a mutation under a lapsed lease with 409
reason: lease. So, as Microsoft’s ownAgentClientdoes, every request that changes the app claims the lease again first (a claim the holder has is a heartbeat). A 409leaseon an action names the holder from the agent’sdetails, or says the lease lapsed when there is none. capture.snapshotreadsGET /ui/tree?envelope=trueand flattens it into the snapshot the engine projects. The agent answers 409capture-changed-during-readwhen the screen moves during the read, as it does while an animation runs, so the tree read and the hit-test are tried up to four times with a short wait before they fail. MAUI types become agent-device kinds (Entryistext-field,Labelisstatic-text,Switchisswitch); a type without a mapping keeps its name in kebab case (vertical-stack-layout), and the agent’s ownlink,heading, andlistitemroles win over the type. TheAutomationIdis the identifierGetByTestIdreads. A field’s text is its value; a password field reportspassword: trueand no value, because the agent writes[REDACTED]in place of the text and never sends the plaintext. A switch, check box, or radio button reportschecked.displayedandopacitygivevisibleToUser. The viewport is the window’s bounds.interactiveOnlykeeps the interactive, labelled, and identified elements and re-parents their children.- Actions carry the element id and the
captureEpochof the tree the ref came from: tap (a tap at a bare point hit-tests the point first,GET /ui/hit-test, since the agent’s tap takes an element), double tap (gesture doubletap), long press (gesture longpress, 1 s by default), fill, clear (a fill with empty text), type (the agent’skeywithtexton the field that has the focus, read off a fresh tree, andkey: "text", a name that is no key, so a typeddeleteorenteris appended rather than pressed), Enter (keyon the focused field), a screen scroll (gesture swipeon the page the other way, half the viewport by default), a node swipe and scroll, a pan, back (POST /ui/actions/back, which pops the navigation, a modal, or a Shell route), the app’s theme (PUT /device/app/themefordevice.setAppearance), the foreground app (the agent’s status), and screenshots (GET /ui/screenshot?scale=native, with the logical size from the window’s bounds). apps.openchecks that the agent runs the app the target names and answers its identity. A relaunch, launch arguments, deep links, install, close, home, alerts, orientation, folding, the clipboard, the keyboard dismiss, hover, a drag between nodes, a long press or pan at a bare point, and every device setting but the theme are refused asUNSUPPORTED_OPERATION, which a test sees asUNSUPPORTED_CAPABILITY.sessions.closedoes nothing.settleoptions are ignored: the engine settles on observe.normalizeStatusBaris ignored.- Errors: a 409 (a stale capture, a changed tree), an element the agent does not find, or a ref the newest tree does not have is
COMMAND_FAILEDwithreason: ref_not_found, a retryableNODE_STALE; a 501, a 404 that names no element, and thenot_supportedandunsupported-capabilitycodes areUNSUPPORTED_OPERATION; a mutation lease another host holds names the holder; an agent that does not answer isSESSION_NOT_FOUND(INVALID_STATE), with the hint that the app may not be running. The agent answers{ success: false, error, reason }rather than the RFC 7807 body its spec shows, so both shapes are read. E2E_DEVFLOW_TRACE=1prints each call to the agent (method, URL, status) to stderr, since loopback calls skip a system proxy such as Proxyman. Bodies are not printed: they hold typed text.- The sample (
samples/MobileApp) registers the agent under#if DEBUGand runs the same tests withE2E_MOBILE_PROVIDER=devflowagainst the app you launched. The two tests that send the app to the background or relaunch it are inconclusive in this mode. - The package is in CI’s pack step but not in the release workflow until the device-side verbs land. Not verified against a running app: every wire detail was read from the agent’s source and spec in
dotnet/maui-labsatmain(the0.1.0-preview.13packages). The unit tests use a fake agent. Among the things to check on a device: the window bounds as the viewport on iOS and Android, whetherroleis set on MAUI elements, the key action withtext, the lease body, and the broker list.
WinApp engine (not in upstream)
Section titled “WinApp engine (not in upstream)”E2E.WinApp drives a Windows desktop app through Windows UI Automation, with the library behind the winapp ui command of the Windows App CLI (Microsoft.Windows.SDK.BuildTools.WinApp.UIAutomation), in the test’s own process. Upstream has no desktop engine, so every type here is .NET-only. The package follows the shape of E2E.Mobile: WinAppEngine is the IEngine (Platform windows, capabilities observation, actions, keyboard, scroll, screenshot, and pinned app; no location, history, video, or tap modifiers), WindowSession the IEngineSession, UiaProjection the projection into the shared node model, WindowActions the action mapping, WindowKeys the key grammar, WinAppErrors the error table, Window the fixture, WinAppTools the agent tools, and WinAppTargetEngine the one that reads a TargetConfig. The library is reached through one seam, IWindowDriver, whose only implementation, UiAutomationDriver, is the only type marked [SupportedOSPlatform("windows")]; the rest of the package builds and is unit-tested on any platform (tests/E2E.WinApp.Tests, with a scripted driver). What it does:
- A
windowstarget takesapp.appPath(the executable to launch, relative to the config folder;E2E_WINAPP_APP_PATHoverrides it) andapp.bundleId(the process name). With a path the engine launches the executable itself (WindowProcess: no shell,ArgumentList, stdout and stderr pumped into the attempt’s app log, a visible top-level window awaited withinLaunchTimeout, the process tree killed on timeout, cancel, or close). With a name alone it attaches to the running app and never ends it.app.open()has no URL to open, so it relaunches the app;restartandclearStaterelaunch too, and nothing is deleted. A URL passed toopenisUNSUPPORTED_CAPABILITY. - An observation inspects the main window and every other visible window of the process (a WinUI flyout, menu, or combo box list opens in a popup window of its own), nests the flat element list the library returns by depth, and projects UI Automation control types onto the browser roles (
Buttonisbutton, aToggleSwitchclass isswitch,Editistextbox, aPasswordBoxclass is a securetextboxwith no value,List/ListItem,ComboBox,Tab/TabItem,Windowiswindowfor the app window anddialogfor aContentDialog, popup, or flyout; an unmapped type is its name in lower case). The test id is theAutomationId. Boxes are logical pixels, relative to the main window, read through the window’s DPI. Refs are the library’s selector slugs, bound for the session in a bounded list, since UI Automation identity is stable across inspections: an element the library no longer finds isNODE_STALE. - Actions prefer the control’s own pattern: tap invokes, selects, toggles, or expands an invokable control and clicks the middle of any other; fill and clear use the value pattern and fall back to focus,
Ctrl+A, and typed text for a control that refuses it (aRichEditBox); press and type focus the control, bring the window forward, and send keys throughSendInput, which reaches the foreground window only (WinUI controls are windowless and drop posted messages); a window that cannot come forward isNOT_ACTIONABLE; select expands a combo box, observes again, and selects the option by name, value, or automation id; check and uncheck use the toggle pattern (a radio button is selected and cannot be unchecked; nothing is sent when the state already matches); a swipe turns the mouse wheel over the node for up and down, and uses its scroll pattern for left and right or when the desktop cannot take input. On the screen it turns the mouse wheel over the largest scrollable control (the pattern steps by a share of the whole extent, too coarse to land a row of a long virtualized list in view; six notches move a screen or so), scrolls that control by pattern when the desktop cannot take input, and turns the wheel over the window when there is none (up and down only: a sideways swipe with nothing to scroll isNOT_ACTIONABLE). A sensitive fill never puts the value in a message. The session also offers hover, long press, drag, a right click, and typed text. - A screenshot is the main window, encoded as PNG by the package’s own
PngEncoder(the CLI tool strips the Skia natives), with every secure field painted black; a secure field without a box withholds the image. Windowis the fixture:OpenAppAsync(the app under test, withRelaunch, or another running app by name),CloseAppAsync,ForegroundAppAsync,ListWindowsAsync,ResizeAsync,MaximizeAsync,RestoreAsync,MinimizeAsync, andAlertAsync, which answers the dialog the newest observation saw: accept presses itsPrimaryButton(else a button named OK, Yes, Confirm, Accept, Save, or Continue), dismiss itsCloseButtonorSecondaryButton(else sends Escape).WinAppTools.Create(engine)gives the agentopen_appandalert, scoped towindows.- Errors: a launch that fails or shows no window is
ENVIRONMENT_UNAVAILABLE; a vanished element is a retryableNODE_STALE; an offscreen control, a refused value, and a lost foreground areNOT_ACTIONABLE; an app the engine cannot find isINVALID_STATE(with the hint to callapp.open()); anything else the library reports isENGINE_FAILURE. A caller’s cancel isCANCELLED. The engine never producesOPERATION_TIMEOUT. e2e exploreande2e mcptake awindowstarget (TargetEngines.Creategets awindowsfactory the way it gets a device one;WindowsTargetsin the CLI builds the engine on Windows and isINVALID_CONFIGon another host). Pattern actions work with the window anywhere; keys, clicks, drags, and the wheel need an unlocked, interactive desktop with the window in the foreground.- Not there:
App.BackAsync(UNSUPPORTED_CAPABILITY: a desktop app has no history), video, tap modifiers, popup windows in screenshots, a deep-link tool, and the clean-up of the app’s data onclearState. A packaged (MSIX) app starts through a broker and exits at once, so it is attached byapp.bundleId, not launched.
WinAppBenchmark
Section titled “WinAppBenchmark”apps/WinAppBenchmark is upstream’s apps/mobile-benchmark rebuilt as a WinUI 3 app with Microsoft.UI.Reactor: 19 of its 25 scenarios, with the same names and the same AutomationId where upstream has a testID. Not ported, because the surface is mobile-only: Permission Prompt, Photo Picker, Stripe PaymentSheet, Apple Pay, Apple Pay Billing Address, and WebView Accessibility. The README of the app lists each change made to fit a desktop window.
apps/WinAppBenchmark.E2ETests ports upstream’s apps/mobile-benchmark suites (smoke, login-form, flows, flows-lists, controls, device, device-fixture, and tests-agent/scenarios), one directory per upstream file and one class per describe. They are real-model tests of the Windows category, so CI builds them and does not run them. Not ported: the tests of the six scenarios the app lacks, the fixtures a desktop window lacks (appearance, clipboard, home, network, location, biometrics, keyboard), describe('refusals') (the Windows engine declares the verbs it checks), the placeholder query, and the pointer-point test (tapAt, a tap position, screen.swipe). The agent scenarios Broken Accessibility, Vision Only, Flattened Registration Form, Flattened Login, and Gestures are declared and skipped with a reason, as upstream declares a gap: agent.act gives the model the tree only, never a screenshot, and it has no long press. The test project README lists each test that differs from its upstream it.
WinAppTest (not in upstream)
Section titled “WinAppTest (not in upstream)”E2E.WinApp.NUnit and E2E.WinApp.XUnit.V3 add WinAppTest, a base class over E2ETest for a windows target, the way MobileAppTest serves a device target. It reads AppId (app.bundleId) and AppPath (E2E_WINAPP_APP_PATH, else app.appPath) from the target, builds a WinAppEngine from CreateWinAppOptions(), hands the agent WinAppTools, sets the step timeout to 90 seconds, and relaunches the app before each test (WinAppHost.OpenAsync, shared by both adapters from src/Shared). Window is the session’s fixture. The base class adds no [Category].
TestMu
Section titled “TestMu”TestMuBrowsers (the E2E.TestMu package, namespace E2E.TestMu) is the port of upstream’s testmuBrowsers() (packages/testmu/src/browsers.ts, with dashboard.ts and web.ts, commit 92f8cb0), ported in part. It is a WebEngineOptions.Browser provider for the TestMu AI (formerly LambdaTest) web grid. Upstream’s mobile device provider is a separate part of the same package (see TestMu mobile). Taken from upstream: the Edge --user-data-dir=remote-debug-profile-edge entry in browserOptions (appended to the given ones, kept if a --user-data-dir= is already there), the idleTimeout default of 600 seconds, the project default of e2e, the session name e2e-<run id>-<target>-<slot> (a per-attempt lease is e2e-<run id>-<target>-attempt-<attempt id>), the Windows 11 default for Platform, the credentials read trimmed with a blank value counted as unset (the error names each missing variable), and the validation of Capabilities at construction (INVALID_CONFIG for a nested LT:Options, browserName, user, accessKey, name, a browserOptions that is not an array of strings, and a number JSON cannot encode). Not taken: the /puppeteer CDP URL (the port keeps the Playwright-protocol endpoint, below), the browserName check (the port also lets pw-chromium through), and the unknown-key check (a record has no unknown keys). The port keeps the seam’s names (IBrowserProvider, BrowserLease) and adds only the provider, TestMuBrowsers, and its options record, TestMuBrowsersOptions. Only the web grid is covered; TestMu’s real devices are not. Differences from a provider like upstream’s kernel:
- It calls no TestMu API.
AcquireAsyncbuildswss://cdp.lambdatest.com/playwright?capabilities=<JSON>frombrowserName,browserVersion, andLT:Options(platform,build,name,user,accessKey, and the options that were set). The grid starts the session when the engine connects. The lease’sProtocolisPlaywright: the grid answers Playwright’s own protocol on this URL, not CDP, so the engine dials withChromium.ConnectAsyncand opens its own browser context, because the browser has none.ReleaseAsynccalls nothing, because the grid ends a session when its connection closes (checked against a real account).SweepAsyncis the interface default. - The TestMu dashboard shows these sessions without a Passed or Failed mark. TestMu marks a session through its Automation API or a
lambdatest_actioncall, which needs the test result, and e2e passes no test result to a provider (upstream’sBrowserReleaseContexthas none either). The provider also does not look up the session id. - The credentials are
LT_USERNAMEandLT_ACCESS_KEY, or the aliasesTESTMU_USERNAMEandTESTMU_ACCESS_KEY, read from the run’s environment. The access key is part of the endpoint, so the lease id is the session name (e2e-<run id>-<target>-<slot>ore2e-<run id>-<target>-attempt-<attempt id>), which is what progress lines print. The engine puts no endpoint in an error or a log line, and Playwright drops the query string from the URL it prints when aws://orwss://connection fails, so a refused connection does not show the key (a test checks it). Each lease endpoint is about 650 bytes, and a worker’s leases travel in one environment variable capped at 16384 bytes, so a target with more than about 20 worker slots fails at prepare. - Checked against a real account:
ConnectOverCDPon this URL fails withProtocol error (Browser.getVersion),Chromium.ConnectAsyncworks, and closing the connection ends the session.TestMuLiveTestsrepeats that through the engine and reads the session’s end from TestMu’s Automation API. It is skipped withoutLT_USERNAMEandLT_ACCESS_KEY. Mobile devices are not covered. - Behavior change: a
Capabilitiesentry namedname,user, oraccessKeyused to replace the provider’s value. It is now refused withINVALID_CONFIG, as upstream does. TestMuBrowsersFunctionTests(Browsers/) ports theits of upstream’sbrowsers.test.tsfor the Edge flag and the validation. Two are partial (Partial_prefix): the Edgeitthat also checks upstream’s build (run-1; the port’s ise2e-<run id>) andbrowserVersioninsidecapabilities, and the rejectionit.each, of which the rows foruser/accessKey,name, nestedLT:Options,browserName,browserOptions(a string,[1], null) and a value JSON cannot encode are covered. TheprojectandidleTimeoutdefaults are checked in the .NET-onlyTestMuBrowsersTests, because the upstreamitthat checks them also checks the/puppeteerURL and theWindows 11default together withbuild: run-1(the port’s build ise2e-<run id>). Theits for the session name and for a missing or blank credential are ported. Not ported: the/puppeteerURL (the port’s endpoint differs), the lease id without spaces,undefinedvalues keeping the defaults (C# has noundefined), the unknown-key,browserName, non-objectcapabilities, Date, Map, function, BigInt and cycle rows (a typed record andJsonNodecannot hold them), the log line (the port logs nothing), the credentials taken fromprocess.env(a test would change the process environment, which the live test reads). The otherTestMuBrowsersTestsandTestMuLiveTestsare .NET-only, intests/E2E.TestMu.Tests.
Browser install command
Section titled “Browser install command”Upstream’s e2e-web command (packages/web/src/cli.ts) has one subcommand, install. It runs the pinned Playwright CLI. The port has it as e2e install [chromium|firefox|webkit ...] [--with-deps], in the tool that already ships the Playwright driver. It installs Chromium when no browser is named, forwards the options before the browsers, exits with the Playwright code, prints usage for -h or --help anywhere, and exits 2 for an unknown option or browser. As upstream, the install runs with PLAYWRIGHT_SKIP_BROWSER_GC=1 unless the user set it. The engine itself drives Chromium only.
Upstream’s command lives in the web package (e2e-web, from @e2e-dev/web), apart from the e2e command. The port is one tool, and WebEngine is in the E2E package, so the command is e2e install in E2E.Cli, the one place the Playwright driver ships. The code follows upstream’s split: E2eWebCli.MainAsync is main and CliIo is its CliIo (E2eWebCli is named for the command; a method called Main would be taken for an entry point), WebEngine.RunPlaywrightCli is runPlaywrightCli, and BrowserConnection.BrowserNames and IsBrowserName are BROWSER_NAMES and isBrowserName.
Not ported:
- A command named
e2e-web, andnpx,bunx, andpnpm execusage lines.dotnet e2e installis the local tool form. - Upstream’s exit code 2 for a missing or unknown command.
e2eexits 1 for both, as it does for every command. - Forwarding SIGINT and SIGTERM to the installer and the 128-plus-signal exit code.
Microsoft.Playwright.Program.Mainruns in this process. Ctrl+C in a terminal reaches its driver in the same process group; a SIGTERM sent toe2ealone does not. install.test.ts(ensureBrowsersInstalled) is the first-launch install of the engine, not the command. The port covers it inE2E.Tests/Install, and upstream’s own checks of the installed-browser cache and of the run’s environment have no counterpart: the port leaves that to Playwright’s install (see Web engine).
MCP server
Section titled “MCP server”Ported from upstream packages/e2e/src/mcp at commit ee901b333ed12352418dfb07bda5eafe32e71c8d (docs(agents): show several personas running one suite (#1258)), apart from what is listed below. e2e mcp serves over stdio with the server name e2e, the tools, resources, and logging capabilities, and upstream’s four fixed tools with their names, descriptions, and closed argument schemas. Stdout carries only the protocol; every other write goes to stderr, and the MCP SDK logs nothing. The flags are --config, --target, --headed, a hidden --headless, and --max-sessions (1 through 16, default 4). A session is headless by default; open_session takes headed to show the browser or hide it, and --headed makes headed the default for a session that does not say (upstream commit dc2afca). A bad command line exits with 2, a disconnect or a signal with 0, and an unexpected error with 1. The resources e2e://guide and e2e://guide/<topic> serve the bundled skill; an unknown topic fails with UNKNOWN_TOPIC.
A session is an attempt on the config’s target with an engine of its own: a Chromium WebEngine for a web target, or a MobileEngine for an ios or android target (see Device sessions below), or a WinAppEngine for a windows target (see WinApp engine), so parallel agents each drive their own browser, device, or app (a windows target with only app.bundleId attaches to one running app, which they share). open_session loads e2e.config.json fresh, claims the config (CONFIG_IN_USE for another config while a session is open), and fails UNKNOWN_TARGET for a target the config does not declare, CONFIG_NOT_FOUND for no config, SESSION_OPEN when no slot is free. call and tools name a session, or fail NO_SESSION or SESSION_REQUIRED. A session ends on close_session, after 30 minutes without a call, or after 4 hours. It never reads or writes the replay cache. Every configured secret is a secret of the session: any output, error, log line, and the close summary shows its value by name (<secret:name>). type_secret fills a secret into an editable field (a password secret needs a password field), and after a fill screenshot answers PIXEL_TAINTED and attaches nothing.
The catalog is what the testing agent’s tools do, so a coding agent acts as the model of an act does: observe, tap, double_tap, type, press, select, check, uncheck, clear, scroll_to, scroll, navigate, back, screenshot, type_secret, and locate. scroll_to and scroll need EngineCapabilities.Scroll, back needs History, screenshot needs Screenshot, and type_secret needs a secrets entry. navigate allows http and https only (POLICY_DENIED for any other scheme).
Differences:
- Arguments name a node by its
ref(target: "e12", fromobserve), as the testing agent’s tools do, where upstream uses node ids (n42) and a revision. Actions answer with the screen after the action, not with a diff of what changed since the last one. A failed action is an error result:tap e9 failed: NOT_FOUND: node e9 is not on the current screen; re-observe for the newest refs. - The grammar is the port’s:
typeandtype_secrettaketextandsecret;uncheckandclearare tools of their own. Upstream’slong_press,right_click,hover,drag,upload, and the point tools (tap_at,hover_at,type_at,press_at,select_at) have no counterpart in the port’s actions. locatewrites theScreencall a test uses (Screen.GetByRole("button", "Save")) and the port’s codes:NOT_FOUNDandSTRICT_MODE, where upstream namesLOCATOR_NOT_FOUNDandLOCATOR_AMBIGUOUS.- As upstream, a
locatewith astatus,alert, orlistitemname that matches nothing points atScreen.GetByRole(role).Filter(text)andScreen.GetByText(spelled asGetByRole(role).filter({ hasText })upstream), and anexact: falsequery is not told to loosen the match again (upstream #1147,5e9597c): the advice says “an” before a role that starts with a vowel (an alert,a status), and for anexact: falsequery it keeps the option in the text call,Screen.GetByText("Saved.", exact: false)(getByText(q, { exact: false })upstream). The web engine names those three roles from their content, so there the name also matches; the advice still holds, since the text matches either way. - Test lines (upstream #1317,
9ceb071): every action answers with theScreenorAppcall that repeats it, after the first line of its result, and the read-onlytest_linestool returns them as a draft test body. The code is C# (await Screen.GetByRole("button", "Save").TapAsync();,Secrets.Get(name),Filter(text),Nth(i),new RoleOptions { Visible = true },App.OpenAsync(path)), andSessionTestLines(Mcp/TestLines.cs) is upstream’sSessionTestLines(mcp/test-lines.ts). Differences: the port’s tree carries a node’s test id, soGetByTestIdis the last query tried for a node, where upstream reads the id off the live node; the port resolves each locator through the engine’sLocateAsync(2 s each, binding only the matches, so a device keeps the agent’s node ids valid) when the engine has one, else on one fresh look at the screen per node withLocatorResolver, where upstream spends one engine resolve on each (the cap of 10 tries stays); the live look comes in the port’s resolver order, so the twin of a shadow root is told apart by its place in that order and never by the box it kept; there are no credentials, so a fill namesSecrets.Get(name)only; atypethat fell back to the keyboard does not exist, because the port’stypealways fills; a locator tap takes nomodifiers, so atapordouble_tapwithmodifiersprintsNo test line:; apresshas notimes. The web engine names a list item from its content, so a row is found by that name (GetByRole("listitem", "Offer B Reserve")) before it is found byFilter(text). The draft ends with a line that tells the agent to add anExpectafter each step, where upstream also names thecredentialsandsecretsimports. The session catalog reads the newest observation from the agent’s observation feed, where upstream wraps the step context. Not ported with it: the reserved tool name (the port has no project tools), the telemetry event of an MCP session. The drafted lines run as a test class of a project built in a temporary directory withdotnet test(DraftedTestRun), where upstream runs them with itsrunProject. - The fixed tools check their arguments with a strict check of the JSON Schema subset tool schemas use, since the .NET MCP SDK does not validate them. The messages are zod’s, so an unknown argument reads
Invalid arguments for tool <name>: Unrecognized key: "<key>", as upstream. - The server instructions name
e2e.config.json,E2ETestclasses, anddotnet testin place ofe2e.config.ts,tests/*.e2e.ts, andnpx e2e run. - The guide has upstream’s eight topics, written for .NET, plus
writing-tests-nunitandwriting-tests-xunit, one per test framework.exploredescribesExplore.ExploreAsync, andexploreandbug-bashsay that the bug bash fan-out is not ported. - The 2026-07-28 MCP revision deprecates logging. The server still declares it and sends its log lines, as upstream does.
- The config declares one target, so
TARGET_REQUIREDcannot happen. A session’s engine is its own, so sessions never share an engine instance, andENGINE_IN_USEhappens only for a device (see Device sessions below). The credential registry upstream keeps process-wide has no counterpart (the port has nocredentials). - The
e2etool ships the Playwright driver of every platform (about 215 MB) and none of the SkiaSharp native libraries, since it never compares screenshots. Chromium installs on the first session, as for a test. - Device sessions:
open_sessionon aniosorandroidtarget builds aMobileEngineon a local simulator or emulator (see Explore). The catalog leaves outnavigatefor an engine that declaresPinnedApp, as the engine has no URL to open; before the first observation the session launches the pinned app fresh (TargetEngines.LaunchesPinnedApp, thenApp.OpenAsync), as upstream’slaunchesPinnedAppdoes (upstream #1252,3bd32ef). The summary addsApp: <bundleId or appPath>, launched fresh., and theopen_sessiondescription names the pinned app. It installs no build, so a local device target needsapp.bundleId. The launch is bounded by the session’s time limit, as upstream bounds it by the attempt’s timeout, and a cancel of the request during the launch surfaces asCANCELLED. A device runs one app, so a second session on a device target fails withENGINE_IN_USE(upstream’s code for two sessions on one engine instance; the port builds an engine per session, soSessionRegistry.ClaimEngineclaims the device, .NET-only) until the first closes. Hosted devices are not ported yet. Awindowstarget builds aWinAppEnginethe same way (WindowsTargets.Create,E2E.Cli), on Windows only (INVALID_CONFIGelsewhere), and launches the executableapp.appPathnames or attaches to the processapp.bundleIdnames before the first observation.
Not ported:
start_recordingandstop_recording(Kernel video), the project’s own tools (defineTool,toModelOutput), custom engines, mobile tools, and per-session telemetry.- Redaction of what user code prints: a config is JSON, so no user code runs in the server.
e2e initregistering the server. The port has noe2e init(see below); register it withclaude mcp add e2e -- e2e mcpor a.mcp.jsonentry.
Agents
Section titled “Agents”E2ESessionOptions describes the default agent with Model, Judge, AgentExecutor, AgentSystem, AgentTools, AgentContext, MaxSteps, MaxModelCalls, JudgmentTimeout, and ProviderOptions, and names the others in Agents (an AgentOptions per name). E2ETest fills both from agents.<name> in e2e.config.json. A call picks an agent with its Agent option; an empty or unknown name throws INVALID_ARGUMENT (unknown agent "x"; configured: default, ...). Each agent starts from the defaults and inherits nothing from another. Settings are checked when the session starts and fail with INVALID_CONFIG.
-
A test or a class picks the agent for its calls with
[Agent("name")](upstream’sagentoption oftestanddescribe). The closest pin wins: a call’sAgent, the method, the class, a nested class’s enclosing class, thenE2E_AGENT(the port’s--agent, one name; a pin wins over it, as upstream). An unknown name fails the test withINVALID_ARGUMENTwhen the session starts, where upstream fails at collection.[AgentContext("...")]is upstream’sagentContext: the layers join outer to inner with newlines and follow the agent’s owncontext, for every agent a call picks, and the cache key sees the joined text. .NET-only symbols:AgentAttribute,AgentContextAttribute,E2EFixture.RunAgent,E2ESessionOptions.AgentandTestAgentContext, andTestAgentOptions(internal). Not ported: a list of agents (agent: ['buyer', 'admin'], one result per agent), because NUnit and xUnit have no collection step to fan a test out; the serial-group and setup-test rules that go with it; and--agentlisting several names. -
judgejudgesassert,waitFor, andextract, and defaults to the model.actalways uses the model -
systemis appended to the built-in act rules. Judges never see it -
context(at most 16384 UTF-8 bytes) is told to every model call: after the act rules asProject context:, and to judges inside<project-context> -
maxStepsandmaxModelCallsare 1 through 100, 25 by default -
judgmentTimeout(30 s) boundsassert,waitFor, andextractunless the call setsTimeout. A per-call timeout must be positive; awaitForinterval is 100 ms through 60 s -
providerOptionsis a dictionary of JSON objects by provider. It rides everyModelRequest, andOpenAiCompatibleModeladds the fields under itsProviderkey (openaiby default) to the chat-completions body as given, so write the wire names (reasoning_effort, notreasoningEffort). It cannot replacemodel,messages, ortools. Every other client does the same with its own key (anthropic,google,bedrock,azure,xai,openrouter,gateway) and refuses its own core fields. As upstream, requests to OpenAI (and Azure over Responses) carrystore: falseand a prompt cache key per system prompt, and Anthropic requests carry cache breakpoints on the system prompt and the newest message; provider options win over both -
assertandextractmake one model call and one repair round.waitForjudges at once, then again only afterIntervaland on a changed screen, and every call, repairs included, counts againstMaxModelCalls. It endsSTEP_TIMEOUT(waitFor timed out; last judgment: ...) orSTEP_BUDGET_EXHAUSTED(waitFor exhausted its model-call budget; last judgment: ...) -
As upstream, the judge prompt says a value shown in more than one place holds only when every place agrees, unless the statement names the place or is about some item among several. The wording follows upstream, with “instruction” as “statement” and the
"fails"verdict as “false”, because the port’s judge answers throughdone -
ExtractAsync<T>: the type argument is the schema, in place of a Standard Schema. The judge gets the JSON schema ofT(JsonSchemaExporter), and the answer must deserialize intoTwith required members and nullable annotations respected. A failure gets one repair round with the validation error, thenMODEL_OUTPUT_INVALID(extracted data failed schema validation: ...). A judge that says the data is not shown endsASSERTION_INCONCLUSIVE(nothing to extract: ...) -
tools(AgentOptions.Tools,AgentToolson the default agent) are project tools the model is offered beside the agent’s own. As upstream’sdefineTool, anAgentTooldeclaresmutatesandplatforms: a tool is offered only on the platforms it names, and a mutating tool takes an action slot. Its output is redacted and cut at 16384 characters. A name the agent already uses (observe,done,navigate,back,scroll,scroll_to, or a verb) is refused withINVALID_CONFIG. Differences: the tool is anAgentToolbuilt with its schema as aJsonObjectand a delegate, since the port has nodefineTool, AI SDK tool, ortoModelOutput; a tool that wants upstream’sgetToolContexttakes the second constructor, whose delegate gets anAgentToolContext(ObserveAsync,AttachScreenshotAsync), and the first constructor keeps working; and upstream’s reserved names for tools it adds elsewhere (complete_step,locate, the recording tools,report_finding) have no counterpart. Upstream records a mutating tool as a gap in the trace, and so does the port: a recording holds an action of kindtoolwith the tool name, a replay runs the actions before it and then hands the step to the model, and the step is never replayed whole. Older versions of this port cannot read that action and run the step live. As upstream, a recording that stops at such a gap (reasongap) is not stale: a strict cache run replays what it can and runs the rest of the step live. The recording keeps the tool’s name only, never its arguments -
tapanddouble_taptake an optionalmodifierslist (Shift,Control,Alt,Meta,ControlOrMeta) on an engine that declaresEngineCapabilities.TapModifiers, as upstream’stap,double_tap, andright_clickdo on an engine that declarestapModifiers. The web engine declares it. The device engines and the document engine do not, and refuse the field withUNSUPPORTED_CAPABILITY. The held keys are recorded asmodifiersof thetapordoubleTapaction and replayed from the cache. An entry whosemodifiersis empty, repeats a key, or sits on another action is a miss (invalid-entry). The tools do not close their schemas, so a tool called with a field its engine does not declare is refused by the action, not by the schema. There is noright_click, because no engine here has a secondary tap. -
actalso offers adouble_taptool, which upstream does not have. Without it, the agent cannot open a control that reacts only to a double-click, such as a TodoMVC label that opens its editor. It runs the locatordoubleTap, and the replay cache records it asdoubleTap. Upstream and older versions of this port cannot replay that entry, so they run the step live -
ActOptions.OnActionis upstream’sonAction: a hook that runs after each action once the screen settles, for replayed actions too. The hook is anActionHookdelegate returning aTask, and the event’ssourceis theActionSourceenum (Model,Replay). Upstream’s check thatonActionis a function has no counterpart, since the delegate type says it. A hook that throws fails the step (ACTION_FAILED,agent.act onAction failed: ...) and leaves the step’s recording alone. Upstream takes a screenshot from the hook withapp.screenshot, which is not ported, so the port’s tests record the events and the screen the hook reads. Tests:agent-act-on-action.test.tsisAgentActOnAction/(6 of 6, plus one .NET-only test for a replayed step whose hook never completes; the first is renamed and partial: it does not check that each hook step holds one screenshot artifact. The others record the hook’s events in a list where upstream takes anapp.screenshotstep, and check the same behavior). Theact-validationit“takes an onAction function and refuses any other value” is skipped: a delegate cannot be anything but a function. .NET-only:AgentException.FromActionHook(internal), where upstream has anActionHookErrorsubclass, becauseAgentExceptionis sealed -
actparams are checked as upstream: JSON-safe values, at most 32 levels deep, no cycle, and at most 64 KiB once aSecretis projected to its name and purpose and aValues.Uniqueto its value. The instruction is at most 8192 UTF-8 bytes. Each limit throwsINVALID_ARGUMENTwith the upstream message before any model call. A value reached twice through different paths is not a cycle -
VisiononAssertAsync,WaitForAsync, andExtractAsyncis aVisionMode(Off,On,Only) in place of upstream’sfalse,true, and'only', and an undefined value throwsINVALID_ARGUMENT.Onsends a masked screenshot beside the tree, andOnlysends the screenshot alone, with a note that no tree is attached on purpose. The screenshot is sized for the model (long side at most 768 pixels) and its note names the regions masked for security. A secret filled in the attempt (by a locator or by the agent), an engine that captures no pixels, or masking the engine cannot prove (fewer masked regions than secure nodes in the tree, or no count at all) takes the image away and the judgment goes on with the tree;Onlyhas no tree to go on with, so it fails withPOLICY_DENIEDthat namesPIXEL_TAINTED,MASKING_UNPROVEN, orUNSUPPORTED_CAPABILITY, before any model call. The judge’s system text says an attached screenshot is data, as upstream’s policy does.WaitForAsynctakes a screenshot with each look and counts a changed picture as a changed screen. An inconclusive judgment made from the tree alone ends with the hint to passVision = VisionMode.On, firm once a judgment of the attempt has received pixels and conditional before (“if the engine captures pixels”), and none when pixels cannot help: a secret was filled, the engine declares noScreenshotcapability, or an earlier request for pixels was degraded. The hint’s words name the C# option -
AssertOptions.Screenshot(on by default) keeps the redacted viewport after the verdict, a passing one too, under.e2e/results/<result>/attempt-<n>/screenshots/<n>-assert.png, andAgentException.Screenshotnames it on a failed judgment as a path relative to the project root, where upstream names it relative to the artifacts directory. An engine that captures no pixels keeps none. A secret fill does not stop it: the screenshot is the project’s own artifact. Taking it never fails the judgment. The port’s step record has novisionInput,visionDegraded,visionOnly, orpixelByteson a step, novision.pixelsorassert.screenshotpolicy events, and no observation revision in a pixels-only request -
executor(AgentOptions.Executor,AgentExecutoronE2ESessionOptionsand the fixtures) replaces the built-in agent foractandassert. The socket isIStepExecutor:Name, andVersion,Cache,Model, andJudgewith defaults, so a hand-rolled executor writesNameandRunStepAsync(context, cancellationToken). The token is upstream’ssignal; the context’sAttempt.CancellationTokenis upstream’sattempt.signal.StepExecutorContextis a class a test can build and override:Step,Attempt,Target,ReplayedPrefix,Model,ProviderOptions,Ledger,AgentContext,Budgets,Actions,PixelsTainted,ObserveAsync,AttachTranscript, andAttachScreenshotAsync. An executor is code, soagents.<name>.executorine2e.config.jsonstaysINVALID_CONFIGand namesAgentOptions.Executor. As upstream,SystemandToolsare refused beside an executor, and theModelandJudgean executor brought are the agent’s, for the judgments too. An entry that names another isINVALID_CONFIG(agents.default.model (gpt-5.4) and the executor's own model (gpt-5.4-mini) differ; configure the model in one place). Two models are the same when they are one instance or share aName, where upstream compares the provider and the id -
The
Ledgertext an act reads is upstream’sserializeLedgerover the attempt’s step records:1. app.open passed :: /per completed step, thenobserved: <handoff>when it has one, up to 8192 bytes, newest kept, with[n earlier step(s) omitted]first. A failed step is in it, with its status. Each label and handoff is stripped of ANSI sequences and control characters and cut to 256 and 700 bytes. A step is recorded foragent.act,agent.assert,agent.waitFor,agent.extract, a locator action andlocator.waitFor, anexpectmatcher on a locator, the browser, or the screen, andapp.open,app.back,app.restart, andapp.clearState. TheBrowserfixture’s own calls andexpect.pollare not steps yet, except thatBrowser.WaitForURLAsyncrunsToHaveURLAsync, so it is recorded asexpect.toHaveURL. The records are internal; no report reads them yet. A replayed act hands later steps the text the replay returns (Replayed recorded actions.), where upstream hands them the verdict the recording run wrote, because the port’s cache entry keeps no verdict. -
ToolLoop.CreateToolLoopExecutorandDefaultAgent.CreateBuiltInAgentare public. Differences:ToolLoopExecutorOptions.Toolsreturns a list ofToolLoopTool(aModelTooland a delegate that answers aToolLoopResult), where upstream’s returns an AI SDKToolSet;BuildPromptanswers a string, where upstream’s can answer a whole message history; the chassis addsdone, where upstream addscomplete_step; there is nomaxTurnsto remove.ToolLoopHelpershasGuardAsync,Concluding, andReportHardStop, and the loop guards every tool call itself. The built-in agent drives the context the runtime builds, so it runs as a step’s executor or inside a custom executor that passes its context on, and not on a context built by hand -
The verbs are the ones the port’s engines have:
TapAsyncandDoubleTapAsync(both takeClickOptions, whoseModifiersneedTarget.TapModifiers, as upstream’stap(target, { modifiers })needstarget.tapModifiers),TypeAsync,TypeSecretAsync,ClearAsync(upstream clears withtype),PressAsync,SelectAsync,CheckAsync,ScrollToAsync,ScrollUntilAsync,ScrollAsync,NavigateAsync, andBackAsync. Not ported, because no engine here has them: long press, secondary tap, hover, drag, upload, the point verbs (tapAt,hoverAt,hitTest),typeText,pressKey, anddismissKeyboard.Target.Verbsfollows the engine’s capabilities: the scroll verbs needScroll,BackneedsHistory. A node is named by its ref (new ExecutorTarget("e12"), the[ref=e12]of a line), where upstream namesn42. Actions and looks run in call order, mutating project tools included, and a mutatingRunToolAsyncbody that calls an action or a look waits behind itself, as upstream’s does. A verb checks its argument before it takes a slot (INVALID_ARGUMENT), but the port has no key grammar, soPressAsynctakes any non-blank key -
ObserveAsyncanswersText,Truncated,KeyboardVisible, andPath, and withExecutorObserveOptionsTree(a list of rootExecutorNodes, redacted, with no value or selection on a secure field, a link target absolute and cut as a line cuts it) andPixels(the masked screenshot sized for the model, orPixelsWithheld, aVisionDegradation). It has noRevision, noViewport, and noframePath, since the port has none.ReplayedPrefixcarries the actions as the text the hand-off notice lists,ReplayHandOffReasonadds the port’sActionBudgetandInvalidEntry, and has noviewport-changed. A replay that meetsACTION_MAY_HAVE_COMMITTEDstops asaction-uncertainand names the action, which the built-in agent’s notice repeats.KeyboardVisibleis upstream’skeyboardVisible(commit5fce7b2):Observation.KeyboardVisiblecarries the engine’s measured on-screen keyboard state toExecutorObservation.KeyboardVisible, and the mobileDeviceSessionsets it from the capture’s keyboard fact (visibleis true,absentis false, and an unmeasured capture leaves it null). The web engine measures none, so it is always null there. Not ported: the part of that commit in@e2e-dev/decision(dismiss_keyboardand the page fingerprint), and thesdk-types.tschecks, which the C# types make. Tests:FixtureRuntime/ProjectToolDispatchTestsandE2E.Mobile.Tests’Engine/ObservationTests. -
The runtime owns the budgets and the clock.
RecordModelCallthrowsSTEP_BUDGET_EXHAUSTEDpastMaxModelCalls, and a verdict never outranks a hard stop: a passed one over a spent budget, a timeout, or a code-less failure after one ends with the runtime’s code. An executor that ignores its token gets one second to unwind after the step is stopped, and then the step settles without it. The closed verdict grammar is upstream’s: an empty summary, an unknown code, a passed verdict with a code, or a blocked one without a blockable code isMODEL_OUTPUT_INVALID, and so is a runtime code the runtime did not record. A thrown error that is no agent error becomesACTION_FAILED, as upstream’stoAgentErrordoes, and a cancel by the caller stays anOperationCanceledException. The message of a failed or blocked verdict is upstream’sagent.act failed: <summary>oragent.act blocked: <summary>(agent.assertfor an assert). The step keeps the bare summary as its explanation, as upstream does. A hard stop (Step timed out, a spent budget) that ends the step with no verdict, or over a passed or code-less one, has a message with no prefix (the reason, then the verdict’s summary if there is one); a coded failed or blocked verdict gets the prefix, also after a hard stop -
AssertAsyncon an agent with an executor goes to it as a step of kindAssert, with the agent’s budgets andJudgmentTimeout. An assert is never cached, and a passing one verifies the acts before it.Visionis refused withUNSUPPORTED_CAPABILITY, since the executor decides what its model sees, andScreenshotkeeps its evidence after the verdict, bounded by the action timeout: a capture that outlives it is dropped and the verdict stands.WaitForAsyncandExtractAsynckeep the judge, which is the executor’sJudgeorModel.ExecutorCache.Offnever replays or records, and takes no look before the step. A step with the cache on that cannot read its first screen (no app opened) still runs, and records nothing -
TypeSecretAsyncfills a secret the step’s params declared, and refuses withPOLICY_DENIEDany other name, a disabled or non-editable field, and a field whose purpose does not fit a password secret. A secret the attempt does not know isAUTH_CREDENTIAL_UNAVAILABLE. The built-in agent’sfill_secretgoes through the same check, so its refusals read as the dispatcher’s messages and not asSecret fill refused: ....ExecutorStep.Paramsis JSON: aSecretis its name and, when set, its purpose, and every string and key that holds a secret value the attempt knows is redacted.ExecutorAttempthas the session’sTestTitleasTestId, a fresh id per session, andIndexas the attempt number minus one;ExecutorStep.Indexcounts the agent steps of the attempt, where upstream’s counts every step.AttachTranscriptandAttachScreenshotAsyncwrite under the attempt’s results directory (transcript-<n>.txt,screenshots/<n>-<label>.png), and, as upstream, only underE2E_DEBUG=1.AttachTurnskeeps the step’s last 12 turns on the step record, redacted, as upstream’sattachTurnsdoes, and the tool loop reports every turn it takes with the same clips. Its loop notes cover the events the port’s loop has (a reply without a tool call, a shrunk history); upstream’s others (a forced tool choice, the wind-down notice, a resent stalled request) have no counterpart. Deliberate difference: upstream clips a turn’s text and then redacts it, so a cut can split a secret and leave a part the redactor no longer matches. The port redacts first and clips after, both in the loop and again inAttachTurns, which also enforces the bounds itself (the last 12 turns, 8 calls of 200 characters, an outcome of 2048) for any executor. A clip never splits a surrogate pair. The loop does not yet attach its debug transcript. The port has no step events
Not ported:
maxObservationBytesandmaxInputTokens. The port sends each observation whole, so it has nothing to cut; the keys are rejected ine2e.config.json
Explore
Section titled “Explore”Explore.ExploreAsync ports upstream’s explore (packages/e2e/src/explore), the library under e2e explore, and e2e explore ports the command (see below). An exploration is the planner (ExtractAsync), the explorer (the built-in agent with the exploration rules and report_finding), and the loop of charters under the exploration’s budgets, with upstream’s outcome policy: a failed step is not a finding, a step at its limit is neither, three unproductive steps in a row end the run, reported issues fail it, and a run that explored nothing, or whose charters were all blocked, stays blocked. The planner’s prompt, the repairs of its answers (padded digit runs, a lone title or charter), and the explorer’s rules are upstream’s text, with done where upstream says complete_step and fill_secret where it says type_secret.
- The port has no runner, so
ExploreAsyncstarts oneE2ESessionand returns anExploreOutcome:Status(RunStatus:Passed,Failed,Blocked),ExitCode(0, 1, 2, or 3, from the error that ended the run by upstream’s table of agent codes),Error(theE2EExceptionthat ended the run), andExplore(upstream’srun.exploreblock asReportExplore). A config, agent, target, goal, or budget error throws before anything starts, as upstream does. A cancelled token throwsOperationCanceledException, where upstream’s interrupt is exit code 130. - A failure while closing the exploration (the engine’s
finishhook, or the session’s own release) does not throw and does not replace the outcome: as upstream’s runner records it as a run error of phasecleanupand folds its category into the exit code (precedence 130, 4, 3, 2, 1, 0),ExploreOutcome.ExitCodeis the verdict’s combined with the cleanup failure’s (an engine failure is 3),Erroris the verdict’s error or else the first cleanup failure, and a failure that is not an e2e error is anENGINE_FAILURE, as upstream’stranslateProvisioningErrormakes it. The findings stay inExplore. - Times are
TimeSpans:ExploreOptions.Timeout,ExploreBudgets.Timeout, andReportExploreStep.Durationwhere upstream hastimeoutMsanddurationMs. The step bounds areExplore.StepBounds(1 to 12, 8) and the time boundsExplore.TimeoutBounds(3 to 15 minutes, 10). - Upstream’s
ExploreOptions.rawConfigisRawConfig, anE2EConfig. Becausee2e.config.jsonholds a provider and a model id only,AgentsgivesAgentOptionsby name over the config’s, for a model of your own or a project tool, andEnginegives the engine, where upstream’s config file holds both.OnProgressreceives theExploreProgressmoments that upstream sends asexplorerun events throughonEvent;FindingReportedis upstream’sfindingphase. - The config declares one target, so there is no choice among several and no notice for it;
Targetonly has to name that one (UNKNOWN_TARGETotherwise). The port rejectscredentialsin the config, soCredentialsgives the accounts in code, by name, asUserCredentials (Credentials.Userreads one from the environment). Each travels into every step as the username and the secret<name>.password(purposepassword), and a secret anywhere in the params of anact, at any depth, is declared and remembered, as upstream’s walk of the params finds it. The size bound of the account inventory, a quarter of the params limit, is upstream’s. - A target’s engine is chosen from its platform, a .NET-only bridge: upstream’s config holds a live engine handle, and
e2e.config.jsonstill rejects anenginekey.webis aWebEngine.iosandandroidare aMobileEngine(MobileTargetEngine.Create,E2E.Mobile) built fromapp.bundleIdorapp.appPathandE2E_MOBILE_DEVICE, as forMobileAppTest.windowsis aWinAppEngine(WinAppTargetEngine.Create,E2E.WinApp; the CLI passesWindowsTargets.Createas thewindowsfactory ofTargetEngines.Create, which isINVALID_CONFIGoff Windows) built fromapp.appPathorapp.bundleId, as forWinAppTest.ExploreOptions.Enginestill wins. The device engine needs Node 22.12 or newer and a booted simulator or emulator.E2E_MOBILE_PROVIDER=limrunordevflowmakes the engine lease a hosted device, as the config of upstream’s CLI does withmobile({ provider: limrun() })(the port names the provider by variable, like the adapters). Any other value fails withINVALID_CONFIG;E2E.EasandE2E.TestMu.Mobileare not referenced byE2E.Cli, because upstream’s CLI loads providers from the config and the port’s tool ships only Limrun and DevFlow. Upstream’sopenStandaloneAttempt(run/standalone.ts) prepares the engine for one slot of a run of its own and finishes that run when the attempt closes, on every path (engines.prepareandengines.finish,run/provision.ts). The port has the same:Standalone.OpenAttemptAsyncisopenStandaloneAttempt, overStandalone.OpenWorkerAsync(openWorker) and itsStandaloneHost(openAttempt,close), andPreparedEnginesisprovision.ts’s class of that name. Only upstream’s one-shot kind exists: the hosted kind publishes each session for another test runner, and the port’s tests run in their own. An engine takes part by implementingIPreparableEngine(.NET-only: upstream’s engine handle carries optionalprepareandfinishmembers).MobileEnginedoes;WebEnginedoes not, because it leases for the attempt it starts (below).e2e exploreande2e mcpopen their session through the host, so the lease happens when the session opens, never when the engine is built or whenENGINE_IN_USErefuses a second session, andMobileEngine.StartAsyncleases nothing itself. What prepare leaves (the device binding) reaches the worker inEngineStartOptions.Env, under the run’s own variables. The finish runs once, within the cleanup timeout, and a hook that outlives it is abandoned and reported asCLEANUP_TIMEOUTwhether or not it honors its token, as upstream does. A finish that fails is an error (close_sessionshows it as aCleanup:line, ande2e exploreends with it), because a hosted device bills until it is released. A start that fails or is cancelled after the lease was granted finishes the engine, with a failed finish added to the start’s message; a caller cancel surfaces asCANCELLEDand not as a provider failure; a lease the provider grants after the caller stopped waiting is released where it lands; and the end of the process finishes an engine still held (AppDomain.ProcessExit, .NET-only; a killed process is covered by the provider’s own time limit, 15 minutes for Limrun). Progress lines of the provider (session URLs, lease ids) have nowhere to go in a session and are dropped, as inWebEngine’s standalone run;LIMRUN_API_KEYis registered as a secret, so an error that quotes it is redacted. DevFlow’s agent runs inside the app, soIDeviceProvider.AttachesToRunningApp(.NET-only; a default interface member, false) makes the firstapp.open()attach with no relaunch. Live runs are not possible without Limrun credits or a DevFlow agent: the tests use a fake provider that counts leases and releases. Ported, in part, through the host:OpenHostLifecycleTests.Partial_Host_close_cancels_an_opening_session_and_waits_for_disposal_and_finish_exactly_once(upstream’sopen-host-lifecycleit, without the count of worker disposals, because the port has no workerdisposehook) andEngineTargetsTests.Partial_Ends_the_run_before_any_worker_starts_when_prepare_failsandPartial_Abandons_a_finish_hook_that_outlives_the_cleanup_budget_as_a_cleanup_error_instead_of_hanging_the_run(upstream’sengine-targetit, without the run’s status, results, and error events, because the port has no run). .NET-only:StandaloneHostTests,PreparableEngineTests,HostedDeviceSessionTests,ExploreHostedDeviceTests, andDeviceTargetsTests. The .NET-only symbols of this change are:IPreparableEngine,StandaloneAttempt(upstream’s type of that name, over the port’sE2ESession), the process-exit finish ofStandaloneHost,PrepareScopeandFinishScope(upstream’s, in C# casing),E2ESession.LaunchAsync(the start with the worker’s environment),DeviceTargetsandHostedDeviceProviders(they stand in for the provider upstream’s config holds as code),IDeviceProvider.AttachesToRunningApp, theMobileTargetEngine.Createoverloads that take a provider,SessionHostOptions.Credentials, andFakeCloud(test helper). TheE2E_MOBILE_PROVIDERmapping to Limrun and DevFlow is .NET-only, because upstream names a provider ine2e.config.tsand the port’s config file cannot hold code. DevFlow is .NET-only too: upstream has no DevFlow package, so the attach behavior has no upstream counterpart.EngineCapabilities.PinnedAppis the .NET-only stand-in for upstream’s test of the engine handle (a session withrestartand noopen):MobileEnginedeclares it.TargetEngines.LaunchesPinnedApp(target, engine)is upstream’slaunchesPinnedApp(config/targets.ts, upstream #1252): the target has noapp.urland the engine pins an app. The port’s config holds a platform and no engine handle, so it takes the engine too. Explore opens the app first when the target has a URL orLaunchesPinnedAppholds (openApp), so a device target’s pinned app launches before the planner’s first look. To make that call work,App.OpenAsyncon a target with no URL on a pinned-app engine launches the app throughRestartAsync, as upstream’slaunchPinnedAppdoes inrun/fixtures.ts, and refuses a path withAPP_URL_REQUIRED; upstream’sUNSUPPORTED_CAPABILITYfor an engine withoutrestarthas no port, becausePinnedAppimplies it. Tests:TargetEngineChoiceTestsandMobileTargetEngineTests(.NET-only);E2eExploreTests.Launches_a_device_targets_pinned_app_before_the_planners_first_lookandSessionHostTests.Launches_a_device_targets_pinned_app_before_the_first_observationport upstream’s twoitof #1252.AppSteeringHooksTestsports threeitof upstream’sapp steering hooksfor a device (app.open()launches through restart,app.open(path)isAPP_URL_REQUIRED, and the engine’s own reason is kept); the fourth, an engine with no relaunch hook, has no port, becausePinnedAppstands for that hook. .NET-only:SessionHostTests.Surfaces_a_cancel_during_the_launch_of_a_pinned_app_as_a_cancel_and_disposes_the_engine. The explore test reads the attempt’s steps fromreport.json, as upstream reads the report. - The goal is redacted for the secrets of the config and the credentials’ passwords, which the session also redacts wherever they appear.
- The tool is named
report_finding. A project tool of that name isINVALID_CONFIGfor every agent, as upstream’sHARNESS_TOOL_NAMEShas it (doneis reserved with the grammar’s names). The finding is recorded withArtifactId, the path of the evidence screenshot relative to the project root (.e2e/results/<result>/attempt-1/screenshots/<n>-finding-<k>.png), where upstream names an artifact of its report. The exploration is a test of the virtual fileexplore(Explore.ExploreFile, upstream’sEXPLORE_FILE): the file joins the goal in the result id and leads<result>(explore-<goal words>-<id>), and every prompt opens withTest: <goal>, as upstream’s does, where ane2e mcpsession, which runs no test (noTestFile), gets no such line. The explore executor keeps the model’s screenshot, so none is kept once a secret was filled (the model gets no pixels then) or when the engine captures none. A finding has noobservationRevision, since the port has no observation revision. e2e explore [goal](E2E.Cli,ExploreCommand) takes--config,--target,--agent,--max-steps,--timeout(milliseconds),--headed,--output,--debug,--ai-trace,--trace, and--video, with upstream’s bounds, and exits with the outcome’s code: 0, 1, 2, 3, and 130 for an interrupt. A bad flag exits 2 with commander’s message (option '--max-steps <n>' argument '99' is invalid. must be an integer from 1 through 12). EachE2E_USER_<NAME>_USERNAMEand_PASSWORDpair in the environment is passed asCredentials(named<name>in lower case), where upstream reads the config’scredentials. A username with no password, or a short one, exits 2.- The output is upstream’s list reporter without its live window:
explore-text.tsisExploreText(the severity words, why-it-ended text, step counts, and the order of findings), andlist-explore.tsisExploreViewinE2E.Cli. The header, each finding and each step stream as they happen, then theFindingssection, theAssessment, and theFindingsandStepssummary rows. Differences: noRUNheader, no live window, a step line counts no actions (the port has no engine events), the screenshot is named by its path under the project root, and the failure of a run that is not the findings’ verdict prints asCODE: messagebelow the record. --debug,--ai-trace,--trace [mode], and--video [mode]are upstream’s, with its parser: a bare--traceor--videoison, the optional value is greedy (--trace "Explore checkout"reads the goal as the mode and is refused withwrite --trace=<mode>, or put test files before --trace), and the old spellingsallandretriesare refused with the mode they meant.ExploreOptions.Trace,Video,Debug, andAiTracecarry them; aTraceorVideois aRunsource over the target’s and the config’s, else the target’s mode.E2E_TRACE,E2E_VIDEO,E2E_DEBUG, andE2E_AI_TRACEset them without the flag. An exploration is one attempt, so a retry mode keeps nothing, andExploreOptions.Noticesays to pass--trace onor--video on(upstream’srecordingNoticesforexplore, asExplore.RecordingNotices, internal), as it says that a--videothe engine cannot record is skipped. An exploration’s verdict (ASSERTION_FAILEDwith an issue finding) gets no trace page (isExploreVerdict, asTracePages.IsExploreVerdict), andReportRun.Explorecarries the record for that.e2e mcptakes none of the four, as upstream.- Not ported:
--sessionand--reporter(the command refuses them with exit 2, since running without a session or a report would change what the run means),--reporter json(the port has no reporters;.e2e/report.jsonhas the record underrun.explore, see Report document), a start from a restored session, and the--artifactsrefusal (the port never had the flag). - Tests:
E2eExploreTestsports theitofexplore.test.tsthat says to pass--video onwhen a retry mode would record nothing (partial: the notice comes throughExploreOptions.Notice, and a scripted engine that declares the video capability stands in for the web engine). The--traceand--videoparser tests ofcli.test.tsare aboute2e run, which the port lacks;ExploreFlagsTests(.NET-only) covers the same parser one2e explore, andExploreRecordingTests(.NET-only) covers the files.
Config
Section titled “Config”E2ETest loads the nearest e2e.config.json above the test assembly directory, then above the working directory, once per run. Supported keys: targets (one entry with name, platform, and app with url, command, and readyUrl), timeout, launchTimeout, actionTimeout, assertionTimeout, cleanupTimeout, retries, agents, cache (mode, dir, strict), and secrets. Defaults, the CI cache demotion to read-only, E2E_SECRET_<NAME> overrides, the 6 code point secret minimum, and INVALID_CONFIG for unknown keys follow upstream.
app.url is normalized as upstream does, with System.Uri instead of WHATWG: a missing scheme becomes https://, or http:// for a loopback host. Userinfo, a query, a fragment, a scheme other than http(s), and plain HTTP on a non-loopback host are INVALID_APP_URL, with upstream’s messages. A base URL set in code (E2EFixture.BaseUrl, E2ESessionOptions.BaseUrl) follows the same rule. Port 0 on 127.0.0.1 or [::1] asks for a free port and needs an app.command to start the app on it (INVALID_CONFIG without one, or with reuseExisting); any other host with port 0 is INVALID_APP_URL. A base URL set in code with port 0 is INVALID_CONFIG, since only app.command starts an app on a free port. Keys of the old .NET shape (app, engine, agent, timeouts) fail with a hint to the upstream key.
app.command and app.readyUrl port upstream’s config/command.ts and config/app.ts (CommandConfig, Command.Normalize, TargetApps.ResolveTargetApp), and ManagedProcess ports run/managed-process.ts (readiness polling, the preflight probe and reuseExisting, the startup-failure tail of command.log, inheritEnv). executable is started with an argument list, never a shell. {port} expands in args, in env values, and in readyUrl, and a url on port 0 takes a free loopback port (AppPorts.AllocateTargetPorts, upstream’s allocateTargetPorts). The first test, e2e explore, or e2e mcp session that needs the command starts it (E2EFixture.StartSessionAsync, Explore, and the MCP session call RunApps.StartAsync) and it runs until the process ends, where ManagedProcess.KillManagedProcessGroups takes it down on the way out; on Windows the process runs in its own job object (ProcessJob, .NET-only), which ends it and what it started when it is stopped, and when this process is killed outright, as a test host is once its run ends. On Unix a descendant is read with pgrep -P when the app is stopped, so one that outlives a leader that exited earlier is not found. The app command differs from upstream’s in these ways:
- There is no runner, so a command starts once per test process (a .NET-only holder,
RunApps), not once per run. Two processes that declare one command, such asdotnet testande2e mcp, each start their own, so give a shared app a fixed port andreuseExisting. Upstream’sSharedAppProcessespool,declaredCommands, and the enginepreparehooks thatstartDeclaredProcessesshares a provision with are not ported (one target, no pool). - A started process is stopped with
SIGTERMand thenProcess.Kill(entireProcessTree: true)aftershutdownTimeout. On Windows, which has no signal to ask a process to stop, the first step is the kill. There are no process groups, so a descendant that outlives its leader is not signalled; the twomanaged-processintegration tests about that (stops a surviving child after the command leader exits on SIGTERM,lets a child finish its shutdown after the leader exits) are not ported.stdoutandstderrare read together intolog(or dropped when there is none), line by line as the process writes them. - A cancelled token ends
ManagedProcess.StartAsyncby throwingOperationCanceledExceptionafter taking the process down, where upstream’sstartreturns and the caller reads the signal.startupTimeoutandshutdownTimeoutareTimeSpans (CommandConfig.StartupTimeout,ShutdownTimeout), and a value over 2147483647 milliseconds, which a .NET timer cannot wait, isINVALID_CONFIG. A readiness probe still waiting when the process exits ends there. - JSON has no
secrets.get()handle, so the message that names one for a string value ofargs,env, orurlis not reachable, and anenventry ofnullstands for upstream’sundefined.command.logcontainment resolves symlinks asrelativeToProjectRootdoes (Paths.RelativeToProjectRoot). An app key outside the port’s set is named in the port’s own words (unknown targets[0].app key "readyURL"), where upstream saystarget "web" app has unknown key; the command’s own keys use upstream’s message. - The resolved app has
Base,PortRequest,Command, andReadyUrl;site,environment, andidentityare not ported, and neither is the config digest (digestCommand,digestTargetApp), so theconfig.test.tstests that digest an app, and theidentityandenvironmenttests, are not ported. - Tests:
ManagedProcess/ports theitoftests/unit/managed-process.test.tsandManagedProcessIntegration/those oftests/integration/managed-process.test.ts, with the program they start (E2E.Tests.App) where upstream runsnode -e; the notice tests wait out a real budget where upstream moves a fake clock.AppPorts/portsapp-ports.test.ts(partial: one target, and noidentityor digest).Config/ports the command andreadyUrltests ofconfig.test.ts.RunApps/is .NET-only. Not ported:target-processes.test.tsandshared-app-processes.test.ts, which drive the runner.
Differences:
- JSON has no engine handles or model instances. The fixture’s
CreateEnginechooses the engine,platformmust beweb, andtargetsholds one entry.agents.<name>.modelandjudgeare model ids for the .NET-onlyprovider(see Models), with the .NET-onlybaseUrlandapiKeyEnvbeside them. A subscription provider refusesbaseUrlandapiKeyEnv.E2ETestpassesagents.defaultas the session’s own agent settings and every other entry throughCreateAgents - A secret set to
nullreads onlyE2E_SECRET_<NAME>. Provider functions do not exist in JSON. A test reads secrets withSecrets.Get(name) retriesis validated and resolved (1 in CI, 0 elsewhere) but NUnit retries still come from[Retry]cache.stricttreats any replay that finds a recording but does not finish it as stale. Retries replay too, and a strict run never writes or deletes a recording, whatevercache.modesays- A launch or cleanup timeout fails with
ENVIRONMENT_UNAVAILABLE traceandvideoare accepted at the config root and on the target, as upstream. The modes areoff,on,retain-on-failure,on-first-retry, andon-all-retries(RecordingMode,RecordingSource,ResolvedRecording). The mode is resolved as upstream’sresolveConfigdoes it: the flag, else the target’s key, else the config root’s, else the default (retain-on-failurefortrace,offforvideo), and a mistake in a target’s key is reported even when the flag wins. The oldtrace: 'all'andtrace: 'retries'spellings, bare or as{ record }, are refused with the mode they meant. The port recordsvideoon the web engine (see Video) and writes the trace page (see Trace page): the settings are read and carried onTargetConfig.TraceandVideo, the fixture’sTraceandVideoproperties, andE2ESessionOptions. The port has no--traceand--videoflags, so the .NET-only variablesE2E_TRACEandE2E_VIDEOstand in for them, with sourceRun.RecordingSource.Testis the source of a setting made for one test; the port has no per-test option yet. The per-attempt rules (attemptKeep,attemptVideo,keeps,recordsOnSomeAttempt) are inRecordingModes, internal. The port has no config digest, so upstream’s “keeps the mode, at the top and on a target, out of the digest” has no port; the replay cache key does not read these settings.config.test.tstests are inConfig/:TraceTestsandVideoTests(the three of the fouritthat do not need a digest; the flag is the variable, so “lets a target override the config, and the flag override both” and “refuses a mode it does not know” are partial),ArtifactsConfigTests(only the old trace spellings; the port has noartifactskey), and “uses CI defaults for retries and workers…” inResolveConfigTests(partial, no workers).recording-modes.test.tsisRecordingModes/- Not supported, and rejected with
INVALID_CONFIG:projectId,tests,failOnSkippedFailure,workers,artifacts,output,reporters,credentials,cache.store, the non-URLappkeys, and the agent keystools(setAgentOptions.Toolsin code),executor(setAgentOptions.Executorin code),maxObservationBytes, andmaxInputTokens. Credentials still come fromE2E_USER_<NAME>_USERNAMEand_PASSWORD
Web engine
Section titled “Web engine”WebEngine launches Chromium through Playwright and builds a semantic tree in the page: explicit roles, the upstream implicit role table (landmarks, lists, tables with rows and cells, dialogs, options, img alt="" as presentation unless it has a title, a tabindex, or a global ARIA attribute, and role="presentation" or role="none" ignored on a focusable element or one with a global ARIA attribute, select multiple as listbox, and the rest), accessible name, text, test id, heading level, and the disabled, checked, expanded, selected, pressed, focused, and hidden states. As upstream, the name reads aria-labelledby before aria-label and associated labels. Disabled covers :disabled (including a disabled fieldset), aria-disabled="true" on the element, and aria-disabled inherited from an ancestor for the roles it applies to. Checked is the native state for checkbox and radio inputs and aria-checked="true" elsewhere; mixed reads as not checked. Hidden is what renders, as upstream: display: none, a visibility other than visible, content under content-visibility: hidden, and content a closed details folds away. A display: contents element is shown when a child element is, or when it is visibility: visible and its own text has a box. One exception, described below: a box with no size does not hide a node. aria-hidden is a separate state, not part of hidden. A child that sets visibility: visible under a hidden parent is visible; nothing under display: none is. An explicit role is the first recognized token of the role attribute; with none recognized, the node keeps its native role. role="img" is reported as image, and an open details is expanded. A closed select lists up to 60 options under it: the ones it offers, so an option that is hidden, display: none, aria-hidden, or inert, or sits under such an optgroup, is left out before the limit counts, as upstream’s isOfferedOption does (#900). A visibility: hidden option stays listed, since a closed select paints none of its options anyway, and a disabled one keeps its state. Each observed element keeps a stable ref for the life of the document, and the engine runs an action on the element that ref names, with no fallback to a text or role lookup in the page. The agent adds the fallback upstream’s agent has: a tap, double_tap, fill, fill_secret, press, select, check, uncheck, or clear whose node the engine reports NODE_STALE finds the node again by its role, name, and test id in a fresh look and retries, up to twice, scroll and swipe do not retry, and a ref the newest look no longer lists (not one it lists as hidden) is found the same way from the last node it named. A description that matches no node or several fails the verb with LOCATOR_NOT_FOUND. Upstream also matches a node’s placeholder, input purpose, and container, and forgives a test id that changed; the port matches the three fields it records, and never finds again a node with no name and no test id. Because the port records no container for a node, upstream’s containerKey fix for windowed lists (841ea3fa, a row is not keyed by its list’s first rendered row) does not apply. Upstream wraps the retry in commitTargeted and the step’s accounting; the port has no such wrapper, so each verb runs the loop (TargetedAsync) directly, and ObservationFeed.Relocate takes the redactor as an argument. Firefox and WebKit launch options are not exposed yet; the package reference can drive them later. .NET-only: the first launch in a process runs Playwright’s Chromium install, and E2E_SKIP_BROWSER_INSTALL=1 turns it off. .NET-only: dotnet test has no flag like upstream’s --headed, so when WebEngineOptions.Headless is unset, E2E_HEADLESS=0 or false shows the window. As upstream, a run is headless by default. As upstream, a headless run installs with --only-shell and a headed run installs the full build, and the install runs with PLAYWRIGHT_SKIP_BROWSER_GC=1 unless the user set it. Upstream checks the cache before it spawns the install; the port leaves that to Playwright’s install, which does nothing when the build is there. Playwright’s .NET Program.Main starts the driver with the process environment, so the port sets PLAYWRIGHT_SKIP_BROWSER_GC on the process, not on the install alone.
The walk goes through open shadow roots and closed ones that page script attached (an init script records them, as upstream does). Each iframe is a boundary node whatever its role, so an <iframe role="none"> stays listed with the role none, as upstream’s walk emits it before any role check, and the engine reads that frame’s document through Playwright and puts it under the node, so same-origin and cross-origin frames are both listed and acted on. One observation lists at most 3000 nodes across all frames. When a document has more, nodes that intersect the viewport come first and the rest of the budget goes to the others in document order; the observation reports Truncated, and the snapshot tells the model to scroll. A locator resolves against that tree, so matches come in tree order: a match inside a closed root sits where its host is, where upstream lists the light-DOM matches first and the closed root’s after them. OpenAsync waits for the load event.
WebEngineOptions carries the upstream web() options: Viewport (default 1280 by 720, null for no emulation), TestIdAttribute, Headers, BasicAuth, UserAgent, Locale, TimezoneId, ColorScheme, ReducedMotion, InitScripts, Connect (a CDP endpoint resolver, with an optional ReconnectEndpoint), and Browser (an IBrowserProvider, upstream’s browser: BrowserProvider). Differences:
Headersride requests to the base URL’s host. Upstream scopes them to the app’s site, which can include sibling subdomainsBrowserports upstream’sBrowserProviderseam (provider.ts):IBrowserProvider(Name,Scope,AcquireAsync,ReleaseAsync,SweepAsync),BrowserProviderScope(Worker,Attempt),BrowserRequest,BrowserLease, andBrowserReleaseContext, and the engine side,LeasedBrowsers.BrowserLease.Protocol(BrowserProtocol.Cdp, the default, orPlaywright) is .NET-only: aPlaywrightlease is dialed withChromium.ConnectAsync, the engine opens its own context with the options it gives a launched one, and it needsWorkerscope and noReconnectEndpointbecause that connection cannot be reattached (a dropped one is replaced by a new lease). A provider is an interface, so upstream’sasBrowserProviderchecks that a method exists have no port; the engine still refuses a blank name, a scope outside the enum, andConnectbesideBrowser(INVALID_CONFIG).ScopeandSweepAsyncare default interface members: a provider that leaves them out leases per worker and sweeps nothing. Neither a request nor a context carries upstream’ssignal:AcquireAsync,ReleaseAsync, andSweepAsynctake aCancellationToken.Downloads(BrowserProviderDownloads, withBrowserDownloadContext) andRecordAsync(ProviderRecordContext,ProviderRecording,ProviderRecordingStopContext,ProviderRecordingResult) are default interface members too: a provider with no downloads returns null, and one that cannot record a browser returns null fromRecordAsync, where upstream leaves the method out.LeasedBrowsers.Downloads()andRecorder()are upstream’sdownloads()andrecorder(), andProviderRecordings.StopProviderRecordingAsyncisstopProviderRecording(recording.ts, withVideoSegmentasVideoFileorVideoLink).ProviderRecording.StartedAtis aDateTimeOffset, so upstream’sisProviderRecordinghas no port. The engine callsRecordAsyncthroughRecorderwhen an attempt records video (see Video).Browser.WaitForDownloadAsynccallsDownloads(upstream’swaitForDownload, withsaveDownloadsTo,saveFromBrowser, andsaveLocallyofdownloads.tsasDownloads.SaveDownloadsToAsync,SaveFromBrowserAsync, andSaveLocallyAsync). A provider’s directory is checked at config load too- A runner calls
WebEngine.PrepareAsync,Init,FinishAsync, andDisposeAsyncas upstream’sprepare,init,finish, anddispose, withEnginePrepareInfo,EnginePrepareResult,EngineInitInfo, andEngineFinishInfo; an attempt isStartAsyncand the dispose of its session (startAttemptandendAttempt). The hand-off to a worker travels in the sameE2E_WEB_BROWSERS_<TARGET>_<DIGEST>environment variable, capped at 16384 bytes. The port has no runner, so an engine whoseInitnever ran leases for the one attempt it starts, as upstream’s standalone run does: it prepares one slot of a run of its own, readingEngineStartOptions.Env(the process environment by default), and gives the lease back, and sweeps, when the session is disposed, with the default cleanup timeout for each step (the engine does not see the session’s).EngineStartOptions.AttemptIdnames the attempt to a per-attempt lease - Differences from upstream’s lease flow, all from the port’s engine. It keeps no browser connection between attempts, so a worker dials the endpoint of the lease it holds at every attempt, and leases a replacement only after an attempt attached to a browser that was no longer connected when the attempt ended (upstream: when the next attempt finds the connection dropped). A start that fails gives its lease back itself, since there is no
endAttemptto call after it. A lease that arrives too late, after the attempt was cancelled or the worker disposed, is given back under a cleanup budget of its own (upstream passes the signal that already aborted). Upstream’sstatecapability, which an attempt-scoped provider drops, does not exist in the port, so onlyapp.clearState()is refused (UNSUPPORTED_CAPABILITY, naming the scope) - The lease tests are
BrowserProvider/inE2E.Tests, frombrowser-provider.test.ts. Not ported: “rejects a sweep that is not a function” and “rejects a record that is not a function” (both are methods of the interface), “releases the replacement on dispose even when ending the attempt failed” (the port’sDisposeAsyncdoes not end an attempt), and “refuses, at the start and named, a recording without a start time and a stop” (a recording is typed). The other tests of the recording group are ported in full, on a real Chromium the provider leases (upstream drives a scripted page), throughStartVideoAsyncandStopVideoAsyncof the engine session. Also not ported: “knows a recording by a parseable start time and a stop” ofprovider-recording.test.ts. The rest of that file isProviderRecording/, andprovider-downloads.test.tsisProviderDownloads/, on a real Chrome over CDP, with theBrowserfixture over the engine session Browser.OnDialogAsyncandBrowser.WaitForDownloadAsyncare upstream’sonDialogandwaitForDownload.DialogHandleris upstream’sDialogHandler(DialogHandler.AcceptandDismissare its'accept'and'dismiss', and aFunc<Dialog, Task>converts to it),Dialog.Typeis aDialogType(Alert,Confirm,Prompt,BeforeUnload) where upstream’s is a string union, andOnDialogAsyncreturnsFunc<Task>for upstream’s unsubscribe. The engine side isDialogRouter(dialogs.ts) andErrorLatchandWebSupport.SanitizeFilename(support.ts), andWebSession.ArtifactPathis the surface’sartifactPath. Differences: the engine session hasOnDialogAsyncandWaitForDownloadAsync(default members ofIBrowserSession), and theBrowserfixture attaches the file to the step (attachArtifact), since the port’s fixture holds no context;BrowserDownloadis the result upstream types inline. A dialog handler’s failure is latched and fails the next step, as a route handler’s does, but the port has nosettleAttempt, so one that no step follows is dropped (upstream fails the attempt with it). A test framework’s assertion is not anE2EException, so one thrown in a handler fails the next step asENGINE_FAILURE,dialog handler failed: ..., where upstream’sexpectfailure keeps itsASSERTION_FAILED. A download is adownloadartifact of thebrowser.waitForDownloadstep, with the media type of its extension. The port has no artifact store, so upstream’s rewrite of a text-like download through the secret ledger has no port: the record saysincomplete, as upstream’s does for a session without secrecy. Not ported: “a dialog handler assertion that no step follows” and “fails a %s handler assertion at the attempt end when no step follows it” ofweb-platform.test.ts(nosettleAttempt), and nothing else. The frame tests ofdialogs-frames.e2e.tsareDialogsFrames/FramesTests, and the download tests trigger the link withbrowser.locator('a[download]')as upstream does- With
ReconnectEndpoint, as upstream, each attempt rides the default context of a fresh browser fromCdpEndpoint, refuses a browser an earlier attempt of the sameWebEnginerode, and reconnects once to the same browser and page after a transport drop.Headers,BasicAuth,UserAgent,Locale, andTimezoneIdareINVALID_CONFIG, andapp.clearState()isUNSUPPORTED_CAPABILITY. After a reconnect,PerformAsync, the agent’s key press, and the viewport swipe fail withNODE_STALEuntil the next observation; the browser fixture’s keyboard and mouse are direct input and keep working. As upstream, the reconnect is spent from the budget of the operation that found the transport dropped, both for the CDP dial and for the operation itself BasicAuthtakes aSecretpassword. As upstream, the attempt redacts the password and theAuthorizationcredential the browser sends with it, padded and not, as text only: it does not withhold screenshot pixels, and the agent cannot fill it by name. The port has no engine secrets to declare, so the engine resolves the password through the attempt when it starts. A plain string password is not redacted- An
InitScriptsentry is aWebInitScript: source (a string converts to one),WebInitScript.FromPath, orWebInitScript.FromFunction. A C# lambda cannot run in the page, so a function is its JavaScript source. A relative path resolves against the project root (EngineStartOptions.ProjectRoot, whichE2ESessionpasses fromE2ESessionOptions.ProjectRoot). Each attempt reads the files when its session starts; upstream reads them once per process ColorSchemeandReducedMotionare the enumsWebColorSchemeandWebReducedMotion, andBrowser.EmulateMediaAsynctakes aWebMedia; upstream’s are string unions. A value outside an enum (a cast number) isINVALID_CONFIGin the options andINVALID_ARGUMENTinEmulateMediaAsync; anullmedia throwsArgumentNullException. A feature or value outside the type cannot be written, so upstream’s unknown-key and wrong-type refusals have no port. Playwright sets the media per page, so a tab the app opens itself gets a test’s change as it appears, on a persistent context too, so a script it runs at load may still read the configured values, as upstream. After a reconnect the new transport sets the media again on the app’s own tabs
The tree is a subset of upstream’s: names follow upstream’s accname rules, and nodes are listed by role, test id, or direct text (not by name alone, or as an empty painted box). So getByText answers with the innermost element that reads the text, such as a <strong> inside a paragraph, as Playwright does. Text inside a <label> of a control, or in an element an aria-labelledby names, is not listed: the labelled element carries it as its name, so getByText answers with that element (Playwright answers with the label and retargets a fill to the control). A node listed for its text alone does not count against the 3000-node budget: such nodes have an allowance of their own, so a page full of text cannot push controls out. In the agent snapshot, a node that lists its children shows its own text, read as a person reads its line: the text of an inline link, emphasis, code, or span in the sentence stays in place, so <p>Read our <a>privacy policy</a> for details.</p> reads whole while the link is also listed as a control. Inline text-only elements are not listed again, unless the line is longer than the 512-character text bound, or the element has a test id, a role, or can be acted on (tabindex, onclick, or an <a> with no href). getByText still answers with such an inline element: the page script sends it as a bare text node under the line, which the agent does not read. This differs from upstream, whose text engine finds the element in the page. A leaf such as a button or a heading shows its whole text. Locator reads (TextContentAsync, text matchers) still read the whole rendered text, as upstream’s node read does. A contenteditable editing host (the root of a rich-text editor, never a canvas, a frame, or a native control) is a textbox named by aria-label, aria-placeholder, or the data-placeholder its editor paints, with its rendered text as the value, as upstream. Its children stay in the tree as content, so getByText finds them. A <p> inside an editor has no role. A document in designMode is not an editing host, though its <html> is editable; upstream has no such check. Playwright’s role selector knows a bare host only by an explicit role="textbox", while the port’s GetByRole reads the tree and finds it. Upstream’s getByText runs Playwright’s text engine; the port matches the tree, so text split across child elements with no text of its own between them (<div><b>a</b><i>b</i></div>) is not one match.
The snapshot leaves out a checkbox’s or radio’s value, which inputValue still reads (on by default).
The page script reads the text selected inside the focused input, textarea, or contenteditable host into SemanticNode.Selection, as upstream does. A caret, an unfocused field, and a secure field have none. The agent snapshot lists it as selection="..." after the field’s value, verbatim and escaped (a lone space counts, an empty selection does not), as upstream does. The runner drops it from a field whose value or text holds a registered secret, and from a field cut at its length limit when the selection is not in the part shown. Differences: the port’s press has no times, so one call presses the key once; and the snapshot shows a value as it is, a newline included, where upstream escapes it.
Upstream leaves an aria-hidden subtree out of its tree walk, and its text and visibility reads go through Playwright. Here every locator read comes from the tree, so the port lists the subtree and marks each node AriaHidden (.NET-only), and role queries and the agent skip those nodes. They are not hidden: a painted aria-hidden node is visible to isVisible, toBeVisible, and toBeHidden, as upstream, and a visible query keeps it. An action on one is not refused as hidden. A box with no size is not hidden here, since role queries skip hidden nodes and an empty landmark has no size. A hidden node reads its DOM text (textContent) instead of innerText, so a text query still finds a visibility: hidden copy.
Password fields and autocomplete=current-password fields are marked secure. Their values, and their value attribute, are omitted from the snapshot. A text, value, or attribute expectation on a secure field fails with POLICY_DENIED, as upstream. So do the textContent, inputValue, allTextContents, and getAttribute reads (for any attribute).
Selected is aria-selected="true" or a selected <option>, expanded is aria-expanded="true" or an open details, and focused is the active element, followed into shadow roots. Attributes (for getAttribute and toHaveAttribute) and the client rect (for boundingBox, null for a node with no layout box: display: none on it or an ancestor, or any display: contents element) are read for every kept node; they are not sent to the model. The tree reduces a link’s href attribute as upstream’s tree does: origin and whole path, without userinfo, with ?… or #… for a dropped query or fragment, mailto: and tel: kept, blob: reduced to its inner origin and path, and data:… or javascript:… for an inline payload. Upstream’s node read keeps the whole value; the port reads every locator attribute from the tree, so it keeps the whole href on the side and getAttribute and toHaveAttribute read that. The agent snapshot lists a node’s test id, its href, and its placeholder when it has no name, as upstream’s line does: a link target on the app’s origin shows as its path, any other as the whole URL, and a target over 256 characters is redacted, then cut there and ends with …. The executor tree carries treeHref’s result: a link target on the app’s origin keeps the origin and loses no more of its path than the line does. The snapshot is not part of the cache key or of a recording, so showing them does not change the replay policy version. The snapshot joins its lines with \n on every OS, as upstream does (the port used Environment.NewLine, so on Windows the lines ended \r\n); the cache key and recordings do not embed the snapshot text, so no recording is invalidated. A rect in an iframe is in the top-level viewport, shifted past the frame’s border and padding and clipped to its content box.
A display value query never matches a checkbox or radio, which carries a value it never shows. Text, label, placeholder, test id, and display value queries keep hidden matches, so toBeVisible fails on a hidden match and toBeHidden passes on no match or one hidden match. Role queries skip hidden nodes, and toBeAttached and waitFor attached or detached also match hidden nodes for role queries. A single-node matcher that still sees more than one match at its deadline fails with STRICT_MODE.
Playwright failures never escape as PlaywrightException. WebEngine throws EngineException with an upstream engine code from EngineErrorCodes: NOT_ACTIONABLE when an action timed out before its input was dispatched or the target does not take that input, ACTION_MAY_HAVE_COMMITTED when it timed out after dispatch, NODE_STALE when the element or its document is gone, OPERATION_TIMEOUT for a navigation or key press timeout, and ENGINE_FAILURE otherwise. EngineException.Retryable is true only for NODE_STALE and FRAME_NOT_FOUND; any other retryable claim becomes ENGINE_FAILURE, as upstream. As upstream (commit c23944c), a Playwright timeout stays a timeout: StaleOr and NavigationStaleOr never turn a TimeoutException into NODE_STALE, even when its call log says an element detached or the page navigated, so a read or a navigation that times out is OPERATION_TIMEOUT and is not retried. The action path is unchanged by that commit. Tests: Errors/StaleOrTests and Errors/NavigationStaleOrTests. A sensitive fill’s value is redacted from the message. As upstream, check and uncheck click once and read the state back on the same element until just before the action timeout, so a control that commits its state late passes. A control gone by the first read (replaced, or navigated away) took the click, so the action passes. Each of these is NOT_ACTIONABLE: a control that goes later in its old state, a click that leaves the state unchanged at the end of the wait, and an uncheck on a checked radio. The port ends the poll min(250 ms, timeout/4) before the action deadline, which upstream does not: upstream polls to the full timeout, but here the deadline that abandons the call would win the race and report ACTION_MAY_HAVE_COMMITTED. The document engine still uses NOT_FOUND for a ref missing from its last observation.
A page stuck in a script fails an operation at the action timeout, not the test timeout. As upstream, each page operation (a navigation, back, forward, reload, observe, an action, a scroll, browser.evaluate, browser.title, browser.setViewport, browser.emulateMedia, a key press, typing, and browser.mouse) has one deadline, the action timeout, that every page call in it shares. Playwright’s own timeout is set 250 ms short of what is left, or half of it when less than 500 ms is left, so Playwright’s reason (what blocked an action, or that its input was dispatched) arrives first. A call Playwright never answers, such as an evaluate or input on a page stuck in a script, is abandoned at the deadline. A read cut off that way fails with OPERATION_TIMEOUT. An action or input cut off that way is ACTION_MAY_HAVE_COMMITTED, because it may have reached the page.
A scroll moves three quarters of the viewport or the scrolled element with scrollBy. Upstream sends a wheel gesture. back is the browser history.
video records an attempt on the web engine. The modes, their precedence, and E2E_VIDEO are under Config. This is the rest of upstream’s video.ts, provider-video.ts, attempt-session.ts (startVideo, collectVideo), and the video half of execute.ts.
IEngineSession.LocateAsyncis upstream’slocate(EngineSession.locate), with the flatLocatorExpressionrecord and the budget left as atimeoutargument. Core resolves a locator through it when the session saysCanLocate(.NET-only, transitional).WebEngineandDocumentEngineimplement it: each reads its semantic tree and resolves the expression over it with the core resolver, so a match is a node of that observation with the ref the page script minted. Upstream’s weblocate(surface.ts) projects the expression onto Playwright selectors (locators.ts), which the port’s page script replaces; the port resolves over the tree it already reads, so only theselectorandframeexpression kinds ask Playwright (see theBrowser.Locatorbullet below). The web engine reads under thetimeoutit is given (what is left of the wait, at least 500 ms), or the action timeout when that is shorter; the document engine answers at once and does not use it.LocatorExpression.IncludeHiddenis .NET-only:toBeAttachedandwaitForset it so an engine that resolves over its tree matches hidden role nodes, which they matched before the engine located; upstream’s Playwright has no such switch. A locate hands the trace the observation it read (TraceFeed.ShowScreen, andEngineStartOptions.Screenon the document engine), as core’sAgent.ObserveAsyncdid for the resolve it replaces; upstream’s weblocateshows none. A retryableEngineExceptionof the read (NODE_STALEduring a navigation) is now retried until the wait’s deadline, asresolveOncedoes. Any session that says noCanLocate(the WinApp engine, a scripted engine) has its locator resolved overObserveAsyncin core, as before.Screen.GetByTestIdon such an engine, and theLocatorResolverreaders of screenshot masks ande2e mcp, still resolve over the observation.Browser.Locator(selector)andBrowser.FrameLocator(selector)are upstream’sbrowser.locatorandbrowser.frameLocator(browser.ts).FrameScreenis upstream’sFrameScreen: aScreen(soScreenis no longer sealed, though only the port can derive from it) whose every query goes through aframeexpression, withLocator(selector)andFrameLocator(selector)for a CSS or XPath selector and a nested frame. A locator of a frame screen canOr,Filter, and scope as any locator, sinceScreen.OwnLocatorcompares the attempt’s root screen (upstream compares the context).LocatorExpressionhas theselectorkind (the device’s, now the web engine’s too) and theframekind (Selectoris the iframe’s selector,Sourcethe scoped expression).WebEngine.LocateAsyncresolves them inWebSession.ProjectAsync, over the tree: Playwright says which elements a selector, or an iframe, names (WebLocatorsis upstream’slocators.ts, withFrameSelectors), the elements’ refs go to the resolver as arefsexpression (LocatorExpression.Refs, .NET-only, internal), and a frame scopes its queries to the iframe node’s descendants, where the walk hangs the frame’s document. Differences: an element the tree leaves out (no role, test id, or text of its own) matches nothing, where upstream’s Playwright locator matches any element; an iframe the page does not show has no node, so it isFRAME_NOT_FOUND(retryable) and its content is not reachable; a read that does not wait (Count,AllAsync,AllTextContentsAsync,IsVisible,IsHidden,toBeAttached,toBeVisible,toBeHidden,toHaveCount, a text list, andWaitForforDetachedorHidden) answers no match for a missing frame (MissingFrame.Empty, upstream’smissingFrame: 'empty'), and any other resolve polls for it until its timeout and fails withNOT_FOUND(FRAME_NOT_FOUND) orSTRICT_MODE(FRAME_AMBIGUOUS), as upstream’stranslateLocatorErrormaps them toLOCATOR_NOT_FOUNDandLOCATOR_AMBIGUOUS. AFilter(has)resolves inside each match, so a frame its locator shares with the source is the source’s; ahasfrom another frame fails withUNSUPPORTED_CAPABILITY. A frame selector must match aniframeorframeelement, else the frame is missing. A selector that captures with*before its last part fails withINVALID_LOCATORwhen the locator resolves; upstream finds out when its read selector returns nothing for the captured element, the port reads the spelling (WebLocators.RefuseCapture, which splits on>>outside quotes), and its message saysFilter(Locator)where upstream saysfilter({ has }).BrowserExpect.ToHaveClassAsync(locator, expected)is upstream’sexpect(browser).toHaveClass, onExpect.That(browser)andExpect.Soft(browser). A string is the whole class list, whitespace-normalized, and aRegexis tested against it. A locator with no match yet keeps it polling (observed: no node), a node with noclassattribute fails it (observed: no class attribute;class=""is an empty list), and several matches fail it at once withSTRICT_MODE(upstream’sLOCATOR_AMBIGUOUS). The step’s label ispage, as the otherexpect(browser)matchers’ here, where upstream’s is the expected pattern.IEngineSession.StartVideoAsyncandStopVideoAsyncare upstream’sartifacts.startVideoandartifacts.stopVideo, andEngineCapabilities.Videois itsvideoartifact capability.EngineStartOptions.ArtifactsDirisEngineAttemptContext.artifactsDir: the attempt’s.e2e/results/<result>/attempt-<n>.WebEnginestarts the video once the attempt’s page is open, andWebSession(upstream’sAttemptSession) tells it when pages open and close.VideoRecorderandProviderVideoimplementIAttemptVideo(internal), upstream’sAttemptVideo.VideoRecorderwritesvideo/video.webmand, for later pages,video/video-part<n>.webm, each with itsStartedAt, from Playwright’sIPage.Screencast.StartAsync.ProviderVideoreturns the one segment the provider made: a file it wrote undervideo/(Kernel’sreplay.mp4) or aVideoLink. The first-frame wait is the sameFirstFrameWait(1 s).WebScreencastOptions(WebEngineOptions.Screencast) is upstream’sWebScreencastOptions;Sizeis aWebViewportand a size under 1 pixel or aQualityoutside 0 to 100 isINVALID_CONFIG. The oldweb({ video })key has no port, since the options are typed.- A browser provider that records is asked through
LeasedBrowsers.Recorder. Upstream leavesrecordout of a provider that cannot record, and the engine then records the screencast. A .NET provider is an interface, so it returns null fromRecordAsyncandProviderVideo.IsRecordingis false: the session then arms aVideoRecorderinstead. A page the app closes under aProviderVideoneeds nothing, as upstream. ExecuteVideo(internal) is upstream’sexecute.tsvideo code:PlanisvalidateEnginefor the one attempt a session is (UNSUPPORTED_ARTIFACTwhen a mode a target or a test set cannot be recorded by the engine; a config-root orE2E_VIDEOmode is skipped),StartAsyncis the video part oflaunchSession(a default mode fails quietly, a set one fails the start), andStopAsyncisstopVideo,closeSession, andregisterVideos(a video the mode does not keep for the verdict is deleted, a link that is nothttp(s)fails the cleanup after the other recordings are kept).E2ESessionstarts the video after the engine, finalizes it before the engine is released when the session is disposed, and reads the verdict from the errorCompleteis given: no error is a pass, aSkipExceptionis a skip. A required video that cannot be finalized makesDisposeAsyncthrow. The kept segments areE2ESession.Videos(internal); the port has no report to register them in.- Differences: a test run has no
--videoflag (E2E_VIDEO, see Config;e2e explore --videois the flag itself), no run-wide check, and no notice, so a run-wide mode that an engine cannot record is skipped without one, and a retry mode with no retries says nothing.e2e explorehas both notices (see Explore). A session knows only its attempt number, not the test’s retries, soUNSUPPORTED_ARTIFACTis raised only when the attempt records, where upstream raises it before any test when some attempt would. xUnit v3 has no retry, soon-first-retryandon-all-retriesrecord nothing there. The test optionvideoandtest.describeoptions have no port (RecordingSource.Testis set by code that builds the session). A mode set on a test is not checked against a serial group, which the port does not have.MobileEnginerecords the device screen with agent-device (see Mobile engine); a device provider’s own recording (DeviceProvider.record) is not ported. - Tests:
Video/(video.test.ts,VideoRecorderTests, the nineit),ProviderVideo/(provider-video.test.ts, both),BrowserProvider/RecordingTests(the recording group ofbrowser-provider.test.ts, see Web engine),ProtectedAppOptions/WebScreencastTests(the firstitofdescribe('web({ screencast })'), partial: typed options have no fractional size or unknown key; “refuses the old video key, naming screencast” has no port),EngineContract/VideoArtifactsTests(describe('video artifacts'), all seven partial, session level, no report;describe('video capability and plan notices')is not ported, it needs the runner’s notices) and “surfaces UNSUPPORTED_ARTIFACT before any attempt…” inRunnerEngineContractTests(partial), andVideoModes/(video-modes.test.ts, partial: no runner, report, or serial group, so thewizardgroup is not run, and the modes are already resolved).E2ESessionCleanupTestsnow fails the start with a required video, since a bad results directory is refused before the engine starts.
Trace feed
Section titled “Trace feed”What the trace page shows about the app is collected as upstream’s trace-feed.ts collects it, and kept on the attempt’s step records. The page that reads it is described in Trace page.
EngineStartOptions.ScreenandEngineStartOptions.Environmentare upstream’sEngineAttemptContext.screenandenvironment.TraceFeed(Engine/TraceFeed.cs, internal) is upstream’s class: after an action,ScreenAfterActionAsynccaptures the screen like an observation without publishing it, so the refs a test or the model holds stay valid, within one second;TellEnvironmenttells the browser (chromium 141.0) and the page’s user agent once an attempt, without anyone waiting for it. The web engine builds the feed when the attempt starts. It hands the screen afterOpenAsync,BackAsync,PerformAsync,PressAsync, andSwipeAsync(upstream’sopen,back,perform,pressKey, and the viewport swipe), and tells the environment afterOpenAsyncandBackAsync(upstream’snavigating). Upstream’stypeTextand the point actions have no engine member here.- An attempt that keeps a trace (the same keep rule as the video:
attemptKeepof the resolvedtracemode) hands the engine aScreensink. An attempt that keeps none hands none, and takes no screen from what the test observes. The default mode,retain-on-failure, keeps one for every attempt, since the verdict is not known before the end. SessionScreens(Run/SessionScreens.cs) is upstream’sscreensofTargetSessionandEnvironmentFactsis itscreateEnvironmentFacts: a name told again overwrites, blank values are dropped, at most 8 facts, each redacted and then cut to 40 and 200 characters. The port has nocreateEngineSession, so they are classes of their own.AttemptScope.ObserveAsyncshows each observation the test, the screen fixture, and the agent take to the same sink.StepRecorder.RecordScreenandStepScreens.CompareScreensandTraceScreen(Run/StepScreens.cs) are upstream’srecordScreenandstep-screens.ts. A step keeps how the last screen it saw differs from the step before’s (StepRecord.Screen): the node lines added, removed, or changed, without node ids, 12 at most. Differences: the engine hands the port a typedObservation, soSessionScreenshas no check for a malformed snapshot ortreeUnavailable, and no revision to mint. The screen’s location is the route (/todos), not the whole URL, and a node that is hidden oraria-hiddenis left out, as the lines the model reads leave it out. The byte budget is upstream’s default ofagents.<name>.maxObservationBytes, 262144, since the port has no such key.E2ESession.Steps,E2ESession.Environment, andE2ESession.AppLog(internal) hold what the page reads.- The app log is collected apart from the feed, as upstream does it.
EngineStartOptions.AppLogandEngineStartOptions.Navigationare upstream’sEngineAttemptContext.appLogandnavigation, andAppLogEntryis itsAppLogEntry;AppLogSourceandAppLogLevel, the records’ enums, are public for it.AppLog.InstallandNavigationLog(Engine/AppLog.cs, internal) areinstallAppLogandNavigationLogofweb/src/app-log.ts. On every context the web engine configures, a persistent one too, it reports console output (errorandassertas errors,warning,logandinfoas info;debugand the rest are left out, and so is Chrome’sFailed to load resource:echo, which the network lines tell), uncaught errors, failed requests (one the browser cancelled is left out), and responses with a status of 400 or more (5xx an error, 4xx a warning, a missing/favicon.icoleft out). It tells where the page went:navigated to /checkout,the new tab navigated to /help,frame "pay" loaded /embed, andthe app opened a new tab at /help; the test stays on its page. A navigation the engine makes itself is not told, since its step says it:NavigatingAsync(upstream’snavigating) wrapsOpenAsyncandBackAsync, and alsoForwardAsync,ReloadAsync, andGotoAsync, as upstream wrapsbrowser.goto,reload,back, andforward. It also tells the environment, as upstream’snavigatingdoes. Differences: the console location is Playwright’sURL:line:columnstring, read to(at URL:line), and an uncaught error is the text Playwright gives (the message, then the firstatframe), where upstream reads the error’s name, message, and stack. - As upstream, a tab’s first document is told by its opening, a page that is not the test’s current page counts as a tab (so the test page’s navigation while
RestartAsyncreplaces it reads as a tab’s), and a request a test aborts itself (net::ERR_FAILED,ERR_BLOCKED_BY_CLIENT) is logged: only the browser’s own cancellations are left out. - .NET-only names:
StepHooks,E2ESession.PhaseandE2ESession.AppLog(internal),StepEvents(the step’s events, safe to read while the engine appends), andAppLog,NavigationLog, andAppLogRouteas the homes of upstream’sinstallAppLog,NavigationLog, andAppLogRoute. .NET-only tests:StepHooks/StepHooksTestsinE2E.TestsandE2E.NUnit.Tests.EngineStartOptions.AppLogandNavigationare for engine authors, asScreenandEnvironmentare; no user guide describes the engine contract, so the guides tell only what the trace page shows. AppLogRoute(Run/AppLogRoute.cs) is upstream’sAppLogRouteofTargetSession, built bycreateAppLogRoute(engine/session.ts), andAppEventandAppLogLimitsare itsAppEventandAPP_LOG_LIMITS. The engine reports from the moment it starts, before any step records, so what arrives before the recorder takes it waits: errors and warnings up to 200, info up to 100, navigations up to 50, so chatter never crowds out an error. An entry with a source or a level outside the enums, or a blank text, is dropped. The route is a class of its own, since the port has nocreateEngineSession.StepRecorder.RecordAppEventandAppLog()are upstream’srecordAppEventandappLog(): a line goes to the attempt’s app log with the step running in this async context, else the step that started last, redacted and then cut to 300 characters; where the app went is anavigationevent of that step, and one before the first step is told under it.- Step events, #443. A locator action records an
engineevent, upstream’srecordActionof the locator engine: the action, the node it landed on as the resolve read it (role and label, secrets redacted), andappeared after Nmswhen the node took more than one round to appear. An action that failed records its code. The port retries no stale node, so an event never sayswent stale;PressSequentiallyAsyncis onepressSequentiallyevent where upstream focuses first and recordsfocus; the actionselectOptionis namedselect, as the engine contract names it.RecordedTarget.TargetLabelis upstream’stargetLabel(cache/trace.ts). An assertion on a locator andWaitForAsyncrecord apollevent with the count of reads and, when the reading changed or the poll failed, the readings in order:SampleHistory(Expect/SampleHistory.cs) is upstream’s class (expect/samples.ts),RecordAsyncitsrecord. An agent step records the events upstream’sinstrumentPhaserecords (Phases.InstrumentPhaseAsync,agent/phases.ts;PhaseHost,PhaseSpec, andPhaseDetailfor itshost,spec, anddetail;PhaseDetailis .NET-only, since upstream’s is aPartial<StepEvent>; the port’sPhaseSpechas noapi, which upstream never reads). Every look the step takes is anobservationevent with the nodes it read and its size in bytes, as a model reads it, and a look taken inside an action is named:relocatefor the fresh look that re-finds a stale node,pagefor the look between the pages of a scroll. Every grammar action is anengineevent named for the verb (tap,doubleTap,type,typeSecret,press,selectOption,check,uncheck,scrollIntoView,scrollUntil,scroll,navigate,back; andclear, which upstream has no verb for), with the action it recorded as its detail, and a project tool is one namedtool:<name>(ActionNames.ProjectToolEventPrefix,agent/action-names.ts). A judgment records oneobservationevent for its look. Differences: the port’s action body commits and notifies inside the phase (the action is recorded and the test’sOnActionhook has run when the event ends), where upstream’s phase ends before it, so anengineevent also holds the settle look and the hook; themodelevents are recorded where each model call is counted (AddModelCall), so the model phase announces no activity; and there is noagent.cachephase orpolicyevent. - Agent step events, #648.
Observation.Revision(public, set by the harness; upstream’sObservation.revision) isb<n>for the attempt’s n-th capture throughAttemptScope.ObserveAsync, so an observation an engine hands a locator directly has none. A step records the revision of the last screen it read asStepRecord.ObservationRevision(StepAgentDetails.ObservationRevision), and the report writes it asobservationRevisionon any agent step that read one.StepAgentDetails.ObservationByteskeeps the largest observation inStepMetrics.ObservationBytes. Tests:AgentPhases/InstrumentPhaseTestshas the oneitof upstream’sdescribe('instrumentPhase'); theretryingObserveandcheckStepClockblocks ofagent-phases.test.tsare not ported, because the port has not extracted those two functions. The events and the revision are read in the ported tests ofagent.test.ts(thewaitForand repair cases),agent-act.test.ts(the executor step and the stale node),agent-vision.test.ts(observationBytes) and the secret-fill describe ofagent.test.ts(typeSecret). Not ported: thepolicyevents (recordPolicyEvent) and theobservationevent of a denied pixel fallback (agent-fallback-handoff.test.ts’sPOLICY_DENIEDobservation event), because the port has no pixel fallback. - The hook a step ran in, #443.
StepRecorder.Phaseis upstream’sphase, andE2ESession.Phase(internal) itsenter. Upstream’s runner entersbeforeEachandafterEachitself. The port has no runner: NUnit or xUnit calls the hooks. So whenPhaseis null,StepHooks(Run/StepHooks.cs, .NET-only) reads the step’s call stack. The first method outside the library that carries a mark decides: an NUnit[SetUp]isbeforeEach, a[TearDown]isafterEach, a[Test],[TestCase], or[Fact]is the body, and a test class’sInitializeAsyncorDisposeAsyncis the xUnit pair. A step called after anawaitinside a helper the hook called belongs to the body, since the stack no longer shows the hook.[Theory]and[TestCaseSource]are test methods too, andInitializeAsyncandDisposeAsynccount on a class that implementsIAsyncLifetime.OneTimeSetUpandOneTimeTearDownare not hooks of a test (upstream’sbeforeAllandafterAll), and their steps carry none. - Tests:
StepScreens/(step-screens.test.ts, all 11it; “hands every engine screen to the routed sink …” is partial, with no malformed ortreeUnavailablesnapshot to drop, and is named for what it checks),Traces/TracesTests(see Trace page),AppLog/(unit/app-log.test.ts, all 7it:StepRecorderRecordAppEventTestsandTheSessionAppLogRouteTests; “holds lines and navigations until a sink is routed …” is partial and named for what it checks, since a typed entry cannot have a source that is no source),WebAppLog/(web/tests/integration/app-log.test.ts, bothit, in Chromium;AppLog/is taken by the unit test of the same name; the server of upstream’s firstitdestroys the socket of/api/reset, which anHttpListenercannot do, so a route aborts that request and the line endsnet::ERR_FAILED),SampleHistory/(unit/sample-history.test.ts, all 6it), and the .NET-onlyTraceFeed/WebTraceFeedTests, which drive the web engine in Chromium, where no upstreamitdoes, andE2E.NUnit.Tests’StepHooks/StepHooksTests, which runs a fixture with a[SetUp]and a[TearDown]through NUnit and reads the trace page.
Trace page
Section titled “Trace page”Upstream’s --trace writes <output>/results/<result>/trace.md, a markdown page that tells the whole attempt: what failed and where to look, every step with what it did, the cache’s decisions, what the app logged, the agent’s last turns, the screen at failure, and what the attempt ran on. You open the file; there is no viewer. A test run has no --trace flag, so trace and the .NET-only variable E2E_TRACE choose which attempts keep a page (see Config); the default is retain-on-failure. e2e explore --trace [mode] is the flag itself.
- A session writes the page when it is released, after the video is stopped, the engine is closed, and the debug table and the AI trace are written, and before a required video’s failure is rethrown. It writes when an attempt of the test kept a trace, by
attemptKeepandkeeps(the rule the video follows), and the page then tells every attempt, see below. A page that cannot be written prints a line on stderr and never hides the test’s result. The page goes in the test’s directory under the results directory,.e2e/results/<result>/trace.md, beside theattempt-<n>directories, as upstream’s does. A session is one attempt, and each adds itself toRunReport(.NET-only,Report/RunReport.cs), which collects the attempts of a test: a first attempt starts the test over, a later one extends it. The page tells every attempt the test has had so far, as upstream’s does from the run report, and is written when any of them kept a trace (upstream’stracedResultIds). So a flaky test readsPassed after 1 failed attemptwith the last failure, and a test that failed every attempt readsFailed the same way on both attempts, even when the passing or last attempt kept none itself. The run keeps the attempts of a test only while it has not passed and the mode is notoff: an attempt that passed or was skipped ends the test and drops them. The port cannot know the last attempt of a test that keeps failing, since the adapters do not tell the session the retry count, so those entries stay until the process exits. Upstream’s runner holds every result of the run in memory too (const results: ResultRecord[] = []inrun/runner.ts), for its report. - The MCP server’s sessions set
tracetooff, so they write no page and take no screens: an MCP session is for looking, ande2e mcphas no--trace, as upstream. An exploration takes the target’s mode, or--trace; its own verdict gets no page. TracePages.WriteTracePagesAsyncandTracePages.TracePageare upstream’swriteTracePagesandTRACE_PAGE(report/traces.ts).FailureText.RenderTracePage,ToldAttempt,FailedStepOf,AttemptsLine,FailureSource,EvidenceOf,DetailLines, andStepLabelare the matching functions ofreport/failure-text.ts, andMarkdownTextandReportFormatarereport/markdown-text.tsand the parts ofreport/format.tsthe page uses.ArtifactPaths.ResultSegmentisresultSegment(run/artifacts.ts; it also strips a.csextension). All are internal.Report1Document,ReportRun,ReportResult,ReportAttempt,ReportError,ReportArtifact,FailureEvidence, andOutcome(Report/ReportRecords.cs) are the part of upstream’s report the page reads, andAttemptReport.Build(Run/AttemptReport.cs, .NET-only) fills them from the step recorder, the environment facts, and the video segments of one attempt.ReportAttemptis upstream’sReportAttemptandAttemptViewin one, since the port has no serial groups. The file is the first step’s file (unknownwithout a step), the run id is one per process, and the title is the test’s title as one part.RunReport.Addjoins an attempt to its test’s result and derives the result’s status from the attempts (Flakywhen the final attempt passed after one that did not), where upstream’s runner keeps the result record andbuildReportreads it.- What the page tells: the title, the file, the status and duration, what the attempt ran on, the error (a fence in it cannot close the block),
Look at:, whether the attempts failed alike, every step (its label, duration, model calls, the video offset, the hook, the explanation of a failed one, the cache’s decision, the steps’ screenshots, and how the screen changed), the app log, the agent’s last turns, the screen at failure, other errors, and the evidence. Every piece of text goes throughMarkdownText(a mention or a URL becomes code, markup is escaped, a loopback origin is dropped), and the whole page goes through the attempt’s redactor again before it is written. - Not filled by the port yet, each with its issue. The renderer shows them when a record carries them, and the tests build such records by hand. A serial group’s members: the port has no serial groups. Filled since: the agent’s turns of a step (#481), the step events, the hook, and the app log (see Trace feed), and what became of a step’s recording with the model that recorded it (see Trace page).
- Recording outcome.
E2ESession.Flushis upstream’ssettleStagedTraces: it returns aCacheWriteper step index, and the session setsStepRecord.CacheWriteon each step that carries cache detail. A step decides two itself when it concludes:Evicted(a passed step anend-mismatchhand-off had to repair) andNoChange(the step changed nothing a replay could check and read no entry; one that read an entry is evicted at the settlement instead). The settlement reports the rest:Saved,Kept(the replayed entry, or one holding the same flow, which ignores the recording model),Unconfirmed, andEvicted(an entry the step read that the settlement deleted: a whole replay no check confirmed, a replay that ended in a failure, or an entry the step read and replaced with nothing). A flow the step recorded anew isUnconfirmedwhen no check confirmed it, whether or not an older entry was deleted, as upstream’ssettleStagedTracesreports it (only an entry replayed whole isevicted). Both reportEvictedonly afterIStepCache.Deletereturned, as upstream’sevictandsettleStagedTracesdo since689620d(#1072).IStepCache.Deletereturns when the entry is absent and throws when deletion fails, as upstream’sCacheStore.deletedoes, andFileStepCache.Deleteignores only a missing file or directory, as upstream’sFileCacheStoreignores onlyENOENT. ADeletethat throws leaves the entry in place:E2ESession.FlushreportsUnconfirmedfor a whole replay or a new recording and nothing for an act that recorded nothing (upstream’sevictstages nothing then), andStepTraceSession.ConcludeAsync(upstream’sevictfor a repairedend-mismatch) stages no decision. Upstream also evicts a failed step’s consumed replay inconclude; the port does both at the settlement, so a deleteConcludeAsynccould not make is tried once more there. Tests:StepCache/StepTraceSessionTests“does not stage an eviction outcome when deleting a consumed entry rejects” and the threeitoftrace-cache-config.test.tsinTraceCacheConfig/FlushStagedTracesTests, the default-store one a no-op as root and on Windows, as upstream.NotWrittenis a store whoseWritethrows; the port’sFileStepCachehas no size limit on a write. Unlike upstream, a cancelled attempt reports none, since it never settles. The page tells the model after the cache decision:recorded with `fake-loop/model-A`.CacheEntry.RecordedByandCacheInfo.RecordedByare upstream’srecordedBy, but one string,IAgentModel.Name, which spells provider and model id together (upstream’s is aproviderand amodel). The recorder redacts it and drops one that is blank or past 300 characters, and a read does the same, so a malformed one never makes an entry a miss. It is the agent’s configured model, which an executor’sModelbecomes when the agent sets none; an agent with none records none. It never keys the entry. The port has no list reporter, so therecorded withnote on a list line has no port;cache.recordedByandcache.writeare inreport.json(see Report document). Tests:CacheModelProvenance/holds bothitofcache-model-provenance.test.ts, each partial: they read the step record, the entry file, andreport.json(checked against the schema), not the list line ortrace: 'on', and the malformed cases are one string (the nine cases of the first are seven). The second is also partial where an executor reports no model: upstream’s report names the executor, the port’s saysunknown.StepCache/CacheWriteTests(.NET-only) covers each outcome.TraceRecorderTestshas the two oversized cases as one theory, the blank cases as one theory, and the redaction in “records a secret fill by name only”. The twoitoftrace-cache.test.tsthat read a bounded or blankrecordedByare covered by theoversized-model,blank-model, andinvalid-modelcases of the first test. - Error details.
ErrorDetails(E2EException.Details, public) is upstream’sErrorDetailsofinternal/errors.ts, with the same fields in C# casing:Locator,Expected,Observed,Matches,Role,Name,TestId, andWaitedMs. A locator that matched nothing (NOT_FOUND) carries the locator as written, what it asked for, and how long it waited. One that matched too much (STRICT_MODE) carries the same without the wait and withMatches. AwaitForthat timed out carries the wait. A locator matcher that failed carriesLocator,Expected(withnotwhen negated),Observed, andMatches, andToHaveScreenshotcarries the stored screenshot’s path asExpectedand what it observed.LocatorResolver.LocatorDetailsandLocatorExpression.ExpressionHintsare upstream’slocatorDetailsandexpressionHints.ErrorSerializer.Serializekeeps the details as upstream’sserializeErrordoes: text redacted, stripped of control characters, cut at 2 KiB, empty text dropped. The port has no report boundary, soErrorRedaction.Failurealso redacts the details text when it builds the error.Screen.ScrollUntilVisibleAsyncsets them too, on itsLOCATOR_NOT_FOUND. Upstream sets details in one more place the port has no feature for: a tap at a point. Not ported: the details of an error classified from another module copy (there is one copy of an assembly). - Failure evidence.
FailureEvidence.CaptureAsyncis upstream’scaptureFailureEvidence(run/failure-evidence.ts), with its options, its 5 s budget for the observation and the screenshot together, its limits, and its scoring of the nodes closest to what a locator asked for. A failed attempt that keeps a trace page looks at the app once more and keeps the screen asattempt-<n>/screen-at-failure.txt, one#id role "name" text="…" [states]line for each node (indented by depth, hidden nodes left out as the lines the model reads leave them out, a node with no role read astext), a masked screenshot asattempt-<n>/screenshots/failure.png, the location, and for a locator that matched nothing or too much, up to five candidates. The page shows them under## Screen at failure. The look is taken once per attempt, the moment the test body fails, ahead of the test’s own teardown (upstream’scaptureEvidence, called after the body, the late soft failures, and the settled callbacks, and again after the teardown for a failure that first landed there).E2ESession.CaptureFailureEvidenceAsyncis that look, and an adapter calls it: the NUnit adapter wraps the test method (IWrapTestMethod, sinceITestActionruns after[TearDown]) and looks when the body throws or anExpect.Softfailed; the xUnit adapter looks from aBeforeAfterTestAttributeafter the test method, when the result isFailed. A failure that first lands in the teardown is looked at when the session is released, as upstream does. Differences: an xUnitExpect.Softfailure is kept on the session until the test is disposed, so it is looked at then, after the test’s ownDisposeAsync(closing the soft failures early would move them out of the test’s result). An attempt whose trace page is not kept looks at nothing. The screenshot is dropped when the attempt knows a secret (as every pixel the port keeps; upstream keeps its masked failure screenshot), when the look at the screen failed, and when the engine does not report that it covered a region for each secure node. The viewport is the screenshot’s size in viewport pixels, since the port’s observation has none, so it is absent without a screenshot. When the look fails (a crashed page, a closed app) there is no evidence, not the last screen a step saw. The candidates are listed for the codesNOT_FOUND,STRICT_MODE, andTIMEOUT(upstream’sLOCATOR_NOT_FOUNDandLOCATOR_AMBIGUOUStoo, which an agent action raises). - Differences: the page has no
sourceUrlorartifactsUrllink and noStandalonemode, and its footer does not point atreport.json, which the port writes beside the results (see Report document). An attempt that did not skip but carries a skip (a test that calledSkipand then failed on a soft failure) names it on its page afterLook at:, asAlso skipped: <reason>.lastTurnLinesandscreenLinesbelong to the run page and are not ported. Not ported:writeHostTrace(run/host-trace.ts) and the hosted session’s page, which need the hosted session (#403). - .NET-only names:
E2ESession.Evidence(what the session captured; upstream reads it from the report),E2ESession.CaptureFailureEvidenceAsyncandE2EFixture.CaptureFailureEvidenceAsync(upstream’s localcaptureEvidenceclosure; the fixture’s is for the adapters),LooksBeforeTeardownAttribute(NUnit and xUnit; internal), NUnitE2ETest.CaptureEvidenceOf(internal; reaches the protected fixture method), xUnitE2ETest.LookAtFailureand itsRunningmap (the attribute is handed the test, not the instance),FailureEvidence.ScreenFile,ScreenshotFile,ScreenArtifactId, andScreenshotArtifactId(upstream registers the files with its artifact sink),FailureEvidenceOptions.ObserveandCapture(upstream hands the open session),AttemptReport(and itsResultIdandResultSegmentOf, which give the result id and directory name; a .NET test has no one source file, so the test class stands for upstream’sfile: the fixture passes its full name asE2ESessionOptions.TestFile, it joins the title in the id hash as upstream’stestIddoes, and its simple name leads the segment; a session started with noTestFile, such asexploreor a directE2ESession.StartAsync, hashes the title alone),AttemptReport.TestIdandEncodeTitle(internal; upstream’stestIdandencodeTitleininternal/ids.ts, with a title path of one title; the title is percent-encoded so a::in it cannot pass for the separator; the hash input carries a NUL mark so a title-only session cannot share an id with a test id),E2ESessionOptions.TestFile(public, optional; the one new member of the public API, set byE2EFixture, so the NUnit and xUnit adapters change nothing),TraceOutput,WriteTracePagesOptions,ReportResult.Directory(an attempt writes underArtifactPaths.ResultSegmentof the result’s id and title, so two titles that slug alike keep apart; the segment leads with the test class’s name when the fixture started the session, and the page goes in that directory),StepRecorder.AddModelCall(upstream’susage.recordinStepAccounting),FileStepCache.CacheDirectory(internal), andCacheInfo.Entry(upstream’sentryofStepCacheInfo),OptionalTextConverter(upstream’sreadRecordedModeltolerates a malformedrecordedBy; the port’s is a string, which a converter reads leniently),PendingAct.Decided(upstream’sdecidedstaged entry), andCacheInfo.WithRecordedBy(upstream spreadsrecordedByinto the info). - Tests:
Traces/holdsunit/traces.test.ts(all 11it:WriteTracePagesTests,TracePageNamesTests,TheStepsOnATracePageTests,TheAppLogOnATracePageTests, over hand-built records) andintegration/traces.test.ts(TracesTests, all 6it, in a session over a fake engine, with no runner). InTracesTests, “keeps each step’s events, files what the app logged …” is named for the part it checks,Keeps_each_steps_events_and_files_what_the_app_logged_under_the_step_it_happened_in_on_the_page(partial: it reads no run event, which the port has no run to emit); the two “names the step … on the page and in the summary” tests are partial (no summary reporter); “keeps the traces the mode asks for” runs sessions with the attempt number in place of a runner’s retries.ReportFailureText/holdsreport-failure-text.test.ts:detailLines,attemptsLine,failedStepOf,failureSource,evidenceOf, andrenderTracePage(15it; the footer of “tells the whole story” has noreport.jsonpointer). Not ported from that file: “lastTurnLines and screenLines” (the run page), and “a serial member on its page” (no serial groups)..NET-only:TracePageRedactionTests.FailureEvidence/holdsfailure-evidence.test.ts(FailureEvidenceTests, 6 of its 7itinE2E.Tests, in sessions over a fake engine; theit.eachis one theory). “looks at the screen before teardown runs …” isE2E.NUnit.Tests/FailureEvidence/FailureEvidenceTests(through NUnit, with a[TearDown]in place ofafterEach), so all 7itof the file are ported, with a .NET-only soft-failure variant beside it; the xUnit adapter’s look has no test project. “keeps the evidence of two tests whose ids sanitize to the same directory name apart, each report digest matching its file” isKeeps_the_evidence_of_two_tests_whose_titles_slug_alike_apart(partial: the port’s report artifacts carry no sha256 to compare).Keeps_the_evidence_of_two_tests_with_one_title_in_two_classes_apartandGives_a_title_with_a_separator_in_it_another_id_than_the_same_text_split_into_file_and_titleare .NET-only (upstream’s runner rejects a duplicate test id; the port keys on the class instead of the file).Errors/ErrorDetailsTestsholds theerror detailstests ofunit/errors.test.ts: two of three, the redaction one partial (the port keeps no stack text), and not “keeps the details of an error classified from another module copy”. The details of the failure are asserted inScriptedMatchers/,ScreenshotAssertions/, andAgentExecutorContext/where upstream asserts them, and the failure capture beside an assertion’s own screenshot inAssertEvidenceUnderACustomExecutorTests.open-host-trace.test.tsis not ported with the hosted session (#403).
Test filters
Section titled “Test filters”Upstream #1206 (da13624, feat(runner): run only the tests a pull request can affect) adds filters to the config, --changed-since <ref> on e2e run and e2e list, and the “left out” part of the report. #1345 (47127f7) adds context.configFile. The port has no runner, no collect or select, no e2e run or e2e list, and no reporters other than the JSON report, so it takes the filter seam, the change detection, and the report fields, and leaves the wiring to a host. A test framework selects tests itself (dotnet test --filter), and the port keeps no list of every test before the first one runs, which a filter needs, so nothing in E2ETest calls a filter.
Ported:
ITestFilter(upstream’sTestFilter:Name, andFilterAsyncforfilter),TestFilterCandidate,TestFilterChanges,TestFilterContext(withConfigFile, #1345), andTestFilterResult(Exclude,Note) are public (Run/TestFilter.cs, upstream’stypes.ts). A host calls the internalTestFilters.ApplyTestFilters(Run/TestFilters.cs, upstream’srun/test-filters.ts) with its candidates: filters run in order on the tests the ones before kept, a filter that throws, returns an invalid result, or runs past 5 minutes (FilterTimeout) leaves nothing out with a warning, and reasons, notes, and log lines are redacted and cut to one line of 1024 code points.context.Redactalso masks every run of 8 or more characters of a secret.TestFilters.RepoConfigFileis upstream’srepoConfigFile,TestFilters.FilterRecordsandFilterSummaryarefilterRecordsandfilterSummary, andLeftOut.LeftOutTestsisleftOutTests(Report/LeftOut.cs,report/left-out.ts).Changes.FindChangesAsync(Run/Changes.cs, upstream’sfindChanges): every file that differs from the merge base of the ref and HEAD, staged, unstaged, deleted, and untracked files included, a rename as both paths, sorted by code point, with a 30 second budget. A ref git cannot measure from isINVALID_CHANGED_SINCE(aConfigurationException).Git.RunGitAsync,GitResult, andGitFailureare upstream’srunGit,GitResult, andGitFailure;Paths.OnDiskPathisonDiskPath;Time.BudgetTextisbudgetText;ReportFormat.FilterDecisionisfilterDecision.- The report:
run.changes(ReportChanges) andrun.filters(ReportFilter,FilterOutcome) onE2E.Reports.ReportRun,SkipInfo.Filter(afilteredskip names the filter),ErrorPhase.Filter, and thefilterphase in the wire form.schema/report-v1.schema.jsonand both fixtures are upstream’s at #1345.
Differences:
- An interrupt surfaces as an
OperationCanceledException, where upstream rejects withINTERRUPTED. The context’sSignalfollows the interrupt only while the call runs. The work a timed-out or interrupted call leaves behind is not recorded (upstream’sleaveWorkBehindkeeps the CLI from waiting for it; the port has no CLI process to keep). - A result is typed, so the refusals of a result that is not
{ exclude, note? }(an array, a string, an unknown key, a map, a numeric note, a value that cannot be shown as text, and a reason that is not a string) cannot happen. A null result, a nullExclude, an empty reason, and an id no collected test has are refused, with the port’s wording for the first two. - The redactor reads a newline and a space alike in a run it masks, so it takes the space before a key’s text into the marker in a diff line, where upstream keeps it. Nothing leaks.
Not ported:
filtersine2e.config.json(a filter is code, and the JSON config holds none),--changed-since,--no-filter,INVALID_CHANGED_SINCEas a CLI exit 2, theNO_FILTERSwarning,filterSelection,filterCandidates,selectwithexcluded(applyExclusions), thelistandrunoutput of a left-out pair, the left-out lines of the list, markdown, and JUnit reporters, telemetry, thefiltersetup step of a run,pull_request_targetguidance, and the CI recipe. They need the runner,collect,select, and the reporters.@e2e-dev/decision’sdecisionFilterand its tests, including #1345’sleaves nothing out, making no call, when the config the run loaded changed, whatever its name, and goes by name for any other: the package is not ported (see Upstream packages).- Tests:
Changes/FindChangesWithChangedSinceTestshas all 10itofchanges.test.ts(theINTERRUPTEDone isPartial_rejects_once_the_run_is_interrupted; the--no-relativeone runs apart from Windows, as upstream’s does).Time/BudgetTextTestsisbudgetText.SchemaFixtures/ReportV1SchemaTestshas the filteritofschema-fixtures.test.ts.TestFilters/ApplyTestFiltersTests,AFilterPastTheDeadlineTests, andFilterRecordsAndFilterSummaryTestsporttest-filters.test.tsover candidates built by hand, since the port has nocollectorselect; the ones that differ arePartial_. Not ported from it: the twofilterSelectionit, the threefilterCandidatesit, therefuses a resultcases above, andleaves nothing out when a filter throws a value that cannot be shown as text.TestFilters/WhatAFilterIsToldTestsis #1345’snames the config file the run loadedoverRepoConfigFile, partial. Not ported: the rest ofintegration/test-filters.test.ts,select.test.ts’s cases forexcluded,config.test.ts’sfilterscases,cli*.test.ts,report-list,report-markdown,report-junit,telemetry-events, andpackages/decision’s tests.
Report document
Section titled “Report document”Upstream writes report.json, a report-1 document of the whole run (report/build.ts), checked by schema/report-v1.schema.json. The port writes it too (#496), with the differences below.
- When and where. Upstream builds the document once, when its runner ends the run, and writes it to
<output>/report.json. A test framework runs the port’s attempts as sessions with no run around them, so each session adds its attempt to the process’s run (ReportFile.WriteAsync, .NET-only) and the whole document is written again, atomically, toreport.jsonbeside the results directory (.e2e/report.jsonby default, asai-trace.jsonis). The file is complete after every attempt.run.finishedAtandrun.statusare those of the attempts so far, not final, andrun.exitCodeis the worst exit code of the results, as upstream’sverdictExitCodesderives it: a failed or timed-out result exits 1, or 2, 3, or 4 when the category of an attempt error isconfiguration,infrastructure, orinternal(an agent code’s category comes fromAgentError.CategoryByCode, upstream’sCATEGORY_BY_CODE, so a blockedAUTH_CREDENTIAL_UNAVAILABLEexits 2), and an interruption exits 130, which outweighs the rest.run.statusfollows the code (passed,failed,interrupted, elseerror), andblockedis derived from the results afterwards. A result keeps its wire form until it gains an attempt, so a large suite does not serialize every result again after each attempt. A result id is the test’s file and title, so a test that runs twice in one process (another target, a repeat) is one result that its last run replaces, asRunReportalready treats it. Test processes that share an output directory overwrite one another’s file, and a file from an earlier run is replaced by the first attempt. It is written whatever thetracemode, for passing tests too. A write that fails (disk, permission) prints one line to stderr and does not fail the test. - A closed session’s report (
feat(runner): expose the closed hosted session report,b750125).E2ESession.Report()is upstream’ssession.report(): once the session is disposed and its trace page, when the mode keeps one, is written, it returns the attempt’s report-1 document as a single-result run (ReportFile.SingleAsync, .NET-only; upstream’swriteHostTracereturns theReportit builds). It returns null before that and when the page cannot be written. The return type is the publicE2E.Reports.Report, upstream’sReport(an alias ofReport1Document) with its nested types in C# casing and the names ofreport/build.tsandrun/steps.ts(ReportRun,ReportTarget,ReportResult,ReportAttempt,ReportStep,ReportError,ArtifactRecord,StepEvent,StepMetrics,StepModelInfo,StepCacheRecord,FailureEvidence,SkipInfo,VcsInfo, and the rest). They live in theE2E.Reportsnamespace because the port’s own internal records for the same parts keep upstream’s names inE2E(ReportRunStatusandReportExploreare shared and stay inE2E). The typed report is read from the document thatreport.jsonholds, so the two tell one attempt alike;ReportSerializeris .NET-only and the only place that reads it; the library never writes the typed form. Differences: the typed report has noserialGroupsand nocarried(the port has none), noextensionsand nostack; enum members stand for upstream’s string unions (ResultStatus.TimedOutfortimed-out); durations stayDurationMsas upstream, butReportExplorekeeps itsTimeSpanmembers; a session that keeps no trace (the mode keeps none for the attempt’s verdict) still returns its report, where upstream’s hosted session always writes a trace; a trace page that cannot be written is reported on stderr and the session releases without a failure, so upstream’sclose()failure for it has no port.E2ESession.AttemptId(internal) is upstream’sattemptId. Tests:OpenHostReport/OpenHostReportTestshas bothitofopen-host-report.test.ts; the first is partial and renamedReturns_independent_closed_reports_that_remain_stable_across_a_second_session(nogadget.dropFile()fixture, and the soft failure isCloseSoftFailures’ result rather than an entry ofclose()’s failures), the second is partial and renamedReturns_null_when_the_trace_write_fails_and_the_session_still_releases(noclose()failure check). .NET-only:Tells_the_same_result_as_report_json_for_the_same_sessionchecks thatReport()andreport.jsontell one attempt alike, which upstream’s hosted session (noreport.json) has no counterpart for.TypedReport/TypedReportTests(.NET-only) reads the valid report-1 fixture into aReportand writes it back, and checks that the document is the same bar the serial groups,carried,extensions, andstack. The document’srun.idis the session’s own, so it is not compared.ReportFile.DetectVcs(.NET-only) asks version control once per process and project root for bothreport.jsonandReport(); a failed lookup is asked again, andAsks_for_the_commit_once_per_project_root_however_many_sessions_ask_and_asks_again_after_a_failurecovers both. schema/report-v1.schema.jsonis upstream’s schema, unchanged.schema/fixtures/report-v1.valid.jsonandreport-v1.invalid.jsonare upstream’s fixtures. The E2E package packsschema/*.schema.jsonunderschema/(not the fixtures), as upstream’sfilesdoes.schema/session-v1.schema.jsonand its two fixtures are upstream’s, unchanged. Upstream’sagent-judgment-v1andagent-judgment-v2schemas are not ported: the port has noagent-judgment-2response grammar (JUDGMENT_SCHEMA,validateJudgmentResponse), so its judge ends with thedonetool.- Session store (
run/sessions.ts,SessionStore).SessionStore(internal) is upstream’s: one AES-256-GCM envelope file per session, authenticated over the whole envelope (identity, expiry, and secrecy in the clear), the learned secret values only inside the ciphertext, a memoized decrypt that still checks expiry on every load, and file names that are percent-escaped, cut, and hashed.SessionIdentity,SavedSession,EngineState, andSavedSecrecyare the records of upstream’s interfaces. Methods areSaveAsyncandLoadAsync(save,load);CreateandForWorkertake an optionalTimeProvider(.NET-only, a test’s clock).ForWorkertakes the key asbyte[]. A key of the wrong length isE2EExceptionwithSESSION_KEY_INVALID, since the port has no infrastructure error class. A file is written0600and the directory0700on Unix (AtomicWrite.WriteFileAtomicAsynctakes the mode; Windows has none). A payload or envelope field of the wrong type isSESSION_INVALID, where upstream’sTypeErrorescapes. Not ported yet:targetIdentity,SessionStaging,openSession,writeHostTrace, andopenHostTrace, which need setup sessions and the hosted session’s trace (#705). Theagent-judgmentschemas are #706. - Tests:
SessionEnvelope/isunit/session-envelope.test.ts(all 10it:SessionEnvelopeTests,SessionLoadTests,SessionFileNamesTests), andSchemaFixtures/SessionV1SchemaTestsis the two sessionitofdescribe.each(schemas). WriteReport(Report/WriteReport.cs, internal) is upstream’sreport/write.ts:WriteTextReportAsync,WriteJsonReportAsync(two-space indent, a trailing newline), andToPosixPath.AtomicWrite.WriteFileAtomicAsyncis upstream’swriteFileAtomic(internal/atomic-write.ts): a sibling temporary file with random bytes in its name, moved over the target;AtomicWrite.WriteFileAtomicis the synchronous twin (writeFileAtomicSync), whichFileStepCache.Writeuses becauseIStepCache.Writeis synchronous. On Windows both retry a move the file system refused while another move was replacing the target or a reader held it open (see Replay cache). The trace page and the failure screen file are written throughWriteTextReportAsync, as upstream’straces.tsdoes (it replaces the port’s ownTracePages.WriteTextAsync). The writer serializes with the port’s JSON defaults (camel case, null omitted), so a caller redacts the text and shapes the document. Difference: a string with a lone UTF-16 surrogate makesWriteJsonReportAsyncthrow, whereJSON.stringifywrites an escape; the builder that cuts text to the schema’s limits must not split a surrogate pair. Not ported: the filemodeoption, andfileReporter, since the port has no reporters.- The schema is checked in tests only. The tests use JsonSchema.Net 8.0.5 (MIT; version 9 and later use another license), in the test helper project
tests/E2E.Tests.Schema. The shipped package validates nothing and has no new dependency.ReportSchema.AssertValidReportis upstream’sassertValidReport, withformatasserted as Ajv does. - Run-level blocks (
Report/BuildReport.cs, upstream’sreport/build.ts):DescribeTarget,ProjectSource,ComputeSummary,DeriveRunStatus,ComputeUsage,SerializeTarget, andBuild, which assemblesReport1DocumentwithReportRun’s new members (Status,ExitCode,StartedAt,FinishedAt,Project,Environment,Vcs,Errors,Summary,Limits,Usage).ReportRun.TargetsareReportTargets now, not names.ReportResultgainedDeclarationIndex,Tags, andSelected;ReportArtifactgainedSize;StepMetricsgainedObservationBytes(the largest observation the step read, in bytes of the text a model reads; a judgment that sends pixels alone counts none, as upstream’s does not; sousage.maxObservationBytesis the largest of them).AttemptReport.Buildfills each kept file’sSize, sousage.artifactBytesadds them up. The engine version of a per-attempt target isIEngine.Version, which the session carries inAttemptScope.EngineVersion; a blank version readsunversioned.Git.RunAsyncremoves the variables that point git at a repository (GIT_DIR,GIT_WORK_TREE, and the like) before it starts git, so a hook or wrapper cannot move the repository it reads; upstream passes the parent’s environment through. A target’sbaseOriginis the scheme, host, and non-default port of the app URL, without credentials, and only for http and https, asnew URL(...).originis for a web URL. Differences: the port has no serial groups, no--last-failedrerun (carried), and nofailOnSkippedFailure, soDeriveRunStatusreads results only.DescribeTargettakes anIEngine, which has no name, so the engine name is its platform; its flag capabilities are spelled in camel case (tapModifiers);stateCapabilityis false, since the port has no state hooks. A target’s environment is alwaystest(noapp.environmentkey), the project isunknownwith a zero digest (no project id or config digest, as upstream reports without a config),limitsare the defaults (the config rejectsmaxObservationBytesandmaxInputTokens), andenvironment.runtimereads.NET <version>where upstream’s readsnode <version>. - .NET-only names in this part:
ReportEngine,ReportProject,ReportEnvironment(upstream writes these as inline object shapes),ReportRunStatus(upstream’s status string union),BuildReportOptions.TargetProvenanceis upstream’s own,Git.RunAsync’sbudgetparameter (for tests), andDescribeTarget(platform, version, capabilities)(the session knows its engine by those three, not as anIEngine). Vcs.DetectAsyncandGit.RunAsync(Internal/Vcs.cs,Internal/Git.cs) are upstream’sdetectVcsandgit(internal/vcs.ts,internal/git.ts): git is asked with a one-second budget, and a checkout without git falls back toGITHUB_SHAandGITHUB_HEAD_REForGITHUB_REF_NAME. Difference: a cancelled token surfaces as a cancellation, where upstream has no token.- Wire form (
Report/ReportWire.cs, internal; upstream’sserialize*functions and the report-1 shapes ofreport/build.ts). Upstream types the shapes and letsJSON.stringifydrop what is absent; the port buildsJsonObjects, which omit what is absent the same way. Text is cut to the schema’s bounds without splitting a UTF-16 surrogate pair, and a lone surrogate reads as U+FFFD (JSON.stringifyescapes it; the port’s writer refuses it). A result’s text goes through the redactors of that test’s attempts, as its trace page does.ReportErrorgainedCategoryandRetryable; the stack never enters (SerializedErrorhas none).ReportAttemptgainedId(a UUID version 7,AttemptScope.AttemptId, now minted with the dashes),StartedAt,Cleanup(alwayscomplete: the port does not tell a failed or forced cleanup from the attempt), andSkip;ReportResultgainedPlatform;ReportSkipandAttemptCleanupare .NET-only. The run id is a UUID version 7, one per process. A result id is the full 64-hex SHA-256 that its directory name is the head of (the port’s digest, not upstream’s).testIdisfile::titleasAttemptReport.TestIdspells it. - Fields the port cannot fill, and what the wire says: a judgment step that failed before it read a screen has
observationRevisionunrecorded(upstream requires one; the revision,b1,b2, and so on, is minted for each capture of the attempt byAttemptScope.ObserveAsyncand kept asObservation.Revision) and itsexplanationreads empty when the judge gave none;model.endpointisprovider-default,adapterVersionthe package version,policyVersionunversioned,tokenAccountingprovider,peakTokensPerCall0, andproviderandmodelare split at the first slash ofIAgentModel.Name,unknownwhen the name has none;cache.recordedByis dropped when the name has no slash;cache.entryis dropped unless it is a SHA-256; a skip is alwaysexplicit(aSkipcall);visionInput,visionDegraded,visionOnly, and the tool-proposal event kind have no port; a finding’sartifactIdis left out, since the port names its screenshot by path; an artifact iscompleteonly for a log whose file was read, and every video and screenshot isincomplete(pixels cannot be proven free of a secret); thesizeandsha256come from the file under the results directory when the report is written;serialGroupsis[]andcarriedis absent; a target’sindexis the order its first attempt reached the report. Results sort by file, declaration index, target index, then, since the port declares no index, by source line, title, and id, so the order does not depend on which attempt finished first. - Tests:
ReportStatus/holdsreport-status.test.ts:ResultTagsTests,ResultSourceTests,RunStatusDerivationTests(the three non-serial cases), andRunLimitsTests(the default-limits case), each asserting the document is valid report-1 as upstream’sbuilddoes, andErrorStacksTests(partial: no serial member; the port’s error keeps no stack, so the test throws a real exception and checks that neither its stack nor its path reaches the document). Not ported: the three serial-group cases (“is blocked when the failing member of the last attempt carries a blockable code”, “stays failed when the failing member of the last attempt failed on the product”, “reads the last attempt, not an earlier blocked one”; the port has no serial groups), and “fills the block from the largest per-agent values” (the config rejects those keys).Vcs/DetectVcsTestshas all threeitofvcs.test.ts. .NET-only:ReportParts/ReportPartsTests(upstream reaches these blocks only through whole runs),ReportJson/ReportFileTests(the file as sessions write it, the artifact digest, redaction, the surrogate cut), and the helperGitRepo(upstream’stests/helpers/git-repo.ts). - Tests:
SchemaFixtures/ReportV1SchemaTestshas everyitof upstream’sdescribe.each(schemas)forreport-v1;SchemaFixtures/SchemaFixturesTestshas the first top-levelit(a schema for every fixture pair); the second (it publishes every schema) is partial: it reads the project file’s packed items and the glob, notpackage.json, anddotnet packwas checked by hand to hold the schema and no fixture.ReportWrite/WriteJsonReportTestshas bothitofreport-write.test.ts. .NET-only: the two-space/newline test and theToPosixPathtest. observationTruncated(upstream82039ad, #1301) isReportStep.ObservationTruncated, written byReportWire.Stepwhen true and absent otherwise.ObservationFeedmarks it for any look of the step that a capture or byte limit cut, including one a settled look took before its last, andObservationFeed.ReportDetails()is upstream’sreportDetails. A judgment records it inAgent.LookAsync, where upstream does it inInvocation.observe. Tests:AgentObservationReport/AgentObservationReportFieldsTestsportsagent-observation-report.test.ts, both partial (Partial_prefix): the port has no fixture-enginerunProject, so each test runs its own session over an engine wrapper that marks every observation truncated and validates its ownreport.json, where upstream runs one project of both tests and validates its report.SchemaFixtures/ReportV1SchemaTestshas the boolean/string/absentit. Not ported: thesdk-types.tscheck (the C# type makes it).- Tests that read the report: a test whose upstream
itreadsoutcome.reportgives its session a results directory (ReportDirectory, .NET-only, inReportJson/) and readsreport.jsonafter the session is disposed, checked against the schema. They areAgentAct/(the hand-rolled executor, the default tool loop, and the blocked verdict),EngineTargets/,CacheModelProvenance/,ScriptedActions/,TruncatedSecrets/,TransformedSecrets/, andAgentPolicy/ASecretTheObservedScreenShowsTests. What the report still lacks keeps a test partial: the run’sexitCodeis 0, 1, or 130 (a blocked run reads 1, where upstream reads 2); an engine has no name besides its platform (targets[].engine.nameis the platform); an action an executor takes records noengineevent;app.restartandapp.clearStaterecord the reopen as a step of its own;clickanddblclickare thetapanddoubleTapsteps; a step whose executor reports no model readsunknownwhere upstream names the executor.Traces/TracesTestsstays partial: the lead it reads is in upstream’ssummary.md, and the port has no summary reporter.
App and browser fixtures
Section titled “App and browser fixtures”App.RestartAsync closes the tab and opens a new one in the same browser context, so cookies and storage survive. App.ClearStateAsync replaces the context with a clean one. Both then reopen the app at its base URL, as upstream, and keep a viewport set with Browser.SetViewportAsync. Without a base URL they leave a blank page.
Differences from upstream:
app.open,back,forward, andreloadwait forloadwithin the action timeout. Upstream waits within the test timeout.Browser.GotoAsync(url, GotoOptions?)resolves the URL against the base URL likeApp.OpenAsync, with the same scheme rule.GotoOptions.WaitUntilisLoad(the default),DomContentLoaded, orNetworkIdle, andTimeoutdefaults to the action timeout. Upstream’s default is the test timeout.Browser.WaitForURLAsyncisToHaveURLAsyncas a wait, as upstream, so it verifies the previousactand fails withexpect.toHaveURL failed. It takes a string, resolved against the base URL, or aRegex, and polls until the assertion timeout or the given timeout.ToHaveURLAsynctakes a string or aRegexinstead of upstream’sstring | RegExp. A string is compared after both sides go throughSystem.Uri(not WHATWG), which agrees with Chromium on the default port, the trailing slash, dot segments, and percent-encoding. ARegexis tested against the URL as Chromium serializes it.Browser.WaitForResponseAsynctakes upstream’s glob string or a .NETRegex(upstream an ECMAScriptRegExp), and the timeout as aTimeSpan?. As upstream, the timeout bounds the match only;WebResponse.TextAsyncandJsonAsync<T>wait for the body up to the action timeout, which is fixed for the session (upstream reads the budget when the body is read). A failed match isOPERATION_TIMEOUTwith the port’s message form (waitForResponse: <Playwright message>).Browser.EvaluateAsync<T>takes the script as a string (an expression, or a function source that is called with the optional argument) and deserializes the JSON result toT. A throwing script fails withEVALUATE_FAILEDand the message it threw, a result that is not JSON (such asInfinity) withINVALID_ARGUMENT, and a browser that closes under it withENGINE_FAILURE.Browser.RouteAsynctakes the same glob string orRegexasWaitForResponseAsync, and a handler that returns aTask.WebRoutehasRequest,FulfillAsync(RouteFulfillResponse:Status,Headers,ContentType,Json,Body,Path),ContinueAsync(RouteContinueOverrides:Url,Method,Headers,PostData),FallbackAsync, andAbortAsync, with upstream’s checks, codes, and messages.UnrouteAsyncremoves the routes added with an equal glob, or aRegexwith the same source and options.UnrouteAllAsyncremoves every route added through the browser and keeps the configured site headers.Pathresolves against the project root (E2ESessionOptions.ProjectRoot, whichE2ETestsets to the config’s directory, else the working directory). Differences: options are typed, so an unknown key, a non-string header value, and anaborterror code do not compile instead of failing withINVALID_ARGUMENT;Jsonis serialized withSystem.Text.Jsonweb defaults, andnullmeans no JSON body; a handler error raised after the test’s last browser call is dropped (upstream waits for running handlers and fails the attempt with it).Browser.AddInitScriptAsynctakes source or aWebInitScript, and a JSON argument only withWebInitScript.FromFunction, the function’s JavaScript source.- Fixture calls are not recorded as harness steps.
- Navigation URLs resolve with
System.Uri, not WHATWG. As upstream, onlyhttp:,https:, and the exactabout:blankare admitted. But a tab or newline inside a URL is escaped, not stripped, soview-\tsource:file:opens as an http path under the base URL (or fails withAPP_URL_REQUIREDwithout one) instead ofPOLICY_DENIED. A percent-encoded unreserved character in the path is decoded (%66ile:becomesfile:). Neither opens a scheme other than http(s).
DocumentEngine supports app.back (it rebuilds the previous route), app.restart, and app.clearState (both drop the history and reopen the base URL). It has no viewport: DocumentPage.OnScroll and DocumentElement.OnScroll let a page load more rows when it is scrolled. It has no browser, so every Browser member fails with UNSUPPORTED_CAPABILITY. A custom engine opts in by implementing IBrowserSession, and by overriding the default BackAsync, RestartAsync, and ClearStateAsync on IEngineSession, which otherwise fail with UNSUPPORTED_CAPABILITY.
Models
Section titled “Models”Upstream takes any AI SDK model instance. JSON cannot hold one, so agents.<name>.provider names the client, and code builds one with ModelProviders, Subscriptions, or a client’s options. Each client speaks its vendor’s wire protocol directly:
| Upstream | provider |
.NET |
|---|---|---|
@ai-sdk/openai chat, @ai-sdk/openai-compatible, Ollama |
openai, openai-compatible |
OpenAiCompatibleModel |
@ai-sdk/openai Responses, @ai-sdk/azure |
openai-responses, azure |
OpenAiResponsesModel (ModelProviders.AzureOpenAi) |
@ai-sdk/anthropic |
anthropic |
AnthropicModel |
@ai-sdk/google |
google |
GoogleModel |
@ai-sdk/amazon-bedrock |
bedrock |
BedrockModel, signed with SigV4 or a Bedrock API key |
@ai-sdk/xai |
xai |
OpenAiCompatibleModel at api.x.ai |
@openrouter/ai-sdk-provider |
openrouter |
OpenAiCompatibleModel at openrouter.ai |
gateway() from ai |
gateway |
OpenAiCompatibleModel at ai-gateway.vercel.sh |
chatgpt() |
chatgpt |
Subscriptions.ChatGpt |
copilot() |
copilot |
Subscriptions.Copilot (CopilotModel) |
grok() |
grok |
Subscriptions.Grok |
opencodeConsole() |
opencode-console |
Subscriptions.OpenCodeConsole (OpenCodeConsoleModel) |
Keys come from the environment variable the AI SDK reads (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, AZURE_API_KEY, XAI_API_KEY, OPENROUTER_API_KEY, AI_GATEWAY_API_KEY, AWS_*), or the one apiKeyEnv names.
Subscriptions follow upstream: the same four logins (openai PKCE on port 1455 or --device, github-copilot from gh or an OAuth App’s device flow with enterprise hosts, opencode-console and spacexai device flows), the same credentials file ($XDG_CONFIG_HOME/e2e/oauth.json, mode 0600, locked and replaced atomically, unknown entries kept), E2E_OAUTH_CREDENTIALS, refresh two minutes ahead of expiry with one refresh shared per file (a cancelled call stops waiting for it, and the refresh finishes for the others), a retry after a 401, Copilot’s chat-or-Responses choice from the plan’s listing, OpenCode Console’s per-model protocol from the workspace config, and OPENCODE_API_KEY, which replaces the login for the model and for e2e models opencode-console; bare e2e models also lists OpenCode Console when only the key is set, and a missing login’s message names the key. The OpenCode Console model always asks the Anthropic route for 8192 output tokens, as upstream does when a call sets no cap. A missing or rejected login fails the step with MODEL_PROVIDER_FAILED, blocked, naming the OAuthException code (NOT_LOGGED_IN, LOGIN_REQUIRED). Tests: oauth/errors.test.ts is OauthErrors/ (DescribeResponseTests: both it of describeResponse; ReadJsonBodyTests: the it of readJsonBody, which is TokenEndpoint.ReadJsonBodyAsync) and oauth/token-endpoint.test.ts is OauthTokenEndpoint/RequestTokensTests (all 3 it).
Differences:
- Requests are not streamed. The Codex backend only streams, so its event stream is folded back into the final response, as upstream folds it
- The Vercel AI Gateway and OpenRouter are reached over their OpenAI-compatible chat API, so a gateway model gets no Anthropic cache breakpoints or OpenAI cache key. The gateway reads
VERCEL_OIDC_TOKENfrom the environment but does not runvercel env pullitself - Gemini tool schemas go as
parametersJsonSchema, unconverted. Gemini 3 thought signatures are kept per model instance and sent back with their function calls - A Responses model (OpenAI, Azure, and the subscription backends) replays its reasoning on the next turn as upstream does: nothing is stored, so the request asks for
reasoning.encrypted_contentand sends each encrypted reasoning item back inline, ahead of the turn’s text and tool calls (all reasoning items first, not interleaved as returned). The request asks for it from every model except those namedgpt-3*,gpt-4*,chatgpt-4o*, andgpt-5-chat*, as the AI SDK does. Anthropic replays its thinking blocks, signature and redacted data included, ahead of the turn’s text and tool calls, and chat completions replayreasoning_content(read fromreasoning_contentorreasoning) on the assistant message, as the AI SDK does. Google replays its thought parts (thought: true, with theirthoughtSignature) and Bedrock itsreasoningContentblocks (reasoningTextwith its signature, or the redacted content) the same way, ahead of the turn’s text and tool calls. A Gemini text part with athoughtSignatureand nothought: truekeeps its text but loses the signature (#684). A Bedrock request is signed again for every attempt (ModelHttp.SendAsynctakes asigncallback), as the AI SDK signs inside each fetch, so a retry after a long wait carries a freshx-amz-date - On Anthropic, the tool loop stops replaying the thinking from a message it rewrote since the last request (an executor’s
PrepareMessageschanged it, or the history was shrunk after a context overflow) on, as upstream does, since Anthropic binds each thinking block to the prefix before it. The loop compares each message by value with what the last request sent (MessageShape), drops the reasoning withProviderHints.AfterEdit(upstream’safterEdit, taking the model, since the port has noproviderHints(model)factory; itsSpeaksAnthropiccheck reads the model object where upstream’s reads a provider and model id), and notes it on the turn. The drop is permanent for the step, and reasoning produced after the rewrite stays. OnlyAnthropicModeland such aBedrockModeldo this: a Bedrock model whose id names Anthropic behind an optional region prefix (eu.anthropic.claude-…) does it too, as upstream’sspeaksAnthropicrecognizes it (the id is read off theBedrockModel; a Bedrock application inference profile ARN is not recognized, as upstream). The port has no Vertex or OpenRouter client that replays Anthropic thinking, and it has no per-turn elision of superseded screens, so those two rewrites are the only ones. The part of the change that keeps the system prompt fixed across a switch toautohas nothing to do here, since the port sendstool_choice: autofrom the first turn azureuses the Responses API at/openai/v1withapi-version=v1, as the AI SDK’sazure()does. Bedrock signs withAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY(andAWS_SESSION_TOKEN) when both are set and otherwise resolves the AWS credential chain (shared profile viaAWS_PROFILEor the default profile, SSO, container and instance roles) with the AWS SDK for .NET (AWSSDK.Core), as the AI SDK’s amazon-bedrock provider does with the AWS SDK for JavaScript. Upstream has no symbol for it, since the AI SDK owns the chain, soAwsCredentialChainandBedrockModelOptions.CredentialChain(internal, a test seam) are .NET-only. A chain that finds nothing ends the step withMODEL_PROVIDER_FAILED; the chain’s own message is redacted- A model request the provider refuses as too large ends with
CONTEXT_OVERFLOW, blocked, and names the same status and body. The port uses upstream’s patterns for the refusal (every provider’s wording, any HTTP 413, a throttling text vetoed) on the message and the body. As upstream does,agent.actthen shrinks the step’s history once and sends the request again: every superseded screen is elided, and any text past 16,384 characters is cut to its head with a notice. The refused request spends no model call, and the retry runs through the turn’s preparation again, so an executor’sPrepareMessagessees the shrunk history, as upstream’sprepareMessagesdoes. A second refusal, or a first one with nothing to shrink, ends the step and says which. The port has no screenshots and no earlier budget for stale screens, so a screen is elided only at a refusal. - A model that refuses a request (finish reason
content-filter) ends the step withMODEL_REFUSEDinstead ofMODEL_PROVIDER_FAILED, as upstream does, and the step cache reads it as a failed flow likeMODEL_OUTPUT_INVALID. The port has no AI SDK to map each provider’s stop word, so its providers do:refusal(Anthropic),content_filter(chat completions, and Responsesincomplete_details),SAFETY,RECITATION,BLOCKLIST,PROHIBITED_CONTENT,SPII, andIMAGE_SAFETY(Gemini, and a prompt it blocks with no candidate, bypromptFeedback.blockReason),guardrail_intervenedandcontent_filtered(Bedrock). A refused turn that still called a tool is kept, as upstream keeps a usable answer. The message names the model without a provider, because anIAgentModelhas only a name, and quotes the provider’s word redacted and cut at 128 characters - A model GitHub Copilot does not serve to the account (
model_not_supported, a 4xx answer) ends the step withMODEL_UNAVAILABLE, blocked, naming the model and the causes (the plan, the Copilot settings, an unknown id), as upstream’scopilot()does. Upstream also marks the failure so its runner reports it once and stops the run; the port has no run-level error list and no workers, so every step that calls the model fails the same way. A model on the wrong endpoint (unsupported_api_for_model) stays aMODEL_PROVIDER_FAILED - A model request answered with an error status ends with
MODEL_PROVIDER_FAILEDand names the HTTP status and the response body, secrets redacted, cut at 1 KB. The port never parses a provider’s error body, so it quotes every one. Before it fails, the request is sent again up to 5 times (upstream’sTRANSPORT_RETRIES,ModelHttp.TransportRetries) after a network failure or a status the AI SDK treats as retryable (408, 409, 429, and every 5xx), waiting the provider’sretry-after-msorretry-afterwhen it is under a minute (or under the backoff) and otherwise 2 seconds doubled each time; the failure is the last attempt’s. A request that got no response in 120 seconds is not sent again, and the step’s deadline or a cancel ends the wait. The port has no forcedtool_choiceto retry withauto: every request already sendsauto - Every model request sends upstream’s identity headers, replacing the same headers set on the client, but names this port so its traffic is not counted as upstream’s:
User-Agent: e2e-dotnet/<version> (<platform>; <arch>)(the NuGet version; the platform and arch by Node’s names),HTTP-Referer: https://github.com/hardkoded/e2e-dotnet, andX-Title: e2e-dotnet. Model requests appendruntime/dotnet/<version>after it, where upstream has the AI SDK’s user agent. Login requests send the same user agent, as upstream’s do, and still name thee2eoriginator and referrer that upstream registered e2e loginwith no provider shows a numbered list instead of upstream’s picker.e2e modelsprints the same columns.e2e loginprints the sign-in URL on a line of its own, as upstream does, with plain lines where upstream uses clack log steps- A
ModelMessageof theuserrole can carryImages(ModelImage: the bytes, a media type, and the size), and every provider sends them after the turn’s text: animage_urlpart with a data URL for chat completions,input_imagefor Responses, a base64imageblock for Anthropic,inlineDatafor Google, and animageblock with its format for Bedrock. The AI SDK builds these parts upstream, so the port writes each provider’s shape itself. A model that cannot read images answers with the provider’s error, which ends the step withMODEL_PROVIDER_FAILED. A judgment sends one when it asks for vision (see Agents); vision onactis not ported.Pixels.SizeForModel(internal) caps a screenshot’s long side at 768 pixels with upstream’s box filter and keeps the scale (image pixels per CSS pixel) in step EngineScreenshot.MaskedRegionCountcounts the regions the engine covered, which upstream carries asmaskedRegionCounton observed pixels: the web engine counts the secure fields of every frame, and the mobile engine the secure nodes it painted. It is null for an engine that cannot say. A secret fill, by a locator or by the agent, raises the attempt’s exposure, which withholds pixels from a model for the rest of the attempt; the port has upstream’sfilledlevel only, because it has no engine-held secrets- Not ported:
e2e initwriting the model config