// Copyright 2026 TesterArmy.
// Modified by Dario Kondratiuk.
// SPDX-License-Identifier: Apache-2.0
using System.Text.Json;
using System.Text.Json.Nodes;
using E2E.Engine;
using E2E.Internal;
using Report = E2E.Reports.Report;
namespace E2E;
///
/// One test attempt: an engine session, the fixtures, and the replay cache.
/// Call after the test body. Disposing without
/// records the attempt as failed.
///
public sealed class E2ESession : IAsyncDisposable
{
private readonly AttemptScope _scope;
private readonly CancellationTokenSource _timeout;
private readonly IEngineSession _engine;
private readonly IDisposable _errorRedaction;
private readonly TextWriter? _debugOutput;
private readonly long _bodyStarted = System.Diagnostics.Stopwatch.GetTimestamp();
private int _state;
private int _disposed;
private ExecuteVideo? _video;
private TraceOutput? _trace;
private int _evidenceTried;
private FailureEvidence? _evidence;
private Exception? _error;
private long _bodyMs;
private AttemptStatus _status = AttemptStatus.Failed;
private Lazy? _report;
private E2ESession(AttemptScope scope, CancellationTokenSource timeout, IEngineSession engine, App app, Browser browser, Agent agent, Screen screen, TestContext context, IDisposable errorRedaction, TextWriter? debugOutput)
{
_debugOutput = debugOutput;
_errorRedaction = errorRedaction;
_scope = scope;
_timeout = timeout;
_engine = engine;
App = app;
Browser = browser;
Agent = agent;
Screen = screen;
Context = context;
}
public App App { get; }
public Browser Browser { get; }
public Agent Agent { get; }
public Screen Screen { get; }
public TestContext Context { get; }
/// The engine session, for a host that drives the app without a test body.
internal IEngineSession Engine => _engine;
/// The unique id of this attempt, the one its report names.
internal string AttemptId => _scope.AttemptId;
/// The attempt's step timeline, with the screen each step left when the attempt keeps a trace.
internal IReadOnlyList Steps => _scope.Steps.All();
/// The hook steps run in from now on; null for the test body (upstream's enter). A test framework's hook methods name theirs themselves.
internal StepPhase? Phase
{
get => _scope.Steps.Phase;
set => _scope.Steps.Phase = value;
}
/// What the app logged this attempt, each line under the step it happened in.
internal IReadOnlyList AppLog => _scope.Steps.AppLog();
/// What the engine said the attempt runs on, redacted and bounded; null when it said nothing.
internal IReadOnlyDictionary? Environment => _scope.Environment.Read(_scope.Redactor.Redact);
/// What the attempt saw the moment it failed, once the session is released and the trace page keeps the attempt; null otherwise.
internal FailureEvidence? Evidence => _evidence;
/// Redacts the values of the secrets this attempt knows.
internal Redactor Redactor => _scope.Redactor;
/// The secrets may fill by name, and every output redacts.
internal IReadOnlyList Secrets => _scope.Secrets;
/// Adds secrets the attempt knows from the start, as an act does with its params.
internal void Remember(IEnumerable secrets) =>
_scope.Remember(secrets.ToDictionary(secret => secret.Name, secret => (object?)secret, StringComparer.Ordinal));
/// Registers a value for redaction alone, as the redaction ledger does: the agent may not fill it, and it withholds no pixels.
internal void Register(string name, string value) => _scope.Register(name, value);
///
/// The video segments this attempt kept, set once the session is disposed: files in the attempt's video
/// directory, or links to recordings a hosted service keeps. The port has no report to register them in.
///
internal IReadOnlyList Videos { get; private set; } = [];
/// What the session explored, set by an exploration before it is released: its verdict is told by its findings, and gets no trace page.
internal ReportExplore? Exploration { get; set; }
///
/// The attempt's report-1 document once the session is disposed and its trace page, when the mode keeps one, is written; null before that and
/// when the page cannot be written. It tells this attempt alone, as a single-result run: its status is the session's, not the verdict of the
/// test around it. Artifact paths are relative to the results directory. Upstream's session.report().
///
public Report? Report() => Volatile.Read(ref _report)?.Value;
public int Replayed => _scope.Replayed;
public int HandedOff => _scope.HandedOff;
public int Missed => _scope.Missed;
public int ModelCalls => _scope.ModelCalls;
public int InputTokens => _scope.InputTokens;
public int OutputTokens => _scope.OutputTokens;
public static Task StartAsync(E2ESessionOptions options, CancellationToken cancellationToken = default) => LaunchAsync(options, null, cancellationToken);
/// Starts the session, as , with the environment a host's prepare left for the engine's worker. Null leaves the engine to read the process environment.
internal static async Task LaunchAsync(E2ESessionOptions options, IReadOnlyDictionary? env, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(options.Engine);
ArgumentException.ThrowIfNullOrWhiteSpace(options.TestTitle);
var agents = ResolveAgents(options);
var runLimits = ResolvedAgent.RunLimits(agents.Values);
if (options.Agent is { } pinned && !agents.ContainsKey(pinned))
{
throw new TestException("INVALID_ARGUMENT", "unknown agent \"" + pinned + "\"; configured: " + string.Join(", ", agents.Keys));
}
var baseUrl = string.IsNullOrWhiteSpace(options.BaseUrl) ? null : Urls.NormalizeAppUrl(options.BaseUrl, "the base URL").Href;
var recording = ExecuteVideo.Plan(options);
var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (options.TestTimeout > TimeSpan.Zero && options.TestTimeout != Timeout.InfiniteTimeSpan)
{
timeout.CancelAfter(options.TestTimeout);
}
var debug = new DebugTrace(options.Debug);
var projectRoot = Path.GetFullPath(string.IsNullOrEmpty(options.ProjectRoot) ? Directory.GetCurrentDirectory() : options.ProjectRoot);
var attemptDir = Path.Combine(
Path.GetFullPath(options.ResultsDirectory ?? Path.Combine(projectRoot, ".e2e", "results")),
AttemptReport.ResultSegmentOf(options.TestFile, options.TestTitle),
"attempt-" + options.Attempt.ToString(System.Globalization.CultureInfo.InvariantCulture));
IEngineSession engine;
// An attempt that keeps a trace takes the screen each action leaves; one that does not asks the engine for none.
var screens = new SessionScreens(RecordingModes.AttemptKeep(options.Trace.Mode, options.Attempt - 1) is not null);
var environment = new EnvironmentFacts();
var appLog = new AppLogRoute();
// The values the engine registers while it starts, before the attempt's scope exists to take them.
var ledger = new List<(string Name, string Value)>();
var launch = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token);
try
{
if (Bounded(options.LaunchTimeout))
{
launch.CancelAfter(options.LaunchTimeout);
}
engine = await debug.Time("session.launch", () => options.Engine.StartAsync(
new EngineStartOptions
{
BaseUrl = baseUrl,
ActionTimeout = options.ActionTimeout,
ProjectRoot = projectRoot,
Env = env,
ArtifactsDir = attemptDir,
Screen = screens.Traced ? screens.Push : null,
Environment = environment.Push,
AppLog = appLog.Push,
Navigation = appLog.Navigated,
ResolveSecret = (secret, resolve) =>
{
var plaintext = Secrecy.ResolveSecretValue(secret, ledger);
Secrecy.RegisterDerivedSecrets(secret.Name, resolve?.Derived?.Invoke(plaintext) ?? [], ledger);
return Task.FromResult(plaintext);
},
},
launch.Token)).ConfigureAwait(false);
}
catch (Exception ex) when ((ex is OperationCanceledException || ex is EngineException { Code: EngineErrorCodes.Cancelled }) && launch.IsCancellationRequested && !timeout.IsCancellationRequested)
{
timeout.Dispose();
WriteDebug(debug, options.DebugOutput);
throw new EngineException(
"ENVIRONMENT_UNAVAILABLE",
"The engine did not start within the launch timeout (" + options.LaunchTimeout.TotalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ms).",
ex);
}
catch
{
timeout.Dispose();
WriteDebug(debug, options.DebugOutput);
throw;
}
finally
{
launch.Dispose();
}
try
{
var video = await ExecuteVideo.StartAsync(recording, engine, attemptDir, options.LaunchTimeout, timeout.Token).ConfigureAwait(false);
// Outside strict mode, a later attempt never replays but still records, so the attempt number does not turn the cache off.
// Strict mode treats the store as the reviewed source of truth: every attempt replays, and nothing is written or evicted.
var cacheOn = options.CacheEnabled && options.CacheMode != CacheMode.Off && options.Cache is not null;
var softFailures = new SoftFailures(options.OnSoftFailure);
var testFailed = options.TestFailed ?? (static () => false);
var scope = new AttemptScope
{
ArtifactsDir = attemptDir,
ArtifactStore = options.ArtifactStore,
Session = engine,
Screens = screens,
Environment = environment,
Debug = debug,
AiTrace = options.AiTrace ? new AiTraceRecorder() : null,
AiTraceScope = options.AiTrace ? new AiTraceScope { Test = options.TestTitle, TestId = options.TestTitle, Target = string.IsNullOrWhiteSpace(options.TargetName) ? options.Engine.Platform : options.TargetName, Agent = options.Agent ?? "default", Attempt = options.Attempt - 1 } : null,
AiTracePath = options.AiTrace ? AiTrace.PathFor(options.ResultsDirectory is null ? Path.Combine(projectRoot, ".e2e") : Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.ResultsDirectory))) ?? projectRoot) : null,
BaseUrl = baseUrl,
Agents = agents,
AgentName = options.Agent ?? "default",
Cache = options.Cache,
CacheEnabled = cacheOn,
AppIdentity = options.AppIdentity!,
CacheWrite = cacheOn && options.CacheMode == CacheMode.ReadWrite && !options.CacheStrict,
ReplayEligible = options.CacheStrict || options.Attempt == 1,
CacheStrict = options.CacheStrict,
CleanupTimeout = options.CleanupTimeout,
TestTitle = options.TestTitle,
TestFile = options.TestFile,
ProjectRoot = projectRoot,
TargetName = string.IsNullOrWhiteSpace(options.TargetName) ? options.Engine.Platform : options.TargetName,
Ended = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token),
EnginePlatform = options.Engine.Platform,
EngineName = options.Engine.Name,
EngineVersion = options.Engine.Version,
EngineCapabilities = options.Engine.Capabilities,
Attempt = options.Attempt,
ActionTimeout = options.ActionTimeout,
ReplayTimeout = options.ReplayTimeout,
StepTimeout = options.StepTimeout,
Token = () => timeout.Token,
TestFailed = () => softFailures.Any || testFailed(),
};
foreach (var (name, value) in ledger)
{
scope.Register(name, value);
}
// A screen is read once, when its step ends, redacted with what the attempt knows by then.
screens.Route(screen => scope.Steps.RecordScreen(() => StepScreens.TraceScreen(screen, new TraceScreenOptions(scope.Redactor, runLimits.MaxObservationBytes, scope.AppOrigin))));
// What the app did on its own while the engine launched waits here, and goes under the step it happened in.
appLog.Route((appEvent, at) => scope.Steps.RecordAppEvent(appEvent, at));
var app = new App(engine, baseUrl, () => timeout.Token, scope.Steps, (options.Engine.Capabilities & EngineCapabilities.PinnedApp) != 0, scope.TargetName);
var browser = new Browser(engine, options.Engine.Platform, baseUrl, projectRoot, options.ActionTimeout, options.AssertionTimeout, () => timeout.Token, scope.MarkVerified, softFailures) { Steps = scope.Steps };
softFailures.Redact = text => scope.Redactor.Redact(text);
browser.Redact = text => scope.Redactor.Redact(text);
browser.Failed = scope.Implicate;
browser.Attach = scope.AttachArtifact;
var agent = new Agent(scope);
var screen = new Screen(
scope.ObserveAsync,
(node, action, token) => engine.PerformAsync(node, action, token),
() => timeout.Token,
scope.MarkVerified,
options.ActionTimeout,
options.AssertionTimeout,
softFailures,
locate: engine.CanLocate ? engine.LocateAsync : null,
swipe: engine.SwipeAsync);
var fixtureContext = new EngineFixtureContext(screen);
browser.Context = fixtureContext;
(engine as IEngineFixtureHost)?.CreateFixtures(fixtureContext);
screen.Steps = scope.Steps;
screen.Exposure = scope.Exposure;
screen.RememberSecret = secret => scope.Remember(new Dictionary(StringComparer.Ordinal) { [secret.Name] = secret });
screen.Redact = text => scope.Redactor.Redact(text);
screen.Failed = scope.Implicate;
screen.RenderTree = observation => Screen.TreeText(observation, scope.Redactor, scope.AppOrigin, runLimits.MaxObservationBytes);
screen.Screenshots = scope.Screenshots = new ScreenshotContext
{
ProjectRoot = projectRoot,
Suffix = "-" + (string.IsNullOrWhiteSpace(options.TargetName) ? options.Engine.Platform : options.TargetName) + "-" + ScreenshotContext.OperatingSystemName(),
Title = options.TestTitle,
ResultsDirectory = attemptDir,
Update = options.UpdateSnapshots,
Ci = options.Ci ?? E2EConfig.IsCi(),
ScrollsIntoView = (options.Engine.Capabilities & EngineCapabilities.Scroll) != 0,
DirectoryOf = options.SnapshotDirectoryOf ?? (static file => file is null ? null : file + "-snapshots"),
Capture = token => engine.ScreenshotAsync(token),
};
var context = new TestContext
{
App = app,
Browser = browser,
Platform = options.Engine.Platform,
Agent = agent,
Screen = screen,
CancellationToken = timeout.Token,
};
var keep = RecordingModes.AttemptKeep(options.Trace.Mode, options.Attempt - 1);
var trace = new TraceOutput(keep, attemptDir, (options.Cache as FileStepCache)?.CacheDirectory);
return new E2ESession(scope, timeout, engine, app, browser, agent, screen, context, ErrorRedaction.Install(screen.Redact), options.DebugOutput) { _video = video, _trace = trace };
}
catch
{
// The attempt never took ownership, so release what it started.
var cleanup = engine.DisposeAsync().AsTask();
await (Bounded(options.CleanupTimeout) ? cleanup.WaitAsync(options.CleanupTimeout) : cleanup).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
timeout.Dispose();
throw;
}
}
///
/// Ends expect.soft collection and returns one ASSERTION_FAILED
/// for every failure kept since the session started, or null. Call it when
/// the test body settles and fail the test with the result. Later soft
/// matchers throw as hard ones would.
///
public TestException? CloseSoftFailures() => Screen.SoftFailures.Close();
///
/// Collects a failure the engine holds from a path no step awaited (a route or dialog handler), within the cleanup budget, and
/// throws it as it was so its code is kept. An adapter calls it when the test body has settled, then again after the teardown, before
/// the verdict. Upstream's settleAttempt.
///
public async Task SettleAttemptAsync(CancellationToken cancellationToken = default)
{
var settling = _scope.Session.SettleAttemptAsync(_scope.CleanupTimeout, cancellationToken);
try
{
await (Bounded(_scope.CleanupTimeout) ? settling.WaitAsync(_scope.CleanupTimeout, cancellationToken) : settling).ConfigureAwait(false);
}
catch (TimeoutException ex) when (!settling.IsCompleted)
{
// Only the wait timing out: a TimeoutException a handler threw (a Playwright timeout) keeps its own classification.
throw new EngineException("CLEANUP_TIMEOUT", "settling the attempt timed out", ex);
}
}
///
/// Writes verified acts and evicts the unverified ones this attempt recorded or
/// replayed. A passed or skipped attempt confirms only what a later check verified.
/// An act that missed the cache and never passed leaves its key alone. An entry a
/// verified replay finished, or one that already holds the same flow, is not
/// rewritten. A model outage leaves existing entries in place, unless a failure on the app implicated them too. A cancelled
/// writes nothing.
///
public void Complete(Exception? error = null, CancellationToken cancellationToken = default)
{
if (Interlocked.CompareExchange(ref _state, 1, 0) != 0)
{
return;
}
_scope.Debug.Record("test.body", System.Diagnostics.Stopwatch.GetElapsedTime(_bodyStarted).TotalMilliseconds);
try
{
Commit(error, cancellationToken);
}
finally
{
EndAttempt();
Volatile.Write(ref _state, 2);
}
}
public async ValueTask DisposeAsync()
{
Exception? commitFailure = null;
try
{
if (Interlocked.CompareExchange(ref _state, 1, 0) == 0)
{
_scope.Debug.Record("test.body", System.Diagnostics.Stopwatch.GetElapsedTime(_bodyStarted).TotalMilliseconds);
try
{
Commit(new TestException("TEST_FAILED", "The session ended before it was completed."), CancellationToken.None);
}
finally
{
EndAttempt();
Volatile.Write(ref _state, 2);
}
}
}
#pragma warning disable CA1031 // The engine is released first, then this is rethrown.
catch (Exception ex)
#pragma warning restore CA1031
{
commitFailure = ex;
}
try
{
await ReleaseAsync().ConfigureAwait(false);
}
catch (Exception) when (commitFailure is not null)
{
// The commit failure is the one to report.
}
if (commitFailure is not null)
{
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(commitFailure);
}
}
private async ValueTask ReleaseAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
// The video is finalized before the engine goes, and a required one that cannot be is reported once the engine is released.
Exception? videoFailure = null;
try
{
// A failure that first landed in the teardown has had no look yet.
if (_error is { } failure)
{
await CaptureFailureEvidenceAsync(failure).ConfigureAwait(false);
}
if (_video is not null)
{
try
{
await _video.StopAsync(_status, _scope.CleanupTimeout, _scope.Artifacts?.Sink).ConfigureAwait(false);
}
#pragma warning disable CA1031 // The engine is released first, then this is rethrown.
catch (Exception ex)
#pragma warning restore CA1031
{
videoFailure = ex;
}
Videos = _video.Kept;
}
await _scope.Debug.Time("session.close", async () =>
{
var cleanup = _engine.DisposeAsync().AsTask();
if (Bounded(_scope.CleanupTimeout))
{
await cleanup.WaitAsync(_scope.CleanupTimeout).ConfigureAwait(false);
}
else
{
await cleanup.ConfigureAwait(false);
}
}).ConfigureAwait(false);
}
catch (TimeoutException ex)
{
throw new EngineException(
"ENVIRONMENT_UNAVAILABLE",
"The engine did not shut down within the cleanup timeout (" + _scope.CleanupTimeout.TotalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ms).",
ex);
}
finally
{
_errorRedaction.Dispose();
_scope.Ended.Dispose();
_timeout.Dispose();
WriteDebug(_scope.Debug, _debugOutput, _scope.Steps.All());
WriteAiTrace(_scope);
await WriteTracePageAsync().ConfigureAwait(false);
}
if (videoFailure is not null)
{
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw(videoFailure);
}
}
// Tells the executors that hold state for the attempt it is over.
private void EndAttempt()
{
try
{
_scope.Ended.Cancel();
}
catch (ObjectDisposedException)
{
// The attempt was released already.
}
catch (AggregateException)
{
// An executor's own release callback failed. That is its failure to report, and it must not hide the attempt's result.
}
}
// The session's own agent settings are the default agent. Agents names the others.
private static Dictionary ResolveAgents(E2ESessionOptions options)
{
var agents = new Dictionary(StringComparer.Ordinal)
{
["default"] = ResolvedAgent.From("default", new AgentOptions
{
Model = options.Model,
Judge = options.Judge,
Executor = options.AgentExecutor,
System = options.AgentSystem,
Context = options.AgentContext,
MaxSteps = options.MaxSteps,
MaxModelCalls = options.MaxModelCalls,
JudgmentTimeout = options.JudgmentTimeout,
MaxObservationBytes = options.MaxObservationBytes,
MaxInputTokens = options.MaxInputTokens,
ProviderOptions = options.ProviderOptions,
Tools = options.AgentTools,
}),
};
if (options.Agents is null)
{
return WithTestContext(agents, options.TestAgentContext);
}
foreach (var pair in options.Agents)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
throw new ConfigurationException("INVALID_CONFIG", "agent names must be non-empty");
}
if (string.Equals(pair.Key, "default", StringComparison.Ordinal))
{
throw new ConfigurationException(
"INVALID_CONFIG",
"Agents cannot hold \"default\"; the session's Model, Judge, AgentExecutor, AgentSystem, AgentTools, AgentContext, MaxSteps, MaxModelCalls, JudgmentTimeout, and ProviderOptions are the default agent");
}
agents[pair.Key] = ResolvedAgent.From(pair.Key, pair.Value);
}
return WithTestContext(agents, options.TestAgentContext);
}
// Every prompt reads the agent's own context, then the test's agentContext, whichever agent a call picks.
private static Dictionary WithTestContext(Dictionary agents, string? testContext)
{
return agents.ToDictionary(pair => pair.Key, pair => pair.Value.WithTestContext(testContext), StringComparer.Ordinal);
}
// A table is a courtesy: a stderr that cannot be written must not hide the test's own result.
private static void WriteDebug(DebugTrace debug, TextWriter? output, IReadOnlyList? steps = null)
{
if (!debug.Enabled)
{
return;
}
try
{
var writer = output ?? Console.Error;
writer.Write(debug.Summary());
if (steps is not null)
{
writer.Write(DebugSteps.AgentStepTable([steps]));
}
}
catch (IOException)
{
// Nothing reads the table.
}
}
// The trace is a courtesy as well: a file that cannot be written must not hide the test's own result.
private static void WriteAiTrace(AttemptScope scope)
{
if (scope.AiTrace is null || scope.AiTracePath is null)
{
return;
}
try
{
AiTrace.Collect(scope.AiTrace, scope.AiTracePath);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.Error.WriteLine("[e2e] could not record the AI trace " + scope.AiTracePath + ": " + ex.Message);
}
}
///
/// Looks at the app once more and keeps what the screen holds, the moment a failure is known: an adapter calls it when
/// the test body fails, ahead of the test's own teardown, so a teardown that navigates away cannot replace the evidence
/// (upstream's captureEvidence). It looks once per attempt. A failure that first lands in the teardown is
/// looked at when the session is released. Only an attempt whose trace page is kept looks.
///
public async Task CaptureFailureEvidenceAsync(Exception error)
{
ArgumentNullException.ThrowIfNull(error);
var status = ExecuteVideo.StatusOf(error);
if (status == AttemptStatus.Passed || _trace is not { Keep: { } keep } trace || !RecordingModes.Keeps(keep, status) || Interlocked.Exchange(ref _evidenceTried, 1) != 0)
{
return;
}
// The look is a courtesy: one that fails must not hide the test's own result.
try
{
_evidence = await FailureEvidence.CaptureAsync(new FailureEvidenceOptions
{
Observe = token => _scope.Session.ObserveAsync(token),
Capture = token => _scope.CaptureAsync(token),
Error = error,
Redactor = _scope.Redactor,
AppOrigin = _scope.AppOrigin,
MaxObservationBytes = ResolvedAgent.RunLimits(_scope.Agents.Values).MaxObservationBytes,
AttemptDir = trace.AttemptDir,
}).ConfigureAwait(false);
}
#pragma warning disable CA1031 // Evidence is a courtesy: a look that fails must not hide the test's own result.
catch (Exception ex) when (ex is not OperationCanceledException)
#pragma warning restore CA1031
{
_evidence = null;
}
}
// The page is a courtesy as well: one that cannot be written must not hide the test's own result.
private async Task WriteTracePageAsync()
{
if (_trace is not { } trace)
{
return;
}
// Every attempt joins its test's report, kept or not, so the page of a test tells all its attempts.
var kept = trace.Keep is { } keep && RecordingModes.Keeps(keep, _status);
try
{
// The artifacts' sizes and digests, and the hand-offs to the store, finish before the record is read.
await SettleArtifactsAsync().ConfigureAwait(false);
// Every attempt joins its test's report, and the run's report.json tells them all.
var (single, id) = AttemptReport.Build(_scope, trace, _status, _error, _bodyMs, Exploration, _evidence);
var (report, traced, redact) = RunReport.Add(single, trace.ResultsRoot, _scope.Attempt, kept, _scope.Redactor.Redact);
// The document is a courtesy too: the page below is still written when it cannot be.
try
{
await ReportFile.WriteAsync(report, ReportFile.ProjectRootOf(_scope.ProjectRoot, trace.ResultsRoot), trace.ResultsRoot, redact).ConfigureAwait(false);
}
#pragma warning disable CA1031 // The document is a courtesy: whatever fails in building it must not fail a test that passed.
catch (Exception ex) when (ex is not OperationCanceledException)
#pragma warning restore CA1031
{
Console.Error.WriteLine("[e2e] could not write " + ReportFile.FileName + " for " + _scope.TestTitle + ": " + _scope.Redactor.Redact(ex.Message));
}
if (traced)
{
await TracePages.WriteTracePagesAsync(
report,
new HashSet(StringComparer.Ordinal) { id },
new WriteTracePagesOptions(_scope.ProjectRoot!, trace.ResultsRoot, trace.CacheDir),
redact).ConfigureAwait(false);
}
var document = await ReportFile.SingleAsync(single, _scope.ProjectRoot, trace.ResultsRoot, redact).ConfigureAwait(false);
Volatile.Write(ref _report, new Lazy(() => ReportSerializer.Read(document)));
if (!traced && _scope.Attempt == 1)
{
// Upstream empties the results directory when a run starts. A first attempt that keeps nothing clears the page an earlier run left.
try
{
File.Delete(Path.Combine(trace.TestDir, TracePages.TracePage));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// A page that cannot be removed is stale, not a failure of the test.
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.Error.WriteLine("[e2e] could not write the trace page for " + _scope.TestTitle + ": " + _scope.Redactor.Redact(ex.Message));
}
}
// A store that hangs must not hold the session: the hand-offs are bounded by the cleanup timeout, and one that is not done by then
// leaves its record without a ref (the store's failure is its own).
private async Task SettleArtifactsAsync()
{
if (_scope.CreatedArtifacts is not { } artifacts)
{
return;
}
using var bound = new CancellationTokenSource();
if (Bounded(_scope.CleanupTimeout))
{
bound.CancelAfter(_scope.CleanupTimeout);
}
try
{
await artifacts.SettleAsync(bound.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (bound.IsCancellationRequested)
{
Console.Error.WriteLine("[e2e] the artifacts of " + _scope.TestTitle + " were not all stored within the cleanup timeout.");
}
}
private static bool Bounded(TimeSpan timeout) => timeout > TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan;
private void Commit(Exception? error, CancellationToken cancellationToken)
{
_status = ExecuteVideo.StatusOf(error);
_error = error;
_bodyMs = (long)System.Diagnostics.Stopwatch.GetElapsedTime(_bodyStarted).TotalMilliseconds;
if (!_scope.CacheWrite || _scope.Cache is null || cancellationToken.IsCancellationRequested)
{
return;
}
// A model outage says nothing about the recording, so it stays, unless a failure on the app implicated it too.
// Strict mode never reaches here: it writes nothing.
var preserve = (AttemptScope.KeepsCache(error) || _scope.KeepCache) && !_scope.ImplicatesUnconfirmed;
var writes = Flush(_scope.Cache, _scope.Acts, preserve);
foreach (var step in _scope.Steps.All())
{
if (step.Cache is not null && writes.TryGetValue(step.Index, out var write))
{
step.CacheWrite = write;
}
}
}
///
/// Settles the acts of one attempt: a verified act's recording is written, and an unverified one that
/// recorded or replayed is evicted, unless says the failure implicates nothing.
/// Returns what became of each step's recording, by step index.
///
internal static IReadOnlyDictionary Flush(IStepCache cache, IEnumerable acts, bool preserve)
{
var outcomes = new Dictionary();
foreach (var act in acts)
{
var recorded = act.Completed && act.Entry is { Actions.Count: > 0 } && !act.ParamCollision;
var outcome = act.Decided;
try
{
if (outcome is not null)
{
// The step settled its own recording when it concluded.
}
else if (act.Verified && recorded)
{
if (act.ReplayedWhole)
{
outcome = CacheWrite.Kept;
CompleteProvenance(cache, act.Key, act.Step);
}
else if (HoldsSameFlow(cache, act.Key, act.Entry!))
{
outcome = CacheWrite.Kept;
}
else
{
// A store that throws did not write the recording.
outcome = CacheWrite.NotWritten;
cache.Write(act.Key, act.Entry!);
outcome = CacheWrite.Saved;
}
}
// An entry read for an act that then passed with nothing to record is evicted too: it did not serve
// this pass, and nothing replaces it, so every later run would hand off the same way.
else if (!preserve && (recorded || act.ConsumedReplay || (act.Completed && act.ReadEntry)))
{
// An entry the step read existed for certain, so deleting it is an eviction, reported only once the delete
// returned. A new recording's delete only clears what an earlier run left, so it stays unconfirmed. A step
// that recorded nothing and kept no whole replay reports nothing when the delete throws, as upstream's
// evict stages nothing then.
var evicts = !recorded || act.ReplayedWhole;
outcome = recorded || act.ReplayedWhole ? CacheWrite.Unconfirmed : null;
cache.Delete(act.Key);
if (evicts)
{
outcome = CacheWrite.Evicted;
}
}
else if (recorded)
{
outcome = CacheWrite.Unconfirmed;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The cache is disposable, as a replay's read is: a store that fails here costs the next run a live step.
}
if (act.StepIndex is { } index && outcome is { } settled)
{
outcomes[index] = settled;
}
}
return outcomes;
}
// A verified whole replay proved which step an entry recorded before the provenance fields belongs to,
// so it writes them in. Strict mode matches only entries that record the whole step. Every other entry stays as stored.
private static void CompleteProvenance(IStepCache cache, string key, CacheEntry? step)
{
if (step is null || cache.Read(key).Entry is not { CallIndex: null } existing)
{
return;
}
existing.SetStep(step);
cache.Write(key, existing);
}
// Rewriting an identical flow would only churn a committed cache directory. The model that recorded it decides nothing
// a replay does, and neither does the measured end wait, so another model recording the same flow rewrites nothing.
private static bool HoldsSameFlow(IStepCache cache, string key, CacheEntry entry)
{
var existing = cache.Read(key).Entry;
return existing is not null
&& string.Equals(
FlowOf(existing),
FlowOf(entry),
StringComparison.Ordinal);
}
private static string FlowOf(CacheEntry entry)
{
var flow = JsonSerializer.SerializeToNode(entry, JsonDefaults.Options)!.AsObject();
flow.Remove("recordedBy");
flow.Remove("endWaitMs");
flow.Remove("keyedBy");
return flow.ToJsonString();
}
}
/// How to open one . Retries set above 1 so the cache records but does not replay, except under , where every attempt replays.
public sealed class E2ESessionOptions
{
public required IEngine Engine { get; init; }
/// The default agent's model.
public IAgentModel? Model { get; init; }
/// The default agent's judge for assert, waitFor, and extract. Defaults to .
public IAgentModel? Judge { get; init; }
/// The default agent's upstream system: text appended to the act rules. Judges never see it.
public string? AgentSystem { get; init; }
/// The default agent's project tools, offered to the model beside the agent's own. See .
public IReadOnlyDictionary? AgentTools { get; init; }
/// The default agent's custom brain, upstream's executor. See .
public IStepExecutor? AgentExecutor { get; init; }
/// The default agent's upstream context: told to every model call, at most 16384 UTF-8 bytes.
public string? AgentContext { get; init; }
/// The default agent's judgmentTimeout: the deadline of one assert, waitFor, or extract.
public TimeSpan JudgmentTimeout { get; init; } = E2EDefaults.JudgmentTimeout;
/// The default agent's maxObservationBytes. See .
public int MaxObservationBytes { get; init; } = E2EDefaults.MaxObservationBytes;
/// The default agent's maxInputTokens. See .
public int MaxInputTokens { get; init; } = E2EDefaults.MaxInputTokens;
/// The default agent's provider options, keyed by provider. See .
public IReadOnlyDictionary? ProviderOptions { get; init; }
///
/// Named agents besides the default one, picked per call with the Agent option.
/// Each starts from the built-in defaults, not from the default agent.
///
public IReadOnlyDictionary? Agents { get; init; }
/// The agent a call that names none uses, upstream's agent option of a test or run. Unset, it is default. An unknown name is INVALID_ARGUMENT.
public string? Agent { get; init; }
/// Upstream's agentContext of a test or group: appended to the context of every agent, after its own.
public string? TestAgentContext { get; init; }
public string? BaseUrl { get; init; }
/// Anchors the files a test names, such as a route's fulfill Path. Defaults to the working directory.
public string? ProjectRoot { get; init; }
public IStepCache? Cache { get; init; }
///
/// Where artifacts go besides the results directory (upstream's artifacts.store): every artifact the attempt registers, a
/// download or a video, is handed to the store as it is produced, and the store's reference is recorded as the artifact's ref.
/// Null keeps files local only. The automatic failure screen text and screenshot are not handed over yet.
///
public IArtifactStore? ArtifactStore { get; init; }
///
/// The digest of the target app's resolved identity and environment that the replay cache keys on (upstream's
/// SessionIdentity.appIdentity). Unset, entries key on the test environment alone.
///
public string? AppIdentity { get; init; }
public bool CacheEnabled { get; init; } = true;
/// replays but never writes or deletes recordings.
public CacheMode CacheMode { get; init; } = CacheMode.ReadWrite;
///
/// When true, a recording that no longer matches fails the act with REPLAY_STALE. So does a
/// no-entry miss while a holds a recording of the same step under another key.
/// Retries replay too, and the session never writes or deletes a recording, whatever says.
///
public bool CacheStrict { get; init; }
/// How long the engine may take to start. Upstream launchTimeout.
public TimeSpan LaunchTimeout { get; init; } = E2EDefaults.LaunchTimeout;
/// How long the engine may take to shut down on dispose. Upstream cleanupTimeout.
public TimeSpan CleanupTimeout { get; init; } = E2EDefaults.CleanupTimeout;
public required string TestTitle { get; init; }
///
/// The full name of the test's class, which stands for upstream's test file: it joins the title in the result id and leads the
/// name of the result's directory, so two tests with one title in two classes keep apart. The NUnit and xUnit adapters set it,
/// and e2e explore sets . It also makes the session a test, as upstream's
/// environment.test does: every prompt then opens with Test: <TestTitle>. Null keeps the title
/// alone as the identity and adds no title line, as for an e2e mcp session, which runs no test.
///
public string? TestFile { get; init; }
public TimeSpan TestTimeout { get; init; } = E2EDefaults.TestTimeout;
public TimeSpan ActionTimeout { get; init; } = E2EDefaults.ActionTimeout;
public TimeSpan AssertionTimeout { get; init; } = E2EDefaults.AssertionTimeout;
/// How long one ActAsync may run unless says otherwise. Judgments use .
public TimeSpan StepTimeout { get; init; } = E2EDefaults.StepTimeout;
/// How long a replay waits for each recorded target and for the recorded end state.
public TimeSpan ReplayTimeout { get; init; } = E2EDefaults.ReplayTimeout;
/// The default agent's model calls per agent call, 1 through 100.
public int MaxModelCalls { get; init; } = E2EDefaults.MaxModelCalls;
/// Actions one ActAsync may take, 1 through 100. can only lower it.
public int MaxSteps { get; init; } = E2EDefaults.MaxSteps;
///
/// Receives each expect.soft failure as it happens. Null keeps them
/// on the session until .
///
public Action? OnSoftFailure { get; init; }
///
/// Reports whether the test has already failed. Once it returns true, a passing check no longer
/// verifies acts, so a teardown assertion after a failure does not record them.
///
public Func? TestFailed { get; init; }
/// The target's name in a stored screenshot's file name. Defaults to the engine's platform.
public string? TargetName { get; init; }
///
/// E2E_UPDATE_SNAPSHOTS=1: ToHaveScreenshotAsync writes each stored screenshot it finds missing or
/// different, and passes.
///
public bool UpdateSnapshots { get; init; }
/// Where a failed screenshot comparison puts its diff, actual, and expected images. Defaults to .e2e/results under the project root.
public string? ResultsDirectory { get; init; }
/// Where the stored screenshots of the test file a call was made from live. Null keeps them in <test file>-snapshots.
internal Func? SnapshotDirectoryOf { get; init; }
/// Overrides the CI environment variable for ToHaveScreenshotAsync.
internal bool? Ci { get; init; }
/// Which attempts record a trace. The session only carries the setting; says which attempt this is.
public ResolvedRecording Trace { get; init; } = new(RecordingMode.RetainOnFailure, RecordingSource.Default);
/// Which attempts record a video. The session only carries the setting; says which attempt this is.
public ResolvedRecording Video { get; init; } = new(RecordingMode.Off, RecordingSource.Default);
///
/// E2E_DEBUG=1, the port's counterpart of --debug: the session times its phases and the agent's observe, model,
/// and action phases, writes the table to stderr when it is released, and keeps each agent step's transcript.
///
public bool Debug { get; init; }
///
/// E2E_AI_TRACE=1, the port's counterpart of --ai-trace: every model call of the test is recorded in the AI SDK devtools
/// shape and added to ai-trace-<process id>.json next to the results directory (.e2e by default) when the session is released.
///
public bool AiTrace { get; init; }
/// Where the phase table goes when is on. Defaults to stderr.
internal TextWriter? DebugOutput { get; init; }
/// 1 is the first try. Later attempts do not replay, and still record verified acts.
public int Attempt { get; init; } = 1;
}