From 8cdc7ddcb06c99374bccb600813f99d3211e40a1 Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Sat, 22 Aug 2026 23:36:26 +0100
Subject: [PATCH 1/9] feat: isolate E2E test execution in sandboxes
---
plans/sandboxed-e2e-test-runtime.md | 489 ++++++++++++++++++
src/atoms/testRuntimeAtoms.ts | 2 +-
src/ipc/handlers/app_handlers.ts | 2 +
src/ipc/handlers/tests_handlers.test.ts | 172 +++++-
src/ipc/handlers/tests_handlers.ts | 273 +++++++---
src/ipc/services/e2e_test_data_isolation.ts | 33 ++
src/ipc/services/e2e_test_runtime.test.ts | 75 +++
src/ipc/services/e2e_test_runtime.ts | 223 ++++++++
src/ipc/services/e2e_test_workspace.test.ts | 156 ++++++
src/ipc/services/e2e_test_workspace.ts | 192 +++++++
src/ipc/services/isolated_test_db.test.ts | 50 ++
src/ipc/services/isolated_test_db.ts | 44 +-
src/ipc/utils/neon_utils.test.ts | 66 ++-
src/ipc/utils/neon_utils.ts | 36 ++
src/ipc/utils/playwright_bootstrap.ts | 7 +-
src/ipc/utils/test_screenshot.ts | 40 +-
src/main.ts | 4 +
.../handlers/local_agent/tools/run_tests.ts | 1 +
18 files changed, 1778 insertions(+), 87 deletions(-)
create mode 100644 plans/sandboxed-e2e-test-runtime.md
create mode 100644 src/ipc/services/e2e_test_data_isolation.ts
create mode 100644 src/ipc/services/e2e_test_runtime.test.ts
create mode 100644 src/ipc/services/e2e_test_runtime.ts
create mode 100644 src/ipc/services/e2e_test_workspace.test.ts
create mode 100644 src/ipc/services/e2e_test_workspace.ts
diff --git a/plans/sandboxed-e2e-test-runtime.md b/plans/sandboxed-e2e-test-runtime.md
new file mode 100644
index 0000000000..e752da5bd1
--- /dev/null
+++ b/plans/sandboxed-e2e-test-runtime.md
@@ -0,0 +1,489 @@
+# Sandboxed app runtime for user-triggered E2E tests
+
+## Status
+
+Implemented for host-runtime test execution. The Tests panel and agent test tool
+now use a disposable workspace, run-scoped server, sandbox-only Neon env, and
+retained artifact directory. Interactive test recording remains on its existing
+preview-oriented lifecycle and was deliberately not migrated.
+
+## Problem
+
+Neon test execution currently isolates the data but not the app runtime. Dyad
+creates a temporary Neon branch, rewrites the real app's `.env.local`, and
+restarts the user's existing preview against that branch. Playwright then uses
+the existing preview proxy. While the run is active, any user interaction with
+the preview therefore reaches the throwaway branch; setup and teardown also
+interrupt the user's dev server.
+
+The test runner also uses the real app directory as its working directory, so
+reports and any test-time filesystem writes land in the user's project.
+
+## Goal
+
+Run user-triggered E2E tests against a disposable copy of the app with its own
+environment, process, and port, while the normal preview continues running from
+the real app directory and against the user's normal database configuration.
+
+The sandbox must represent the app as it exists when Run is pressed, including
+tracked modifications and relevant untracked/ignored runtime files. It must not
+be limited to the last Git commit.
+
+## Non-goals
+
+- Do not change the recorder. Recording continues to drive the normal preview
+ and use the existing database-isolation lifecycle.
+- Do not replace the app preview iframe or introduce an Electron session
+ partition.
+- Do not copy test-generated source changes back into the real app.
+- Do not use a Git worktree as the required implementation. Git may be absent,
+ unhealthy, or behind the app's current on-disk state.
+- Do not support concurrent test runs for the same app in the first version.
+- Do not install dependencies independently inside every sandbox.
+- Do not silently fall back to running a Neon test against the user's real
+ database or normal preview when sandbox setup fails.
+
+## Product behavior
+
+1. The user presses Run while their normal preview is running.
+2. Setup progress reports distinct steps: preparing Playwright if necessary,
+ copying the current app, creating isolated data, and starting the test
+ server.
+3. The normal preview remains available and is neither stopped nor restarted.
+4. Playwright targets the sandbox server URL only.
+5. Stop terminates Playwright, then the sandbox server, then remote test-data
+ resources, and finally deletes the sandbox directory.
+6. Test results remain visible through the existing result model. Artifacts
+ needed by the result UI are promoted to a Dyad-owned artifact directory
+ before the sandbox is deleted.
+7. A setup failure says which stage failed and confirms that the normal preview
+ was not changed.
+
+The first-ever Playwright bootstrap remains a transparent, one-time project
+setup operation because it intentionally adds `@playwright/test`, Dyad's config,
+and ignore entries to the user's app. It runs before the snapshot. Ordinary
+runs after bootstrap do not mutate the real app.
+
+## Proposed architecture
+
+### 1. `E2eTestWorkspace`
+
+Add an E2E-only workspace service, separate from database isolation:
+
+```ts
+interface E2eTestWorkspace {
+ realAppPath: string;
+ workspacePath: string;
+ artifactPath: string;
+ dispose(): Promise;
+}
+
+createE2eTestWorkspace({
+ appId,
+ appPath,
+ signal,
+ onProgress,
+}): Promise
+```
+
+Place workspaces beneath a Dyad-owned user-data directory such as
+`/test-sandboxes//`, rather than beside the user's repo.
+This gives startup reconciliation a bounded, recognizable cleanup root and
+prevents temp-directory policy differences from leaking into the feature.
+
+#### Copy strategy
+
+Use the existing repository pattern of recursive `fs.cp` with
+`COPYFILE_FICLONE` and a filter on macOS/Linux. A reflink-capable filesystem
+makes the initial clone copy-on-write. Fall back to an ordinary filtered copy
+when cloning is unsupported. Use a filtered ordinary copy on Windows initially
+and measure it before adding platform-native block cloning.
+
+Never hard-link application files: a write through a hard link would mutate the
+real file.
+
+Exclude heavyweight or disposable roots, initially:
+
+- `.git`
+- `node_modules`
+- framework output/caches: `dist`, `build`, `out`, `.vite`, `.next`, `.nuxt`,
+ `.svelte-kit`, `.turbo`, `.cache`
+- test output: `test-results`, `playwright-report`, `coverage`
+- OS metadata such as `.DS_Store`
+
+Do not use `.gitignore` wholesale. Ignored files can be runtime inputs, including
+environment files and generated assets. Preserve all other files as they exist
+on disk, including uncommitted and untracked files.
+
+After copying the source tree, clone `node_modules` into the workspace with the
+same copy-on-write strategy. Do not link the dependency root back to the real
+app: Vite resolves that link to the real directory, after which Nitro runtime
+entries fall outside the sandbox filesystem boundary and fail with
+`ERR_LOAD_URL`. Preserve pnpm's relative links within the cloned dependency tree.
+If dependencies are absent after the pre-snapshot bootstrap, fail setup with an
+actionable error.
+
+Treat writable dependency caches as a measured risk. Verify Vite, Next, and the
+supported package managers in tests; add sandbox-local cache environment
+overrides or exclusions for any tool proven to write beneath `node_modules`.
+
+#### Snapshot consistency
+
+Take the copy while holding the app path and repository-worktree coordination
+claims so Dyad-driven edits, restores, imports, and path changes cannot
+interleave. Release those claims once the copy is complete so the user can keep
+editing while Playwright runs against the captured state. External IDE writes
+cannot be made globally atomic; document the snapshot boundary as the moment
+Run is pressed and make copy failures fail closed.
+
+### 2. `E2eTestDataIsolation`
+
+Do not make `prepareIsolatedTestDatabase` serve two incompatible lifecycles.
+Keep it unchanged for recording, including its real-preview restart and env
+restoration behavior. Extract shared provider primitives where useful, then add
+an E2E-only data-isolation service whose contract targets the workspace:
+
+```ts
+interface PreparedE2eTestData {
+ isolation: TestIsolation;
+ testCredentials?: Record;
+ applyToWorkspace(workspacePath: string): Promise;
+ dispose(): Promise;
+}
+```
+
+#### Neon
+
+1. Create and durably track the temporary branch using the existing Neon branch
+ primitives.
+2. Read the already-copied workspace environment.
+3. Call the existing framework-aware Neon env updater with `workspacePath`,
+ never `realAppPath`.
+4. Provision the optional Better Auth test account as today.
+5. On cleanup, delete/mark the branch using the existing durable ordering.
+
+There is no real `.env.local` restoration step because it was never changed.
+Accordingly, remove E2E-only `envRestored` failure handling after migration;
+retain it in the recorder path. Continue tracking the branch ID durably so a
+crash can clean up a leaked remote branch even though the user's env is safe.
+
+#### Supabase
+
+Continue creating an RLS-scoped test user and cleaning up its data/user. The
+sandbox runtime still provides filesystem/process isolation even though the
+database endpoint does not change.
+
+#### No database
+
+Still use the workspace and test runtime. Return `mode: "none"` only for data
+isolation; it must not mean runtime isolation was skipped.
+
+#### Runtime modes
+
+Implement the first version for host runtime. For Docker/cloud runtime, retain
+the current behavior only where it is already safe and make the lack of runtime
+sandboxing explicit in the UI. For Neon specifically, never degrade to the
+normal preview/real database on sandbox failure. A later phase may provide
+Docker-specific volumes or cloud sandbox clones behind the same contract.
+
+### 3. `E2eTestRuntime`
+
+Add a short-lived runtime owned by the test run:
+
+```ts
+interface E2eTestRuntime {
+ baseUrl: string;
+ processId?: number;
+ stop(): Promise;
+}
+
+startE2eTestRuntime({
+ app,
+ workspacePath,
+ signal,
+ onOutput,
+}): Promise
+```
+
+Extract and reuse app-runtime command detection, managed Node/pnpm selection,
+environment construction, and readiness parsing where possible. Do not call the
+normal `executeApp` path unchanged: it is keyed by `appId`, publishes into
+`runningApps`, drives the app-run actor, owns the normal deterministic ports,
+and could replace or stop the user's preview.
+
+The test runtime must instead:
+
+- have a run-scoped identity rather than using `appId` as a singleton key;
+- run with `cwd: workspacePath`;
+- receive a unique app port;
+- remain outside `runningApps` and normal preview/app-run state;
+- stream setup output into the existing test output channel with a clear
+ `[test server]` prefix;
+- resolve only after an HTTP readiness probe succeeds;
+- stop only the exact child/process tree it started;
+- never call a cleanup helper that kills an unknown process merely because it
+ owns the desired port.
+
+Prefer Playwright targeting the test dev server directly. It does not need the
+normal preview proxy because there is no iframe, stable browser origin, or
+preview navigation UI to preserve. When the disposable Neon branch has Auth
+enabled, register the test server's exact run-scoped origin as a trusted domain
+after its random port is known and before Playwright starts. Registration must
+preserve the HTTP scheme rather than applying the HTTPS normalization used for
+deployment domains, and must target only the disposable branch; teardown
+deletes it with that branch. If authorization fails, stop before Playwright
+rather than producing misleading per-test `INVALID_ORIGIN` failures. Do not
+disable Better Auth's origin checks, use a loopback wildcard, or reuse the
+normal app proxy.
+
+Allocate an OS-available port and pass it through the same framework-specific
+start-command machinery used by the normal runtime. Because selecting a free
+port has a bind race, detect address-in-use startup failure and retry with a new
+port a bounded number of times. Do not reserve a port by killing its listener.
+
+### 4. Make the test core accept explicit execution inputs
+
+Change `runAppTestsCore` so it no longer looks up the real app path and normal
+preview URL internally:
+
+```ts
+runAppTestsCore({
+ appId,
+ appPath: workspace.workspacePath,
+ baseUrl: testRuntime.baseUrl,
+ artifactPath: workspace.artifactPath,
+ // existing selector, output, timeout, and credential options
+});
+```
+
+Keep path/URL resolution in the orchestration layer. Run `npx playwright` with
+the sandbox as `cwd`, write the JSON report inside it, and parse paths relative
+to the sandbox. Before disposal, copy retained traces/screenshots/report data to
+the run's artifact directory and rewrite result attachment paths if the UI
+stores them.
+
+Bootstrap Playwright against the real app before workspace creation. This
+preserves the current user-visible project setup semantics and ensures the
+generated Dyad config and dependency are present in the snapshot. Split
+`ensurePlaywrightBootstrap` from the pure "verify ready" step so code inside the
+sandbox cannot accidentally invoke an install through the shared
+`node_modules` clone.
+
+### 5. Lifecycle orchestration and coordination
+
+Refactor the E2E handler into explicit stages with one cleanup stack:
+
+```text
+register run/cancellation owner
+ -> bootstrap Playwright in real app (when required)
+ -> snapshot workspace
+ -> prepare isolated test data in workspace
+ -> start test runtime
+ -> run Playwright against explicit baseUrl
+ -> retain artifacts
+finally
+ -> stop Playwright/process tree
+ -> stop test runtime
+ -> dispose isolated data
+ -> delete workspace
+ -> publish terminal result
+```
+
+Register every acquired cleanup immediately after acquisition. Run cleanup in
+reverse order and continue after individual cleanup failures, reporting all
+failures without hiding the original run error. Cancellation must be checked
+between every awaited setup stage. Teardown is not abortable once a remote
+branch/user exists.
+
+Do not hold the current broad coordinator claim for the entire Playwright run.
+Use staged claims:
+
+1. **Bootstrap/snapshot:** read `app-path` and `repository-ref`; claim
+ `repository-worktree` and `test-files` while bootstrap files or the snapshot
+ may change/read them.
+2. **Provider lifecycle:** hold `provider` from temporary branch/user creation
+ through deletion, preventing unlink/delete races.
+3. **Sandbox runtime:** use a new run-scoped owner/registry rather than claiming
+ the normal app `runtime` or `runtime-config`, because those claims would
+ unnecessarily block the preview the feature is meant to preserve.
+
+Audit app deletion, app relocation, provider unlink, Stop, replacement Run, and
+application shutdown. These must cancel and await a run where required; they
+must not delete provider state or the sandbox root while cleanup is active.
+Preserve the existing same-app single-run controller ordering. Runs for
+different apps may proceed concurrently once port and resource ownership are
+run-scoped.
+
+### 6. Crash recovery
+
+On Dyad startup:
+
+- remove abandoned directories only beneath the recognized
+ `test-sandboxes` root;
+- never follow directory links during recursive cleanup;
+- rely on the existing durable Neon branch marker/reconciliation to remove
+ leaked remote branches;
+- ensure no real-env restoration gate is applied to E2E-only leaked branches,
+ because the real env was never modified;
+- keep recorder recovery behavior unchanged.
+
+The durable branch state may need to distinguish `recorder-env-swapped` from
+`e2e-cleanup-only`; add that distinction before sharing startup reconciliation.
+Do not infer it solely from whether a sandbox directory still exists.
+
+## Implementation phases
+
+### Phase 1: Workspace primitive and benchmarks
+
+- Add the filtered reflink/copy workspace service and safe disposal.
+- Add copy-on-write dependency-tree cloning with pnpm-link preservation.
+- Add timing/size telemetry without recording absolute user paths.
+- Benchmark representative Vite and Next apps on macOS, Windows, and Linux.
+- Set a soft progress threshold (show ongoing file count/bytes after 500 ms),
+ not a hard correctness timeout.
+
+Exit criterion: a workspace contains current tracked/untracked source and env
+files, excludes known heavy outputs, cannot mutate real source, and starts with
+resolvable dependencies.
+
+### Phase 2: Unregistered host test runtime
+
+- Extract reusable command/env/readiness pieces from `app_runtime_service`.
+- Implement the run-scoped process registry, port retry, output, readiness, and
+ process-tree teardown.
+- Prove that starting/stopping it does not change `runningApps`, the app-run
+ actor, normal proxy URL, or normal preview process.
+
+Exit criterion: normal and test servers run simultaneously from different
+directories and ports, and stopping either leaves the other alive.
+
+### Phase 3: E2E-only data isolation and runner wiring
+
+- Add workspace-targeted Neon env rewriting and shared provider primitives.
+- Keep the recorder on `prepareIsolatedTestDatabase` unchanged.
+- Pass explicit `appPath`/`baseUrl` into `runAppTestsCore`.
+- Promote artifacts, then dispose the sandbox.
+- Replace E2E env-restoration messaging with sandbox-cleanup messaging.
+
+Exit criterion: during a Neon test, the real `.env.local` is byte-identical,
+the normal preview keeps its PID/URL and real branch, and Playwright reaches the
+throwaway branch through the test server.
+
+### Phase 4: Coordination, recovery, and rollout
+
+- Narrow broad whole-run coordinator claims into staged ownership.
+- Complete delete/relocate/unlink/shutdown/rapid-rerun audits.
+- Add abandoned-workspace startup cleanup and distinguish durable Neon cleanup
+ states.
+- Gate the new path behind a temporary feature flag for soak testing, but fail
+ closed rather than silently use the legacy runtime when the flag is enabled.
+- Remove the legacy E2E env-swap path after cross-platform validation. Do not
+ remove or redirect the recorder path.
+
+## Test plan
+
+### Unit tests
+
+- Workspace filter includes modified/untracked/ignored runtime inputs and
+ excludes every declared heavyweight root.
+- LF/CRLF environment files remain valid and only sandbox Neon values change.
+- Reflink failure falls back to copy; cancellation removes a partial workspace.
+- Pnpm dependency realpaths remain inside the sandbox and missing-dependency
+ errors are platform-correct.
+- Sandbox disposal rejects paths outside its owned root and does not follow
+ malicious links.
+- Port collision retries without killing the existing listener.
+- Cleanup stack runs in reverse order, attempts every cleanup, and preserves the
+ primary error plus cleanup diagnostics.
+- Explicit runner paths parse reports and attachments relative to the sandbox.
+
+### Vitest integration tests
+
+- Start a real fixture dev server normally, create a sandbox server, and assert
+ distinct CWDs, PIDs, ports, and environment values.
+- Modify and add fixture files before Run; assert the sandbox sees them while a
+ post-snapshot edit affects only the normal workspace.
+- Assert Playwright bootstrap happens before snapshot and no install command is
+ invoked from the sandbox.
+- Cancel during copy, branch creation, server readiness, Playwright, and cleanup;
+ assert no live child/workspace remains and remote cleanup is attempted.
+- Start Run twice rapidly; assert the second waits for the first cleanup and
+ cannot delete the successor's workspace or branch.
+- Delete/unlink/relocate/shutdown coordination tests prove there is no orphaned
+ process or provider race.
+- Recorder regression tests assert it still uses the existing preview-oriented
+ isolation service and restart behavior.
+
+### Playwright E2E coverage
+
+Add one broad test using a fixture app whose page displays a server-read env
+marker:
+
+1. Start the normal preview with marker `real` and capture its PID/URL.
+2. Run a test with sandbox marker `isolated`.
+3. While it is running, verify the normal preview still displays `real` and is
+ interactive.
+4. Verify the generated test observes `isolated`.
+5. Stop or finish the run and assert the original PID/URL and `.env.local`
+ content never changed.
+6. Assert the sandbox process/directory is gone and retained results remain
+ readable.
+
+Add a second targeted cancellation case only if the integration harness cannot
+exercise real process-tree teardown reliably.
+
+## Observability and acceptance criteria
+
+Log structured timings for bootstrap, copy, data setup, server readiness, test
+execution, artifact promotion, and cleanup. Include app ID/run ID but never env
+contents, credentials, or absolute project paths.
+
+The feature is complete when:
+
+- the real app environment is byte-identical before, during, and after E2E;
+- the normal preview process and URL do not change during E2E;
+- Playwright can only reach the sandbox server URL supplied to its config;
+- current uncommitted and relevant untracked files are tested;
+- test writes and reports do not pollute the real app;
+- Stop and app shutdown leave no child process, sandbox, test user, or Neon
+ branch after cleanup/reconciliation;
+- filtered workspace creation is normally faster than test-server startup;
+- Windows fallback performance is measured and acceptable;
+- recorder behavior and its tests are unchanged.
+
+## Key risks and mitigations
+
+- **Shared `node_modules` is mutated:** prohibit sandbox installs, split
+ bootstrap verification from installation, and isolate proven writable caches.
+- **Framework command logic diverges:** extract command construction/readiness
+ from the normal runtime rather than duplicating it.
+- **Artifacts disappear with the workspace:** promote them before disposal and
+ test every attachment path consumed by the UI.
+- **External editor writes during copy:** coordinate all Dyad writers and define
+ a best-effort on-disk snapshot boundary; fail on inconsistent filesystem
+ errors rather than silently mixing in the real directory later.
+- **Port race:** bounded retry on bind failure; never kill an unknown listener.
+- **Crash leaks remote data:** preserve durable branch markers and distinguish
+ E2E cleanup-only state from recorder env-restoration state.
+- **Sandbox cleanup escapes its root:** validate canonical containment and do
+ not traverse links.
+- **Normal preview is accidentally registered/restarted:** keep the test runtime
+ out of `runningApps` and add assertions around the app-run actor and proxy.
+
+## Likely code areas
+
+- `src/ipc/handlers/tests_handlers.ts`: orchestration, explicit runner inputs,
+ cancellation, artifacts, and staged coordination.
+- `src/ipc/services/isolated_test_db.ts`: recorder-compatible service remains;
+ extract only provider primitives shared with the new E2E service.
+- `src/ipc/services/app_runtime_service.ts`: extract reusable command/env and
+ readiness helpers without reusing singleton runtime registration.
+- New `src/ipc/services/e2e_test_workspace.ts`.
+- New `src/ipc/services/e2e_test_runtime.ts`.
+- New `src/ipc/services/e2e_test_data_isolation.ts`.
+- `src/ipc/utils/playwright_bootstrap.ts`: split mutating bootstrap from sandbox
+ readiness verification.
+- `src/ipc/utils/neon_test_branch.ts`: durable cleanup-state distinction.
+- Startup reconciliation and app deletion/shutdown paths that currently recover
+ or stop tests.
+- Unit/integration tests beside each service plus one broad packaged E2E spec.
diff --git a/src/atoms/testRuntimeAtoms.ts b/src/atoms/testRuntimeAtoms.ts
index 176e089ab2..e4fb06a206 100644
--- a/src/atoms/testRuntimeAtoms.ts
+++ b/src/atoms/testRuntimeAtoms.ts
@@ -44,7 +44,7 @@ export type TestRunPhase =
| "setup" // first-run Playwright bootstrap streaming
| "running" // playwright test executing
| "stopping" // Stop pressed; killing the Playwright process tree
- | "cleaning-up"; // tests gone; isolation teardown still restoring the app
+ | "cleaning-up"; // tests gone; isolated provider data is still being removed
export interface TestRunState {
phase: TestRunPhase;
diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts
index 4f10566413..c42fdeabef 100644
--- a/src/ipc/handlers/app_handlers.ts
+++ b/src/ipc/handlers/app_handlers.ts
@@ -146,6 +146,7 @@ import {
trackedBranchId,
} from "../utils/neon_test_branch";
import type { AppSearchResult } from "@/lib/schemas";
+import { endTestsForApp } from "./tests_handlers";
import {
getRgExecutablePath,
@@ -491,6 +492,7 @@ async function deleteAppById(
const { envRestored } = await endRecordingForApp(appId, "app-stopped", {
skipRestart: true,
});
+ await endTestsForApp(appId);
if (!envRestored) {
// The app directory is about to be removed, so a stale `.env.local`
// inside it goes with it — this is diagnosis, not a refusal.
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 1a92143d14..54c7ed934a 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -74,6 +74,10 @@ vi.mock("../services/git_service", () => ({
const queueCloudSandboxSnapshotSyncMock = vi.hoisted(() => vi.fn());
const prepareIsolatedTestDatabaseMock = vi.hoisted(() => vi.fn());
+const ensurePlaywrightBootstrapMock = vi.hoisted(() => vi.fn());
+const createE2eTestWorkspaceMock = vi.hoisted(() => vi.fn());
+const startE2eTestRuntimeMock = vi.hoisted(() => vi.fn());
+const spawnStreamingMock = vi.hoisted(() => vi.fn());
const broadcastToRegisteredWindowsMock = vi.hoisted(() => vi.fn());
// Partially mocked: this module is pulled in transitively by the runtime
// service, so replacing it wholesale breaks whenever an unrelated export is
@@ -94,6 +98,39 @@ vi.mock("../services/isolated_test_db", async (importOriginal) => {
prepareIsolatedTestDatabase: prepareIsolatedTestDatabaseMock,
};
});
+vi.mock("../utils/playwright_bootstrap", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ ensurePlaywrightBootstrap: ensurePlaywrightBootstrapMock,
+ };
+});
+vi.mock("../utils/socket_firewall", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getPackageManagerCommandEnv: vi.fn(
+ (env: NodeJS.ProcessEnv = process.env) => env,
+ ),
+ };
+});
+vi.mock("../services/e2e_test_workspace", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ createE2eTestWorkspace: createE2eTestWorkspaceMock,
+ retainE2eTestArtifacts: vi.fn(),
+ };
+});
+vi.mock("../services/e2e_test_runtime", () => ({
+ startE2eTestRuntime: startE2eTestRuntimeMock,
+}));
+vi.mock("../utils/spawn_streaming", () => ({
+ spawnStreaming: spawnStreamingMock,
+}));
vi.mock("@/ipc/utils/window_broadcast", async (importOriginal) => {
const actual =
await importOriginal();
@@ -117,6 +154,30 @@ describe("tests handlers", () => {
removeFileAndCommitMock.mockClear();
queueCloudSandboxSnapshotSyncMock.mockClear();
prepareIsolatedTestDatabaseMock.mockReset();
+ ensurePlaywrightBootstrapMock.mockReset();
+ ensurePlaywrightBootstrapMock.mockResolvedValue({ installed: false });
+ createE2eTestWorkspaceMock.mockReset();
+ createE2eTestWorkspaceMock.mockImplementation(
+ async ({ appPath }: { appPath: string }) => ({
+ workspacePath: appPath,
+ artifactPath: path.join(TEMP_BASE, "artifacts"),
+ dispose: vi.fn(),
+ }),
+ );
+ startE2eTestRuntimeMock.mockReset();
+ startE2eTestRuntimeMock.mockResolvedValue({
+ baseUrl: "http://127.0.0.1:49999",
+ process: null,
+ stop: vi.fn(),
+ });
+ spawnStreamingMock.mockReset();
+ spawnStreamingMock.mockResolvedValue({
+ code: 1,
+ stdout: "",
+ stderr: "no report",
+ aborted: false,
+ timedOut: false,
+ });
broadcastToRegisteredWindowsMock.mockClear();
harness = setupHandlerTestHarness();
registerTestsHandlers();
@@ -142,7 +203,7 @@ describe("tests handlers", () => {
}
describe("tests:run", () => {
- it("owns the working tree without excluding Git ref snapshots", async () => {
+ it("releases the working tree after snapshotting the sandbox", async () => {
const appId = seedApp("app");
harness.db
.update(apps)
@@ -180,15 +241,23 @@ describe("tests handlers", () => {
).toMatchObject({
resources: [
{ resource: "app-path", mode: "read" },
- { resource: "repository-ref", mode: "read" },
- "repository-worktree",
"provider",
- "runtime",
- "runtime-config",
"test-files",
],
allowCompatibleQueueBypass: true,
});
+ expect(
+ requests.find(
+ ({ operation }) => operation === "prepare-e2e-test-workspace",
+ ),
+ ).toMatchObject({
+ resources: [
+ { resource: "app-path", mode: "read" },
+ { resource: "repository-ref", mode: "read" },
+ "repository-worktree",
+ "test-files",
+ ],
+ });
});
it("refuses atomically when a recording starts at coordinator admission", async () => {
@@ -229,7 +298,7 @@ describe("tests handlers", () => {
}
});
- it("reports an unrestored test-run environment", async () => {
+ it("reports an unfinished isolated-database cleanup", async () => {
const appId = seedApp("app");
harness.db
.update(apps)
@@ -250,7 +319,96 @@ describe("tests handlers", () => {
source: "panel",
});
- expect(result.infraError?.message).toMatch(/real database settings/i);
+ expect(result.infraError?.message).toMatch(/isolated test database/i);
+ expect(result.infraError?.message).toMatch(/settings were not changed/i);
+ });
+
+ it("authorizes the isolated server origin before Playwright starts", async () => {
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ const events: string[] = [];
+ const authorizeRuntimeOrigin = vi.fn(async () => {
+ events.push("authorize");
+ });
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "neon-branch" },
+ authorizeRuntimeOrigin,
+ teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ });
+ startE2eTestRuntimeMock.mockImplementation(async () => {
+ events.push("server");
+ return {
+ baseUrl: "http://127.0.0.1:49999/path",
+ process: null,
+ stop: vi.fn(),
+ };
+ });
+ spawnStreamingMock.mockImplementation(async () => {
+ events.push("playwright");
+ return {
+ code: 1,
+ stdout: "",
+ stderr: "no report",
+ aborted: false,
+ timedOut: false,
+ };
+ });
+
+ await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(authorizeRuntimeOrigin).toHaveBeenCalledWith(
+ "http://127.0.0.1:49999",
+ );
+ expect(events).toEqual(["server", "authorize", "playwright"]);
+ });
+
+ it("stops and cleans up when Neon origin authorization fails", async () => {
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ const stop = vi.fn().mockResolvedValue(undefined);
+ const teardown = vi.fn().mockResolvedValue({ envRestored: true });
+ const dispose = vi.fn().mockResolvedValue(undefined);
+ createE2eTestWorkspaceMock.mockResolvedValue({
+ workspacePath: path.join(TEMP_BASE, "app"),
+ artifactPath: path.join(TEMP_BASE, "artifacts"),
+ dispose,
+ });
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "neon-branch" },
+ authorizeRuntimeOrigin: vi
+ .fn()
+ .mockRejectedValue(new Error("Neon unavailable")),
+ teardown,
+ });
+ startE2eTestRuntimeMock.mockResolvedValue({
+ baseUrl: "http://127.0.0.1:49999",
+ process: null,
+ stop,
+ });
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(/Neon Auth/i);
+ expect(spawnStreamingMock).not.toHaveBeenCalled();
+ expect(stop).toHaveBeenCalledOnce();
+ expect(teardown).toHaveBeenCalledOnce();
+ expect(dispose).toHaveBeenCalledOnce();
});
});
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 0a4ca04e54..41a320e9ff 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -53,10 +53,18 @@ import { parseTestCases } from "../utils/parse_test_cases";
import { getPackageManagerCommandEnv } from "../utils/socket_firewall";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
import { sendTelemetryEvent } from "../utils/telemetry";
+import type { PreparedIsolation } from "../services/isolated_test_db";
+import { prepareE2eTestDataIsolation } from "../services/e2e_test_data_isolation";
import {
- prepareIsolatedTestDatabase,
- type PreparedIsolation,
-} from "../services/isolated_test_db";
+ createE2eTestWorkspace,
+ retainE2eTestArtifacts,
+ rewriteE2eArtifactPath,
+ type E2eTestWorkspace,
+} from "../services/e2e_test_workspace";
+import {
+ startE2eTestRuntime,
+ type E2eTestRuntime,
+} from "../services/e2e_test_runtime";
import { readTestScreenshotDataUrl } from "../utils/test_screenshot";
import { isRecordingActive } from "../services/recording_registry";
import { readSettings } from "@/main/settings";
@@ -95,6 +103,29 @@ function isNoTestsFoundOutput(output: string): boolean {
return /\bno tests found\b/i.test(output);
}
+function rewriteResultArtifactPaths(
+ results: TestResult[],
+ workspacePath: string,
+ artifactPath: string,
+): TestResult[] {
+ return results.map((result) => ({
+ ...result,
+ screenshotPath: rewriteE2eArtifactPath(
+ result.screenshotPath,
+ workspacePath,
+ artifactPath,
+ ),
+ tests: result.tests?.map((test) => ({
+ ...test,
+ screenshotPath: rewriteE2eArtifactPath(
+ test.screenshotPath,
+ workspacePath,
+ artifactPath,
+ ),
+ })),
+ }));
+}
+
/**
* The relative paths of every spec under the app's `e2e-tests/` folder, sorted.
* Shared by the Tests panel listing and the agent's run_tests tool (so a
@@ -167,6 +198,20 @@ export function isTestRunActive(appId: number): boolean {
return testRunControllers.has(appId);
}
+/** Abort every sandbox/test runner during Electron's synchronous quit phase. */
+export function stopAllAppTestsSync(): void {
+ for (const run of testRunControllers.values()) {
+ run.controller.abort();
+ }
+}
+
+export async function endTestsForApp(appId: number): Promise {
+ const run = testRunControllers.get(appId);
+ if (!run) return;
+ run.controller.abort();
+ await run.done;
+}
+
async function getApp(appId: number) {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
@@ -209,6 +254,12 @@ function emitRunState(
export interface RunAppTestsCoreOptions {
appId: number;
+ /** Explicit execution directory for an isolated test workspace. */
+ appPath?: string;
+ /** Explicit test-server URL. Legacy callers use the normal preview URL. */
+ baseUrl?: string;
+ /** Bootstrap is performed against the real app before sandbox creation. */
+ skipBootstrap?: boolean;
/** When set, runs a single spec file (relative path); otherwise runs all. */
testFile?: string;
/**
@@ -253,12 +304,14 @@ export interface RunAppTestsCoreOptions {
}
/**
- * Bootstrap Playwright (if needed), run the tests against the running dev
- * server's proxy URL, and parse the JSON report. Backs the `tests:run` IPC
- * handler (the UI "Run" button).
+ * Bootstrap Playwright when requested, run against an explicit sandbox server
+ * (or the legacy preview URL for direct callers), and parse the JSON report.
*/
export async function runAppTestsCore({
appId,
+ appPath: explicitAppPath,
+ baseUrl: explicitBaseUrl,
+ skipBootstrap = false,
testFile,
testLine,
grep,
@@ -270,7 +323,7 @@ export async function runAppTestsCore({
testEnv,
}: RunAppTestsCoreOptions): Promise {
const app = await getApp(appId);
- const appPath = getDyadAppPath(app.path);
+ const appPath = explicitAppPath ?? getDyadAppPath(app.path);
const emit = (chunk: string, phase: "setup" | "running") =>
onOutput?.(chunk, phase);
const normalizedTestFile =
@@ -287,7 +340,7 @@ export async function runAppTestsCore({
}
// Gate: the dev server must be running so baseURL resolves.
- const baseUrl = getRunningTestBaseUrl(appId);
+ const baseUrl = explicitBaseUrl ?? getRunningTestBaseUrl(appId);
if (!baseUrl) {
return {
appId,
@@ -301,17 +354,19 @@ export async function runAppTestsCore({
// 1. Lazy bootstrap (install Playwright + browser, write config), streamed.
let installed = false;
- try {
- const result = await ensurePlaywrightBootstrap({
- appPath,
- signal,
- onOutput: (chunk) => emit(chunk, "setup"),
- });
- installed = result.installed;
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- logger.error(`Playwright bootstrap failed: ${message}`);
- return { appId, results: [], infraError: { message } };
+ if (!skipBootstrap) {
+ try {
+ const result = await ensurePlaywrightBootstrap({
+ appPath,
+ signal,
+ onOutput: (chunk) => emit(chunk, "setup"),
+ });
+ installed = result.installed;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error(`Playwright bootstrap failed: ${message}`);
+ return { appId, results: [], infraError: { message } };
+ }
}
if (signal?.aborted) {
@@ -627,9 +682,8 @@ export async function runAppTestsWithIsolation({
testFile: normalizedTestFile ?? undefined,
testLine,
grep,
- // Only `cleaning-up` carries this, and only so the UI can name the work
- // accurately: the Neon path restarts the preview, the Supabase path
- // touches nothing the user can see.
+ // Only `cleaning-up` carries this so the UI can name the remote provider
+ // cleanup accurately. The normal preview is not restarted.
isolation,
});
};
@@ -679,23 +733,20 @@ export async function runAppTestsWithIsolation({
emitOutput(event, appId, runId, chunk, phase);
let finalResult: RunAppTestsResult = { appId, results: [] };
- // Set by isolation teardown below when `.env.local` couldn't be put back. The
- // run may have produced perfectly good results, but the app is still pointed
- // at the temporary branch, so the caller has to be told rather than left to
- // relaunch it against isolated data.
- let envRestoreFailed = false;
+ let workspace: E2eTestWorkspace | undefined;
+ // The real env is never changed. This only reports a sandbox/provider cleanup
+ // failure (for example, a temporary Neon branch left for startup recovery).
+ let isolationCleanupFailed = false;
/**
- * Fold a failed `.env.local` restore into a result. Applied on BOTH exits —
- * an unexpected rejection inside the lock must not swallow it, or the run
- * reports an ordinary infrastructure error while the app is still pointed at
- * the temporary branch.
+ * Fold failed provider cleanup into a result. Applied on both exits so an
+ * unexpected rejection cannot hide a temporary branch left for recovery.
*/
- const withEnvRestoreWarning = (
+ const withIsolationCleanupWarning = (
result: RunAppTestsResult,
): RunAppTestsResult => {
- if (!envRestoreFailed) return result;
+ if (!isolationCleanupFailed) return result;
const restoreMessage =
- "Dyad couldn't restore your app's real database settings after the test run. Restore .env.local before running the app again.";
+ "Dyad couldn't finish cleaning up the isolated test database. Your app settings were not changed; Dyad will retry remote cleanup on next startup.";
return {
...result,
// Appended rather than substituted: an isolation-setup failure explains
@@ -723,17 +774,45 @@ export async function runAppTestsWithIsolation({
// so this call exists only for the ordering barrier.)
await getApp(appId);
- // Own the runtime/test resources across the whole isolation lifecycle
- // (prepare → run → teardown). Startup reconciliation owns the same
- // resources, so a rapid Run after launch cannot interleave its env swap
- // and dev-server restart with reconciliation and use the real database.
+ // Bootstrap and snapshot under the real working-tree claim, then release it
+ // before Playwright runs so ordinary app editing can continue against the
+ // normal preview while this run uses its captured filesystem state.
+ workspace = await appOperationCoordinator.run(
+ {
+ appId,
+ operation: "prepare-e2e-test-workspace",
+ resources: [
+ readAppResource("app-path"),
+ readAppResource("repository-ref"),
+ "repository-worktree",
+ "test-files",
+ ],
+ refuseWhenRecording: "run tests",
+ },
+ async () => {
+ const app = await getApp(appId);
+ const realAppPath = getDyadAppPath(app.path);
+ await ensurePlaywrightBootstrap({
+ appPath: realAppPath,
+ signal: controller.signal,
+ onOutput: (chunk) => emit(chunk, "setup"),
+ });
+ emit("Copying the app into an isolated test workspace…\n", "setup");
+ return createE2eTestWorkspace({
+ appId,
+ appPath: realAppPath,
+ signal: controller.signal,
+ onProgress: (message) => emit(message, "setup"),
+ });
+ },
+ );
+
+ // The live test only owns provider/test inputs. It deliberately does not
+ // claim the normal runtime or runtime-config: its process is run-scoped and
+ // never registered in runningApps.
const testRunResources = [
readAppResource("app-path"),
- readAppResource("repository-ref"),
- "repository-worktree",
"provider",
- "runtime",
- "runtime-config",
"test-files",
] as const;
if (appOperationCoordinator.isBusy(appId, testRunResources)) {
@@ -759,6 +838,7 @@ export async function runAppTestsWithIsolation({
},
async () => {
let prepared: PreparedIsolation | undefined;
+ let testRuntime: E2eTestRuntime | undefined;
try {
const app = await getApp(appId);
@@ -774,14 +854,27 @@ export async function runAppTestsWithIsolation({
}
const runtimeMode = readSettings().runtimeMode2 ?? "host";
+ if (runtimeMode !== "host") {
+ return {
+ appId,
+ results: [],
+ infraError: {
+ message: `Isolated E2E test servers currently require host runtime. Switch from ${runtimeMode} runtime before running tests.`,
+ },
+ isolation: {
+ mode: "none" as const,
+ reason: `Sandboxed E2E execution is not available in ${runtimeMode} runtime yet.`,
+ },
+ };
+ }
// Set up isolation so the run never mutates the user's real data:
// Neon apps get a throwaway copy-on-write branch, Supabase apps get
// a throwaway RLS-scoped test user, and no-DB apps run as-is.
- prepared = await prepareIsolatedTestDatabase({
+ prepared = await prepareE2eTestDataIsolation({
app,
+ workspacePath: workspace!.workspacePath,
emit,
- runtimeMode,
signal: controller.signal,
});
@@ -796,8 +889,43 @@ export async function runAppTestsWithIsolation({
};
}
+ emit("Starting the isolated test server…\n", "setup");
+ testRuntime = await startE2eTestRuntime({
+ workspacePath: workspace!.workspacePath,
+ startCommand: app.startCommand,
+ signal: controller.signal,
+ onOutput: (chunk) => emit(chunk, "setup"),
+ });
+
+ if (prepared.authorizeRuntimeOrigin) {
+ const runtimeOrigin = new URL(testRuntime.baseUrl).origin;
+ emit(
+ "Authorizing the isolated test server for sign-in…\n",
+ "setup",
+ );
+ try {
+ await prepared.authorizeRuntimeOrigin(runtimeOrigin);
+ } catch (error) {
+ logger.error(
+ `Failed to authorize isolated E2E origin ${runtimeOrigin} for app ${appId}: ${error}`,
+ );
+ return {
+ appId,
+ results: [],
+ infraError: {
+ message:
+ "Dyad couldn't authorize the isolated test server with Neon Auth, so the tests were not run. Check your Neon connection and try again.",
+ },
+ isolation: prepared.isolation,
+ };
+ }
+ }
+
const result = await runAppTestsCore({
appId,
+ appPath: workspace!.workspacePath,
+ baseUrl: testRuntime.baseUrl,
+ skipBootstrap: true,
testFile: normalizedTestFile ?? undefined,
testLine,
grep,
@@ -808,29 +936,37 @@ export async function runAppTestsWithIsolation({
onOutput: emit,
testEnv: prepared.testCredentials,
});
+ await retainE2eTestArtifacts(workspace!);
+ result.results = rewriteResultArtifactPaths(
+ result.results,
+ workspace!.workspacePath,
+ workspace!.artifactPath,
+ );
return { ...result, isolation: prepared.isolation };
} finally {
- // Always restore the app to its real database, even on the
- // infraError early-return, abort, or throw. `teardown` is safe to
- // call exactly once; on the infraError path it's a NOOP (isolation
- // already restored).
+ if (testRuntime) {
+ try {
+ await testRuntime.stop();
+ } catch (error) {
+ logger.error(
+ `Failed to stop isolated test server for app ${appId}: ${error}`,
+ );
+ }
+ }
+ // Always clean up provider isolation, even on an infraError, abort, or
+ // throw. The sandbox env can be discarded, but remote branches/users
+ // still require their guaranteed teardown.
if (prepared) {
try {
// Announce the teardown before it starts. It restores
- // `.env.local`, restarts the dev server and deletes the temporary
- // branch/user, takes no AbortSignal, and routinely outlasts the
- // process kill by a wide margin (the Neon branch delete retries
- // with backoff). Without this the UI reports "running" for the
- // whole wait. Skipped for `none`, whose teardown is a NOOP that
- // would only flash the label.
+ // the temporary branch/user, takes no AbortSignal, and may
+ // outlast the process kill because Neon deletion retries with
+ // backoff. Skipped for `none`, whose teardown is a NOOP.
if (prepared.isolation.mode !== "none") {
emitProgress("cleaning-up", prepared.isolation);
}
- // Fail closed across the await: a teardown that throws has said
- // nothing about whether the env came back, and "unknown" has to
- // read the same as "no".
- envRestoreFailed = true;
- envRestoreFailed = !(await prepared.teardown()).envRestored;
+ isolationCleanupFailed = true;
+ isolationCleanupFailed = !(await prepared.teardown()).envRestored;
} catch (error) {
logger.error(
`Failed to tear down isolated test environment for app ${appId}: ${error}`,
@@ -840,12 +976,12 @@ export async function runAppTestsWithIsolation({
}
},
);
- finalResult = withEnvRestoreWarning(finalResult);
+ finalResult = withIsolationCleanupWarning(finalResult);
return finalResult;
} catch (error) {
// Surface an unexpected failure as an infra error on the run-state event so
// the panel leaves its spinner state, then rethrow for the caller.
- finalResult = withEnvRestoreWarning({
+ finalResult = withIsolationCleanupWarning({
appId,
results: [],
infraError: {
@@ -861,6 +997,15 @@ export async function runAppTestsWithIsolation({
cause: error,
});
} finally {
+ if (workspace) {
+ try {
+ await workspace.dispose();
+ } catch (error) {
+ logger.error(
+ `Failed to remove isolated test workspace for app ${appId}: ${error}`,
+ );
+ }
+ }
if (externalSignal) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
@@ -939,7 +1084,11 @@ export function registerTestsHandlers() {
const app = await getApp(params.appId);
const appPath = getDyadAppPath(app.path);
return {
- dataUrl: await readTestScreenshotDataUrl(appPath, params.path),
+ dataUrl: await readTestScreenshotDataUrl(
+ appPath,
+ params.path,
+ params.appId,
+ ),
};
},
);
diff --git a/src/ipc/services/e2e_test_data_isolation.ts b/src/ipc/services/e2e_test_data_isolation.ts
new file mode 100644
index 0000000000..b27b0c5126
--- /dev/null
+++ b/src/ipc/services/e2e_test_data_isolation.ts
@@ -0,0 +1,33 @@
+import { apps } from "@/db/schema";
+import {
+ prepareIsolatedTestDatabase,
+ type PreparedIsolation,
+} from "./isolated_test_db";
+
+type AppRow = typeof apps.$inferSelect;
+
+/**
+ * E2E-only adapter for provider isolation. Unlike the recorder-facing default,
+ * this writes only inside the disposable workspace and never restarts the
+ * normal preview.
+ */
+export function prepareE2eTestDataIsolation({
+ app,
+ workspacePath,
+ emit,
+ signal,
+}: {
+ app: AppRow;
+ workspacePath: string;
+ emit: (chunk: string, phase: "setup" | "running") => void;
+ signal?: AbortSignal;
+}): Promise {
+ return prepareIsolatedTestDatabase({
+ app,
+ emit,
+ runtimeMode: "host",
+ signal,
+ appPathOverride: workspacePath,
+ restartApp: false,
+ });
+}
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
new file mode 100644
index 0000000000..34089c0479
--- /dev/null
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -0,0 +1,75 @@
+// @vitest-environment node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import {
+ buildE2eTestStartCommand,
+ startE2eTestRuntime,
+} from "./e2e_test_runtime";
+import { runningApps } from "@/ipc/utils/process_manager";
+
+describe("buildE2eTestStartCommand", () => {
+ it("starts npm without reinstalling dependencies", () => {
+ const command = buildE2eTestStartCommand({
+ workspacePath: path.resolve("app"),
+ port: 45678,
+ });
+ expect(command.command).toBe("npm run dev -- --port 45678");
+ expect(command.command).not.toContain("install");
+ expect(command.env.PORT).toBe("45678");
+ });
+
+ it("supports an explicit port placeholder in custom commands", () => {
+ const command = buildE2eTestStartCommand({
+ workspacePath: path.resolve("app"),
+ port: 45678,
+ startCommand: "custom-server --listen {port}",
+ });
+ expect(command.command).toBe("custom-server --listen 45678");
+ });
+
+ it("uses pnpm when the sandbox contains its lockfile", () => {
+ const root = fs.mkdtempSync(path.join(process.cwd(), ".e2e-runtime-test-"));
+ try {
+ fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "");
+ const command = buildE2eTestStartCommand({
+ workspacePath: root,
+ port: 45678,
+ });
+ expect(command.command).toContain("pnpm");
+ expect(command.command).not.toContain("install");
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ it("starts and stops a server without registering the normal app runtime", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-runtime-"));
+ fs.writeFileSync(
+ path.join(root, "server.mjs"),
+ `import http from "node:http";
+const port = Number(process.argv[2]);
+http.createServer((_request, response) => response.end("sandbox"))
+ .listen(port, "127.0.0.1");
+`,
+ );
+ let runtime: Awaited> | undefined;
+ const registeredRuntimeCount = runningApps.size;
+ try {
+ runtime = await startE2eTestRuntime({
+ workspacePath: root,
+ startCommand: `"${process.execPath}" server.mjs {port}`,
+ });
+ await expect(
+ fetch(runtime.baseUrl).then((response) => response.text()),
+ ).resolves.toBe("sandbox");
+ expect(runningApps.size).toBe(registeredRuntimeCount);
+ } finally {
+ await runtime?.stop();
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
new file mode 100644
index 0000000000..61d3446b5a
--- /dev/null
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -0,0 +1,223 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import fs from "node:fs";
+import net from "node:net";
+import path from "node:path";
+import log from "electron-log";
+
+import { killProcess } from "@/ipc/utils/process_manager";
+import {
+ getPackageManagerCommandEnv,
+ PNPM_PM_ON_FAIL_IGNORE_ARG,
+} from "@/ipc/utils/socket_firewall";
+
+const logger = log.scope("e2e_test_runtime");
+const SERVER_READY_TIMEOUT_MS = 120_000;
+const SERVER_READY_POLL_MS = 250;
+
+export interface E2eTestRuntime {
+ baseUrl: string;
+ process: ChildProcess;
+ stop(): Promise;
+}
+
+export async function allocateE2eTestPort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.unref();
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close(() =>
+ reject(new Error("Could not allocate a test port.")),
+ );
+ return;
+ }
+ server.close((error) => (error ? reject(error) : resolve(address.port)));
+ });
+ });
+}
+
+export function buildE2eTestStartCommand({
+ workspacePath,
+ port,
+ startCommand,
+}: {
+ workspacePath: string;
+ port: number;
+ startCommand?: string | null;
+}): { command: string; env: NodeJS.ProcessEnv } {
+ if (startCommand?.trim()) {
+ const command = startCommand.includes("{port}")
+ ? startCommand.replaceAll("{port}", String(port))
+ : `${startCommand.trim()} -- --port ${port}`;
+ return { command, env: { ...process.env, PORT: String(port) } };
+ }
+
+ if (fs.existsSync(path.join(workspacePath, "pnpm-lock.yaml"))) {
+ return {
+ command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} run dev --port ${port}`,
+ env: { ...getPackageManagerCommandEnv(), PORT: String(port) },
+ };
+ }
+ return {
+ command: `npm run dev -- --port ${port}`,
+ env: { ...process.env, PORT: String(port) },
+ };
+}
+
+function delay(ms: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new Error("Test run stopped."));
+ return;
+ }
+ const timer = setTimeout(resolve, ms);
+ signal?.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timer);
+ reject(new Error("Test run stopped."));
+ },
+ { once: true },
+ );
+ });
+}
+
+async function waitForReady({
+ baseUrl,
+ process: child,
+ signal,
+ outputTail,
+ spawnError,
+}: {
+ baseUrl: string;
+ process: ChildProcess;
+ signal?: AbortSignal;
+ outputTail: () => string;
+ spawnError: () => Error | undefined;
+}): Promise {
+ const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ if (signal?.aborted) throw new Error("Test run stopped.");
+ const startError = spawnError();
+ if (startError) {
+ throw new Error(
+ `Could not start the isolated test server: ${startError.message}`,
+ );
+ }
+ if (child.exitCode !== null || child.signalCode !== null) {
+ throw new Error(
+ `The isolated test server exited before becoming ready.\n${outputTail()}`,
+ );
+ }
+ try {
+ const response = await fetch(baseUrl, {
+ signal: AbortSignal.timeout(1_000),
+ });
+ if (response.status < 500) return;
+ } catch {
+ // The server has not bound yet.
+ }
+ await delay(SERVER_READY_POLL_MS, signal);
+ }
+ throw new Error(
+ `The isolated test server did not become ready within 2 minutes.\n${outputTail()}`,
+ );
+}
+
+async function startE2eTestRuntimeOnce({
+ workspacePath,
+ startCommand,
+ signal,
+ onOutput,
+}: {
+ workspacePath: string;
+ startCommand?: string | null;
+ signal?: AbortSignal;
+ onOutput?: (chunk: string) => void;
+}): Promise {
+ if (signal?.aborted) throw new Error("Test run stopped.");
+ const port = await allocateE2eTestPort();
+ const baseUrl = `http://127.0.0.1:${port}`;
+ const { command, env } = buildE2eTestStartCommand({
+ workspacePath,
+ port,
+ startCommand,
+ });
+ const child = spawn(command, [], {
+ cwd: workspacePath,
+ env,
+ shell: true,
+ stdio: "pipe",
+ detached: false,
+ });
+
+ let tail = "";
+ const append = (data: unknown) => {
+ const chunk = String(data);
+ tail = `${tail}${chunk}`.slice(-8_000);
+ onOutput?.(`[test server] ${chunk}`);
+ };
+ child.stdout?.on("data", append);
+ child.stderr?.on("data", append);
+ let startError: Error | undefined;
+ child.once("error", (error) => {
+ startError = error;
+ append(error.message);
+ });
+
+ let stopPromise: Promise | undefined;
+ const stop = () => {
+ stopPromise ??= (async () => {
+ if (child.pid && child.exitCode === null && child.signalCode === null) {
+ await killProcess(child);
+ }
+ })();
+ return stopPromise;
+ };
+ const onAbort = () => void stop();
+ signal?.addEventListener("abort", onAbort, { once: true });
+
+ try {
+ await waitForReady({
+ baseUrl,
+ process: child,
+ signal,
+ outputTail: () => tail,
+ spawnError: () => startError,
+ });
+ logger.info(`Isolated E2E server ready on port ${port}`);
+ return {
+ baseUrl,
+ process: child,
+ stop: async () => {
+ signal?.removeEventListener("abort", onAbort);
+ await stop();
+ },
+ };
+ } catch (error) {
+ signal?.removeEventListener("abort", onAbort);
+ await stop();
+ throw error;
+ }
+}
+
+export async function startE2eTestRuntime(
+ options: Parameters[0],
+): Promise {
+ let lastError: unknown;
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ return await startE2eTestRuntimeOnce(options);
+ } catch (error) {
+ lastError = error;
+ const message = error instanceof Error ? error.message : String(error);
+ if (!/EADDRINUSE|address already in use/i.test(message)) throw error;
+ options.onOutput?.(
+ "[test server] The selected port was taken; retrying with another port…\n",
+ );
+ }
+ }
+ throw lastError;
+}
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
new file mode 100644
index 0000000000..0626becee8
--- /dev/null
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -0,0 +1,156 @@
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/paths/paths", () => ({ getUserDataPath: vi.fn() }));
+
+import { getUserDataPath } from "@/paths/paths";
+import {
+ createE2eTestWorkspace,
+ retainE2eTestArtifacts,
+ rewriteE2eArtifactPath,
+ shouldCopyE2eWorkspacePath,
+} from "./e2e_test_workspace";
+
+const roots: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ roots
+ .splice(0)
+ .map((root) => fs.rm(root, { recursive: true, force: true })),
+ );
+});
+
+async function tempRoot() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-e2e-workspace-"));
+ roots.push(root);
+ return root;
+}
+
+describe("E2E test workspace", () => {
+ it("copies current source while excluding heavyweight roots", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "src"), { recursive: true });
+ await fs.mkdir(path.join(appPath, "node_modules", "pkg"), {
+ recursive: true,
+ });
+ await fs.mkdir(path.join(appPath, ".git"), { recursive: true });
+ await fs.writeFile(path.join(appPath, "src", "new.ts"), "uncommitted");
+ await fs.writeFile(path.join(appPath, ".env.local"), "REAL=1\n");
+ await fs.writeFile(path.join(appPath, "node_modules", "pkg", "x"), "x");
+
+ const workspace = await createE2eTestWorkspace({ appId: 7, appPath });
+ expect(
+ await fs.readFile(
+ path.join(workspace.workspacePath, "src", "new.ts"),
+ "utf8",
+ ),
+ ).toBe("uncommitted");
+ expect(
+ await fs.readFile(
+ path.join(workspace.workspacePath, ".env.local"),
+ "utf8",
+ ),
+ ).toBe("REAL=1\n");
+ await fs.writeFile(
+ path.join(workspace.workspacePath, "src", "new.ts"),
+ "sandbox-only",
+ );
+ expect(await fs.readFile(path.join(appPath, "src", "new.ts"), "utf8")).toBe(
+ "uncommitted",
+ );
+ const nodeModulesStat = await fs.lstat(
+ path.join(workspace.workspacePath, "node_modules"),
+ );
+ expect(nodeModulesStat.isDirectory()).toBe(true);
+ expect(nodeModulesStat.isSymbolicLink()).toBe(false);
+ await expect(
+ fs.stat(path.join(workspace.workspacePath, ".git")),
+ ).rejects.toThrow();
+
+ await workspace.dispose();
+ await expect(fs.stat(workspace.workspacePath)).rejects.toThrow();
+ });
+
+ it.runIf(process.platform !== "win32")(
+ "keeps pnpm dependency realpaths inside the sandbox",
+ async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ vi.mocked(getUserDataPath).mockReturnValue(path.join(root, "user-data"));
+ const packageStore = path.join(
+ appPath,
+ "node_modules",
+ ".pnpm",
+ "nitro@3",
+ "node_modules",
+ "nitro",
+ );
+ await fs.mkdir(packageStore, { recursive: true });
+ await fs.writeFile(path.join(packageStore, "package.json"), "{}");
+ await fs.symlink(
+ path.join(".pnpm", "nitro@3", "node_modules", "nitro"),
+ path.join(appPath, "node_modules", "nitro"),
+ "dir",
+ );
+
+ const workspace = await createE2eTestWorkspace({ appId: 8, appPath });
+ const sandboxNodeModules = path.join(
+ workspace.workspacePath,
+ "node_modules",
+ );
+ const nitroRealpath = await fs.realpath(
+ path.join(sandboxNodeModules, "nitro"),
+ );
+
+ expect(path.relative(sandboxNodeModules, nitroRealpath)).not.toMatch(
+ /^\.\./,
+ );
+ expect(nitroRealpath).not.toContain(path.join(appPath, "node_modules"));
+ },
+ );
+
+ it("retains and rewrites screenshot artifacts before disposal", async () => {
+ const root = await tempRoot();
+ const workspacePath = path.join(root, "workspace");
+ const artifactPath = path.join(root, "artifacts");
+ const screenshot = path.join(workspacePath, "test-results", "shot.png");
+ await fs.mkdir(path.dirname(screenshot), { recursive: true });
+ await fs.writeFile(screenshot, "png");
+
+ await retainE2eTestArtifacts({ workspacePath, artifactPath });
+ expect(
+ rewriteE2eArtifactPath(screenshot, workspacePath, artifactPath),
+ ).toBe(path.join(artifactPath, "test-results", "shot.png"));
+ expect(
+ await fs.readFile(
+ path.join(artifactPath, "test-results", "shot.png"),
+ "utf8",
+ ),
+ ).toBe("png");
+ });
+
+ it("uses a root-based exclusion policy", () => {
+ const appPath = path.resolve("app");
+ expect(
+ shouldCopyE2eWorkspacePath(appPath, path.join(appPath, "src", "a.ts")),
+ ).toBe(true);
+ expect(
+ shouldCopyE2eWorkspacePath(
+ appPath,
+ path.join(appPath, "node_modules", "x"),
+ ),
+ ).toBe(false);
+ expect(
+ shouldCopyE2eWorkspacePath(
+ appPath,
+ path.join(appPath, "test-results", "x"),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
new file mode 100644
index 0000000000..1045f98abc
--- /dev/null
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -0,0 +1,192 @@
+import { constants as fsConstants, promises as fs } from "node:fs";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import log from "electron-log";
+
+import { getUserDataPath } from "@/paths/paths";
+
+const logger = log.scope("e2e_test_workspace");
+
+const EXCLUDED_ROOTS = new Set([
+ ".git",
+ "node_modules",
+ "dist",
+ "build",
+ "out",
+ ".vite",
+ ".next",
+ ".nuxt",
+ ".svelte-kit",
+ ".turbo",
+ ".cache",
+ "test-results",
+ "playwright-report",
+ "coverage",
+]);
+
+export const E2E_TEST_SANDBOX_DIR = "test-sandboxes";
+export const E2E_TEST_ARTIFACT_DIR = "test-artifacts";
+
+export interface E2eTestWorkspace {
+ workspacePath: string;
+ artifactPath: string;
+ dispose(): Promise;
+}
+
+export function shouldCopyE2eWorkspacePath(
+ appPath: string,
+ candidatePath: string,
+): boolean {
+ const relative = path.relative(appPath, candidatePath);
+ if (!relative) return true;
+ const [root] = relative.split(path.sep);
+ return root !== ".DS_Store" && !EXCLUDED_ROOTS.has(root);
+}
+
+function assertOwnedPath(root: string, candidate: string): void {
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
+ throw new Error(`Refusing to remove path outside the E2E workspace root.`);
+ }
+}
+
+async function copyNodeModules(
+ appPath: string,
+ workspacePath: string,
+ signal?: AbortSignal,
+) {
+ const source = path.join(appPath, "node_modules");
+ try {
+ const stat = await fs.stat(source);
+ if (!stat.isDirectory()) throw new Error("not a directory");
+ } catch {
+ throw new Error(
+ "The app's dependencies are not installed. Start the app successfully before running tests.",
+ );
+ }
+
+ // Do not link the node_modules root to the real app. Vite resolves that root
+ // symlink before applying its filesystem allowlist; Nitro's server entry then
+ // appears to live outside the sandbox and fails with ERR_LOAD_URL. A reflink
+ // keeps package files copy-on-write while preserving pnpm's *relative* links
+ // inside a sandbox-local dependency tree.
+ await fs.cp(source, path.join(workspacePath, "node_modules"), {
+ recursive: true,
+ verbatimSymlinks: true,
+ ...(process.platform === "win32"
+ ? {}
+ : { mode: fsConstants.COPYFILE_FICLONE }),
+ filter: () => !signal?.aborted,
+ });
+ if (signal?.aborted) throw new Error("Test run stopped.");
+}
+
+export async function createE2eTestWorkspace({
+ appId,
+ appPath,
+ signal,
+ onProgress,
+}: {
+ appId: number;
+ appPath: string;
+ signal?: AbortSignal;
+ onProgress?: (message: string) => void;
+}): Promise {
+ if (signal?.aborted) throw new Error("Test run stopped.");
+
+ const sandboxRoot = path.join(getUserDataPath(), E2E_TEST_SANDBOX_DIR);
+ const artifactRoot = path.join(getUserDataPath(), E2E_TEST_ARTIFACT_DIR);
+ await Promise.all([
+ fs.mkdir(sandboxRoot, { recursive: true }),
+ fs.mkdir(artifactRoot, { recursive: true }),
+ ]);
+ const oldArtifacts = await fs.readdir(artifactRoot, { withFileTypes: true });
+ await Promise.all(
+ oldArtifacts
+ .filter(
+ (entry) => entry.isDirectory() && entry.name.startsWith(`${appId}-`),
+ )
+ .map((entry) =>
+ fs.rm(path.join(artifactRoot, entry.name), {
+ recursive: true,
+ force: true,
+ }),
+ ),
+ );
+
+ const runName = `${appId}-${Date.now()}-${randomUUID()}`;
+ const workspacePath = path.join(sandboxRoot, runName);
+ const artifactPath = path.join(artifactRoot, runName);
+ assertOwnedPath(sandboxRoot, workspacePath);
+ assertOwnedPath(artifactRoot, artifactPath);
+
+ let disposed = false;
+ const dispose = async () => {
+ if (disposed) return;
+ disposed = true;
+ assertOwnedPath(sandboxRoot, workspacePath);
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ };
+
+ try {
+ await fs.cp(appPath, workspacePath, {
+ recursive: true,
+ verbatimSymlinks: true,
+ ...(process.platform === "win32"
+ ? {}
+ : { mode: fsConstants.COPYFILE_FICLONE }),
+ filter: (candidatePath) => {
+ if (signal?.aborted) return false;
+ return shouldCopyE2eWorkspacePath(appPath, candidatePath);
+ },
+ });
+ if (signal?.aborted) throw new Error("Test run stopped.");
+ onProgress?.("Cloning installed dependencies into the test workspace…\n");
+ await copyNodeModules(appPath, workspacePath, signal);
+ return { workspacePath, artifactPath, dispose };
+ } catch (error) {
+ await dispose();
+ throw error;
+ }
+}
+
+export async function retainE2eTestArtifacts({
+ workspacePath,
+ artifactPath,
+}: Pick): Promise {
+ const source = path.join(workspacePath, "test-results");
+ try {
+ if (!(await fs.stat(source)).isDirectory()) return;
+ } catch {
+ return;
+ }
+ await fs.rm(artifactPath, { recursive: true, force: true });
+ await fs.mkdir(artifactPath, { recursive: true });
+ await fs.cp(source, path.join(artifactPath, "test-results"), {
+ recursive: true,
+ verbatimSymlinks: false,
+ });
+}
+
+export function rewriteE2eArtifactPath(
+ screenshotPath: string | undefined,
+ workspacePath: string,
+ artifactPath: string,
+): string | undefined {
+ if (!screenshotPath) return undefined;
+ const absolute = path.isAbsolute(screenshotPath)
+ ? path.resolve(screenshotPath)
+ : path.resolve(workspacePath, screenshotPath);
+ const relative = path.relative(workspacePath, absolute);
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
+ return path.join(artifactPath, relative);
+}
+
+export async function reconcileOrphanE2eTestWorkspaces(): Promise {
+ const sandboxRoot = path.join(getUserDataPath(), E2E_TEST_SANDBOX_DIR);
+ try {
+ await fs.rm(sandboxRoot, { recursive: true, force: true });
+ } catch (error) {
+ logger.warn(`Failed to remove abandoned E2E test workspaces: ${error}`);
+ }
+}
diff --git a/src/ipc/services/isolated_test_db.test.ts b/src/ipc/services/isolated_test_db.test.ts
index 0df5d3b38e..a1de7a0419 100644
--- a/src/ipc/services/isolated_test_db.test.ts
+++ b/src/ipc/services/isolated_test_db.test.ts
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createTempTestBranch: vi.fn(),
+ markTestBranchCleanupOnly: vi.fn().mockResolvedValue(undefined),
// The mark-then-delete tail lives in `neon_test_branch` and is tested there
// (`markAndDeleteTempTestBranch`), including the ordering it depends on. What
// teardown owes it is the branch it actually created — the app row it holds is
// stale by this point — so that is what these tests pin.
markAndDeleteTempTestBranch: vi.fn().mockResolvedValue(undefined),
createNeonTestAccount: vi.fn(),
+ ensureNeonAuthTrustedOrigin: vi.fn().mockResolvedValue(null),
createTempTestUser: vi.fn(),
deleteTempTestUser: vi.fn().mockResolvedValue(undefined),
checkRls: vi.fn().mockResolvedValue({ tablesWithoutRls: [] }),
@@ -44,11 +46,16 @@ const mocks = vi.hoisted(() => ({
vi.mock("../utils/neon_test_branch", () => ({
createTempTestBranch: mocks.createTempTestBranch,
+ markTestBranchCleanupOnly: mocks.markTestBranchCleanupOnly,
markAndDeleteTempTestBranch: mocks.markAndDeleteTempTestBranch,
}));
vi.mock("../utils/neon_test_account", () => ({
createNeonTestAccount: mocks.createNeonTestAccount,
}));
+vi.mock("../utils/neon_utils", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ensureNeonAuthTrustedOrigin: mocks.ensureNeonAuthTrustedOrigin,
+}));
vi.mock("../../supabase_admin/supabase_context", () => ({
getPublishableKey: mocks.getPublishableKey,
}));
@@ -206,6 +213,7 @@ describe("prepareIsolatedTestDatabase — Supabase test-user path", () => {
DYAD_TEST_SUPABASE_URL: "https://sb-1.supabase.co",
});
expect(prepared.infraError).toBeUndefined();
+ expect(prepared.authorizeRuntimeOrigin).toBeUndefined();
await prepared.teardown();
expect(mocks.deleteTempTestUser).toHaveBeenCalledWith(
@@ -266,6 +274,7 @@ describe("prepareIsolatedTestDatabase — non-Neon paths", () => {
});
expect(prepared.isolation).toEqual({ mode: "none" });
expect(prepared.infraError).toBeUndefined();
+ expect(prepared.authorizeRuntimeOrigin).toBeUndefined();
});
it("discloses for non-host runtimes on a Neon app (no branch created)", async () => {
@@ -281,6 +290,40 @@ describe("prepareIsolatedTestDatabase — non-Neon paths", () => {
});
describe("prepareIsolatedTestDatabase — Neon happy path", () => {
+ it("targets a sandbox without restarting or marking the real env as swapped", async () => {
+ mocks.createTempTestBranch.mockResolvedValue({
+ branchId: "test-br",
+ databaseUrl: "postgres://temp",
+ neonAuthBaseUrl: "https://auth",
+ });
+
+ const prepared = await prepareIsolatedTestDatabase({
+ app: makeApp({ neonProjectId: "proj-1" }),
+ emit,
+ runtimeMode: "host",
+ appPathOverride: "/sandboxes/run-1",
+ restartApp: false,
+ });
+
+ expect(prepared.infraError).toBeUndefined();
+ expect(mocks.updateNeonEnvVars).toHaveBeenCalledWith(
+ expect.objectContaining({
+ appPath: "/sandboxes/run-1",
+ connectionUri: "postgres://temp",
+ }),
+ );
+ expect(mocks.markTestBranchCleanupOnly).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1 }),
+ "test-br",
+ );
+ expect(mocks.executeApp).not.toHaveBeenCalled();
+ await prepared.teardown();
+ expect(mocks.markAndDeleteTempTestBranch).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1 }),
+ "test-br",
+ );
+ });
+
it("checks the direct dev server instead of the HTML-rewriting proxy", async () => {
mocks.createTempTestBranch.mockResolvedValue({
branchId: "test-br",
@@ -467,6 +510,12 @@ describe("prepareIsolatedTestDatabase — auth provisioning", () => {
email: "neon-test@dyad.test",
password: "neon-pw",
});
+ await prepared.authorizeRuntimeOrigin?.("http://127.0.0.1:49999");
+ expect(mocks.ensureNeonAuthTrustedOrigin).toHaveBeenCalledWith({
+ projectId: "proj-1",
+ branchId: "test-br",
+ origin: "http://127.0.0.1:49999",
+ });
} finally {
fetchSpy.mockRestore();
}
@@ -514,6 +563,7 @@ describe("prepareIsolatedTestDatabase — auth provisioning", () => {
expect(mocks.createNeonTestAccount).not.toHaveBeenCalled();
expect(prepared.authSetup).toBeUndefined();
+ expect(prepared.authorizeRuntimeOrigin).toBeUndefined();
} finally {
fetchSpy.mockRestore();
}
diff --git a/src/ipc/services/isolated_test_db.ts b/src/ipc/services/isolated_test_db.ts
index c3c2bc4bde..749f634fc2 100644
--- a/src/ipc/services/isolated_test_db.ts
+++ b/src/ipc/services/isolated_test_db.ts
@@ -5,6 +5,7 @@ import { getDyadAppPath } from "../../paths/paths";
import { apps } from "../../db/schema";
import {
createTempTestBranch,
+ markTestBranchCleanupOnly,
markAndDeleteTempTestBranch,
} from "../utils/neon_test_branch";
import { createNeonTestAccount } from "../utils/neon_test_account";
@@ -22,6 +23,7 @@ import {
updateNeonEnvVars,
} from "../utils/app_env_var_utils";
import { detectFrameworkType } from "../utils/framework_utils";
+import { ensureNeonAuthTrustedOrigin } from "../utils/neon_utils";
import { runningApps, stopAppByInfo } from "../utils/process_manager";
import { cleanUpPort, executeApp } from "./app_runtime_service";
import { appRunActorService } from "./app_run_actor_service";
@@ -92,6 +94,12 @@ export interface PreparedIsolation {
* failed. Never contains privileged keys.
*/
authSetup?: IsolationAuthSetup;
+ /**
+ * Authorize the run-scoped server origin with the isolated auth provider.
+ * Only Neon Auth isolation supplies this; the E2E runner calls it after the
+ * server chooses its port and before Playwright sends any requests.
+ */
+ authorizeRuntimeOrigin?: (origin: string) => Promise;
teardown: (options?: TeardownOptions) => Promise;
}
@@ -123,11 +131,17 @@ export async function prepareIsolatedTestDatabase({
emit,
runtimeMode,
signal,
+ appPathOverride,
+ restartApp = true,
}: {
app: AppRow;
emit: EmitOutput;
runtimeMode: string;
signal?: AbortSignal;
+ /** E2E-only sandbox path. The recorder deliberately omits this. */
+ appPathOverride?: string;
+ /** E2E sandboxes start their own runtime after isolation is prepared. */
+ restartApp?: boolean;
}): Promise {
// Supabase: isolate via a throwaway, RLS-scoped test user.
if (app.supabaseProjectId) {
@@ -135,7 +149,8 @@ export async function prepareIsolatedTestDatabase({
}
// No Neon project → nothing to isolate.
- if (!app.neonProjectId) {
+ const neonProjectId = app.neonProjectId;
+ if (!neonProjectId) {
return { isolation: { mode: "none" }, teardown: NOOP_TEARDOWN };
}
@@ -150,7 +165,7 @@ export async function prepareIsolatedTestDatabase({
};
}
- const appPath = getDyadAppPath(app.path);
+ const appPath = appPathOverride ?? getDyadAppPath(app.path);
let envSnapshot: string | null = null;
let envModified = false;
let branchId: string | undefined;
@@ -178,7 +193,7 @@ export async function prepareIsolatedTestDatabase({
"setup",
);
}
- if (envRestored && !options.skipRestart) {
+ if (envRestored && restartApp && !options.skipRestart) {
try {
await restartAppInPlace({ app, appPath });
} catch (error) {
@@ -222,6 +237,12 @@ export async function prepareIsolatedTestDatabase({
// 2. Create the throwaway branch (off the preview branch, CoW).
const branch = await createTempTestBranch(app);
branchId = branch.branchId;
+ // The E2E sandbox never points the real app env at this branch. Persist the
+ // cleanup-only form immediately so crash/startup reconciliation cannot
+ // mistake this run for the recorder's real-env swap.
+ if (!restartApp) {
+ await markTestBranchCleanupOnly(app, branchId);
+ }
// 3. Point the app at the throwaway branch. Mark the env as modified before
// the write so a partial failure still triggers a restore in teardown.
@@ -237,9 +258,11 @@ export async function prepareIsolatedTestDatabase({
// 4. Restart so the dev server reads the throwaway branch, then wait until
// it's serving again before Playwright points at it.
- emit("Starting the app against the isolated test database…\n", "setup");
- const processId = await restartAppInPlace({ app, appPath });
- await waitForServerReady(app.id, signal, processId);
+ if (restartApp) {
+ emit("Starting the app against the isolated test database…\n", "setup");
+ const processId = await restartAppInPlace({ app, appPath });
+ await waitForServerReady(app.id, signal, processId);
+ }
// 5. If the app uses Neon Auth, provision a throwaway Better Auth account on
// the branch so auth-gated recordings/tests can sign in. Best-effort: on
@@ -285,6 +308,15 @@ export async function prepareIsolatedTestDatabase({
isolation: { mode: "neon-branch" },
testCredentials,
authSetup,
+ authorizeRuntimeOrigin: branch.neonAuthBaseUrl
+ ? async (origin) => {
+ await ensureNeonAuthTrustedOrigin({
+ projectId: neonProjectId,
+ branchId: branch.branchId,
+ origin,
+ });
+ }
+ : undefined,
teardown,
};
} catch (error) {
diff --git a/src/ipc/utils/neon_utils.test.ts b/src/ipc/utils/neon_utils.test.ts
index f785f62736..61fb1f1dc1 100644
--- a/src/ipc/utils/neon_utils.test.ts
+++ b/src/ipc/utils/neon_utils.test.ts
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => {
where,
generateCookieSecret: vi.fn(() => "generated".padEnd(64, "0")),
readEnvVarsOrEmpty: vi.fn(),
+ getNeonClient: vi.fn(),
};
});
@@ -37,7 +38,7 @@ vi.mock("@/ipc/utils/app_env_var_utils", () => ({
}));
vi.mock("@/neon_admin/neon_management_client", () => ({
- getNeonClient: vi.fn(),
+ getNeonClient: mocks.getNeonClient,
}));
vi.mock("@/neon_admin/neon_context", () => ({
@@ -56,6 +57,7 @@ vi.mock("electron-log", () => ({
}));
import {
+ ensureNeonAuthTrustedOrigin,
getOrCreateNeonAuthCookieSecret,
syncActiveNeonAuthCookieSecretFromEnv,
} from "@/ipc/utils/neon_utils";
@@ -323,3 +325,65 @@ describe("syncActiveNeonAuthCookieSecretFromEnv", () => {
expect(mocks.set).not.toHaveBeenCalled();
});
});
+
+describe("ensureNeonAuthTrustedOrigin", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("preserves an HTTP loopback origin exactly", async () => {
+ const listBranchNeonAuthTrustedDomains = vi.fn().mockResolvedValue({
+ data: { domains: [] },
+ });
+ const addBranchNeonAuthTrustedDomain = vi
+ .fn()
+ .mockResolvedValue({ data: undefined });
+ mocks.getNeonClient.mockResolvedValue({
+ listBranchNeonAuthTrustedDomains,
+ addBranchNeonAuthTrustedDomain,
+ });
+
+ const result = await ensureNeonAuthTrustedOrigin({
+ projectId: "proj-1",
+ branchId: "br-test",
+ origin: "http://127.0.0.1:35129/path",
+ });
+
+ expect(result).toBe("http://127.0.0.1:35129");
+ expect(addBranchNeonAuthTrustedDomain).toHaveBeenCalledWith(
+ "proj-1",
+ "br-test",
+ {
+ domain: "http://127.0.0.1:35129",
+ auth_provider: "better_auth",
+ },
+ );
+ });
+
+ it("does not add an origin that is already trusted", async () => {
+ const listBranchNeonAuthTrustedDomains = vi.fn().mockResolvedValue({
+ data: {
+ domains: [
+ {
+ domain: "http://127.0.0.1:35129",
+ auth_provider: "better_auth",
+ },
+ ],
+ },
+ });
+ const addBranchNeonAuthTrustedDomain = vi.fn();
+ mocks.getNeonClient.mockResolvedValue({
+ listBranchNeonAuthTrustedDomains,
+ addBranchNeonAuthTrustedDomain,
+ });
+
+ const result = await ensureNeonAuthTrustedOrigin({
+ projectId: "proj-1",
+ branchId: "br-test",
+ origin: "http://127.0.0.1:35129",
+ });
+
+ expect(result).toBeNull();
+ expect(addBranchNeonAuthTrustedDomain).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/ipc/utils/neon_utils.ts b/src/ipc/utils/neon_utils.ts
index 03a3e8deeb..993cdd3199 100644
--- a/src/ipc/utils/neon_utils.ts
+++ b/src/ipc/utils/neon_utils.ts
@@ -397,6 +397,42 @@ export async function ensureNeonAuthTrustedDomain({
return toAdd;
}
+/**
+ * Registers an exact HTTP(S) origin without applying deployment-domain
+ * normalization. Run-scoped E2E servers use HTTP loopback origins, so changing
+ * their scheme to HTTPS would register a different origin and Better Auth would
+ * continue rejecting sign-in requests.
+ */
+export async function ensureNeonAuthTrustedOrigin({
+ projectId,
+ branchId,
+ origin,
+}: {
+ projectId: string;
+ branchId: string;
+ origin: string;
+}): Promise {
+ const trustedOrigin = new URL(origin).origin;
+ const neonClient = await getNeonClient();
+ const existing = await neonClient.listBranchNeonAuthTrustedDomains(
+ projectId,
+ branchId,
+ );
+ const alreadyTrusted = (existing.data?.domains ?? []).some(({ domain }) => {
+ try {
+ return new URL(domain).origin === trustedOrigin;
+ } catch {
+ return domain === trustedOrigin;
+ }
+ });
+ if (alreadyTrusted) return null;
+ await neonClient.addBranchNeonAuthTrustedDomain(projectId, branchId, {
+ domain: trustedOrigin,
+ auth_provider: NeonAuthSupportedAuthProvider.BetterAuth,
+ });
+ return trustedOrigin;
+}
+
export interface ResolvedNeonBranchEnvVars {
databaseUrl: string;
neonAuthBaseUrl?: string;
diff --git a/src/ipc/utils/playwright_bootstrap.ts b/src/ipc/utils/playwright_bootstrap.ts
index e0c49568ad..947f7d0c70 100644
--- a/src/ipc/utils/playwright_bootstrap.ts
+++ b/src/ipc/utils/playwright_bootstrap.ts
@@ -121,10 +121,9 @@ export function buildPlaywrightConfig(channel: BrowserChannel | null): string {
: `// Uses Playwright's bundled Chromium (downloaded on first run).`;
return `import { defineConfig } from "@playwright/test";
-// ${DYAD_CONFIG_SENTINEL}. The dev server is started separately by Dyad's
-// preview, so we point baseURL at the already-running proxy URL (passed via
-// env) rather than using Playwright's \`webServer\` (which would double-start
-// the app).
+// ${DYAD_CONFIG_SENTINEL}. Dyad starts an isolated test server separately, so
+// we point baseURL at its run-scoped URL (passed via env) rather than using
+// Playwright's \`webServer\` (which would double-start the app).
// ${browserNote}
export default defineConfig({
testDir: "./${E2E_TEST_DIR}",
diff --git a/src/ipc/utils/test_screenshot.ts b/src/ipc/utils/test_screenshot.ts
index d20722a0cb..5413685dd8 100644
--- a/src/ipc/utils/test_screenshot.ts
+++ b/src/ipc/utils/test_screenshot.ts
@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
+import { getUserDataPath } from "@/paths/paths";
+import { E2E_TEST_ARTIFACT_DIR } from "@/ipc/services/e2e_test_workspace";
const logger = log.scope("test_screenshot");
@@ -17,7 +19,8 @@ const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
* Read a Playwright failure screenshot as a PNG data URL, enforcing the same
* containment guards as the `tests:screenshot` IPC handler: PNG-only, resolved
* through symlinks, and inside the app's `test-results/` directory. Returns
- * null if the path is missing, not a PNG, or escapes the app dir.
+ * null if the path is missing, not a PNG, or escapes both the app and Dyad's
+ * retained per-run artifact root.
*
* Shared by the IPC handler (renderer thumbnails) and the agent's run_tests
* tool (attaching a failure screenshot to the model).
@@ -25,6 +28,7 @@ const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export async function readTestScreenshotDataUrl(
appPath: string,
screenshotPath: string,
+ appId?: number,
): Promise {
// Playwright reports absolute paths, but resolve relative ones against the
// app dir just in case.
@@ -42,12 +46,20 @@ export async function readTestScreenshotDataUrl(
// read escapes. Resolve the app path too so ancestor symlinks (e.g.
// /var -> /private/var on macOS) don't leave a `..` prefix.
let realAppPath: string;
+ let realArtifactRoot: string | undefined;
let realPath: string;
try {
[realAppPath, realPath] = await Promise.all([
fs.promises.realpath(appPath),
fs.promises.realpath(resolved),
]);
+ try {
+ realArtifactRoot = await fs.promises.realpath(
+ path.join(getUserDataPath(), E2E_TEST_ARTIFACT_DIR),
+ );
+ } catch {
+ // No retained sandbox artifacts yet.
+ }
} catch (error) {
logger.warn(`Failed to resolve screenshot path ${resolved}: ${error}`);
return null;
@@ -57,17 +69,33 @@ export async function readTestScreenshotDataUrl(
if (path.extname(realPath).toLowerCase() !== ".png") {
return null;
}
- const rel = path.relative(realAppPath, realPath);
+ const appRelative = path.relative(realAppPath, realPath);
const insideApp =
- rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
- if (!insideApp) {
+ appRelative !== "" &&
+ !appRelative.startsWith("..") &&
+ !path.isAbsolute(appRelative);
+ const artifactRelative = realArtifactRoot
+ ? path.relative(realArtifactRoot, realPath)
+ : "";
+ const insideArtifacts =
+ artifactRelative !== "" &&
+ !artifactRelative.startsWith("..") &&
+ !path.isAbsolute(artifactRelative);
+ if (!insideApp && !insideArtifacts) {
return null;
}
// Only serve screenshots under `test-results/`, not any PNG in the app. Use
// split (not a string prefix) so a sibling like `test-results-foo/` can't
// slip through.
- const [firstSegment] = rel.split(path.sep);
- if (firstSegment !== "test-results") {
+ const segments = (insideApp ? appRelative : artifactRelative).split(path.sep);
+ const testResultsSegment = insideApp ? segments[0] : segments[1];
+ if (
+ insideArtifacts &&
+ (appId === undefined || !segments[0].startsWith(`${appId}-`))
+ ) {
+ return null;
+ }
+ if (testResultsSegment !== "test-results") {
return null;
}
let handle: fs.promises.FileHandle | undefined;
diff --git a/src/main.ts b/src/main.ts
index f7b6607e30..129908a31d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -49,6 +49,8 @@ import { apps } from "./db/schema";
import { eq } from "drizzle-orm";
import { reconcileOrphanTestBranches } from "./ipc/utils/neon_test_branch";
import { reconcileOrphanTestUsers } from "./ipc/utils/supabase_test_user";
+import { reconcileOrphanE2eTestWorkspaces } from "./ipc/services/e2e_test_workspace";
+import { stopAllAppTestsSync } from "./ipc/handlers/tests_handlers";
import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
import {
@@ -457,6 +459,7 @@ export async function onReady() {
// must not block startup.
void reconcileOrphanTestBranches();
void reconcileOrphanTestUsers();
+ void reconcileOrphanE2eTestWorkspaces();
// Cleanup old ai_messages_json entries to prevent database bloat
cleanupOldAiMessagesJson();
@@ -1682,6 +1685,7 @@ app.on("will-quit", () => {
// Synchronously send kill signals to all running apps (fire-and-forget).
// We cannot use async/await here because Electron won't wait for it.
+ stopAllAppTestsSync();
stopAllAppsSync();
// Stop performance monitoring and capture final metrics
diff --git a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
index a736d585f9..f5378bc4bf 100644
--- a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
+++ b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
@@ -409,6 +409,7 @@ async function attachFailureArtifacts(
const dataUrl = await readTestScreenshotDataUrl(
ctx.appPath,
shot.screenshotPath,
+ ctx.appId,
);
if (dataUrl) {
ctx.appendUserMessage([
From 6cad874b60e8c00dd48cdb0fd35dc8f8b014ba8f Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Sun, 23 Aug 2026 19:42:45 +0000
Subject: [PATCH 2/9] Address PR review comments
- Evaluate the testingEnabled and runtime-mode guards before the
prepare-e2e-test-workspace stage, so a refused run no longer mutates the
user's real project via ensurePlaywrightBootstrap or takes a multi-gigabyte
snapshot first.
- Restore E2E testing for Docker/cloud runtime: those runtimes fall back to the
pre-sandbox path (bootstrap + normal preview) with the missing runtime
isolation disclosed on the result. Only Neon apps are refused, because
without a sandbox the run would hit the user's real database.
- Tree-kill run-scoped children synchronously on quit via a new process
registry. Aborting alone routed into the async killProcess/tree-kill path,
which will-quit never awaits, leaving the sandbox server holding its port and
its cwd under /test-sandboxes.
- Scope the startup sandbox sweep: delete run directories individually and skip
any run this process still owns, instead of removing the shared root while a
freshly started run is mid-copy.
- Run custom start commands verbatim instead of appending `-- --port`, and
treat a command as custom only when both installCommand and startCommand are
set, matching getCommand in app_runtime_service. A custom command that never
binds the run-scoped port now gets a {port} hint instead of a bare timeout.
- Select the sandbox package manager with getPackageManagerSignal /
choosePackageManagerFromSignal so a pnpm lockfile falls back to npm when pnpm
is missing or too old, exactly as the normal preview does.
- Thread the stage-1 bootstrap's `installed` flag into runAppTestsCore so the
e2e_tests_run `first_run` telemetry property stops always reporting false.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
---
src/ipc/handlers/tests_handlers.test.ts | 152 +++++++++++
src/ipc/handlers/tests_handlers.ts | 247 +++++++++++++++---
.../e2e_test_process_registry.test.ts | 63 +++++
src/ipc/services/e2e_test_process_registry.ts | 58 ++++
src/ipc/services/e2e_test_runtime.test.ts | 75 +++++-
src/ipc/services/e2e_test_runtime.ts | 76 +++++-
src/ipc/services/e2e_test_workspace.test.ts | 27 ++
src/ipc/services/e2e_test_workspace.ts | 45 +++-
8 files changed, 691 insertions(+), 52 deletions(-)
create mode 100644 src/ipc/services/e2e_test_process_registry.test.ts
create mode 100644 src/ipc/services/e2e_test_process_registry.ts
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 54c7ed934a..3c93ec21dc 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -7,6 +7,8 @@ import { eq } from "drizzle-orm";
import { DyadErrorKind } from "@/errors/dyad_error";
import type { RemoveFileAndCommitResult } from "../services/git_service";
import { apps } from "@/db/schema";
+import { DEFAULT_SETTINGS } from "@/main/settings";
+import { runningApps } from "../utils/process_manager";
import {
appOperationCoordinator,
type AppOperationRequest,
@@ -74,6 +76,8 @@ vi.mock("../services/git_service", () => ({
const queueCloudSandboxSnapshotSyncMock = vi.hoisted(() => vi.fn());
const prepareIsolatedTestDatabaseMock = vi.hoisted(() => vi.fn());
+const readSettingsMock = vi.hoisted(() => vi.fn());
+const sendTelemetryEventMock = vi.hoisted(() => vi.fn());
const ensurePlaywrightBootstrapMock = vi.hoisted(() => vi.fn());
const createE2eTestWorkspaceMock = vi.hoisted(() => vi.fn());
const startE2eTestRuntimeMock = vi.hoisted(() => vi.fn());
@@ -128,6 +132,14 @@ vi.mock("../services/e2e_test_workspace", async (importOriginal) => {
vi.mock("../services/e2e_test_runtime", () => ({
startE2eTestRuntime: startE2eTestRuntimeMock,
}));
+vi.mock("@/main/settings", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, readSettings: readSettingsMock };
+});
+vi.mock("../utils/telemetry", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, sendTelemetryEvent: sendTelemetryEventMock };
+});
vi.mock("../utils/spawn_streaming", () => ({
spawnStreaming: spawnStreamingMock,
}));
@@ -154,6 +166,11 @@ describe("tests handlers", () => {
removeFileAndCommitMock.mockClear();
queueCloudSandboxSnapshotSyncMock.mockClear();
prepareIsolatedTestDatabaseMock.mockReset();
+ readSettingsMock.mockReset();
+ readSettingsMock.mockImplementation(() =>
+ structuredClone(DEFAULT_SETTINGS),
+ );
+ sendTelemetryEventMock.mockReset();
ensurePlaywrightBootstrapMock.mockReset();
ensurePlaywrightBootstrapMock.mockResolvedValue({ installed: false });
createE2eTestWorkspaceMock.mockReset();
@@ -410,6 +427,141 @@ describe("tests handlers", () => {
expect(teardown).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
});
+
+ it("refuses a testing-disabled app before bootstrapping or copying", async () => {
+ const appId = seedApp("app");
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(/Testing isn't enabled/i);
+ // Bootstrap writes into the user's real project and the snapshot copies
+ // the whole app; a refusal must stay side-effect-free.
+ expect(ensurePlaywrightBootstrapMock).not.toHaveBeenCalled();
+ expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
+ });
+
+ it("reports the first run in telemetry when bootstrap installed Playwright", async () => {
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ ensurePlaywrightBootstrapMock.mockResolvedValue({ installed: true });
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "none" },
+ teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ });
+ spawnStreamingMock.mockImplementation(
+ async ({ cwd }: { cwd: string }) => {
+ const reportPath = path.join(cwd, "test-results", "results.json");
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
+ fs.writeFileSync(
+ reportPath,
+ JSON.stringify({
+ suites: [
+ {
+ file: "e2e-tests/a.spec.ts",
+ specs: [
+ {
+ title: "works",
+ file: "e2e-tests/a.spec.ts",
+ line: 1,
+ tests: [{ status: "expected", results: [{}] }],
+ },
+ ],
+ },
+ ],
+ }),
+ );
+ return {
+ code: 0,
+ stdout: "",
+ stderr: "",
+ aborted: false,
+ timedOut: false,
+ };
+ },
+ );
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError).toBeUndefined();
+ expect(sendTelemetryEventMock).toHaveBeenCalledWith(
+ "e2e_tests_run",
+ expect.objectContaining({ first_run: true }),
+ );
+ });
+
+ describe("non-host runtime", () => {
+ afterEach(() => {
+ runningApps.clear();
+ });
+
+ function seedRunningApp(name: string): number {
+ const appId = seedApp(name);
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ readSettingsMock.mockImplementation(() => ({
+ ...structuredClone(DEFAULT_SETTINGS),
+ runtimeMode2: "docker",
+ }));
+ runningApps.set(appId, { proxyUrl: "http://localhost:32100" } as any);
+ return appId;
+ }
+
+ it("keeps running against the normal preview and discloses the gap", async () => {
+ const appId = seedRunningApp("app");
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "supabase-test-user" },
+ teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ });
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(spawnStreamingMock).toHaveBeenCalled();
+ expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
+ expect(startE2eTestRuntimeMock).not.toHaveBeenCalled();
+ expect(result.isolation).toMatchObject({
+ mode: "supabase-test-user",
+ reason: expect.stringMatching(/docker runtime/i),
+ });
+ });
+
+ it("refuses a Neon app rather than testing against the real database", async () => {
+ const appId = seedRunningApp("app");
+ harness.db
+ .update(apps)
+ .set({ neonProjectId: "neon-project" })
+ .where(eq(apps.id, appId))
+ .run();
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(/real database/i);
+ expect(prepareIsolatedTestDatabaseMock).not.toHaveBeenCalled();
+ expect(spawnStreamingMock).not.toHaveBeenCalled();
+ });
+ });
});
describe("stop progress events", () => {
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 41a320e9ff..417092e686 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -53,8 +53,15 @@ import { parseTestCases } from "../utils/parse_test_cases";
import { getPackageManagerCommandEnv } from "../utils/socket_firewall";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
import { sendTelemetryEvent } from "../utils/telemetry";
-import type { PreparedIsolation } from "../services/isolated_test_db";
+import {
+ prepareIsolatedTestDatabase,
+ type PreparedIsolation,
+} from "../services/isolated_test_db";
import { prepareE2eTestDataIsolation } from "../services/e2e_test_data_isolation";
+import {
+ stopE2eTestProcessesSync,
+ trackE2eTestProcess,
+} from "../services/e2e_test_process_registry";
import {
createE2eTestWorkspace,
retainE2eTestArtifacts,
@@ -203,6 +210,12 @@ export function stopAllAppTestsSync(): void {
for (const run of testRunControllers.values()) {
run.controller.abort();
}
+ // Aborting is not enough here. The abort listeners route into `killProcess`,
+ // which tree-kills asynchronously, and `will-quit` does not await async work.
+ // Tree-kill the run-scoped children synchronously too, or a sandbox dev
+ // server survives the quit holding its port and its cwd under
+ // `/test-sandboxes`.
+ stopE2eTestProcessesSync();
}
export async function endTestsForApp(appId: number): Promise {
@@ -260,6 +273,12 @@ export interface RunAppTestsCoreOptions {
baseUrl?: string;
/** Bootstrap is performed against the real app before sandbox creation. */
skipBootstrap?: boolean;
+ /**
+ * Result of that earlier bootstrap. Threaded in with `skipBootstrap` so the
+ * `first_run` telemetry property keeps meaning "Playwright was installed by
+ * this run" instead of always reporting false for sandboxed runs.
+ */
+ bootstrapInstalled?: boolean;
/** When set, runs a single spec file (relative path); otherwise runs all. */
testFile?: string;
/**
@@ -312,6 +331,7 @@ export async function runAppTestsCore({
appPath: explicitAppPath,
baseUrl: explicitBaseUrl,
skipBootstrap = false,
+ bootstrapInstalled = false,
testFile,
testLine,
grep,
@@ -353,7 +373,8 @@ export async function runAppTestsCore({
}
// 1. Lazy bootstrap (install Playwright + browser, write config), streamed.
- let installed = false;
+ // Sandboxed runs bootstrap the real app earlier and pass the outcome in.
+ let installed = bootstrapInstalled;
if (!skipBootstrap) {
try {
const result = await ensurePlaywrightBootstrap({
@@ -443,6 +464,9 @@ export async function runAppTestsCore({
signal,
timeoutMs,
onOutput: (chunk) => emit(chunk, "running"),
+ // Quit tree-kills the runner synchronously; the signal path alone would
+ // leave a headless browser and the sandbox cwd behind.
+ onProcess: trackE2eTestProcess,
});
} catch (error) {
// A spawn failure (e.g. npx missing from PATH) rejects rather than exiting
@@ -570,6 +594,137 @@ export async function runAppTestsCore({
return { appId, results };
}
+/**
+ * Docker/cloud fallback. The run-scoped sandbox server is host-only for now, so
+ * these runtimes keep the pre-sandbox path — bootstrap and run against the
+ * normal preview — with the missing runtime isolation disclosed on the result
+ * rather than losing E2E testing entirely. Neon apps are the one exception:
+ * without a sandbox there is no throwaway branch to point the app at, so the
+ * only way to run would be against the user's real database.
+ */
+async function runTestsAgainstNormalPreview({
+ appId,
+ runtimeMode,
+ signal,
+ emit,
+ emitProgress,
+ onIsolationCleanupFailed,
+ testFile,
+ testLine,
+ grep,
+ headed,
+ parallel,
+ timeoutMs,
+}: {
+ appId: number;
+ runtimeMode: string;
+ signal: AbortSignal;
+ emit: (chunk: string, phase: "setup" | "running") => void;
+ emitProgress: (
+ state: "stopping" | "cleaning-up",
+ isolation?: TestIsolation,
+ ) => void;
+ onIsolationCleanupFailed: (failed: boolean) => void;
+ testFile?: string;
+ testLine?: number;
+ grep?: string;
+ headed?: boolean;
+ parallel?: boolean;
+ timeoutMs?: number;
+}): Promise {
+ return appOperationCoordinator.run(
+ {
+ appId,
+ operation: "run-app-tests",
+ // This path runs Playwright against the user's real working tree and the
+ // normal preview, so it claims both — unlike the sandboxed path, which
+ // releases the tree after snapshotting and never touches the preview.
+ resources: [
+ readAppResource("app-path"),
+ readAppResource("repository-ref"),
+ "repository-worktree",
+ "provider",
+ "runtime",
+ "runtime-config",
+ "test-files",
+ ],
+ allowCompatibleQueueBypass: true,
+ refuseWhenRecording: "run tests",
+ },
+ async () => {
+ const app = await getApp(appId);
+ // Supabase isolation is provider-side and works in any runtime, so only
+ // an app whose isolation depends on the Neon branch swap is refused.
+ if (!app.supabaseProjectId && app.neonProjectId) {
+ return {
+ appId,
+ results: [],
+ infraError: {
+ message: `Isolated E2E test servers aren't available in ${runtimeMode} runtime yet, and Dyad won't run Neon tests against your real database. Switch to host runtime to run tests for this app.`,
+ },
+ isolation: {
+ mode: "none" as const,
+ reason: `Sandboxed E2E execution is not available in ${runtimeMode} runtime yet.`,
+ },
+ };
+ }
+
+ let prepared: PreparedIsolation | undefined;
+ try {
+ prepared = await prepareIsolatedTestDatabase({
+ app,
+ emit,
+ runtimeMode,
+ signal,
+ });
+ // Disclose the missing runtime sandbox, without overwriting a more
+ // specific provider reason (e.g. the Supabase publishable-key hint).
+ const isolation: TestIsolation = {
+ ...prepared.isolation,
+ reason:
+ prepared.isolation.reason ??
+ `Tests run against your normal preview because isolated test servers aren't available in ${runtimeMode} runtime yet.`,
+ };
+ if (prepared.infraError) {
+ return {
+ appId,
+ results: [],
+ infraError: prepared.infraError,
+ isolation,
+ };
+ }
+ const result = await runAppTestsCore({
+ appId,
+ testFile,
+ testLine,
+ grep,
+ headed,
+ parallel,
+ signal,
+ timeoutMs,
+ onOutput: emit,
+ testEnv: prepared.testCredentials,
+ });
+ return { ...result, isolation };
+ } finally {
+ if (prepared) {
+ try {
+ if (prepared.isolation.mode !== "none") {
+ emitProgress("cleaning-up", prepared.isolation);
+ }
+ onIsolationCleanupFailed(true);
+ onIsolationCleanupFailed(!(await prepared.teardown()).envRestored);
+ } catch (error) {
+ logger.error(
+ `Failed to tear down isolated test environment for app ${appId}: ${error}`,
+ );
+ }
+ }
+ }
+ },
+ );
+}
+
export interface RunTestsWithIsolationOptions {
/**
* The invoking IPC event. Its `sender` is where `tests:output` and
@@ -770,14 +925,53 @@ export async function runAppTestsWithIsolation({
// The database lookup intentionally happens only after this run registered
// above. Keeping every await behind registration ensures a rapid second
// invocation chains behind this run instead of racing its isolation setup
- // and env-file swap. (The resolved app is re-fetched inside the lock below,
- // so this call exists only for the ordering barrier.)
- await getApp(appId);
+ // and env-file swap.
+ const guardApp = await getApp(appId);
+
+ // Decide both refusals BEFORE the workspace stage. `ensurePlaywrightBootstrap`
+ // is not read-only — it installs `@playwright/test` into the user's real
+ // project, writes Dyad's config, and can download a browser — and the
+ // snapshot then copies the whole app plus `node_modules`. Neither may run
+ // for a run that is about to be turned away.
+ if (!guardApp.testingEnabled) {
+ finalResult = {
+ appId,
+ results: [],
+ infraError: {
+ message:
+ "Testing isn't enabled for this app. Enable it in the Tests panel before running tests.",
+ },
+ };
+ return finalResult;
+ }
+
+ const runtimeMode = readSettings().runtimeMode2 ?? "host";
+ if (runtimeMode !== "host") {
+ finalResult = withIsolationCleanupWarning(
+ await runTestsAgainstNormalPreview({
+ appId,
+ runtimeMode,
+ signal: controller.signal,
+ emit,
+ emitProgress,
+ onIsolationCleanupFailed: (failed) => {
+ isolationCleanupFailed = failed;
+ },
+ testFile: normalizedTestFile ?? undefined,
+ testLine,
+ grep,
+ headed,
+ parallel,
+ timeoutMs,
+ }),
+ );
+ return finalResult;
+ }
// Bootstrap and snapshot under the real working-tree claim, then release it
// before Playwright runs so ordinary app editing can continue against the
// normal preview while this run uses its captured filesystem state.
- workspace = await appOperationCoordinator.run(
+ const prepareResult = await appOperationCoordinator.run(
{
appId,
operation: "prepare-e2e-test-workspace",
@@ -790,22 +984,26 @@ export async function runAppTestsWithIsolation({
refuseWhenRecording: "run tests",
},
async () => {
- const app = await getApp(appId);
- const realAppPath = getDyadAppPath(app.path);
- await ensurePlaywrightBootstrap({
+ const claimedApp = await getApp(appId);
+ const realAppPath = getDyadAppPath(claimedApp.path);
+ const { installed } = await ensurePlaywrightBootstrap({
appPath: realAppPath,
signal: controller.signal,
onOutput: (chunk) => emit(chunk, "setup"),
});
emit("Copying the app into an isolated test workspace…\n", "setup");
- return createE2eTestWorkspace({
- appId,
- appPath: realAppPath,
- signal: controller.signal,
- onProgress: (message) => emit(message, "setup"),
- });
+ return {
+ installed,
+ workspace: await createE2eTestWorkspace({
+ appId,
+ appPath: realAppPath,
+ signal: controller.signal,
+ onProgress: (message) => emit(message, "setup"),
+ }),
+ };
},
);
+ workspace = prepareResult.workspace;
// The live test only owns provider/test inputs. It deliberately does not
// claim the normal runtime or runtime-config: its process is run-scoped and
@@ -842,6 +1040,8 @@ export async function runAppTestsWithIsolation({
try {
const app = await getApp(appId);
+ // Re-checked under this claim: the two stages take separate claims,
+ // so testing can be turned off between the snapshot and the run.
if (!app.testingEnabled) {
return {
appId,
@@ -853,21 +1053,6 @@ export async function runAppTestsWithIsolation({
};
}
- const runtimeMode = readSettings().runtimeMode2 ?? "host";
- if (runtimeMode !== "host") {
- return {
- appId,
- results: [],
- infraError: {
- message: `Isolated E2E test servers currently require host runtime. Switch from ${runtimeMode} runtime before running tests.`,
- },
- isolation: {
- mode: "none" as const,
- reason: `Sandboxed E2E execution is not available in ${runtimeMode} runtime yet.`,
- },
- };
- }
-
// Set up isolation so the run never mutates the user's real data:
// Neon apps get a throwaway copy-on-write branch, Supabase apps get
// a throwaway RLS-scoped test user, and no-DB apps run as-is.
@@ -892,6 +1077,7 @@ export async function runAppTestsWithIsolation({
emit("Starting the isolated test server…\n", "setup");
testRuntime = await startE2eTestRuntime({
workspacePath: workspace!.workspacePath,
+ installCommand: app.installCommand,
startCommand: app.startCommand,
signal: controller.signal,
onOutput: (chunk) => emit(chunk, "setup"),
@@ -926,6 +1112,7 @@ export async function runAppTestsWithIsolation({
appPath: workspace!.workspacePath,
baseUrl: testRuntime.baseUrl,
skipBootstrap: true,
+ bootstrapInstalled: prepareResult.installed,
testFile: normalizedTestFile ?? undefined,
testLine,
grep,
diff --git a/src/ipc/services/e2e_test_process_registry.test.ts b/src/ipc/services/e2e_test_process_registry.test.ts
new file mode 100644
index 0000000000..260c6a8a5e
--- /dev/null
+++ b/src/ipc/services/e2e_test_process_registry.test.ts
@@ -0,0 +1,63 @@
+// @vitest-environment node
+
+import { EventEmitter } from "node:events";
+import type { ChildProcess } from "node:child_process";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const killProcessTreeSyncMock = vi.hoisted(() => vi.fn());
+vi.mock("@/ipc/utils/kill_process_tree_sync", () => ({
+ killProcessTreeSync: killProcessTreeSyncMock,
+}));
+
+import {
+ stopE2eTestProcessesSync,
+ trackE2eTestProcess,
+ trackedE2eTestProcessCount,
+} from "./e2e_test_process_registry";
+
+function fakeChild(pid: number | undefined): ChildProcess {
+ const child = new EventEmitter() as unknown as ChildProcess;
+ Object.assign(child, { pid, exitCode: null, signalCode: null });
+ return child;
+}
+
+describe("e2e test process registry", () => {
+ beforeEach(() => {
+ stopE2eTestProcessesSync();
+ killProcessTreeSyncMock.mockReset();
+ killProcessTreeSyncMock.mockReturnValue(true);
+ });
+
+ it("tree-kills tracked children synchronously", () => {
+ trackE2eTestProcess(fakeChild(111));
+ trackE2eTestProcess(fakeChild(222));
+
+ stopE2eTestProcessesSync();
+
+ expect(killProcessTreeSyncMock.mock.calls.map(([pid]) => pid)).toEqual([
+ 111, 222,
+ ]);
+ expect(trackedE2eTestProcessCount()).toBe(0);
+ });
+
+ it("forgets a child once it exits", () => {
+ const child = fakeChild(333);
+ trackE2eTestProcess(child);
+ child.emit("exit", 0, null);
+
+ stopE2eTestProcessesSync();
+
+ expect(killProcessTreeSyncMock).not.toHaveBeenCalled();
+ });
+
+ it("skips children that already terminated or never spawned", () => {
+ const exited = fakeChild(444);
+ Object.assign(exited, { exitCode: 0 });
+ trackE2eTestProcess(exited);
+ trackE2eTestProcess(fakeChild(undefined));
+
+ stopE2eTestProcessesSync();
+
+ expect(killProcessTreeSyncMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/ipc/services/e2e_test_process_registry.ts b/src/ipc/services/e2e_test_process_registry.ts
new file mode 100644
index 0000000000..ce7bfeaffc
--- /dev/null
+++ b/src/ipc/services/e2e_test_process_registry.ts
@@ -0,0 +1,58 @@
+import type { ChildProcess } from "node:child_process";
+import log from "electron-log";
+
+import { killProcessTreeSync } from "@/ipc/utils/kill_process_tree_sync";
+
+const logger = log.scope("e2e_test_process_registry");
+
+const runScopedProcesses = new Set();
+
+/**
+ * Track a run-scoped child (the sandbox dev server, the Playwright runner) so
+ * Electron's synchronous quit can terminate it. Returns an unregister callback;
+ * the child's own exit/error also drops it.
+ */
+export function trackE2eTestProcess(child: ChildProcess): () => void {
+ runScopedProcesses.add(child);
+ const forget = () => {
+ runScopedProcesses.delete(child);
+ };
+ child.once("exit", forget);
+ child.once("error", forget);
+ return forget;
+}
+
+/** Number of tracked children. Exposed for tests. */
+export function trackedE2eTestProcessCount(): number {
+ return runScopedProcesses.size;
+}
+
+/**
+ * Tree-kill every tracked child synchronously.
+ *
+ * Aborting the run controllers is not enough on quit: their abort path goes
+ * through `killProcess`/`tree-kill`, which spawns a helper and completes
+ * asynchronously, and Electron's `will-quit` does not await async work. A
+ * surviving sandbox server keeps holding its port and its cwd inside
+ * `/test-sandboxes`, which then makes the next launch's orphan sweep
+ * fail on Windows. `stopAllAppsSync` uses `killProcessTreeSync` for the same
+ * reason.
+ */
+export function stopE2eTestProcessesSync(): void {
+ const children = Array.from(runScopedProcesses);
+ runScopedProcesses.clear();
+ if (children.length === 0) return;
+ logger.info(
+ `Synchronously stopping ${children.length} E2E test process(es) on quit`,
+ );
+ for (const child of children) {
+ const pid = child.pid;
+ if (pid === undefined) continue;
+ if (child.exitCode !== null || child.signalCode !== null) continue;
+ if (!killProcessTreeSync(pid)) {
+ logger.warn(
+ `Failed to synchronously terminate E2E test process (PID ${pid}) during quit`,
+ );
+ }
+ }
+}
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
index 34089c0479..2c1289ac1f 100644
--- a/src/ipc/services/e2e_test_runtime.test.ts
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -3,7 +3,17 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const getPnpmMinimumReleaseAgeSupportMock = vi.hoisted(() => vi.fn());
+vi.mock("@/ipc/utils/socket_firewall", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getPnpmMinimumReleaseAgeSupport: getPnpmMinimumReleaseAgeSupportMock,
+ };
+});
import {
buildE2eTestStartCommand,
@@ -11,9 +21,21 @@ import {
} from "./e2e_test_runtime";
import { runningApps } from "@/ipc/utils/process_manager";
+function mockPnpmAvailable(available: boolean) {
+ getPnpmMinimumReleaseAgeSupportMock.mockResolvedValue({
+ available,
+ minimumReleaseAgeSupported: available,
+ });
+}
+
describe("buildE2eTestStartCommand", () => {
- it("starts npm without reinstalling dependencies", () => {
- const command = buildE2eTestStartCommand({
+ beforeEach(() => {
+ getPnpmMinimumReleaseAgeSupportMock.mockReset();
+ mockPnpmAvailable(false);
+ });
+
+ it("starts npm without reinstalling dependencies", async () => {
+ const command = await buildE2eTestStartCommand({
workspacePath: path.resolve("app"),
port: 45678,
});
@@ -22,20 +44,44 @@ describe("buildE2eTestStartCommand", () => {
expect(command.env.PORT).toBe("45678");
});
- it("supports an explicit port placeholder in custom commands", () => {
- const command = buildE2eTestStartCommand({
+ it("supports an explicit port placeholder in custom commands", async () => {
+ const command = await buildE2eTestStartCommand({
workspacePath: path.resolve("app"),
port: 45678,
+ installCommand: "custom-install",
startCommand: "custom-server --listen {port}",
});
expect(command.command).toBe("custom-server --listen 45678");
});
- it("uses pnpm when the sandbox contains its lockfile", () => {
+ it("runs a custom command verbatim instead of appending a port flag", async () => {
+ const command = await buildE2eTestStartCommand({
+ workspacePath: path.resolve("app"),
+ port: 45678,
+ installCommand: "pip install -r requirements.txt",
+ startCommand: "python server.py",
+ });
+ expect(command.command).toBe("python server.py");
+ expect(command.env.PORT).toBe("45678");
+ });
+
+ it("ignores a start command that has no matching install command", async () => {
+ // `getCommand` in app_runtime_service only treats an app as custom when
+ // both commands are set; the sandbox must agree with the normal preview.
+ const command = await buildE2eTestStartCommand({
+ workspacePath: path.resolve("app"),
+ port: 45678,
+ startCommand: "python server.py",
+ });
+ expect(command.command).toBe("npm run dev -- --port 45678");
+ });
+
+ it("uses pnpm when the sandbox contains its lockfile", async () => {
+ mockPnpmAvailable(true);
const root = fs.mkdtempSync(path.join(process.cwd(), ".e2e-runtime-test-"));
try {
fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "");
- const command = buildE2eTestStartCommand({
+ const command = await buildE2eTestStartCommand({
workspacePath: root,
port: 45678,
});
@@ -46,6 +92,20 @@ describe("buildE2eTestStartCommand", () => {
}
});
+ it("falls back to npm when the lockfile wants pnpm but pnpm is unusable", async () => {
+ const root = fs.mkdtempSync(path.join(process.cwd(), ".e2e-runtime-test-"));
+ try {
+ fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "");
+ const command = await buildE2eTestStartCommand({
+ workspacePath: root,
+ port: 45678,
+ });
+ expect(command.command).toBe("npm run dev -- --port 45678");
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it("starts and stops a server without registering the normal app runtime", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-runtime-"));
fs.writeFileSync(
@@ -61,6 +121,7 @@ http.createServer((_request, response) => response.end("sandbox"))
try {
runtime = await startE2eTestRuntime({
workspacePath: root,
+ installCommand: "true",
startCommand: `"${process.execPath}" server.mjs {port}`,
});
await expect(
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index 61d3446b5a..c90cd5fa50 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -1,12 +1,16 @@
import { spawn, type ChildProcess } from "node:child_process";
-import fs from "node:fs";
import net from "node:net";
-import path from "node:path";
import log from "electron-log";
+import { trackE2eTestProcess } from "@/ipc/services/e2e_test_process_registry";
+import {
+ choosePackageManagerFromSignal,
+ getPackageManagerSignal,
+} from "@/ipc/utils/package_manager_selection";
import { killProcess } from "@/ipc/utils/process_manager";
import {
getPackageManagerCommandEnv,
+ getPnpmMinimumReleaseAgeSupport,
PNPM_PM_ON_FAIL_IGNORE_ARG,
} from "@/ipc/utils/socket_firewall";
@@ -38,23 +42,57 @@ export async function allocateE2eTestPort(): Promise {
});
}
-export function buildE2eTestStartCommand({
+/**
+ * Whether the app supplies its own commands. Mirrors `getCommand` in
+ * `app_runtime_service`: a command counts as custom only when BOTH the install
+ * and the start command are set, so the sandbox and the normal preview never
+ * disagree about which apps are Dyad-managed.
+ */
+export function hasCustomE2eStartCommand({
+ installCommand,
+ startCommand,
+}: {
+ installCommand?: string | null;
+ startCommand?: string | null;
+}): boolean {
+ return Boolean(installCommand?.trim()) && Boolean(startCommand?.trim());
+}
+
+export async function buildE2eTestStartCommand({
workspacePath,
port,
+ installCommand,
startCommand,
}: {
workspacePath: string;
port: number;
+ installCommand?: string | null;
startCommand?: string | null;
-}): { command: string; env: NodeJS.ProcessEnv } {
- if (startCommand?.trim()) {
- const command = startCommand.includes("{port}")
- ? startCommand.replaceAll("{port}", String(port))
- : `${startCommand.trim()} -- --port ${port}`;
+}): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
+ if (hasCustomE2eStartCommand({ installCommand, startCommand })) {
+ // Run the user's command verbatim, exactly as the normal preview does.
+ // Appending `-- --port` would break every custom server that doesn't accept
+ // that flag (a Python server, a shell script, a CLI that spells it
+ // differently) under test only. `{port}` is the explicit opt-in for
+ // pointing a custom server at the run-scoped port; otherwise PORT is the
+ // only hint we can safely supply.
+ const trimmed = startCommand!.trim();
+ const command = trimmed.includes("{port}")
+ ? trimmed.replaceAll("{port}", String(port))
+ : trimmed;
return { command, env: { ...process.env, PORT: String(port) } };
}
- if (fs.existsSync(path.join(workspacePath, "pnpm-lock.yaml"))) {
+ // Select the package manager the same way the normal preview does. Choosing
+ // pnpm from the lockfile alone would break sandboxed runs on machines where
+ // pnpm is missing or too old, even though the normal preview falls back to
+ // npm there.
+ const pnpmSupport = await getPnpmMinimumReleaseAgeSupport();
+ const packageManager = choosePackageManagerFromSignal({
+ signal: getPackageManagerSignal(workspacePath),
+ pnpmAvailable: pnpmSupport.available,
+ });
+ if (packageManager === "pnpm") {
return {
command: `pnpm ${PNPM_PM_ON_FAIL_IGNORE_ARG} run dev --port ${port}`,
env: { ...getPackageManagerCommandEnv(), PORT: String(port) },
@@ -90,12 +128,14 @@ async function waitForReady({
signal,
outputTail,
spawnError,
+ portHint,
}: {
baseUrl: string;
process: ChildProcess;
signal?: AbortSignal;
outputTail: () => string;
spawnError: () => Error | undefined;
+ portHint: string;
}): Promise {
const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
@@ -122,17 +162,19 @@ async function waitForReady({
await delay(SERVER_READY_POLL_MS, signal);
}
throw new Error(
- `The isolated test server did not become ready within 2 minutes.\n${outputTail()}`,
+ `The isolated test server did not become ready within 2 minutes.${portHint}\n${outputTail()}`,
);
}
async function startE2eTestRuntimeOnce({
workspacePath,
+ installCommand,
startCommand,
signal,
onOutput,
}: {
workspacePath: string;
+ installCommand?: string | null;
startCommand?: string | null;
signal?: AbortSignal;
onOutput?: (chunk: string) => void;
@@ -140,11 +182,20 @@ async function startE2eTestRuntimeOnce({
if (signal?.aborted) throw new Error("Test run stopped.");
const port = await allocateE2eTestPort();
const baseUrl = `http://127.0.0.1:${port}`;
- const { command, env } = buildE2eTestStartCommand({
+ const { command, env } = await buildE2eTestStartCommand({
workspacePath,
port,
+ installCommand,
startCommand,
});
+ // A verbatim custom command can only reach the run-scoped port through
+ // `{port}` or PORT. If it ignores both it binds elsewhere and never answers
+ // here, so name the fix instead of leaving a bare timeout.
+ const portHint =
+ hasCustomE2eStartCommand({ installCommand, startCommand }) &&
+ !startCommand!.includes("{port}")
+ ? ` Your custom start command may be ignoring the PORT environment variable — add {port} to it so Dyad can tell it which port to use.`
+ : "";
const child = spawn(command, [], {
cwd: workspacePath,
env,
@@ -152,6 +203,7 @@ async function startE2eTestRuntimeOnce({
stdio: "pipe",
detached: false,
});
+ const untrack = trackE2eTestProcess(child);
let tail = "";
const append = (data: unknown) => {
@@ -173,6 +225,7 @@ async function startE2eTestRuntimeOnce({
if (child.pid && child.exitCode === null && child.signalCode === null) {
await killProcess(child);
}
+ untrack();
})();
return stopPromise;
};
@@ -186,6 +239,7 @@ async function startE2eTestRuntimeOnce({
signal,
outputTail: () => tail,
spawnError: () => startError,
+ portHint,
});
logger.info(`Isolated E2E server ready on port ${port}`);
return {
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
index 0626becee8..69b81527bf 100644
--- a/src/ipc/services/e2e_test_workspace.test.ts
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -8,6 +8,8 @@ vi.mock("@/paths/paths", () => ({ getUserDataPath: vi.fn() }));
import { getUserDataPath } from "@/paths/paths";
import {
createE2eTestWorkspace,
+ E2E_TEST_SANDBOX_DIR,
+ reconcileOrphanE2eTestWorkspaces,
retainE2eTestArtifacts,
rewriteE2eArtifactPath,
shouldCopyE2eWorkspacePath,
@@ -135,6 +137,31 @@ describe("E2E test workspace", () => {
).toBe("png");
});
+ it("sweeps abandoned sandboxes without touching a live run", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+ await fs.writeFile(path.join(appPath, "app.ts"), "app");
+
+ const live = await createE2eTestWorkspace({ appId: 9, appPath });
+ const sandboxRoot = path.join(userData, E2E_TEST_SANDBOX_DIR);
+ const orphan = path.join(sandboxRoot, "9-1-abandoned");
+ await fs.mkdir(orphan, { recursive: true });
+
+ await reconcileOrphanE2eTestWorkspaces();
+
+ await expect(fs.stat(orphan)).rejects.toThrow();
+ expect(
+ await fs.readFile(path.join(live.workspacePath, "app.ts"), "utf8"),
+ ).toBe("app");
+
+ await live.dispose();
+ await reconcileOrphanE2eTestWorkspaces();
+ await expect(fs.stat(live.workspacePath)).rejects.toThrow();
+ });
+
it("uses a root-based exclusion policy", () => {
const appPath = path.resolve("app");
expect(
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index 1045f98abc..5a220f3dc5 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -27,6 +27,13 @@ const EXCLUDED_ROOTS = new Set([
export const E2E_TEST_SANDBOX_DIR = "test-sandboxes";
export const E2E_TEST_ARTIFACT_DIR = "test-artifacts";
+/**
+ * Run directories owned by an in-flight run. The startup orphan sweep skips
+ * these so it can never delete a sandbox out from under a run that started
+ * while the sweep was still walking a multi-gigabyte tree.
+ */
+const activeWorkspaceNames = new Set();
+
export interface E2eTestWorkspace {
workspacePath: string;
artifactPath: string;
@@ -119,13 +126,20 @@ export async function createE2eTestWorkspace({
const artifactPath = path.join(artifactRoot, runName);
assertOwnedPath(sandboxRoot, workspacePath);
assertOwnedPath(artifactRoot, artifactPath);
+ // Claim the run directory before the first byte is copied so a concurrent
+ // orphan sweep already sees it as live.
+ activeWorkspaceNames.add(runName);
let disposed = false;
const dispose = async () => {
if (disposed) return;
disposed = true;
assertOwnedPath(sandboxRoot, workspacePath);
- await fs.rm(workspacePath, { recursive: true, force: true });
+ try {
+ await fs.rm(workspacePath, { recursive: true, force: true });
+ } finally {
+ activeWorkspaceNames.delete(runName);
+ }
};
try {
@@ -182,11 +196,34 @@ export function rewriteE2eArtifactPath(
return path.join(artifactPath, relative);
}
+/**
+ * Remove sandboxes left behind by a crash. Deletes run directories one by one
+ * and skips any run this process still owns, rather than removing the shared
+ * root: the sweep is fire-and-forget from startup and removing a multi-gigabyte
+ * tree is not instantaneous, so a Run pressed right after launch would
+ * otherwise be deleted mid-copy and surface as an unexplained ENOENT.
+ */
export async function reconcileOrphanE2eTestWorkspaces(): Promise {
const sandboxRoot = path.join(getUserDataPath(), E2E_TEST_SANDBOX_DIR);
+ let entries;
try {
- await fs.rm(sandboxRoot, { recursive: true, force: true });
- } catch (error) {
- logger.warn(`Failed to remove abandoned E2E test workspaces: ${error}`);
+ entries = await fs.readdir(sandboxRoot, { withFileTypes: true });
+ } catch (error: any) {
+ if (error?.code !== "ENOENT") {
+ logger.warn(`Failed to list abandoned E2E test workspaces: ${error}`);
+ }
+ return;
+ }
+ for (const entry of entries) {
+ if (activeWorkspaceNames.has(entry.name)) continue;
+ const runPath = path.join(sandboxRoot, entry.name);
+ try {
+ assertOwnedPath(sandboxRoot, runPath);
+ await fs.rm(runPath, { recursive: true, force: true });
+ } catch (error) {
+ logger.warn(
+ `Failed to remove abandoned E2E test workspace ${entry.name}: ${error}`,
+ );
+ }
}
}
From 10e04424fca0e9244020c057ffd3560804ee9c31 Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Sun, 23 Aug 2026 20:21:43 +0000
Subject: [PATCH 3/9] Address second round of PR review comments
- Replace the Tests panel / cancellation-banner copy that still promised
Dyad restores the preview and database. Nothing is restored on the host
path: the run had its own sandbox and its own server. The Neon disclosure
now says the preview keeps its real database, and the
cancellationRestoringTestApp string is renamed to
cancellationRemovingTestDatabase across all five locales.
- Drive the "couldn't finish cleaning up" warning off a new
TeardownResult.remoteCleanupCompleted instead of envRestored, which on the
sandbox path only reported on a workspace file deleted seconds later.
markAndDeleteTempTestBranch now returns the delete verdict.
- Return a structured "Test run stopped." result when Stop lands during
sandbox setup, instead of rejecting the IPC call and recording an internal
product exception for an ordinary cancellation.
- Announce cleaning-up before disposing the sandbox for every isolation mode,
so removing a cloned node_modules tree is not an unlabelled wait with
Run/Record/Delete disabled.
- Add workspace copy/dispose telemetry (durations, entry counts, whether a
reflink was requested; no absolute paths) and a
disableSandboxedE2eTests escape hatch with a Settings toggle. Turning it off
routes through the same non-sandboxed path, still failing closed for Neon.
- Sweep retained test artifacts: startup reconciliation prunes directories
whose app no longer exists, and deleting an app drops its artifacts.
- Remove the readiness poll's per-iteration abort listener, which reached
Node's MaxListenersExceededWarning on any slow sandbox start.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
---
src/components/SandboxedE2eTestsSwitch.tsx | 29 +++
.../chat/CancellationBanner.test.tsx | 19 +-
src/components/chat/CancellationBanner.tsx | 12 +-
.../preview_panel/TestsPanel.test.tsx | 58 +++--
src/components/preview_panel/TestsPanel.tsx | 40 ++--
src/i18n/locales/en/chat.json | 4 +-
src/i18n/locales/es/chat.json | 4 +-
src/i18n/locales/ko/chat.json | 4 +-
src/i18n/locales/pt-BR/chat.json | 4 +-
src/i18n/locales/zh-CN/chat.json | 4 +-
src/ipc/handlers/app_handlers.ts | 10 +
src/ipc/handlers/tests_handlers.test.ts | 219 ++++++++++++++++--
src/ipc/handlers/tests_handlers.ts | 104 ++++++---
src/ipc/services/e2e_test_runtime.ts | 22 +-
src/ipc/services/e2e_test_workspace.test.ts | 54 +++++
src/ipc/services/e2e_test_workspace.ts | 119 ++++++++--
src/ipc/services/isolated_test_db.ts | 28 ++-
src/ipc/utils/neon_test_branch.test.ts | 15 +-
src/ipc/utils/neon_test_branch.ts | 8 +-
src/lib/schemas.ts | 7 +
src/lib/settingsSearchIndex.ts | 20 ++
src/main.ts | 11 +-
src/pages/settings.tsx | 12 +
23 files changed, 665 insertions(+), 142 deletions(-)
create mode 100644 src/components/SandboxedE2eTestsSwitch.tsx
diff --git a/src/components/SandboxedE2eTestsSwitch.tsx b/src/components/SandboxedE2eTestsSwitch.tsx
new file mode 100644
index 0000000000..b8c2097bd6
--- /dev/null
+++ b/src/components/SandboxedE2eTestsSwitch.tsx
@@ -0,0 +1,29 @@
+import { useSettings } from "@/hooks/useSettings";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+/**
+ * Escape hatch for the sandboxed E2E runtime. Stored inverted
+ * (`disableSandboxedE2eTests`) so the sandbox stays the default for everyone
+ * who never opens this, and only an explicit opt-out falls back to running
+ * against the normal preview.
+ */
+export function SandboxedE2eTestsSwitch() {
+ const { settings, updateSettings } = useSettings();
+ const enabled = !settings?.disableSandboxedE2eTests;
+ return (
+
+ {
+ updateSettings({ disableSandboxedE2eTests: !checked });
+ }}
+ />
+
+ Run E2E Tests in an Isolated Sandbox
+
+
+ );
+}
diff --git a/src/components/chat/CancellationBanner.test.tsx b/src/components/chat/CancellationBanner.test.tsx
index 0ec8d60def..cfb882aa29 100644
--- a/src/components/chat/CancellationBanner.test.tsx
+++ b/src/components/chat/CancellationBanner.test.tsx
@@ -18,10 +18,10 @@ vi.mock("react-i18next", () => ({
({
stoppingGeneration: "Stopping…",
cancellationEndingTestRun: "Ending the test run.",
- cancellationRestoringTestApp:
- "Restoring your app's database and preview. This can take a while.",
+ cancellationRemovingTestDatabase:
+ "Removing the temporary test database. This can take a while.",
cancellationCleaningTestData:
- "Cleaning up the test data from this run.",
+ "Cleaning up this run's test sandbox and data.",
})[key] ?? key,
}),
}));
@@ -87,21 +87,26 @@ describe("CancellationBanner", () => {
});
expect(
- screen.getByText(/Restoring your app's database and preview/),
+ screen.getByText(/Removing the temporary test database/),
).toBeTruthy();
expect(screen.getByText(/can take a while/)).toBeTruthy();
+ // The run had its own sandbox and its own server; the user's preview and
+ // `.env.local` were never touched, so nothing is being restored.
+ expect(screen.queryByText(/Restoring/i)).toBeNull();
});
it("does not claim a restore on the Supabase path", () => {
- // That teardown only deletes the temporary test user — no env swap, no
- // dev-server restart, nothing the user sees.
+ // That teardown only deletes the temporary test user and the run's sandbox
+ // copy — no env swap, no dev-server restart, nothing the user sees.
renderBanner({
phase: "cleaning-up",
isolationMode: "supabase-test-user",
source: "agent",
});
- expect(screen.getByText(/Cleaning up the test data/)).toBeTruthy();
+ expect(
+ screen.getByText(/Cleaning up this run's test sandbox/),
+ ).toBeTruthy();
expect(screen.queryByText(/Restoring/)).toBeNull();
});
diff --git a/src/components/chat/CancellationBanner.tsx b/src/components/chat/CancellationBanner.tsx
index ac699c92db..23db686588 100644
--- a/src/components/chat/CancellationBanner.tsx
+++ b/src/components/chat/CancellationBanner.tsx
@@ -11,11 +11,11 @@ import {
* Pinned above the composer while a stopped turn settles.
*
* Stopping is not instant. The agent awaits its in-flight tool, and a
- * `run_tests` call first kills the Playwright process tree and then runs an
- * isolation teardown that accepts no AbortSignal — restoring `.env.local`,
- * restarting the dev server and deleting the temporary Neon branch, whose
- * delete retries with backoff. That wait can pass a minute, and the composer
- * stays locked for all of it.
+ * `run_tests` call first kills the Playwright process tree and then runs a
+ * teardown that accepts no AbortSignal — deleting the temporary Neon branch,
+ * whose delete retries with backoff, and removing the run's sandbox copy of the
+ * app. That wait can pass a minute, and the composer stays locked for all of
+ * it. The user's own `.env.local` and preview are never touched.
*
* The transcript's inline status card scrolls out of view; this stays fused to
* the composer the user just clicked Stop in, so the wait is never unexplained.
@@ -38,7 +38,7 @@ export function CancellationBanner({ appId }: { appId?: number | null }) {
? null
: runState.phase === "cleaning-up"
? runState.isolation?.mode === "neon-branch"
- ? t("cancellationRestoringTestApp")
+ ? t("cancellationRemovingTestDatabase")
: t("cancellationCleaningTestData")
: runState.phase === "stopping"
? t("cancellationEndingTestRun")
diff --git a/src/components/preview_panel/TestsPanel.test.tsx b/src/components/preview_panel/TestsPanel.test.tsx
index 03c21a9bf2..7bc831378c 100644
--- a/src/components/preview_panel/TestsPanel.test.tsx
+++ b/src/components/preview_panel/TestsPanel.test.tsx
@@ -31,6 +31,8 @@ const mocks = vi.hoisted(() => ({
appUrl: "http://localhost:32100" as string | null,
previewUrl: "http://localhost:32100/" as string | null,
previewUrlSource: "dyad" as "none" | "dyad" | "app",
+ app: { id: 1, testingEnabled: true } as Record,
+ settings: {} as Record,
}));
vi.mock("@/ipc/types", () => ({
@@ -52,11 +54,11 @@ vi.mock("@/lib/toast", () => ({
}));
vi.mock("@/hooks/useLoadApp", () => ({
- useLoadApp: () => ({ app: { id: 1, testingEnabled: true } }),
+ useLoadApp: () => ({ app: mocks.app }),
}));
vi.mock("@/hooks/useSettings", () => ({
- useSettings: () => ({ settings: {}, updateSettings: vi.fn() }),
+ useSettings: () => ({ settings: mocks.settings, updateSettings: vi.fn() }),
}));
vi.mock("@/hooks/useRunApp", () => ({
@@ -131,6 +133,8 @@ describe("TestsPanel", () => {
mocks.appUrl = "http://localhost:32100";
mocks.previewUrl = "http://localhost:32100/";
mocks.previewUrlSource = "dyad";
+ mocks.app = { id: 1, testingEnabled: true };
+ mocks.settings = {};
mocks.listAppTests.mockResolvedValue({
specs: [
{
@@ -391,10 +395,32 @@ describe("TestsPanel", () => {
expect(mocks.showError).toHaveBeenCalledWith(error);
});
- it("names the Neon teardown, which restarts the preview", () => {
+ it("tells Neon users their preview keeps its real database", async () => {
+ // The pre-sandbox copy promised a double preview restart. Nothing
+ // restarts any more, so that disclosure would now be a lie.
+ mocks.app = { id: 1, testingEnabled: true, neonProjectId: "neon-proj" };
+ renderPanel();
+
+ expect(
+ await screen.findByText(/Your preview keeps running against your real/),
+ ).toBeTruthy();
+ expect(screen.queryByText(/restart the preview/i)).toBeNull();
+ });
+
+ it("drops the sandbox disclosure when the sandbox is turned off", async () => {
+ mocks.app = { id: 1, testingEnabled: true, neonProjectId: "neon-proj" };
+ mocks.settings = { disableSandboxedE2eTests: true };
+ renderPanel();
+
+ await screen.findByText("signup.spec.ts");
+ expect(screen.queryByText(/Your preview keeps running/)).toBeNull();
+ });
+
+ it("names the Neon teardown without promising a restore", () => {
// This is the wait that can pass a minute (the branch delete retries with
- // backoff), and it visibly reloads the user's preview. Calling it
- // "Running…" — as the panel used to — reads as a hang.
+ // backoff). Calling it "Running…" — as the panel used to — reads as a
+ // hang, but the run had its own sandbox and its own server, so nothing
+ // of the user's is being put back either.
const { store } = renderPanel();
setPhase(store, {
phase: "cleaning-up",
@@ -402,31 +428,31 @@ describe("TestsPanel", () => {
});
expect(
- screen.getByText(/Restoring your database and preview/),
+ screen.getByText(/Removing the temporary test database/),
).toBeTruthy();
+ expect(screen.queryByText(/Restoring/i)).toBeNull();
expect(
- screen.getByRole("button", { name: "Restoring your app" }).textContent,
- ).toContain("Restoring…");
+ screen.getByRole("button", {
+ name: "Removing the temporary test database",
+ }).textContent,
+ ).toContain("Cleaning up…");
});
- it("does not promise a database restore on the Supabase path", () => {
- // That teardown only deletes the temporary test user. It never swaps
- // `.env.local` and never restarts the app, so the Neon copy would lie.
+ it("names the sandbox cleanup on the Supabase path", () => {
+ // That teardown only deletes the temporary test user and the run's
+ // sandbox copy. It never swaps `.env.local` and never restarts the app.
const { store } = renderPanel();
setPhase(store, {
phase: "cleaning-up",
isolation: { mode: "supabase-test-user" },
});
- expect(screen.getByText(/Cleaning up the test data/)).toBeTruthy();
- expect(screen.queryByText(/Restoring your database/)).toBeNull();
+ expect(screen.getByText(/Cleaning up the test sandbox/)).toBeTruthy();
+ expect(screen.queryByText(/Restoring/i)).toBeNull();
expect(
screen.getByRole("button", { name: "Cleaning up test data" })
.textContent,
).toContain("Cleaning up…");
- expect(
- screen.queryByRole("button", { name: "Restoring your app" }),
- ).toBeNull();
});
it("does not carry a completed run's stop latch into the next run", () => {
diff --git a/src/components/preview_panel/TestsPanel.tsx b/src/components/preview_panel/TestsPanel.tsx
index 10af6903be..d6d806822d 100644
--- a/src/components/preview_panel/TestsPanel.tsx
+++ b/src/components/preview_panel/TestsPanel.tsx
@@ -715,7 +715,11 @@ export function TestsPanel() {
(runState.phase === "cleaning-up" && !runState.wasStopped);
const isStopping = runState.phase === "stopping";
const isCleaningUp = runState.phase === "cleaning-up";
- const isRestoringApp =
+ // Nothing about the user's app is restored any more: the run had its own
+ // sandbox copy and its own server, so cleanup is deleting that sandbox and
+ // the temporary database it was pointed at. The real `.env.local` and the
+ // preview were never touched.
+ const isRemovingTestDatabase =
isCleaningUp && runState.isolation?.mode === "neon-branch";
const specsQuery = useQuery({
queryKey: queryKeys.tests.list({ appId: selectedAppId }),
@@ -746,10 +750,15 @@ export function TestsPanel() {
});
const loadingSpecs = specsQuery.isLoading && specs.length === 0;
- const showNeonRestartDisclosure =
+ // Host runs get the sandbox: a throwaway copy of the app, its own server, and
+ // a temporary Neon branch that only that copy points at. Worth saying, since
+ // the alternative a user would assume is "my tests hit my real database" —
+ // but it must not promise the preview restart the old env-swap path did.
+ const showNeonSandboxDisclosure =
specs.length > 0 &&
!!app?.neonProjectId &&
- (settings?.runtimeMode2 ?? "host") === "host";
+ (settings?.runtimeMode2 ?? "host") === "host" &&
+ !settings?.disableSandboxedE2eTests;
// Pop the output drawer when a run starts for the app being viewed. Keyed
// off the global atom's phase transition — not the raw IPC event — so it
@@ -1377,8 +1386,8 @@ export function TestsPanel() {
disabled={showStopping || isCleaningUp}
aria-label={
isCleaningUp
- ? isRestoringApp
- ? "Restoring your app"
+ ? isRemovingTestDatabase
+ ? "Removing the temporary test database"
: "Cleaning up test data"
: showStopping
? "Stopping tests"
@@ -1397,9 +1406,7 @@ export function TestsPanel() {
)}
{isCleaningUp
- ? isRestoringApp
- ? "Restoring…"
- : "Cleaning up…"
+ ? "Cleaning up…"
: showStopping
? "Stopping…"
: "Stop"}
@@ -1448,12 +1455,13 @@ export function TestsPanel() {
)}
>
{isCleaningUp
- ? // The Neon teardown restarts the dev server, so the
- // preview visibly reloads and the copy has to account for
- // it. The Supabase teardown only deletes the test user.
+ ? // The Neon teardown deletes the throwaway branch on
+ // Neon's side, which retries with backoff and is the
+ // slowest case worth naming. Everything else is the local
+ // sandbox and, for Supabase, the temporary test user.
runState.isolation?.mode === "neon-branch"
- ? "Restoring your database and preview… "
- : "Cleaning up the test data… "
+ ? "Removing the temporary test database… "
+ : "Cleaning up the test sandbox… "
: showStopping
? "Stopping the tests… "
: runState.phase === "setup"
@@ -1541,12 +1549,12 @@ export function TestsPanel() {
)}
- {!isRunning && showNeonRestartDisclosure && (
+ {!isRunning && showNeonSandboxDisclosure && (
- Neon test runs restart the preview to switch to a temporary
- database, then restart it again afterward.
+ Neon test runs use a copy of your app and a temporary database.
+ Your preview keeps running against your real one.
)}
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index 885e8d4ba0..cc4fc95c3e 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -80,8 +80,8 @@
"cancelGeneration": "Cancel generation",
"stoppingGeneration": "Stopping…",
"cancellationEndingTestRun": "Ending the test run.",
- "cancellationRestoringTestApp": "Restoring your app's database and preview. This can take a while.",
- "cancellationCleaningTestData": "Cleaning up the test data from this run.",
+ "cancellationRemovingTestDatabase": "Removing the temporary test database. This can take a while.",
+ "cancellationCleaningTestData": "Cleaning up this run's test sandbox and data.",
"sendMessage": "Send message",
"loadingProposal": "Loading proposal...",
"errorLoadingProposal": "Error loading proposal: {{message}}",
diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json
index fa376c9da6..8c28664c1b 100644
--- a/src/i18n/locales/es/chat.json
+++ b/src/i18n/locales/es/chat.json
@@ -80,8 +80,8 @@
"cancelGeneration": "Cancelar generación",
"stoppingGeneration": "Deteniendo…",
"cancellationEndingTestRun": "Finalizando la ejecución de pruebas.",
- "cancellationRestoringTestApp": "Restaurando la base de datos y la vista previa de tu aplicación. Esto puede tardar un poco.",
- "cancellationCleaningTestData": "Limpiando los datos de prueba de esta ejecución.",
+ "cancellationRemovingTestDatabase": "Eliminando la base de datos de prueba temporal. Esto puede tardar un poco.",
+ "cancellationCleaningTestData": "Limpiando el entorno aislado y los datos de prueba de esta ejecución.",
"sendMessage": "Enviar mensaje",
"loadingProposal": "Cargando propuesta...",
"errorLoadingProposal": "Error al cargar la propuesta: {{message}}",
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index f72f4f64aa..5c3bd5e1ae 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -80,8 +80,8 @@
"cancelGeneration": "생성 취소",
"stoppingGeneration": "중지하는 중…",
"cancellationEndingTestRun": "테스트 실행을 종료하는 중입니다.",
- "cancellationRestoringTestApp": "앱의 데이터베이스와 미리보기를 복원하는 중입니다. 시간이 걸릴 수 있습니다.",
- "cancellationCleaningTestData": "이 테스트 실행의 데이터를 정리하는 중입니다.",
+ "cancellationRemovingTestDatabase": "임시 테스트 데이터베이스를 제거하는 중입니다. 시간이 걸릴 수 있습니다.",
+ "cancellationCleaningTestData": "이번 실행의 테스트 샌드박스와 데이터를 정리하는 중입니다.",
"sendMessage": "메시지 보내기",
"loadingProposal": "제안을 불러오는 중...",
"errorLoadingProposal": "제안 불러오기 오류: {{message}}",
diff --git a/src/i18n/locales/pt-BR/chat.json b/src/i18n/locales/pt-BR/chat.json
index 19b6a52530..b1afb113d5 100644
--- a/src/i18n/locales/pt-BR/chat.json
+++ b/src/i18n/locales/pt-BR/chat.json
@@ -80,8 +80,8 @@
"cancelGeneration": "Cancelar geração",
"stoppingGeneration": "Parando…",
"cancellationEndingTestRun": "Encerrando a execução dos testes.",
- "cancellationRestoringTestApp": "Restaurando o banco de dados e a pré-visualização do seu app. Isso pode levar algum tempo.",
- "cancellationCleaningTestData": "Limpando os dados de teste desta execução.",
+ "cancellationRemovingTestDatabase": "Removendo o banco de dados de teste temporário. Isso pode levar algum tempo.",
+ "cancellationCleaningTestData": "Limpando o ambiente isolado e os dados de teste desta execução.",
"sendMessage": "Enviar mensagem",
"loadingProposal": "Carregando proposta...",
"errorLoadingProposal": "Erro ao carregar proposta: {{message}}",
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index 0a3a52a518..eec6531cc1 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -80,8 +80,8 @@
"cancelGeneration": "取消生成",
"stoppingGeneration": "正在停止…",
"cancellationEndingTestRun": "正在结束测试运行。",
- "cancellationRestoringTestApp": "正在恢复应用的数据库和预览。这可能需要一些时间。",
- "cancellationCleaningTestData": "正在清理本次运行的测试数据。",
+ "cancellationRemovingTestDatabase": "正在删除临时测试数据库。这可能需要一些时间。",
+ "cancellationCleaningTestData": "正在清理本次运行的测试沙箱和数据。",
"sendMessage": "发送消息",
"loadingProposal": "正在加载提案...",
"errorLoadingProposal": "加载提案出错:{{message}}",
diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts
index c42fdeabef..04c655048a 100644
--- a/src/ipc/handlers/app_handlers.ts
+++ b/src/ipc/handlers/app_handlers.ts
@@ -147,6 +147,7 @@ import {
} from "../utils/neon_test_branch";
import type { AppSearchResult } from "@/lib/schemas";
import { endTestsForApp } from "./tests_handlers";
+import { removeE2eTestArtifactsForApp } from "../services/e2e_test_workspace";
import {
getRgExecutablePath,
@@ -539,6 +540,15 @@ async function deleteAppById(
// recording the user can still save.
forgetAppRecordedDrafts(appId);
+ // Retained screenshots/traces live outside the app directory, under
+ // /test-artifacts, and are otherwise only replaced by the next run
+ // of this same app — which will never happen now.
+ await removeE2eTestArtifactsForApp(appId).catch((error) =>
+ logger.warn(
+ `App ${appId} was deleted but its retained test artifacts could not be removed: ${error}`,
+ ),
+ );
+
// Only after the deletion has committed — the throw above skips this. Doing
// it earlier means a deletion that then fails leaves a live app pointed at a
// database that no longer exists, which is worse than the leak it prevents.
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 3c93ec21dc..8aa37a354c 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -230,7 +230,10 @@ describe("tests handlers", () => {
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "none" },
infraError: { message: "setup stopped" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
const requests: AppOperationRequest[] = [];
const originalRun = appOperationCoordinator.run.bind(
@@ -315,7 +318,7 @@ describe("tests handlers", () => {
}
});
- it("reports an unfinished isolated-database cleanup", async () => {
+ it("reports a leaked test branch, not an unrestored sandbox env", async () => {
const appId = seedApp("app");
harness.db
.update(apps)
@@ -327,7 +330,10 @@ describe("tests handlers", () => {
infraError: {
message: "Isolation setup stopped before running tests.",
},
- teardown: vi.fn().mockResolvedValue({ envRestored: false }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: false,
+ }),
});
const result = await runAppTestsWithIsolation({
@@ -340,6 +346,37 @@ describe("tests handlers", () => {
expect(result.infraError?.message).toMatch(/settings were not changed/i);
});
+ it("stays quiet when only the sandbox env was left unrestored", async () => {
+ // The sandbox `.env.local` is deleted with the workspace seconds later,
+ // so `envRestored` says nothing the user needs to hear.
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "neon-branch" },
+ infraError: {
+ message: "Isolation setup stopped before running tests.",
+ },
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: false,
+ remoteCleanupCompleted: true,
+ }),
+ });
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toBe(
+ "Isolation setup stopped before running tests.",
+ );
+ });
+
it("authorizes the isolated server origin before Playwright starts", async () => {
const appId = seedApp("app");
harness.db
@@ -354,7 +391,10 @@ describe("tests handlers", () => {
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "neon-branch" },
authorizeRuntimeOrigin,
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
startE2eTestRuntimeMock.mockImplementation(async () => {
events.push("server");
@@ -395,7 +435,9 @@ describe("tests handlers", () => {
.where(eq(apps.id, appId))
.run();
const stop = vi.fn().mockResolvedValue(undefined);
- const teardown = vi.fn().mockResolvedValue({ envRestored: true });
+ const teardown = vi
+ .fn()
+ .mockResolvedValue({ envRestored: true, remoteCleanupCompleted: true });
const dispose = vi.fn().mockResolvedValue(undefined);
createE2eTestWorkspaceMock.mockResolvedValue({
workspacePath: path.join(TEMP_BASE, "app"),
@@ -454,7 +496,10 @@ describe("tests handlers", () => {
ensurePlaywrightBootstrapMock.mockResolvedValue({ installed: true });
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "none" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
spawnStreamingMock.mockImplementation(
async ({ cwd }: { cwd: string }) => {
@@ -501,6 +546,70 @@ describe("tests handlers", () => {
);
});
+ it("returns cleanly when Stop lands during the sandbox copy", async () => {
+ // The workspace copy and the test-server start both signal cancellation
+ // by throwing. Letting that escape rejects the IPC call and records an
+ // internal product exception for an ordinary user cancellation.
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ const stop = new AbortController();
+ createE2eTestWorkspaceMock.mockImplementation(async () => {
+ stop.abort();
+ throw new Error("Test run stopped.");
+ });
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ externalSignal: stop.signal,
+ });
+
+ expect(result.infraError?.message).toBe("Test run stopped.");
+ expect(startE2eTestRuntimeMock).not.toHaveBeenCalled();
+ });
+
+ it("routes around the sandbox when the user turned it off", async () => {
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ readSettingsMock.mockImplementation(() => ({
+ ...structuredClone(DEFAULT_SETTINGS),
+ disableSandboxedE2eTests: true,
+ }));
+ runningApps.set(appId, { proxyUrl: "http://localhost:32100" } as any);
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "none" },
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
+ });
+
+ let result;
+ try {
+ result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+ } finally {
+ runningApps.clear();
+ }
+
+ expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
+ expect(startE2eTestRuntimeMock).not.toHaveBeenCalled();
+ expect(spawnStreamingMock).toHaveBeenCalled();
+ expect(result.isolation?.reason).toMatch(/turned off in Settings/i);
+ });
+
describe("non-host runtime", () => {
afterEach(() => {
runningApps.clear();
@@ -525,7 +634,10 @@ describe("tests handlers", () => {
const appId = seedRunningApp("app");
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "supabase-test-user" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
const result = await runAppTestsWithIsolation({
@@ -601,7 +713,7 @@ describe("tests handlers", () => {
isolation: { mode: "neon-branch" },
teardown: vi.fn().mockImplementation(async () => {
statesWhenTeardownRan = runStates();
- return { envRestored: true };
+ return { envRestored: true, remoteCleanupCompleted: true };
}),
});
@@ -617,13 +729,17 @@ describe("tests handlers", () => {
expect(runStates()).toContain("finished");
});
- it("stays quiet when there is no isolation to tear down", async () => {
- // `NOOP_TEARDOWN` returns immediately, so a `cleaning-up` label would
- // flash for a frame and read as a glitch.
+ it("announces the sandbox deletion even with no isolation to tear down", async () => {
+ // `NOOP_TEARDOWN` returns immediately, but removing the cloned
+ // node_modules tree does not, and the panel keeps Run/Record/Delete
+ // disabled for all of it. An unlabelled wait reads as a hang.
const appId = seedTestableApp("app");
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "none" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
await runAppTestsWithIsolation({
@@ -632,6 +748,37 @@ describe("tests handlers", () => {
source: "panel",
});
+ expect(runStates()).toContain("cleaning-up");
+ });
+
+ it("stays quiet when no sandbox was taken and there is nothing to tear down", async () => {
+ // Without a sandbox to delete, `NOOP_TEARDOWN` returns immediately and a
+ // `cleaning-up` label would flash for a frame and read as a glitch.
+ const appId = seedTestableApp("app");
+ readSettingsMock.mockImplementation(() => ({
+ ...structuredClone(DEFAULT_SETTINGS),
+ disableSandboxedE2eTests: true,
+ }));
+ runningApps.set(appId, { proxyUrl: "http://localhost:32100" } as any);
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "none" },
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
+ });
+
+ try {
+ await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+ } finally {
+ runningApps.clear();
+ }
+
+ expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
expect(runStates()).not.toContain("cleaning-up");
});
@@ -641,7 +788,10 @@ describe("tests handlers", () => {
const appId = seedTestableApp("app");
prepareIsolatedTestDatabaseMock.mockResolvedValue({
isolation: { mode: "none" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
await runAppTestsWithIsolation({
@@ -664,7 +814,10 @@ describe("tests handlers", () => {
let resolveFirstPrepare!: (value: {
isolation: { mode: "neon-branch" };
infraError: { message: string };
- teardown: () => Promise<{ envRestored: boolean }>;
+ teardown: () => Promise<{
+ envRestored: boolean;
+ remoteCleanupCompleted: boolean;
+ }>;
}) => void;
prepareIsolatedTestDatabaseMock
.mockReturnValueOnce(
@@ -675,7 +828,10 @@ describe("tests handlers", () => {
.mockResolvedValueOnce({
isolation: { mode: "none" },
infraError: { message: "second run stopped before execution" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
const firstRun = runAppTestsWithIsolation({
@@ -695,13 +851,25 @@ describe("tests handlers", () => {
resolveFirstPrepare({
isolation: { mode: "neon-branch" },
infraError: { message: "first run stopped before execution" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
await Promise.all([firstRun, secondRun]);
- expect(runStates()).not.toContain("stopping");
- expect(runStates()).not.toContain("cleaning-up");
+ // The superseded run must contribute no progress at all. The replacement
+ // legitimately announces its own sandbox deletion, so the assertion is
+ // scoped to the first run's generation rather than to the whole stream.
+ const supersededRunId = Math.min(
+ ...runStatePayloads().map((payload) => payload.runId),
+ );
+ const supersededStates = runStatePayloads()
+ .filter((payload) => payload.runId === supersededRunId)
+ .map((payload) => payload.state);
+ expect(supersededStates).not.toContain("stopping");
+ expect(supersededStates).not.toContain("cleaning-up");
});
it("attributes a queued run's stop to its own generation", async () => {
@@ -709,7 +877,10 @@ describe("tests handlers", () => {
let resolveFirstPrepare!: (value: {
isolation: { mode: "neon-branch" };
infraError: { message: string };
- teardown: () => Promise<{ envRestored: boolean }>;
+ teardown: () => Promise<{
+ envRestored: boolean;
+ remoteCleanupCompleted: boolean;
+ }>;
}) => void;
prepareIsolatedTestDatabaseMock
.mockReturnValueOnce(
@@ -720,7 +891,10 @@ describe("tests handlers", () => {
.mockResolvedValueOnce({
isolation: { mode: "none" },
infraError: { message: "queued run stopped before execution" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
const firstRun = runAppTestsWithIsolation({
@@ -755,7 +929,10 @@ describe("tests handlers", () => {
resolveFirstPrepare({
isolation: { mode: "neon-branch" },
infraError: { message: "first run superseded" },
- teardown: vi.fn().mockResolvedValue({ envRestored: true }),
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
});
await Promise.all([firstRun, secondRun]);
});
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 417092e686..029cf2c8e1 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -595,16 +595,18 @@ export async function runAppTestsCore({
}
/**
- * Docker/cloud fallback. The run-scoped sandbox server is host-only for now, so
- * these runtimes keep the pre-sandbox path — bootstrap and run against the
- * normal preview — with the missing runtime isolation disclosed on the result
- * rather than losing E2E testing entirely. Neon apps are the one exception:
- * without a sandbox there is no throwaway branch to point the app at, so the
- * only way to run would be against the user's real database.
+ * The non-sandboxed path, taken when the sandbox isn't available (Docker/cloud
+ * runtime) or the user opted out of it. Keeps the pre-sandbox behavior —
+ * bootstrap and run against the normal preview — with the missing runtime
+ * isolation disclosed on the result rather than losing E2E testing entirely.
+ * Neon apps are the one exception: without a sandbox there is no throwaway
+ * branch to point the app at, so the only way to run would be against the
+ * user's real database, and this fails closed instead.
*/
async function runTestsAgainstNormalPreview({
appId,
- runtimeMode,
+ disclosure,
+ neonRefusal,
signal,
emit,
emitProgress,
@@ -617,7 +619,10 @@ async function runTestsAgainstNormalPreview({
timeoutMs,
}: {
appId: number;
- runtimeMode: string;
+ /** Why this run isn't sandboxed, shown on the result's isolation badge. */
+ disclosure: string;
+ /** Why a Neon app can't run at all here, and what to change. */
+ neonRefusal: string;
signal: AbortSignal;
emit: (chunk: string, phase: "setup" | "running") => void;
emitProgress: (
@@ -659,13 +664,8 @@ async function runTestsAgainstNormalPreview({
return {
appId,
results: [],
- infraError: {
- message: `Isolated E2E test servers aren't available in ${runtimeMode} runtime yet, and Dyad won't run Neon tests against your real database. Switch to host runtime to run tests for this app.`,
- },
- isolation: {
- mode: "none" as const,
- reason: `Sandboxed E2E execution is not available in ${runtimeMode} runtime yet.`,
- },
+ infraError: { message: neonRefusal },
+ isolation: { mode: "none" as const, reason: disclosure },
};
}
@@ -674,16 +674,16 @@ async function runTestsAgainstNormalPreview({
prepared = await prepareIsolatedTestDatabase({
app,
emit,
- runtimeMode,
+ // Nothing here depends on the sandbox, and the Neon branch path —
+ // the only branch that reads this — was refused above.
+ runtimeMode: "host",
signal,
});
// Disclose the missing runtime sandbox, without overwriting a more
// specific provider reason (e.g. the Supabase publishable-key hint).
const isolation: TestIsolation = {
...prepared.isolation,
- reason:
- prepared.isolation.reason ??
- `Tests run against your normal preview because isolated test servers aren't available in ${runtimeMode} runtime yet.`,
+ reason: prepared.isolation.reason ?? disclosure,
};
if (prepared.infraError) {
return {
@@ -713,7 +713,9 @@ async function runTestsAgainstNormalPreview({
emitProgress("cleaning-up", prepared.isolation);
}
onIsolationCleanupFailed(true);
- onIsolationCleanupFailed(!(await prepared.teardown()).envRestored);
+ onIsolationCleanupFailed(
+ !(await prepared.teardown()).remoteCleanupCompleted,
+ );
} catch (error) {
logger.error(
`Failed to tear down isolated test environment for app ${appId}: ${error}`,
@@ -945,12 +947,32 @@ export async function runAppTestsWithIsolation({
return finalResult;
}
- const runtimeMode = readSettings().runtimeMode2 ?? "host";
- if (runtimeMode !== "host") {
+ // The sandbox is host-only for now, and snapshotting the app plus its
+ // node_modules is a real copy on filesystems without reflink support — so
+ // the user gets an explicit opt-out. Both routes take the same
+ // non-sandboxed path, and both fail closed for Neon rather than running
+ // against the user's real database.
+ const settings = readSettings();
+ const runtimeMode = settings.runtimeMode2 ?? "host";
+ const sandboxUnavailable =
+ runtimeMode !== "host"
+ ? {
+ disclosure: `Tests run against your normal preview because isolated test servers aren't available in ${runtimeMode} runtime yet.`,
+ neonRefusal: `Isolated E2E test servers aren't available in ${runtimeMode} runtime yet, and Dyad won't run Neon tests against your real database. Switch to host runtime to run tests for this app.`,
+ }
+ : settings.disableSandboxedE2eTests
+ ? {
+ disclosure:
+ "Tests run against your normal preview because isolated test servers are turned off in Settings.",
+ neonRefusal:
+ "Isolated test servers are turned off in Settings, and Dyad won't run Neon tests against your real database. Turn them back on to run tests for this app.",
+ }
+ : null;
+ if (sandboxUnavailable) {
finalResult = withIsolationCleanupWarning(
await runTestsAgainstNormalPreview({
appId,
- runtimeMode,
+ ...sandboxUnavailable,
signal: controller.signal,
emit,
emitProgress,
@@ -1145,15 +1167,21 @@ export async function runAppTestsWithIsolation({
// still require their guaranteed teardown.
if (prepared) {
try {
- // Announce the teardown before it starts. It restores
- // the temporary branch/user, takes no AbortSignal, and may
- // outlast the process kill because Neon deletion retries with
- // backoff. Skipped for `none`, whose teardown is a NOOP.
+ // Announce the teardown before it starts. It removes the
+ // temporary branch/user, takes no AbortSignal, and may outlast
+ // the process kill because Neon deletion retries with backoff.
+ // Skipped for `none`, whose teardown is a NOOP — the sandbox
+ // disposal below announces that case instead.
if (prepared.isolation.mode !== "none") {
emitProgress("cleaning-up", prepared.isolation);
}
isolationCleanupFailed = true;
- isolationCleanupFailed = !(await prepared.teardown()).envRestored;
+ // NOT `envRestored`: the sandbox path never rewrites the real
+ // `.env.local`, so that flag only reports on a workspace file
+ // that is deleted seconds later. A leaked remote branch is the
+ // thing this warning actually describes.
+ isolationCleanupFailed = !(await prepared.teardown())
+ .remoteCleanupCompleted;
} catch (error) {
logger.error(
`Failed to tear down isolated test environment for app ${appId}: ${error}`,
@@ -1166,6 +1194,19 @@ export async function runAppTestsWithIsolation({
finalResult = withIsolationCleanupWarning(finalResult);
return finalResult;
} catch (error) {
+ // A Stop pressed during sandbox setup escapes as a throw — the workspace
+ // copy and the test-server start both signal cancellation that way. That's
+ // an ordinary user cancellation, not an infrastructure failure: return the
+ // same structured result the in-run Stop path produces instead of rejecting
+ // the IPC call and recording an internal product exception for it.
+ if (controller.signal.aborted) {
+ finalResult = withIsolationCleanupWarning({
+ appId,
+ results: [],
+ infraError: { message: "Test run stopped." },
+ });
+ return finalResult;
+ }
// Surface an unexpected failure as an infra error on the run-state event so
// the panel leaves its spinner state, then rethrow for the caller.
finalResult = withIsolationCleanupWarning({
@@ -1185,6 +1226,13 @@ export async function runAppTestsWithIsolation({
});
} finally {
if (workspace) {
+ // Deleting a cloned node_modules tree is tens of thousands of unlinks —
+ // slowest on Windows, where the copy was a real one. The results are
+ // already computed but the panel still has Run/Record/Delete disabled
+ // until `finished`, so label the wait for every isolation mode instead of
+ // leaving it unexplained (the provider teardown above only announces
+ // itself when there was provider state to remove).
+ emitProgress("cleaning-up", finalResult.isolation);
try {
await workspace.dispose();
} catch (error) {
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index c90cd5fa50..c72883b696 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -110,15 +110,19 @@ function delay(ms: number, signal?: AbortSignal): Promise {
reject(new Error("Test run stopped."));
return;
}
- const timer = setTimeout(resolve, ms);
- signal?.addEventListener(
- "abort",
- () => {
- clearTimeout(timer);
- reject(new Error("Test run stopped."));
- },
- { once: true },
- );
+ // `{ once: true }` only removes the listener when it FIRES. The readiness
+ // poll calls this up to ~480 times per run, so without an explicit removal
+ // on the normal path every poll leaves a listener (and its timer closure)
+ // on the run's signal, and Node logs MaxListenersExceededWarning past 10.
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new Error("Test run stopped."));
+ };
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal?.addEventListener("abort", onAbort, { once: true });
});
}
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
index 69b81527bf..eee04c0723 100644
--- a/src/ipc/services/e2e_test_workspace.test.ts
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -8,8 +8,10 @@ vi.mock("@/paths/paths", () => ({ getUserDataPath: vi.fn() }));
import { getUserDataPath } from "@/paths/paths";
import {
createE2eTestWorkspace,
+ E2E_TEST_ARTIFACT_DIR,
E2E_TEST_SANDBOX_DIR,
reconcileOrphanE2eTestWorkspaces,
+ removeE2eTestArtifactsForApp,
retainE2eTestArtifacts,
rewriteE2eArtifactPath,
shouldCopyE2eWorkspacePath,
@@ -162,6 +164,58 @@ describe("E2E test workspace", () => {
await expect(fs.stat(live.workspacePath)).rejects.toThrow();
});
+ it("prunes artifacts for apps that no longer exist", async () => {
+ // Nothing else ever removes these: they're replaced only by the next run
+ // of the same app, which never comes once the app is deleted.
+ const root = await tempRoot();
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ const artifactRoot = path.join(userData, E2E_TEST_ARTIFACT_DIR);
+ const kept = path.join(artifactRoot, "3-1-kept");
+ const orphaned = path.join(artifactRoot, "9-1-orphaned");
+ const unparseable = path.join(artifactRoot, "not-a-run");
+ for (const dir of [kept, orphaned, unparseable]) {
+ await fs.mkdir(dir, { recursive: true });
+ }
+
+ await reconcileOrphanE2eTestWorkspaces({ knownAppIds: new Set([3]) });
+
+ expect((await fs.stat(kept)).isDirectory()).toBe(true);
+ await expect(fs.stat(orphaned)).rejects.toThrow();
+ // Not ours to interpret, so it is left alone rather than guessed at.
+ expect((await fs.stat(unparseable)).isDirectory()).toBe(true);
+ });
+
+ it("leaves artifacts alone when the caller can't say which apps exist", async () => {
+ const root = await tempRoot();
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ const artifact = path.join(userData, E2E_TEST_ARTIFACT_DIR, "9-1-run");
+ await fs.mkdir(artifact, { recursive: true });
+
+ await reconcileOrphanE2eTestWorkspaces();
+
+ expect((await fs.stat(artifact)).isDirectory()).toBe(true);
+ });
+
+ it("drops one app's artifacts without touching another's", async () => {
+ const root = await tempRoot();
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ const artifactRoot = path.join(userData, E2E_TEST_ARTIFACT_DIR);
+ const deleted = path.join(artifactRoot, "9-1-run");
+ const other = path.join(artifactRoot, "10-1-run");
+ for (const dir of [deleted, other]) {
+ await fs.mkdir(dir, { recursive: true });
+ }
+
+ await removeE2eTestArtifactsForApp(9);
+
+ await expect(fs.stat(deleted)).rejects.toThrow();
+ // A prefix match, not a substring match: "10-" must survive removing 9.
+ expect((await fs.stat(other)).isDirectory()).toBe(true);
+ });
+
it("uses a root-based exclusion policy", () => {
const appPath = path.resolve("app");
expect(
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index 5a220f3dc5..f8f5c1c656 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -4,9 +4,17 @@ import { randomUUID } from "node:crypto";
import log from "electron-log";
import { getUserDataPath } from "@/paths/paths";
+import { sendTelemetryEvent } from "@/ipc/utils/telemetry";
const logger = log.scope("e2e_test_workspace");
+// COPYFILE_FICLONE only clones on reflink-capable filesystems (APFS, btrfs,
+// XFS); on ext4 and on Windows — where the mode isn't passed at all — this
+// degrades to a full byte-for-byte copy of the app and its dependency tree on
+// every run. Report timings and entry counts (never absolute paths) so the cost
+// on non-reflink filesystems is measurable in the field.
+const REFLINK_REQUESTED = process.platform !== "win32";
+
const EXCLUDED_ROOTS = new Set([
".git",
"node_modules",
@@ -61,6 +69,7 @@ async function copyNodeModules(
appPath: string,
workspacePath: string,
signal?: AbortSignal,
+ countEntry?: () => void,
) {
const source = path.join(appPath, "node_modules");
try {
@@ -83,7 +92,11 @@ async function copyNodeModules(
...(process.platform === "win32"
? {}
: { mode: fsConstants.COPYFILE_FICLONE }),
- filter: () => !signal?.aborted,
+ filter: () => {
+ if (signal?.aborted) return false;
+ countEntry?.();
+ return true;
+ },
});
if (signal?.aborted) throw new Error("Test run stopped.");
}
@@ -135,13 +148,21 @@ export async function createE2eTestWorkspace({
if (disposed) return;
disposed = true;
assertOwnedPath(sandboxRoot, workspacePath);
+ const startedAt = Date.now();
try {
await fs.rm(workspacePath, { recursive: true, force: true });
+ sendTelemetryEvent("e2e_test_workspace_disposed", {
+ duration_ms: Date.now() - startedAt,
+ platform: process.platform,
+ });
} finally {
activeWorkspaceNames.delete(runName);
}
};
+ let sourceEntries = 0;
+ let dependencyEntries = 0;
+ const startedAt = Date.now();
try {
await fs.cp(appPath, workspacePath, {
recursive: true,
@@ -151,12 +172,26 @@ export async function createE2eTestWorkspace({
: { mode: fsConstants.COPYFILE_FICLONE }),
filter: (candidatePath) => {
if (signal?.aborted) return false;
- return shouldCopyE2eWorkspacePath(appPath, candidatePath);
+ if (!shouldCopyE2eWorkspacePath(appPath, candidatePath)) return false;
+ sourceEntries += 1;
+ return true;
},
});
if (signal?.aborted) throw new Error("Test run stopped.");
+ const sourceMs = Date.now() - startedAt;
onProgress?.("Cloning installed dependencies into the test workspace…\n");
- await copyNodeModules(appPath, workspacePath, signal);
+ await copyNodeModules(appPath, workspacePath, signal, () => {
+ dependencyEntries += 1;
+ });
+ sendTelemetryEvent("e2e_test_workspace_created", {
+ duration_ms: Date.now() - startedAt,
+ source_ms: sourceMs,
+ dependencies_ms: Date.now() - startedAt - sourceMs,
+ source_entries: sourceEntries,
+ dependency_entries: dependencyEntries,
+ reflink_requested: REFLINK_REQUESTED,
+ platform: process.platform,
+ });
return { workspacePath, artifactPath, dispose };
} catch (error) {
await dispose();
@@ -196,34 +231,82 @@ export function rewriteE2eArtifactPath(
return path.join(artifactPath, relative);
}
-/**
- * Remove sandboxes left behind by a crash. Deletes run directories one by one
- * and skips any run this process still owns, rather than removing the shared
- * root: the sweep is fire-and-forget from startup and removing a multi-gigabyte
- * tree is not instantaneous, so a Run pressed right after launch would
- * otherwise be deleted mid-copy and surface as an unexplained ENOENT.
- */
-export async function reconcileOrphanE2eTestWorkspaces(): Promise {
- const sandboxRoot = path.join(getUserDataPath(), E2E_TEST_SANDBOX_DIR);
+/** Run directory names start with `-`; recover the id from one. */
+function runDirectoryAppId(name: string): number | null {
+ const [prefix] = name.split("-");
+ const appId = Number(prefix);
+ return prefix !== "" && Number.isInteger(appId) ? appId : null;
+}
+
+async function removeRunDirectories(
+ root: string,
+ shouldRemove: (name: string) => boolean,
+ label: string,
+): Promise {
let entries;
try {
- entries = await fs.readdir(sandboxRoot, { withFileTypes: true });
+ entries = await fs.readdir(root, { withFileTypes: true });
} catch (error: any) {
if (error?.code !== "ENOENT") {
- logger.warn(`Failed to list abandoned E2E test workspaces: ${error}`);
+ logger.warn(`Failed to list abandoned E2E ${label}: ${error}`);
}
return;
}
for (const entry of entries) {
- if (activeWorkspaceNames.has(entry.name)) continue;
- const runPath = path.join(sandboxRoot, entry.name);
+ if (!shouldRemove(entry.name)) continue;
+ const runPath = path.join(root, entry.name);
try {
- assertOwnedPath(sandboxRoot, runPath);
+ assertOwnedPath(root, runPath);
await fs.rm(runPath, { recursive: true, force: true });
} catch (error) {
logger.warn(
- `Failed to remove abandoned E2E test workspace ${entry.name}: ${error}`,
+ `Failed to remove abandoned E2E ${label} ${entry.name}: ${error}`,
);
}
}
}
+
+/** Drop every retained artifact directory belonging to one app. */
+export async function removeE2eTestArtifactsForApp(
+ appId: number,
+): Promise {
+ await removeRunDirectories(
+ path.join(getUserDataPath(), E2E_TEST_ARTIFACT_DIR),
+ (name) => runDirectoryAppId(name) === appId,
+ "test artifacts",
+ );
+}
+
+/**
+ * Remove sandboxes and artifacts left behind by a crash or a deleted app.
+ *
+ * Sandboxes are deleted one run directory at a time, skipping any run this
+ * process still owns, rather than by removing the shared root: the sweep is
+ * fire-and-forget from startup and removing a multi-gigabyte tree is not
+ * instantaneous, so a Run pressed right after launch would otherwise be deleted
+ * mid-copy and surface as an unexplained ENOENT.
+ *
+ * Artifacts are otherwise only replaced by the next run of the same app, so
+ * without `knownAppIds` a deleted app's screenshots and traces would sit in
+ * user data forever with no surface that shows they exist.
+ */
+export async function reconcileOrphanE2eTestWorkspaces({
+ knownAppIds,
+}: { knownAppIds?: ReadonlySet } = {}): Promise {
+ const userDataPath = getUserDataPath();
+ await removeRunDirectories(
+ path.join(userDataPath, E2E_TEST_SANDBOX_DIR),
+ (name) => !activeWorkspaceNames.has(name),
+ "test workspaces",
+ );
+ if (!knownAppIds) return;
+ await removeRunDirectories(
+ path.join(userDataPath, E2E_TEST_ARTIFACT_DIR),
+ (name) => {
+ const appId = runDirectoryAppId(name);
+ // An unparseable name isn't ours to interpret; leave it alone.
+ return appId !== null && !knownAppIds.has(appId);
+ },
+ "test artifacts",
+ );
+}
diff --git a/src/ipc/services/isolated_test_db.ts b/src/ipc/services/isolated_test_db.ts
index 749f634fc2..363ea4d74b 100644
--- a/src/ipc/services/isolated_test_db.ts
+++ b/src/ipc/services/isolated_test_db.ts
@@ -76,6 +76,14 @@ export interface TeardownResult {
* rather than quietly starting the user's app against isolated data.
*/
envRestored: boolean;
+ /**
+ * False when a remote resource this run created is still out there — today,
+ * a temporary Neon branch whose delete failed and stays tracked for the
+ * startup sweep to retry. The E2E sandbox path never modifies the real env,
+ * so `envRestored` says nothing there; this is the flag that means "the user
+ * has something left over".
+ */
+ remoteCleanupCompleted: boolean;
}
export interface PreparedIsolation {
@@ -106,8 +114,8 @@ export interface PreparedIsolation {
type EmitOutput = (chunk: string, phase: "setup" | "running") => void;
const NOOP_TEARDOWN = async () => {
- // No isolation was set up, so there is nothing to restore.
- return { envRestored: true };
+ // No isolation was set up, so there is nothing to restore or delete.
+ return { envRestored: true, remoteCleanupCompleted: true };
};
/**
@@ -211,15 +219,19 @@ export async function prepareIsolatedTestDatabase({
// it, and the row's id is what the startup sweep reconciles from. App
// deletion — the one case where that row is about to disappear — handles the
// branch itself, after the deletion commits.
+ let remoteCleanupCompleted = true;
if (branchId && envRestored) {
// Shared with the recovery path in `neon_test_branch`: the cleanup-only
// marker is written before the fallible remote delete, so a crash in
// between leaves a row that says the env is real and only the branch is
// outstanding. Both callers must encode that ordering identically or
// teardown and recovery drift apart.
- await markAndDeleteTempTestBranch(app, branchId);
+ remoteCleanupCompleted = await markAndDeleteTempTestBranch(app, branchId);
+ } else if (branchId) {
+ // Deliberately kept, but still outstanding from the user's perspective.
+ remoteCleanupCompleted = false;
}
- return { envRestored };
+ return { envRestored, remoteCleanupCompleted };
};
try {
@@ -330,8 +342,9 @@ export async function prepareIsolatedTestDatabase({
// `NOOP_TEARDOWN` — a no-op answers "restored" and would let the app be
// relaunched against the temporary branch.
let envRestored = false;
+ let remoteCleanupCompleted = false;
try {
- envRestored = (await teardown()).envRestored;
+ ({ envRestored, remoteCleanupCompleted } = await teardown());
} catch (teardownError) {
logger.error(
`Teardown failed during error recovery for app ${app.id}: ${teardownError}`,
@@ -340,6 +353,7 @@ export async function prepareIsolatedTestDatabase({
// Already torn down; this only carries the verdict to whoever asks later.
const settledTeardown = async (): Promise => ({
envRestored,
+ remoteCleanupCompleted,
});
// A user Stop surfaces here too (waitForServerReady & co. throw on abort).
// That's a deliberate cancellation, not an infra failure — don't show the
@@ -401,6 +415,7 @@ async function prepareSupabaseTestUserIsolation({
// Nothing here touches `.env.local` — the Supabase path isolates by test user,
// not by swapping the app's database — so the environment is never at risk.
const teardown = async (): Promise => {
+ let remoteCleanupCompleted = true;
if (testUser) {
try {
await deleteTempTestUser({
@@ -408,12 +423,13 @@ async function prepareSupabaseTestUserIsolation({
supabaseTestUserId: testUser.userId,
});
} catch (error) {
+ remoteCleanupCompleted = false;
logger.error(
`Failed to delete isolated Supabase test user ${testUser.userId} for app ${app.id}: ${error}`,
);
}
}
- return { envRestored: true };
+ return { envRestored: true, remoteCleanupCompleted };
};
try {
diff --git a/src/ipc/utils/neon_test_branch.test.ts b/src/ipc/utils/neon_test_branch.test.ts
index 82cee98190..dcff4616eb 100644
--- a/src/ipc/utils/neon_test_branch.test.ts
+++ b/src/ipc/utils/neon_test_branch.test.ts
@@ -375,7 +375,9 @@ describe("markAndDeleteTempTestBranch", () => {
expect(mocks.deleteProjectBranch).toHaveBeenCalledWith("proj-1", "test-br");
});
- it("does not throw when the remote delete fails", async () => {
+ it("reports the failure without throwing when the remote delete fails", async () => {
+ // Callers promise the user "Dyad will retry remote cleanup on next
+ // startup", so they need the verdict — but a teardown must never throw.
mocks.deleteProjectBranch.mockRejectedValueOnce({
response: { status: 500 },
});
@@ -384,7 +386,16 @@ describe("markAndDeleteTempTestBranch", () => {
makeApp({ neonTestBranchId: "test-br" }),
"test-br",
),
- ).resolves.toBeUndefined();
+ ).resolves.toBe(false);
+ });
+
+ it("reports success when the branch is gone", async () => {
+ await expect(
+ markAndDeleteTempTestBranch(
+ makeApp({ neonTestBranchId: "test-br" }),
+ "test-br",
+ ),
+ ).resolves.toBe(true);
});
});
diff --git a/src/ipc/utils/neon_test_branch.ts b/src/ipc/utils/neon_test_branch.ts
index a7f7f83d40..7ba3b32593 100644
--- a/src/ipc/utils/neon_test_branch.ts
+++ b/src/ipc/utils/neon_test_branch.ts
@@ -352,7 +352,7 @@ export async function deleteTempTestBranch(appData: AppRow): Promise {
export async function markAndDeleteTempTestBranch(
appData: AppRow,
branchId: string,
-): Promise {
+): Promise {
// `deleteTempTestBranch` reads the marker off the row it is given, and the
// caller's copy is stale by now, so carry the branch we actually created.
let cleanupApp: AppRow = { ...appData, neonTestBranchId: branchId };
@@ -364,11 +364,15 @@ export async function markAndDeleteTempTestBranch(
);
}
try {
- await deleteTempTestBranch(cleanupApp);
+ // Still best-effort — never throws — but the verdict is reported now.
+ // Callers that promise the user "Dyad will retry remote cleanup on next
+ // startup" need to know whether the branch actually leaked.
+ return await deleteTempTestBranch(cleanupApp);
} catch (error) {
logger.error(
`Failed to delete temporary test branch ${trackedBranchId(branchId)} for app ${appData.id}: ${error}`,
);
+ return false;
}
}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index f69c291f0f..28949b51c2 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -435,6 +435,13 @@ const BaseUserSettingsFields = {
// preference. Default (unset) is headless + serial.
testHeaded: z.boolean().optional(),
testParallel: z.boolean().optional(),
+ // Escape hatch for the sandboxed E2E runtime, which snapshots the app plus
+ // its node_modules per run. On filesystems without reflink support (ext4,
+ // Windows) that snapshot is a real copy and can be slow. Off by default —
+ // the sandbox is the intended path — and turning it on runs the tests
+ // against the normal preview with the missing isolation disclosed. Neon apps
+ // are refused rather than run against the real database either way.
+ disableSandboxedE2eTests: z.boolean().optional(),
autoExpandPreviewPanel: z.boolean().optional(),
enableChatEventNotifications: z.boolean().optional(),
blockUnsafeNpmPackages: z.boolean().optional(),
diff --git a/src/lib/settingsSearchIndex.ts b/src/lib/settingsSearchIndex.ts
index 4feac26a7e..09148d68e6 100644
--- a/src/lib/settingsSearchIndex.ts
+++ b/src/lib/settingsSearchIndex.ts
@@ -26,6 +26,7 @@ export const SETTING_IDS = {
keepPreviewsRunning: "setting-keep-previews-running",
appBlueprint: "setting-app-blueprint",
testingForNewApps: "setting-testing-for-new-apps",
+ sandboxedE2eTests: "setting-sandboxed-e2e-tests",
chatEventNotification: "setting-chat-event-notification",
maxChatTurns: "setting-max-chat-turns",
maxToolCallSteps: "setting-max-tool-call-steps",
@@ -185,6 +186,25 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.workflow,
sectionLabel: "Workflow",
},
+ {
+ id: SETTING_IDS.sandboxedE2eTests,
+ label: "Run E2E Tests in an Isolated Sandbox",
+ description:
+ "Copy the app into a throwaway workspace with its own server so test runs never touch your preview or real data",
+ keywords: [
+ "testing",
+ "tests",
+ "e2e",
+ "sandbox",
+ "isolated",
+ "copy",
+ "node_modules",
+ "slow",
+ "workflow",
+ ],
+ sectionId: SECTION_IDS.workflow,
+ sectionLabel: "Workflow",
+ },
{
id: SETTING_IDS.autoExpandPreview,
label: "Auto Expand Preview",
diff --git a/src/main.ts b/src/main.ts
index 129908a31d..abd7870e4a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -459,7 +459,16 @@ export async function onReady() {
// must not block startup.
void reconcileOrphanTestBranches();
void reconcileOrphanTestUsers();
- void reconcileOrphanE2eTestWorkspaces();
+ // Also prunes retained test artifacts whose app no longer exists — nothing
+ // else ever removes them, and the user has no surface that shows they exist.
+ void (async () => {
+ const rows = await db.query.apps.findMany({ columns: { id: true } });
+ await reconcileOrphanE2eTestWorkspaces({
+ knownAppIds: new Set(rows.map((row) => row.id)),
+ });
+ })().catch((error) =>
+ logger.error("Failed to reconcile abandoned E2E test workspaces", error),
+ );
// Cleanup old ai_messages_json entries to prevent database bloat
cleanupOldAiMessagesJson();
diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx
index bf7522136c..f36b30eb89 100644
--- a/src/pages/settings.tsx
+++ b/src/pages/settings.tsx
@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { AppBlueprintSwitch } from "@/components/AppBlueprintSwitch";
import { TestingForNewAppsSwitch } from "@/components/TestingForNewAppsSwitch";
+import { SandboxedE2eTestsSwitch } from "@/components/SandboxedE2eTestsSwitch";
import { AutoExpandPreviewSwitch } from "@/components/AutoExpandPreviewSwitch";
import { KeepPreviewsRunningSwitch } from "@/components/KeepPreviewsRunningSwitch";
import { ChatEventNotificationSwitch } from "@/components/ChatEventNotificationSwitch";
@@ -526,6 +527,17 @@ export function WorkflowSettings() {
+
+
+
+ Run each E2E test in a throwaway copy of your app with its own server,
+ so tests never touch your preview or your real database. Turn this off
+ if copying your dependencies makes runs slow — tests then run against
+ your normal preview, and apps using Neon won't run at all rather than
+ test against your real data.
+
+
+
From f6f274ada3ed8495f84d66231db5cf38e6f59d42 Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Sun, 23 Aug 2026 21:24:15 +0000
Subject: [PATCH 4/9] Address third round of PR review comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Stop handing the agent an unreadable artifact path. A sandboxed run retains
error-context.md under /test-artifacts, which read_file's safeJoin
refuses, so the model's first diagnostic step always failed. The page
snapshot is now inlined (bounded, same containment guards as the screenshot
reader) and the traversal path is no longer printed at all.
- Make the dev-server gate conditional on the non-sandboxed path. A sandboxed
run serves its own copy of the app on its own port, so requiring the user's
preview blocked the feature's main benefit and contradicted the panel's own
"your preview keeps running" disclosure. The panel banner, Run/Retry state
and the agent's guardDevServerRunning now share one helper,
usesSandboxedE2eTests.
- Thread `sandboxed` through the run-state payload so cleanup copy only claims
a sandbox when one was taken. The fallback path creates no workspace, and
"Cleaning up the test sandbox…" there was the same inaccuracy the previous
round removed. Adds a separate cancellationCleaningTestSandbox locale key.
- Throw DyadError with DyadErrorKind.Precondition for expected setup failures
(dependencies not installed, server never became ready), so ordinary
user-fixable problems stop being reported to PostHog as product exceptions.
- Add allowCompatibleQueueBypass to the prepare-e2e-test-workspace claim,
matching the run stage: work conflicting only on test-files should not queue
behind a test run that is itself blocked on something unrelated.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
---
src/atoms/testRuntimeAtoms.ts | 8 ++
src/components/chat/CancellationBanner.tsx | 4 +-
.../preview_panel/TestsPanel.test.tsx | 53 +++++++
src/components/preview_panel/TestsPanel.tsx | 31 ++--
src/hooks/useTestRunEvents.ts | 1 +
src/i18n/locales/en/chat.json | 3 +-
src/i18n/locales/es/chat.json | 3 +-
src/i18n/locales/ko/chat.json | 3 +-
src/i18n/locales/pt-BR/chat.json | 3 +-
src/i18n/locales/zh-CN/chat.json | 3 +-
src/ipc/handlers/tests_handlers.test.ts | 4 +
src/ipc/handlers/tests_handlers.ts | 38 +++--
src/ipc/services/e2e_test_runtime.ts | 13 +-
src/ipc/services/e2e_test_workspace.ts | 6 +-
src/ipc/types/tests.ts | 7 +
src/ipc/utils/test_screenshot.ts | 136 ++++++++++++++----
src/lib/e2eSandbox.ts | 22 +++
.../local_agent/tools/run_tests.spec.ts | 73 +++++++++-
.../handlers/local_agent/tools/run_tests.ts | 69 ++++++---
19 files changed, 404 insertions(+), 76 deletions(-)
create mode 100644 src/lib/e2eSandbox.ts
diff --git a/src/atoms/testRuntimeAtoms.ts b/src/atoms/testRuntimeAtoms.ts
index e4fb06a206..3500a39182 100644
--- a/src/atoms/testRuntimeAtoms.ts
+++ b/src/atoms/testRuntimeAtoms.ts
@@ -72,6 +72,12 @@ export interface TestRunState {
* a run completes.
*/
isolation?: TestIsolation;
+ /**
+ * Whether the run executed in an isolated sandbox. Drives the cleanup copy:
+ * the fallback path (Docker/cloud runtime, or the user's opt-out) creates no
+ * workspace, so there is no sandbox to claim Dyad is deleting.
+ */
+ sandboxed?: boolean;
startedAt?: number;
}
@@ -275,6 +281,8 @@ export const applyTestRunStartedAtom = atom(
),
runError: undefined,
isolation: undefined,
+ // Reported by the main process once the run has picked its path.
+ sandboxed: undefined,
startedAt: startedAt ?? Date.now(),
}),
});
diff --git a/src/components/chat/CancellationBanner.tsx b/src/components/chat/CancellationBanner.tsx
index 23db686588..ff20323dd5 100644
--- a/src/components/chat/CancellationBanner.tsx
+++ b/src/components/chat/CancellationBanner.tsx
@@ -39,7 +39,9 @@ export function CancellationBanner({ appId }: { appId?: number | null }) {
: runState.phase === "cleaning-up"
? runState.isolation?.mode === "neon-branch"
? t("cancellationRemovingTestDatabase")
- : t("cancellationCleaningTestData")
+ : runState.sandboxed
+ ? t("cancellationCleaningTestSandbox")
+ : t("cancellationCleaningTestData")
: runState.phase === "stopping"
? t("cancellationEndingTestRun")
: null;
diff --git a/src/components/preview_panel/TestsPanel.test.tsx b/src/components/preview_panel/TestsPanel.test.tsx
index 7bc831378c..09ff14abee 100644
--- a/src/components/preview_panel/TestsPanel.test.tsx
+++ b/src/components/preview_panel/TestsPanel.test.tsx
@@ -318,6 +318,43 @@ describe("TestsPanel", () => {
).toBe(true);
});
+ it("runs sandboxed tests without the preview being up", async () => {
+ // A sandboxed run serves its own copy of the app on its own port. Requiring
+ // the user's preview would block the whole point of the feature — and would
+ // contradict the panel's own "your preview keeps running" disclosure.
+ mocks.appUrl = null;
+
+ renderPanel();
+
+ await screen.findByText("signup.spec.ts");
+ expect(screen.queryByText("Start the app to run tests.")).toBeNull();
+ expect(
+ (
+ screen.getByRole("button", {
+ name: "Run all tests",
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false);
+ });
+
+ it("still requires the preview when the run is not sandboxed", async () => {
+ // The fallback path runs Playwright against the user's preview, so the
+ // gate is still correct there.
+ mocks.appUrl = null;
+ mocks.settings = { disableSandboxedE2eTests: true };
+
+ renderPanel();
+
+ expect(await screen.findByText("Start the app to run tests.")).toBeTruthy();
+ expect(
+ (
+ screen.getByRole("button", {
+ name: "Run all tests",
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ });
+
describe("stopping a run", () => {
/** Put the panel's app into `phase` as if a run had reached it. */
function setPhase(
@@ -445,6 +482,7 @@ describe("TestsPanel", () => {
setPhase(store, {
phase: "cleaning-up",
isolation: { mode: "supabase-test-user" },
+ sandboxed: true,
});
expect(screen.getByText(/Cleaning up the test sandbox/)).toBeTruthy();
@@ -455,6 +493,21 @@ describe("TestsPanel", () => {
).toContain("Cleaning up…");
});
+ it("claims no sandbox when the run never took one", () => {
+ // The fallback path (docker/cloud runtime, or the opt-out) creates no
+ // workspace, so naming one would be the same inaccurate cleanup copy the
+ // Neon "restoring your preview" wording was.
+ const { store } = renderPanel();
+ setPhase(store, {
+ phase: "cleaning-up",
+ isolation: { mode: "supabase-test-user" },
+ sandboxed: false,
+ });
+
+ expect(screen.getByText(/Cleaning up the test data/)).toBeTruthy();
+ expect(screen.queryByText(/test sandbox/)).toBeNull();
+ });
+
it("does not carry a completed run's stop latch into the next run", () => {
const { store } = renderPanel();
setPhase(store, { phase: "running", startedAt: 1000 });
diff --git a/src/components/preview_panel/TestsPanel.tsx b/src/components/preview_panel/TestsPanel.tsx
index d6d806822d..dc864f2b59 100644
--- a/src/components/preview_panel/TestsPanel.tsx
+++ b/src/components/preview_panel/TestsPanel.tsx
@@ -78,6 +78,7 @@ import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { showError, showInfo, showSuccess } from "@/lib/toast";
import { findCaseResult, statusLabel, testKey } from "@/lib/testResultUtils";
+import { usesSandboxedE2eTests } from "@/lib/e2eSandbox";
import { usePreviewIframeController } from "@/preview_iframe/usePreviewIframe";
import { sameOriginStartPath } from "./previewAddressPath";
@@ -702,6 +703,13 @@ export function TestsPanel() {
const parallel = settings?.testParallel ?? false;
const devServerRunning = appUrl.appUrl !== null;
+ // A sandboxed run serves the app itself, on its own port, from its own copy —
+ // the user's preview is not involved, so requiring it would block the whole
+ // point of the feature. The fallback path (Docker/cloud runtime, or the
+ // opt-out) still runs Playwright against the preview and still needs it up.
+ // Recording is unaffected either way: it drives the live preview.
+ const testsNeedDevServer = !usesSandboxedE2eTests(settings);
+ const testRunBlocked = testsNeedDevServer && !devServerRunning;
// Owns the run's whole lifecycle, teardown included. Gates every action that
// must not interleave with it (Run, Record, Delete), because the per-app lock
// is still held during `cleaning-up`.
@@ -1416,13 +1424,13 @@ export function TestsPanel() {
specs.length > 0 && (
runTests()}
- disabled={!devServerRunning}
+ disabled={testRunBlocked}
title="During database-isolated runs, other app operations may wait until the run finishes."
aria-label="Run all tests"
className={cn(
"flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md cursor-pointer",
"bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-900/60",
- !devServerRunning && "opacity-40 cursor-not-allowed",
+ testRunBlocked && "opacity-40 cursor-not-allowed",
)}
>
@@ -1457,11 +1465,14 @@ export function TestsPanel() {
{isCleaningUp
? // The Neon teardown deletes the throwaway branch on
// Neon's side, which retries with backoff and is the
- // slowest case worth naming. Everything else is the local
- // sandbox and, for Supabase, the temporary test user.
+ // slowest case worth naming. Otherwise it's the local
+ // sandbox — when this run took one — and, for Supabase,
+ // the temporary test user.
runState.isolation?.mode === "neon-branch"
? "Removing the temporary test database… "
- : "Cleaning up the test sandbox… "
+ : runState.sandboxed
+ ? "Cleaning up the test sandbox… "
+ : "Cleaning up the test data… "
: showStopping
? "Stopping the tests… "
: runState.phase === "setup"
@@ -1559,8 +1570,8 @@ export function TestsPanel() {
)}
- {/* Dev-server gate banner */}
- {!devServerRunning && specs.length > 0 && (
+ {/* Dev-server gate banner — only when the run actually needs it. */}
+ {testRunBlocked && specs.length > 0 && (
Start the app to run tests.
@@ -1585,10 +1596,10 @@ export function TestsPanel() {
runTests()}
- disabled={isRunning || !devServerRunning}
+ disabled={isRunning || testRunBlocked}
className={cn(
"shrink-0 px-2 py-1 rounded-md bg-amber-200 dark:bg-amber-800 hover:bg-amber-300 dark:hover:bg-amber-700 cursor-pointer text-xs font-medium",
- (isRunning || !devServerRunning) &&
+ (isRunning || testRunBlocked) &&
"opacity-40 cursor-not-allowed",
)}
>
@@ -1698,7 +1709,7 @@ export function TestsPanel() {
tests={spec.tests}
status={fileStatus(spec.file)}
result={runState.results[spec.file]}
- disabled={isRunning || !devServerRunning}
+ disabled={isRunning || testRunBlocked}
deleteDisabled={isRunning || isDeleting}
onRunFile={() => runTests(spec.file)}
onRunCase={(line) => runTests(spec.file, line)}
diff --git a/src/hooks/useTestRunEvents.ts b/src/hooks/useTestRunEvents.ts
index 4d54be8f61..b6a8f8ba9d 100644
--- a/src/hooks/useTestRunEvents.ts
+++ b/src/hooks/useTestRunEvents.ts
@@ -201,6 +201,7 @@ export function useTestRunEvents() {
// teardown accurately. The terminal `finished` event resends
// it, so this never becomes the badge's only source.
isolation: payload.isolation ?? prev.isolation,
+ sandboxed: payload.sandboxed ?? prev.sandboxed,
},
});
return;
diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json
index cc4fc95c3e..c91050cd2a 100644
--- a/src/i18n/locales/en/chat.json
+++ b/src/i18n/locales/en/chat.json
@@ -81,7 +81,8 @@
"stoppingGeneration": "Stopping…",
"cancellationEndingTestRun": "Ending the test run.",
"cancellationRemovingTestDatabase": "Removing the temporary test database. This can take a while.",
- "cancellationCleaningTestData": "Cleaning up this run's test sandbox and data.",
+ "cancellationCleaningTestData": "Cleaning up the test data from this run.",
+ "cancellationCleaningTestSandbox": "Cleaning up this run's test sandbox and data.",
"sendMessage": "Send message",
"loadingProposal": "Loading proposal...",
"errorLoadingProposal": "Error loading proposal: {{message}}",
diff --git a/src/i18n/locales/es/chat.json b/src/i18n/locales/es/chat.json
index 8c28664c1b..2f8c22f6b9 100644
--- a/src/i18n/locales/es/chat.json
+++ b/src/i18n/locales/es/chat.json
@@ -81,7 +81,8 @@
"stoppingGeneration": "Deteniendo…",
"cancellationEndingTestRun": "Finalizando la ejecución de pruebas.",
"cancellationRemovingTestDatabase": "Eliminando la base de datos de prueba temporal. Esto puede tardar un poco.",
- "cancellationCleaningTestData": "Limpiando el entorno aislado y los datos de prueba de esta ejecución.",
+ "cancellationCleaningTestData": "Limpiando los datos de prueba de esta ejecución.",
+ "cancellationCleaningTestSandbox": "Limpiando el entorno aislado y los datos de prueba de esta ejecución.",
"sendMessage": "Enviar mensaje",
"loadingProposal": "Cargando propuesta...",
"errorLoadingProposal": "Error al cargar la propuesta: {{message}}",
diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json
index 5c3bd5e1ae..4a768bf9d2 100644
--- a/src/i18n/locales/ko/chat.json
+++ b/src/i18n/locales/ko/chat.json
@@ -81,7 +81,8 @@
"stoppingGeneration": "중지하는 중…",
"cancellationEndingTestRun": "테스트 실행을 종료하는 중입니다.",
"cancellationRemovingTestDatabase": "임시 테스트 데이터베이스를 제거하는 중입니다. 시간이 걸릴 수 있습니다.",
- "cancellationCleaningTestData": "이번 실행의 테스트 샌드박스와 데이터를 정리하는 중입니다.",
+ "cancellationCleaningTestData": "이 테스트 실행의 데이터를 정리하는 중입니다.",
+ "cancellationCleaningTestSandbox": "이번 실행의 테스트 샌드박스와 데이터를 정리하는 중입니다.",
"sendMessage": "메시지 보내기",
"loadingProposal": "제안을 불러오는 중...",
"errorLoadingProposal": "제안 불러오기 오류: {{message}}",
diff --git a/src/i18n/locales/pt-BR/chat.json b/src/i18n/locales/pt-BR/chat.json
index b1afb113d5..5c3f7054fd 100644
--- a/src/i18n/locales/pt-BR/chat.json
+++ b/src/i18n/locales/pt-BR/chat.json
@@ -81,7 +81,8 @@
"stoppingGeneration": "Parando…",
"cancellationEndingTestRun": "Encerrando a execução dos testes.",
"cancellationRemovingTestDatabase": "Removendo o banco de dados de teste temporário. Isso pode levar algum tempo.",
- "cancellationCleaningTestData": "Limpando o ambiente isolado e os dados de teste desta execução.",
+ "cancellationCleaningTestData": "Limpando os dados de teste desta execução.",
+ "cancellationCleaningTestSandbox": "Limpando o ambiente isolado e os dados de teste desta execução.",
"sendMessage": "Enviar mensagem",
"loadingProposal": "Carregando proposta...",
"errorLoadingProposal": "Erro ao carregar proposta: {{message}}",
diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json
index eec6531cc1..82a2f082e7 100644
--- a/src/i18n/locales/zh-CN/chat.json
+++ b/src/i18n/locales/zh-CN/chat.json
@@ -81,7 +81,8 @@
"stoppingGeneration": "正在停止…",
"cancellationEndingTestRun": "正在结束测试运行。",
"cancellationRemovingTestDatabase": "正在删除临时测试数据库。这可能需要一些时间。",
- "cancellationCleaningTestData": "正在清理本次运行的测试沙箱和数据。",
+ "cancellationCleaningTestData": "正在清理本次运行的测试数据。",
+ "cancellationCleaningTestSandbox": "正在清理本次运行的测试沙箱和数据。",
"sendMessage": "发送消息",
"loadingProposal": "正在加载提案...",
"errorLoadingProposal": "加载提案出错:{{message}}",
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 8aa37a354c..8c4e1f3509 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -277,6 +277,10 @@ describe("tests handlers", () => {
"repository-worktree",
"test-files",
],
+ // Same as the run stage: while the snapshot waits behind an unrelated
+ // blocker, work that only conflicts with it on `test-files` must not
+ // queue behind the whole test run.
+ allowCompatibleQueueBypass: true,
});
});
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 029cf2c8e1..b96e10fe87 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -75,6 +75,7 @@ import {
import { readTestScreenshotDataUrl } from "../utils/test_screenshot";
import { isRecordingActive } from "../services/recording_registry";
import { readSettings } from "@/main/settings";
+import { usesSandboxedE2eTests } from "@/lib/e2eSandbox";
import { DyadError, DyadErrorKind, isDyadError } from "@/errors/dyad_error";
const logger = log.scope("tests_handlers");
@@ -815,6 +816,13 @@ export async function runAppTestsWithIsolation({
});
testRunControllers.set(appId, { controller, done, runId });
+ // Whether this run took a sandbox. Reported on every run-state event so the
+ // cleanup copy can name what is actually being removed — the fallback path
+ // never creates a workspace, and claiming otherwise is the same class of
+ // inaccurate copy this work set out to remove. Declared before the progress
+ // emitter below, which an already-cancelled caller can fire synchronously.
+ let sandboxed = false;
+
/**
* Progress-only run-state events for the two waits a Stop cannot skip. Both
* are emitted only while this controller still owns the app, so a late event
@@ -842,6 +850,7 @@ export async function runAppTestsWithIsolation({
// Only `cleaning-up` carries this so the UI can name the remote provider
// cleanup accurately. The normal preview is not restarted.
isolation,
+ sandboxed,
});
};
@@ -954,20 +963,23 @@ export async function runAppTestsWithIsolation({
// against the user's real database.
const settings = readSettings();
const runtimeMode = settings.runtimeMode2 ?? "host";
- const sandboxUnavailable =
- runtimeMode !== "host"
+ const sandboxUnavailable = usesSandboxedE2eTests(settings)
+ ? null
+ : runtimeMode !== "host"
? {
disclosure: `Tests run against your normal preview because isolated test servers aren't available in ${runtimeMode} runtime yet.`,
neonRefusal: `Isolated E2E test servers aren't available in ${runtimeMode} runtime yet, and Dyad won't run Neon tests against your real database. Switch to host runtime to run tests for this app.`,
}
- : settings.disableSandboxedE2eTests
- ? {
- disclosure:
- "Tests run against your normal preview because isolated test servers are turned off in Settings.",
- neonRefusal:
- "Isolated test servers are turned off in Settings, and Dyad won't run Neon tests against your real database. Turn them back on to run tests for this app.",
- }
- : null;
+ : {
+ disclosure:
+ "Tests run against your normal preview because isolated test servers are turned off in Settings.",
+ neonRefusal:
+ "Isolated test servers are turned off in Settings, and Dyad won't run Neon tests against your real database. Turn them back on to run tests for this app.",
+ };
+ // Recorded once for this run rather than re-read later: the setting can
+ // change while the run is in flight, and the cleanup copy has to describe
+ // what this run actually did.
+ sandboxed = sandboxUnavailable === null;
if (sandboxUnavailable) {
finalResult = withIsolationCleanupWarning(
await runTestsAgainstNormalPreview({
@@ -1003,6 +1015,11 @@ export async function runAppTestsWithIsolation({
"repository-worktree",
"test-files",
],
+ // Same as the run stage below: while this waits behind, say, a git
+ // operation holding `repository-worktree`, an unrelated operation that
+ // only conflicts with this one on `test-files` should still proceed
+ // rather than queue behind a test run it has no reason to wait for.
+ allowCompatibleQueueBypass: true,
refuseWhenRecording: "run tests",
},
async () => {
@@ -1255,6 +1272,7 @@ export async function runAppTestsWithIsolation({
results: source === "agent" ? finalResult.results : undefined,
infraError: source === "agent" ? finalResult.infraError : undefined,
isolation: finalResult.isolation,
+ sandboxed,
});
// A teardown failure must not skip the cleanup below — leaving the
// controller registered and `done` unresolved would make every future
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index c72883b696..dec8e63633 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import net from "node:net";
import log from "electron-log";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { trackE2eTestProcess } from "@/ipc/services/e2e_test_process_registry";
import {
choosePackageManagerFromSignal,
@@ -144,15 +145,20 @@ async function waitForReady({
const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Test run stopped.");
+ // Precondition throughout: a server that won't start or won't answer is a
+ // user/environment problem (a broken start command, a port taken, a build
+ // error), not a Dyad bug, and must not be reported as a product exception.
const startError = spawnError();
if (startError) {
- throw new Error(
+ throw new DyadError(
`Could not start the isolated test server: ${startError.message}`,
+ DyadErrorKind.Precondition,
);
}
if (child.exitCode !== null || child.signalCode !== null) {
- throw new Error(
+ throw new DyadError(
`The isolated test server exited before becoming ready.\n${outputTail()}`,
+ DyadErrorKind.Precondition,
);
}
try {
@@ -165,8 +171,9 @@ async function waitForReady({
}
await delay(SERVER_READY_POLL_MS, signal);
}
- throw new Error(
+ throw new DyadError(
`The isolated test server did not become ready within 2 minutes.${portHint}\n${outputTail()}`,
+ DyadErrorKind.Precondition,
);
}
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index f8f5c1c656..e818822a99 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -5,6 +5,7 @@ import log from "electron-log";
import { getUserDataPath } from "@/paths/paths";
import { sendTelemetryEvent } from "@/ipc/utils/telemetry";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("e2e_test_workspace");
@@ -76,8 +77,11 @@ async function copyNodeModules(
const stat = await fs.stat(source);
if (!stat.isDirectory()) throw new Error("not a directory");
} catch {
- throw new Error(
+ // Precondition, not Internal: the user starts the app to fix this, and it
+ // must not be reported to PostHog as a product exception.
+ throw new DyadError(
"The app's dependencies are not installed. Start the app successfully before running tests.",
+ DyadErrorKind.Precondition,
);
}
diff --git a/src/ipc/types/tests.ts b/src/ipc/types/tests.ts
index 9214b953f0..1a228e4b52 100644
--- a/src/ipc/types/tests.ts
+++ b/src/ipc/types/tests.ts
@@ -450,6 +450,13 @@ export const TestsRunStatePayloadSchema = z.object({
results: z.array(TestResultSchema).optional(),
infraError: z.object({ message: z.string() }).optional(),
isolation: TestIsolationSchema.optional(),
+ /**
+ * Whether this run executed in an isolated sandbox — a throwaway copy of the
+ * app served by its own dev server. False for the fallback path (Docker/cloud
+ * runtime, or the user's opt-out), which creates no workspace, so cleanup
+ * copy can name what is actually being removed.
+ */
+ sandboxed: z.boolean().optional(),
});
export type TestsRunStatePayload = z.infer;
diff --git a/src/ipc/utils/test_screenshot.ts b/src/ipc/utils/test_screenshot.ts
index 5413685dd8..0bf810f8b8 100644
--- a/src/ipc/utils/test_screenshot.ts
+++ b/src/ipc/utils/test_screenshot.ts
@@ -16,28 +16,21 @@ const logger = log.scope("test_screenshot");
const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
/**
- * Read a Playwright failure screenshot as a PNG data URL, enforcing the same
- * containment guards as the `tests:screenshot` IPC handler: PNG-only, resolved
- * through symlinks, and inside the app's `test-results/` directory. Returns
- * null if the path is missing, not a PNG, or escapes both the app and Dyad's
- * retained per-run artifact root.
- *
- * Shared by the IPC handler (renderer thumbnails) and the agent's run_tests
- * tool (attaching a failure screenshot to the model).
+ * Resolve a Playwright artifact path through symlinks and confirm it lives
+ * under the app's `test-results/` directory, or under this app's retained
+ * per-run artifacts in user data. Returns the real path, or null when it
+ * escapes both.
*/
-export async function readTestScreenshotDataUrl(
+async function resolveContainedArtifact(
appPath: string,
- screenshotPath: string,
- appId?: number,
+ artifactPath: string,
+ appId: number | undefined,
): Promise {
// Playwright reports absolute paths, but resolve relative ones against the
// app dir just in case.
- const resolved = path.isAbsolute(screenshotPath)
- ? path.resolve(screenshotPath)
- : path.resolve(appPath, screenshotPath);
- if (path.extname(resolved).toLowerCase() !== ".png") {
- return null;
- }
+ const resolved = path.isAbsolute(artifactPath)
+ ? path.resolve(artifactPath)
+ : path.resolve(appPath, artifactPath);
// No existsSync pre-check: realpath below already rejects a missing path
// (throws → caught → null), and a separate check would open a TOCTOU window
// where the path could be swapped for a symlink between check and resolve.
@@ -61,12 +54,7 @@ export async function readTestScreenshotDataUrl(
// No retained sandbox artifacts yet.
}
} catch (error) {
- logger.warn(`Failed to resolve screenshot path ${resolved}: ${error}`);
- return null;
- }
- // Re-check the extension on the REAL (symlink-resolved) path: a `foo.png`
- // symlink pointing at a `.env.local` would otherwise pass the initial gate.
- if (path.extname(realPath).toLowerCase() !== ".png") {
+ logger.warn(`Failed to resolve test artifact path ${resolved}: ${error}`);
return null;
}
const appRelative = path.relative(realAppPath, realPath);
@@ -84,7 +72,7 @@ export async function readTestScreenshotDataUrl(
if (!insideApp && !insideArtifacts) {
return null;
}
- // Only serve screenshots under `test-results/`, not any PNG in the app. Use
+ // Only serve files under `test-results/`, not anything else in the app. Use
// split (not a string prefix) so a sibling like `test-results-foo/` can't
// slip through.
const segments = (insideApp ? appRelative : artifactRelative).split(path.sep);
@@ -98,6 +86,40 @@ export async function readTestScreenshotDataUrl(
if (testResultsSegment !== "test-results") {
return null;
}
+ return realPath;
+}
+
+/**
+ * Read a Playwright failure screenshot as a PNG data URL, enforcing the same
+ * containment guards as the `tests:screenshot` IPC handler: PNG-only, resolved
+ * through symlinks, and inside the app's `test-results/` directory. Returns
+ * null if the path is missing, not a PNG, or escapes both the app and Dyad's
+ * retained per-run artifact root.
+ *
+ * Shared by the IPC handler (renderer thumbnails) and the agent's run_tests
+ * tool (attaching a failure screenshot to the model).
+ */
+export async function readTestScreenshotDataUrl(
+ appPath: string,
+ screenshotPath: string,
+ appId?: number,
+): Promise {
+ if (path.extname(screenshotPath).toLowerCase() !== ".png") {
+ return null;
+ }
+ const realPath = await resolveContainedArtifact(
+ appPath,
+ screenshotPath,
+ appId,
+ );
+ if (realPath === null) {
+ return null;
+ }
+ // Re-check the extension on the REAL (symlink-resolved) path: a `foo.png`
+ // symlink pointing at a `.env.local` would otherwise pass the initial gate.
+ if (path.extname(realPath).toLowerCase() !== ".png") {
+ return null;
+ }
let handle: fs.promises.FileHandle | undefined;
try {
// O_NOFOLLOW closes the TOCTOU gap between the realpath check above and
@@ -150,3 +172,69 @@ export async function readTestScreenshotDataUrl(
});
}
}
+
+/**
+ * Refuse to inline a page snapshot above this size. `error-context.md` is
+ * Playwright's accessibility-tree dump of the failing page; a pathological one
+ * would otherwise dominate the model request.
+ */
+const MAX_ERROR_CONTEXT_BYTES = 24 * 1024;
+
+/**
+ * Read the `error-context.md` page snapshot Playwright writes beside a failure
+ * screenshot, under the same containment guards as the screenshot reader.
+ *
+ * Used when the artifact lives outside the app — a sandboxed run retains it in
+ * user data, where the agent's `read_file` cannot reach — so the snapshot can
+ * be inlined instead of pointed at with a path that would only fail.
+ */
+export async function readTestErrorContext(
+ appPath: string,
+ screenshotPath: string,
+ appId?: number,
+): Promise {
+ const contextPath = path.join(
+ path.dirname(screenshotPath),
+ "error-context.md",
+ );
+ const realPath = await resolveContainedArtifact(appPath, contextPath, appId);
+ if (realPath === null) {
+ return null;
+ }
+ // Re-check on the REAL path, for the same reason the screenshot reader does:
+ // an `error-context.md` symlink could otherwise point at `.env.local`.
+ if (path.extname(realPath).toLowerCase() !== ".md") {
+ return null;
+ }
+ let handle: fs.promises.FileHandle | undefined;
+ try {
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
+ handle = await fs.promises.open(realPath, fs.constants.O_RDONLY | noFollow);
+ const stats = await handle.stat();
+ if (!stats.isFile()) {
+ return null;
+ }
+ const size = Math.min(stats.size, MAX_ERROR_CONTEXT_BYTES);
+ const buf = Buffer.alloc(size);
+ let offset = 0;
+ while (offset < size) {
+ const { bytesRead } = await handle.read(
+ buf,
+ offset,
+ size - offset,
+ offset,
+ );
+ if (bytesRead === 0) break;
+ offset += bytesRead;
+ }
+ const text = buf.subarray(0, offset).toString("utf8");
+ return stats.size > size ? `${text}\n…(truncated)` : text;
+ } catch (error) {
+ logger.warn(`Failed to read page snapshot ${realPath}: ${error}`);
+ return null;
+ } finally {
+ await handle?.close().catch((error) => {
+ logger.warn(`Failed to close page snapshot ${realPath}: ${error}`);
+ });
+ }
+}
diff --git a/src/lib/e2eSandbox.ts b/src/lib/e2eSandbox.ts
new file mode 100644
index 0000000000..2f36054398
--- /dev/null
+++ b/src/lib/e2eSandbox.ts
@@ -0,0 +1,22 @@
+import type { UserSettings } from "@/lib/schemas";
+
+/**
+ * Whether an E2E test run for this app will execute in an isolated sandbox: a
+ * throwaway copy of the app served by its own run-scoped dev server.
+ *
+ * Shared by the main process (which routes the run) and the renderer/agent
+ * (which gate on whether the user's normal preview is required at all). The
+ * sandbox is host-only for now, and the user can opt out of it.
+ */
+export function usesSandboxedE2eTests(
+ settings:
+ | Pick
+ | null
+ | undefined,
+): boolean {
+ if (!settings) return false;
+ return (
+ (settings.runtimeMode2 ?? "host") === "host" &&
+ !settings.disableSandboxedE2eTests
+ );
+}
diff --git a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.spec.ts b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.spec.ts
index 9bb4fd1c63..ca6e171231 100644
--- a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.spec.ts
+++ b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.spec.ts
@@ -12,6 +12,7 @@ vi.mock("@/ipc/handlers/tests_handlers", () => ({
}));
vi.mock("@/ipc/utils/test_screenshot", () => ({
readTestScreenshotDataUrl: vi.fn(),
+ readTestErrorContext: vi.fn(),
}));
vi.mock("@/main/settings", () => ({
readSettings: vi.fn(() => ({})),
@@ -23,13 +24,17 @@ import {
listSpecFiles,
readSpecTestCases,
} from "@/ipc/handlers/tests_handlers";
-import { readTestScreenshotDataUrl } from "@/ipc/utils/test_screenshot";
+import {
+ readTestErrorContext,
+ readTestScreenshotDataUrl,
+} from "@/ipc/utils/test_screenshot";
import { readSettings } from "@/main/settings";
import { runTestsTool } from "./run_tests";
const runner = vi.mocked(runAppTestsWithIsolation);
const baseUrl = vi.mocked(getRunningTestBaseUrl);
const screenshot = vi.mocked(readTestScreenshotDataUrl);
+const errorContext = vi.mocked(readTestErrorContext);
const specLister = vi.mocked(listSpecFiles);
const caseLister = vi.mocked(readSpecTestCases);
const settingsReader = vi.mocked(readSettings);
@@ -116,8 +121,10 @@ describe("runTestsTool", () => {
screenshot.mockReset();
specLister.mockReset();
caseLister.mockReset();
+ errorContext.mockReset();
baseUrl.mockReturnValue("http://localhost:3000");
screenshot.mockResolvedValue(null);
+ errorContext.mockResolvedValue(null);
// The spec the tests target exists on disk, so pre-flight resolution lets
// the run proceed. Individual tests override this to exercise mismatches.
specLister.mockResolvedValue(["e2e-tests/a.spec.ts"]);
@@ -170,7 +177,12 @@ describe("runTestsTool", () => {
});
it("returns an infra message (uncounted) when the dev server isn't running", async () => {
+ // Only the non-sandboxed path needs the preview; a sandboxed run serves the
+ // app itself (covered separately below).
baseUrl.mockReturnValue(null);
+ settingsReader.mockReturnValue({
+ disableSandboxedE2eTests: true,
+ } as ReturnType);
const ctx = makeCtx();
const out = await runTestsTool.execute(
{ testFile: "e2e-tests/a.spec.ts" },
@@ -315,6 +327,65 @@ describe("runTestsTool", () => {
});
});
+ it("inlines the page snapshot when the artifacts live outside the app", async () => {
+ // A sandboxed run retains artifacts under /test-artifacts.
+ // read_file goes through safeJoin and rejects anything escaping the app, so
+ // a `../../..` path would guarantee the agent's first step fails.
+ runner.mockResolvedValue(
+ failResult(
+ "boom",
+ "/home/u/.config/dyad/test-artifacts/1-2-3/test-results/a/test-failed-1.png",
+ ),
+ );
+ screenshot.mockResolvedValue("data:image/png;base64,ABC");
+ errorContext.mockResolvedValue("- button 'Submit'\n- text 'Oops'");
+ const ctx = makeCtx();
+
+ const out = await runTestsTool.execute(
+ { testFile: "e2e-tests/a.spec.ts" },
+ ctx,
+ );
+
+ expect(out).toContain("- button 'Submit'");
+ // No traversal path, and no instruction to open one.
+ expect(out).not.toContain("..");
+ expect(out).not.toContain("read this first with read_file");
+ });
+
+ it("says the snapshot is unavailable rather than naming an unreadable path", async () => {
+ runner.mockResolvedValue(
+ failResult(
+ "boom",
+ "/home/u/.config/dyad/test-artifacts/1-2-3/test-results/a/test-failed-1.png",
+ ),
+ );
+ screenshot.mockResolvedValue(null);
+ errorContext.mockResolvedValue(null);
+ const ctx = makeCtx();
+
+ const out = await runTestsTool.execute(
+ { testFile: "e2e-tests/a.spec.ts" },
+ ctx,
+ );
+
+ expect(out).toContain("Page snapshot: unavailable for this run.");
+ expect(out).not.toContain("error-context.md");
+ });
+
+ it("does not require the dev server for a sandboxed run", async () => {
+ baseUrl.mockReturnValue(null);
+ runner.mockResolvedValue(passedResult);
+ const ctx = makeCtx();
+
+ const out = await runTestsTool.execute(
+ { testFile: "e2e-tests/a.spec.ts" },
+ ctx,
+ );
+
+ expect(out).toContain("All runnable tests passed");
+ expect(runner).toHaveBeenCalledTimes(1);
+ });
+
it("adds a no-progress note when the failure signature is unchanged", async () => {
runner.mockResolvedValue(failResult("boom"));
const ctx = makeCtx();
diff --git a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
index f5378bc4bf..d2fd0ed23c 100644
--- a/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
+++ b/src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts
@@ -13,7 +13,11 @@ import {
listSpecFiles,
readSpecTestCases,
} from "@/ipc/handlers/tests_handlers";
-import { readTestScreenshotDataUrl } from "@/ipc/utils/test_screenshot";
+import {
+ readTestErrorContext,
+ readTestScreenshotDataUrl,
+} from "@/ipc/utils/test_screenshot";
+import { usesSandboxedE2eTests } from "@/lib/e2eSandbox";
import { readSettings } from "@/main/settings";
import type { RunAppTestsResult, TestResult } from "@/ipc/types/tests";
import { normalizeFailureSignature } from "./test_failure_signature";
@@ -184,8 +188,13 @@ function guardTurnRunLimit(ctx: AgentContext): string | null {
return body;
}
-/** Tests need the dev server; being down does not count as an attempt. */
+/**
+ * The non-sandboxed path runs Playwright against the user's preview, so it
+ * needs one. A sandboxed run serves the app itself from its own copy on its own
+ * port and never touches the preview. Being down does not count as an attempt.
+ */
function guardDevServerRunning(ctx: AgentContext): string | null {
+ if (usesSandboxedE2eTests(readSettings())) return null;
if (getRunningTestBaseUrl(ctx.appId)) return null;
const body =
"The app's dev server isn't running, so the tests can't execute. Ask the user to start the app with the Run button in the preview panel, then call run_tests again. This did NOT count as a fix attempt.";
@@ -401,16 +410,18 @@ async function attachFailureArtifacts(
const rel = path.isAbsolute(shot.screenshotPath)
? path.relative(ctx.appPath, shot.screenshotPath)
: shot.screenshotPath;
- const errorContext = path
- .join(path.dirname(rel), "error-context.md")
- .split(path.sep)
- .join("/");
+ // A sandboxed run retains its artifacts under `/test-artifacts`,
+ // outside the app. `read_file` goes through `safeJoin` and rejects anything
+ // escaping the app directory, so handing the model a `../../..` path would
+ // guarantee its first diagnostic step fails.
+ const readableByAgent = !rel.startsWith("..") && !path.isAbsolute(rel);
const screenshotPath = rel.split(path.sep).join("/");
- const dataUrl = await readTestScreenshotDataUrl(
- ctx.appPath,
- shot.screenshotPath,
- ctx.appId,
- );
+ const [dataUrl, inlineSnapshot] = await Promise.all([
+ readTestScreenshotDataUrl(ctx.appPath, shot.screenshotPath, ctx.appId),
+ readableByAgent
+ ? Promise.resolve(null)
+ : readTestErrorContext(ctx.appPath, shot.screenshotPath, ctx.appId),
+ ]);
if (dataUrl) {
ctx.appendUserMessage([
{
@@ -423,10 +434,25 @@ async function attachFailureArtifacts(
// Only promise the image when it was actually attached — the read can fail
// (missing/oversized/escaping file), and the model would otherwise burn a
// turn looking for an attachment that never arrives.
- const screenshotLine = dataUrl
- ? `\n- Screenshot: ${screenshotPath} (attached to the next message as an image)`
- : `\n- Screenshot: ${screenshotPath} (could NOT be attached as an image — rely on the page snapshot instead)`;
- return `\nArtifacts from THIS run (other test-results directories are stale — do not read them):\n- Page snapshot: ${errorContext} ← read this first with read_file; it shows what was actually on the page${screenshotLine}`;
+ const attachmentNote = dataUrl
+ ? "attached to the next message as an image"
+ : "could NOT be attached as an image — rely on the page snapshot instead";
+
+ if (readableByAgent) {
+ const errorContext = path
+ .join(path.dirname(rel), "error-context.md")
+ .split(path.sep)
+ .join("/");
+ return `\nArtifacts from THIS run (other test-results directories are stale — do not read them):\n- Page snapshot: ${errorContext} ← read this first with read_file; it shows what was actually on the page\n- Screenshot: ${screenshotPath} (${attachmentNote})`;
+ }
+
+ // Out-of-app artifacts: inline the snapshot rather than name a path the model
+ // cannot open, and don't print the traversal path at all — it's meaningless
+ // to the agent and misleading as a location.
+ const snapshotSection = inlineSnapshot
+ ? `\n- Page snapshot (the page state when the test failed; inlined because this run's artifacts live outside the app and read_file cannot reach them):\n\n${inlineSnapshot}\n`
+ : "\n- Page snapshot: unavailable for this run.";
+ return `\nArtifacts from THIS run:${snapshotSection}\n- Screenshot: ${attachmentNote}.`;
}
async function reportFailure(params: {
@@ -464,12 +490,12 @@ async function reportFailure(params: {
: "";
const inconclusiveHint = outcome.allInconclusive
- ? "\nThese are locator/timeout/strict-mode errors (e.g. a selector that matched nothing, matched a hidden element, or matched more than one element). That is almost always a LOCATOR bug in the test — make the selector more precise (exact text/role, filter to the visible element, scope to a container). Only if error-context.md shows the page never rendered is it the app or environment.\n"
+ ? "\nThese are locator/timeout/strict-mode errors (e.g. a selector that matched nothing, matched a hidden element, or matched more than one element). That is almost always a LOCATOR bug in the test — make the selector more precise (exact text/role, filter to the visible element, scope to a container). Only if the page snapshot shows the page never rendered is it the app or environment.\n"
: "";
const nextStep =
remaining > 0
- ? `Next: read error-context.md, decide whether the TEST or the APP is wrong, make one targeted fix, then call run_tests again. ${remaining} attempt(s) remain for this spec this turn.`
+ ? `Next: use the page snapshot from the artifacts above, decide whether the TEST or the APP is wrong, make one targeted fix, then call run_tests again. ${remaining} attempt(s) remain for this spec this turn.`
: `You have now used all ${MAX_ATTEMPTS} attempts for this spec. Stop and summarize the situation for the user.`;
const skippedNote =
@@ -505,15 +531,16 @@ export const runTestsTool: ToolDefinition = {
- Unless you just wrote or edited the spec this turn, READ it with read_file before running it — you need its current content to know the test() titles (for grep) and to interpret failures against what the test actually does.
- By default the whole file runs, so a pass means every test in the spec passes.
- Run the whole file by default. Only add \`grep\` (a regex passed to Playwright's --grep, matched against full hierarchical test titles) when you have a specific reason to narrow the run — e.g. one test keeps failing while the spec's other tests already passed and rerunning them all is slow. A narrowed pass only verifies the tests it matched, not the rest of the file. If the pattern matches no runnable test, the tool reports that nothing executed.
-- Requires the app's dev server to be running (the user starts it with the Run button in the preview panel).
-- On failure you get the error text plus the paths of Playwright's artifacts (error-context.md page snapshot, screenshot) — read error-context.md with read_file to see the page state, then fix and rerun.
+- Runs in an isolated sandbox — a copy of the app served on its own port — so the user's preview does not need to be running. If sandboxing is unavailable (Docker/cloud runtime, or the user turned it off), the tool says so and asks for the dev server instead.
+- On failure you get the error text plus Playwright's artifacts: the error-context.md page snapshot (given to you as a path to read with read_file, or inlined when it lives outside the app) and a screenshot attached as an image. Read the page snapshot to see the page state, then fix and rerun.
- You get ${MAX_ATTEMPTS} fix attempts per spec per turn. When the limit is reached, stop and summarize the situation for the user.
- If you suspect a failure is flaky, rerun once with \`flakeCheck: true\` (does not count against the limit).
- Never rerun something that already passed: once a target (or the whole file) is green and you haven't changed any files, the tool refuses the run — move on instead.`,
inputSchema: runTestsSchema,
defaultConsent: "always",
- // Isolation swaps the app's env file and restarts the dev server, so this
- // must be excluded from read-only / plan modes.
+ // A run writes Playwright's config/deps into the app and provisions remote
+ // test data (a throwaway Neon branch or Supabase user), so this must be
+ // excluded from read-only / plan modes.
modifiesState: true,
isEnabled: (ctx) => ctx.testingEnabled,
From f298d7eaed57beb0c57f1ca3708b98e29bfac6e6 Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Mon, 24 Aug 2026 18:00:07 +0000
Subject: [PATCH 5/9] Address review round 1: setup-failure classification and
sandbox leaks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every finding from the round-1 review of the sandboxed E2E test runtime.
Setup failures no longer escape as internal exceptions. `ensurePlaywrightBootstrap`
and `createE2eTestWorkspace` both run in the sandbox prepare stage, outside the
try/catch that used to cover bootstrap inside `runAppTestsCore`; a registry
timeout, a failed browser download or a missing `node_modules` therefore rejected
the IPC call, recorded an internal product exception and threw out of the agent's
turn instead of counting as a non-attempt infra failure. The stage now reports
setup failures as data and the run resolves to an ordinary `infraError`.
A custom-command app no longer needs `node_modules` to be sandboxed at all. It
need not be a Node project, its install command runs in the sandbox, and the
Run button is enabled without a dev server now — so refusing there made the
sandbox structurally impossible for those apps.
Custom commands run as `install && start`, the same shape `getCommand` builds
for the preview. Running the start command alone skipped codegen, builds and
non-npm dependency setup the server may need, so an app would start under the
preview and fail only under test.
Artifact retention is best-effort again. An `fs.cp` failure after the run had
already produced results (a trace file still held on Windows, a full disk)
discarded the whole run. It now costs at most the screenshots, whose paths are
dropped rather than left pointing into a sandbox that is about to be deleted.
Test-server ports come from a reserved band (52150..52349) instead of an
OS-assigned ephemeral port. The ephemeral range covers almost all of `getAppPort`,
`getAppProxyPort` and the proxy fallback band, so a test server could hold another
app's deterministic port for a whole run and make that app fail to start later
with nothing to point at as the cause. Concurrent allocations are also no longer
handed the same port.
The Neon branch delete is no longer gated on restoring the sandbox's own
`.env.local`. That gate exists because the recorder's swap can leave the real
project pointed at the branch; nothing points at a sandbox copy that is deleted
seconds later, so the gate only leaked a real branch. The matching "restore your
real database settings" warning is now suppressed on that path too.
Also: a dev server that survives `killProcess`'s 5s timeout stays registered so
`will-quit` can still tree-kill it; a failed `dispose()` no longer replaces the
setup error it was cleaning up after; and a server that announces its port was
taken and quietly moved (Vite's default `strictPort: false`) triggers the retry
instead of a two-minute readiness timeout on a dead port.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
---
shared/ports.ts | 35 +++++
src/ipc/handlers/tests_handlers.test.ts | 129 +++++++++++++++++-
src/ipc/handlers/tests_handlers.ts | 95 +++++++++++---
src/ipc/services/e2e_test_runtime.test.ts | 83 +++++++++++-
src/ipc/services/e2e_test_runtime.ts | 137 +++++++++++++++++---
src/ipc/services/e2e_test_workspace.test.ts | 51 ++++++++
src/ipc/services/e2e_test_workspace.ts | 49 +++++--
src/ipc/services/isolated_test_db.test.ts | 39 ++++++
src/ipc/services/isolated_test_db.ts | 24 +++-
9 files changed, 583 insertions(+), 59 deletions(-)
diff --git a/shared/ports.ts b/shared/ports.ts
index 7123fa6937..123ed5ccde 100644
--- a/shared/ports.ts
+++ b/shared/ports.ts
@@ -53,6 +53,41 @@ export const PROXY_FALLBACK_PORT_START = PROXY_PORT_BASE + PROXY_PORT_RANGE;
/** How many consecutive fallback ports to scan before giving up. */
export const PROXY_FALLBACK_MAX_ATTEMPTS = 50;
+/**
+ * Start of the band reserved for run-scoped E2E test servers. It sits directly
+ * above the proxy fallback band so a sandbox server never squats on the
+ * deterministic app or proxy port of some *other* app that merely happens to be
+ * stopped right now — which would make that app fail to start later, with
+ * nothing to point at as the cause. An OS-assigned ephemeral port cannot give
+ * that guarantee: on Linux the ephemeral range (32768–60999) covers almost all
+ * of the reserved bands below.
+ */
+export const E2E_TEST_SERVER_PORT_START =
+ PROXY_FALLBACK_PORT_START + PROXY_FALLBACK_MAX_ATTEMPTS;
+/** Width of the E2E test-server band, so it spans 52150..52349. */
+export const E2E_TEST_SERVER_PORT_RANGE = 200;
+
+/**
+ * Whether a port falls in a band Dyad hands out deterministically, and so must
+ * not be taken by anything that only needs *some* free port.
+ */
+export function isReservedDyadPort(port: number): boolean {
+ const e2ePortBlockBase = getE2ePortBlockBase();
+ if (
+ e2ePortBlockBase != null &&
+ port >= e2ePortBlockBase &&
+ port < e2ePortBlockBase + E2E_PORT_BLOCK_SIZE
+ ) {
+ return true;
+ }
+ return (
+ (port >= APP_PORT_BASE && port < APP_PORT_BASE + APP_PORT_RANGE) ||
+ (port >= PROXY_PORT_BASE && port < PROXY_PORT_BASE + PROXY_PORT_RANGE) ||
+ (port >= PROXY_FALLBACK_PORT_START &&
+ port < PROXY_FALLBACK_PORT_START + PROXY_FALLBACK_MAX_ATTEMPTS)
+ );
+}
+
export function getProxyFallbackPortStart(): number {
const e2ePortBlockBase = getE2ePortBlockBase();
if (e2ePortBlockBase != null) {
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 8c4e1f3509..67b5e75e5f 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
-import { DyadErrorKind } from "@/errors/dyad_error";
+import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import type { RemoveFileAndCommitResult } from "../services/git_service";
import { apps } from "@/db/schema";
import { DEFAULT_SETTINGS } from "@/main/settings";
@@ -80,6 +80,7 @@ const readSettingsMock = vi.hoisted(() => vi.fn());
const sendTelemetryEventMock = vi.hoisted(() => vi.fn());
const ensurePlaywrightBootstrapMock = vi.hoisted(() => vi.fn());
const createE2eTestWorkspaceMock = vi.hoisted(() => vi.fn());
+const retainE2eTestArtifactsMock = vi.hoisted(() => vi.fn());
const startE2eTestRuntimeMock = vi.hoisted(() => vi.fn());
const spawnStreamingMock = vi.hoisted(() => vi.fn());
const broadcastToRegisteredWindowsMock = vi.hoisted(() => vi.fn());
@@ -126,12 +127,14 @@ vi.mock("../services/e2e_test_workspace", async (importOriginal) => {
return {
...actual,
createE2eTestWorkspace: createE2eTestWorkspaceMock,
- retainE2eTestArtifacts: vi.fn(),
+ retainE2eTestArtifacts: retainE2eTestArtifactsMock,
};
});
-vi.mock("../services/e2e_test_runtime", () => ({
- startE2eTestRuntime: startE2eTestRuntimeMock,
-}));
+vi.mock("../services/e2e_test_runtime", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return { ...actual, startE2eTestRuntime: startE2eTestRuntimeMock };
+});
vi.mock("@/main/settings", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, readSettings: readSettingsMock };
@@ -173,6 +176,8 @@ describe("tests handlers", () => {
sendTelemetryEventMock.mockReset();
ensurePlaywrightBootstrapMock.mockReset();
ensurePlaywrightBootstrapMock.mockResolvedValue({ installed: false });
+ retainE2eTestArtifactsMock.mockReset();
+ retainE2eTestArtifactsMock.mockResolvedValue(undefined);
createE2eTestWorkspaceMock.mockReset();
createE2eTestWorkspaceMock.mockImplementation(
async ({ appPath }: { appPath: string }) => ({
@@ -577,6 +582,120 @@ describe("tests handlers", () => {
expect(startE2eTestRuntimeMock).not.toHaveBeenCalled();
});
+ it("reports a failed Playwright bootstrap as an infra error, not a crash", async () => {
+ // This call used to live inside `runAppTestsCore`, which classified it as
+ // an `infraError`. Letting it escape from the sandbox prepare stage would
+ // reject the IPC call, record an internal product exception, and throw
+ // out of the agent's turn instead of counting as a non-attempt.
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ ensurePlaywrightBootstrapMock.mockRejectedValue(
+ new Error("npm registry unreachable"),
+ );
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(/registry unreachable/i);
+ expect(result.results).toEqual([]);
+ expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
+ });
+
+ it("reports a failed sandbox copy as an infra error, not a crash", async () => {
+ // The Run button is enabled without a dev server now, so an app whose
+ // dependencies were never installed reaches this — and must be told so,
+ // not answered with a rejected IPC call.
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ createE2eTestWorkspaceMock.mockRejectedValue(
+ new DyadError(
+ "The app's dependencies are not installed. Start the app successfully before running tests.",
+ DyadErrorKind.Precondition,
+ ),
+ );
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(
+ /dependencies are not installed/i,
+ );
+ expect(startE2eTestRuntimeMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps a finished run's results when artifact retention fails", async () => {
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "none" },
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: true,
+ }),
+ });
+ // Windows still holding a trace file, a full disk — retention is
+ // best-effort and must cost at most the screenshots.
+ retainE2eTestArtifactsMock.mockRejectedValue(new Error("EBUSY"));
+ spawnStreamingMock.mockImplementation(
+ async ({ cwd }: { cwd: string }) => {
+ const reportPath = path.join(cwd, "test-results", "results.json");
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
+ fs.writeFileSync(
+ reportPath,
+ JSON.stringify({
+ suites: [
+ {
+ file: "e2e-tests/a.spec.ts",
+ specs: [
+ {
+ title: "works",
+ ok: true,
+ tests: [{ results: [{ status: "passed" }] }],
+ },
+ ],
+ },
+ ],
+ }),
+ );
+ return {
+ code: 0,
+ stdout: "",
+ stderr: "",
+ aborted: false,
+ timedOut: false,
+ };
+ },
+ );
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError).toBeUndefined();
+ expect(result.results).toHaveLength(1);
+ expect(result.results[0].status).toBe("passed");
+ });
+
it("routes around the sandbox when the user turned it off", async () => {
const appId = seedApp("app");
harness.db
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index b96e10fe87..0be4efa73a 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -69,6 +69,7 @@ import {
type E2eTestWorkspace,
} from "../services/e2e_test_workspace";
import {
+ hasCustomE2eStartCommand,
startE2eTestRuntime,
type E2eTestRuntime,
} from "../services/e2e_test_runtime";
@@ -111,10 +112,15 @@ function isNoTestsFoundOutput(output: string): boolean {
return /\bno tests found\b/i.test(output);
}
+/**
+ * Repoint sandbox-relative artifact paths at the retained copy. `artifactPath`
+ * is undefined when retention failed, which drops the paths instead: the
+ * sandbox is about to be deleted, so a path into it would only fail to open.
+ */
function rewriteResultArtifactPaths(
results: TestResult[],
workspacePath: string,
- artifactPath: string,
+ artifactPath: string | undefined,
): TestResult[] {
return results.map((result) => ({
...result,
@@ -728,6 +734,17 @@ async function runTestsAgainstNormalPreview({
);
}
+/**
+ * Outcome of the sandbox prepare stage. A setup failure is reported as data
+ * rather than thrown so the run still resolves to an ordinary `infraError`
+ * result — the same classification the non-sandboxed path gives a Playwright
+ * bootstrap failure — instead of rejecting the IPC call as an internal
+ * exception.
+ */
+type E2eTestPrepareResult =
+ | { installed: boolean; workspace: E2eTestWorkspace }
+ | { setupError: string };
+
export interface RunTestsWithIsolationOptions {
/**
* The invoking IPC event. Its `sender` is where `tests:output` and
@@ -1022,26 +1039,55 @@ export async function runAppTestsWithIsolation({
allowCompatibleQueueBypass: true,
refuseWhenRecording: "run tests",
},
- async () => {
+ async (): Promise => {
const claimedApp = await getApp(appId);
const realAppPath = getDyadAppPath(claimedApp.path);
- const { installed } = await ensurePlaywrightBootstrap({
- appPath: realAppPath,
- signal: controller.signal,
- onOutput: (chunk) => emit(chunk, "setup"),
- });
- emit("Copying the app into an isolated test workspace…\n", "setup");
- return {
- installed,
- workspace: await createE2eTestWorkspace({
- appId,
+ try {
+ const { installed } = await ensurePlaywrightBootstrap({
appPath: realAppPath,
signal: controller.signal,
- onProgress: (message) => emit(message, "setup"),
- }),
- };
+ onOutput: (chunk) => emit(chunk, "setup"),
+ });
+ emit("Copying the app into an isolated test workspace…\n", "setup");
+ return {
+ installed,
+ workspace: await createE2eTestWorkspace({
+ appId,
+ appPath: realAppPath,
+ hasCustomCommands: hasCustomE2eStartCommand(claimedApp),
+ signal: controller.signal,
+ onProgress: (message) => emit(message, "setup"),
+ }),
+ };
+ } catch (error) {
+ // A Stop is not a setup failure — let it reach the outer catch, which
+ // turns it into the same "Test run stopped." result the in-run Stop
+ // path produces.
+ if (controller.signal.aborted) throw error;
+ // Everything else here — a Playwright install that can't reach the
+ // registry, a browser download that fails, a missing `node_modules`,
+ // a full disk — is an environment problem the user acts on, exactly
+ // like the bootstrap failure `runAppTestsCore` already reports as an
+ // `infraError`. Letting it escape instead would reject the IPC call,
+ // record an internal product exception, and (for the agent) throw out
+ // of the turn rather than count as a non-attempt infra failure.
+ const message =
+ error instanceof Error ? error.message : String(error);
+ logger.error(
+ `Isolated E2E test setup failed for app ${appId}: ${message}`,
+ );
+ return { setupError: message };
+ }
},
);
+ if ("setupError" in prepareResult) {
+ finalResult = withIsolationCleanupWarning({
+ appId,
+ results: [],
+ infraError: { message: prepareResult.setupError },
+ });
+ return finalResult;
+ }
workspace = prepareResult.workspace;
// The live test only owns provider/test inputs. It deliberately does not
@@ -1162,11 +1208,26 @@ export async function runAppTestsWithIsolation({
onOutput: emit,
testEnv: prepared.testCredentials,
});
- await retainE2eTestArtifacts(workspace!);
+ // Best-effort by nature: the run has already produced its results, so
+ // a failed copy (a trace file still held by a browser that hasn't
+ // fully exited on Windows, a full disk) must cost at most the
+ // screenshots — never the whole run. Paths are only rewritten when
+ // the artifacts actually made it out of the sandbox; otherwise they
+ // are dropped, since the sandbox they point into is deleted moments
+ // from now.
+ let retained = false;
+ try {
+ await retainE2eTestArtifacts(workspace!);
+ retained = true;
+ } catch (error) {
+ logger.warn(
+ `Failed to retain isolated test artifacts for app ${appId}: ${error}`,
+ );
+ }
result.results = rewriteResultArtifactPaths(
result.results,
workspace!.workspacePath,
- workspace!.artifactPath,
+ retained ? workspace!.artifactPath : undefined,
);
return { ...result, isolation: prepared.isolation };
} finally {
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
index 2c1289ac1f..6531fbedec 100644
--- a/src/ipc/services/e2e_test_runtime.test.ts
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -16,10 +16,17 @@ vi.mock("@/ipc/utils/socket_firewall", async (importOriginal) => {
});
import {
+ allocateE2eTestPort,
buildE2eTestStartCommand,
+ releaseE2eTestPort,
startE2eTestRuntime,
} from "./e2e_test_runtime";
import { runningApps } from "@/ipc/utils/process_manager";
+import {
+ E2E_TEST_SERVER_PORT_RANGE,
+ E2E_TEST_SERVER_PORT_START,
+ isReservedDyadPort,
+} from "../../../shared/ports";
function mockPnpmAvailable(available: boolean) {
getPnpmMinimumReleaseAgeSupportMock.mockResolvedValue({
@@ -51,17 +58,24 @@ describe("buildE2eTestStartCommand", () => {
installCommand: "custom-install",
startCommand: "custom-server --listen {port}",
});
- expect(command.command).toBe("custom-server --listen 45678");
+ expect(command.command).toBe(
+ "custom-install && custom-server --listen 45678",
+ );
});
- it("runs a custom command verbatim instead of appending a port flag", async () => {
+ it("runs both custom commands verbatim instead of appending a port flag", async () => {
+ // Same `install && start` shape `getCommand` builds for the preview: the
+ // sandbox is a fresh copy, so skipping the install step would drop codegen
+ // or a build the server needs and break the app under test only.
const command = await buildE2eTestStartCommand({
workspacePath: path.resolve("app"),
port: 45678,
installCommand: "pip install -r requirements.txt",
startCommand: "python server.py",
});
- expect(command.command).toBe("python server.py");
+ expect(command.command).toBe(
+ "pip install -r requirements.txt && python server.py",
+ );
expect(command.env.PORT).toBe("45678");
});
@@ -134,3 +148,66 @@ http.createServer((_request, response) => response.end("sandbox"))
}
});
});
+
+describe("allocateE2eTestPort", () => {
+ it("allocates out of Dyad's reserved band, never another app's port", async () => {
+ const port = await allocateE2eTestPort();
+ try {
+ expect(port).toBeGreaterThanOrEqual(E2E_TEST_SERVER_PORT_START);
+ expect(port).toBeLessThan(
+ E2E_TEST_SERVER_PORT_START + E2E_TEST_SERVER_PORT_RANGE,
+ );
+ // The whole point: an OS-assigned ephemeral port would routinely land on
+ // the deterministic app or proxy port of another, currently stopped app.
+ expect(isReservedDyadPort(port)).toBe(false);
+ } finally {
+ releaseE2eTestPort(port);
+ }
+ });
+
+ it("does not hand the same port to two runs starting at once", async () => {
+ const [first, second] = await Promise.all([
+ allocateE2eTestPort(),
+ allocateE2eTestPort(),
+ ]);
+ try {
+ expect(first).not.toBe(second);
+ } finally {
+ releaseE2eTestPort(first);
+ releaseE2eTestPort(second);
+ }
+ });
+});
+
+describe("startE2eTestRuntime port recovery", () => {
+ it("stops polling a dead port when the server announces the clash", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-port-"));
+ // Mimics Vite's default `strictPort: false`: it prints the clash, moves to
+ // another port and keeps running, so nothing throws and nothing ever
+ // answers on the port Dyad picked. Without matching that output the poll
+ // would sit here for the full two-minute readiness timeout.
+ fs.writeFileSync(
+ path.join(root, "server.mjs"),
+ [
+ "const port = Number(process.argv[2]);",
+ "console.log(`Port ${port} is in use, trying another one...`);",
+ "setInterval(() => {}, 1000);",
+ ].join("\n"),
+ );
+ const startedAt = Date.now();
+ try {
+ await expect(
+ startE2eTestRuntime({
+ workspacePath: root,
+ installCommand: "true",
+ startCommand: `"${process.execPath}" server.mjs {port}`,
+ }),
+ ).rejects.toThrow(/already in use|is in use/i);
+ // Three attempts, each bailing on the announcement rather than waiting
+ // out SERVER_READY_TIMEOUT_MS (120s).
+ expect(Date.now() - startedAt).toBeLessThan(30_000);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ }, 60_000);
+});
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index dec8e63633..9f5a9311f3 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -2,6 +2,11 @@ import { spawn, type ChildProcess } from "node:child_process";
import net from "node:net";
import log from "electron-log";
+import {
+ E2E_TEST_SERVER_PORT_RANGE,
+ E2E_TEST_SERVER_PORT_START,
+ isReservedDyadPort,
+} from "../../../shared/ports";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { trackE2eTestProcess } from "@/ipc/services/e2e_test_process_registry";
import {
@@ -25,24 +30,73 @@ export interface E2eTestRuntime {
stop(): Promise;
}
-export async function allocateE2eTestPort(): Promise {
- return new Promise((resolve, reject) => {
+/**
+ * Ports handed out but whose server has not bound yet. The probe below binds
+ * and immediately closes, so without this two runs starting within the same
+ * second — tests for two different apps — would be handed the same port.
+ */
+const pendingE2eTestPorts = new Set();
+
+/** Probe one port. Resolves to the bound port, or null if it's unavailable. */
+function probePort(port: number): Promise {
+ return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
- server.once("error", reject);
- server.listen(0, "127.0.0.1", () => {
+ server.once("error", (error: NodeJS.ErrnoException) => {
+ if (error.code === "EADDRINUSE" || error.code === "EACCES") {
+ resolve(null);
+ return;
+ }
+ reject(error);
+ });
+ server.listen(port, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
- server.close(() =>
- reject(new Error("Could not allocate a test port.")),
- );
+ server.close(() => resolve(null));
return;
}
- server.close((error) => (error ? reject(error) : resolve(address.port)));
+ const bound = address.port;
+ server.close((error) => (error ? reject(error) : resolve(bound)));
});
});
}
+export async function allocateE2eTestPort(): Promise {
+ // Scan Dyad's reserved band first. Binding port 0 would let the OS pick from
+ // the ephemeral range, which on Linux (32768–60999) overlaps the app, proxy
+ // and proxy-fallback bands almost entirely — so a test server could hold
+ // another app's deterministic port for the length of a run and make that app
+ // fail to start with nothing to point at as the cause.
+ for (let offset = 0; offset < E2E_TEST_SERVER_PORT_RANGE; offset += 1) {
+ const port = E2E_TEST_SERVER_PORT_START + offset;
+ if (pendingE2eTestPorts.has(port)) continue;
+ if ((await probePort(port)) !== null) {
+ pendingE2eTestPorts.add(port);
+ return port;
+ }
+ }
+ // Band exhausted (200 concurrent runs, or a foreign service squatting the
+ // whole range): fall back to an OS-assigned port, rejecting any that lands in
+ // a reserved band rather than giving up on running tests at all.
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ const port = await probePort(0);
+ if (
+ port !== null &&
+ !isReservedDyadPort(port) &&
+ !pendingE2eTestPorts.has(port)
+ ) {
+ pendingE2eTestPorts.add(port);
+ return port;
+ }
+ }
+ throw new Error("Could not allocate a test port.");
+}
+
+/** Hand a port back once its server has bound it (or failed to start). */
+export function releaseE2eTestPort(port: number): void {
+ pendingE2eTestPorts.delete(port);
+}
+
/**
* Whether the app supplies its own commands. Mirrors `getCommand` in
* `app_runtime_service`: a command counts as custom only when BOTH the install
@@ -71,17 +125,26 @@ export async function buildE2eTestStartCommand({
startCommand?: string | null;
}): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
if (hasCustomE2eStartCommand({ installCommand, startCommand })) {
- // Run the user's command verbatim, exactly as the normal preview does.
- // Appending `-- --port` would break every custom server that doesn't accept
- // that flag (a Python server, a shell script, a CLI that spells it
- // differently) under test only. `{port}` is the explicit opt-in for
- // pointing a custom server at the run-scoped port; otherwise PORT is the
- // only hint we can safely supply.
- const trimmed = startCommand!.trim();
- const command = trimmed.includes("{port}")
- ? trimmed.replaceAll("{port}", String(port))
- : trimmed;
- return { command, env: { ...process.env, PORT: String(port) } };
+ // Run the user's commands verbatim — no `-- --port` appended, which would
+ // break every custom server that doesn't accept that flag (a Python server,
+ // a shell script, a CLI that spells it differently) under test only.
+ // `{port}` is the explicit opt-in for pointing a custom server at the
+ // run-scoped port; otherwise PORT is the only hint we can safely supply.
+ //
+ // Both commands, in the same `install && start` shape `getCommand` uses for
+ // the preview. Running the start command alone would silently skip a step
+ // the server may depend on — codegen, `prisma generate`, a build, a
+ // non-npm dependency install — so the app would start under the preview and
+ // fail only under test. The sandbox is a fresh copy, so there is nothing
+ // else that would have performed it.
+ const trimmedStart = startCommand!.trim();
+ const start = trimmedStart.includes("{port}")
+ ? trimmedStart.replaceAll("{port}", String(port))
+ : trimmedStart;
+ return {
+ command: `${installCommand!.trim()} && ${start}`,
+ env: { ...process.env, PORT: String(port) },
+ };
}
// Select the package manager the same way the normal preview does. Choosing
@@ -127,6 +190,19 @@ function delay(ms: number, signal?: AbortSignal): Promise {
});
}
+/**
+ * A dev server that found its port taken and quietly moved to another one.
+ * Vite prints this and keeps running (its default is `strictPort: false`), so
+ * without matching it the readiness poll would sit on the dead original port
+ * for the full two minutes and then report a timeout, when a retry on a fresh
+ * port is all that was needed. Matched against the process output rather than a
+ * thrown error, because nothing throws in this case.
+ */
+const PORT_TAKEN_OUTPUT = /port\s+\d+\s+is\s+in\s+use|address already in use/i;
+/** Errors and output that mean "try another port", for the retry loop below. */
+const PORT_TAKEN_MESSAGE =
+ /EADDRINUSE|address already in use|port \d+ is in use/i;
+
async function waitForReady({
baseUrl,
process: child,
@@ -161,6 +237,13 @@ async function waitForReady({
DyadErrorKind.Precondition,
);
}
+ if (PORT_TAKEN_OUTPUT.test(outputTail())) {
+ // Not a Precondition: the retry loop turns this into a fresh port, and
+ // only a repeat failure reaches the user.
+ throw new Error(
+ `The isolated test server reported its port was already in use.\n${outputTail()}`,
+ );
+ }
try {
const response = await fetch(baseUrl, {
signal: AbortSignal.timeout(1_000),
@@ -236,7 +319,15 @@ async function startE2eTestRuntimeOnce({
if (child.pid && child.exitCode === null && child.signalCode === null) {
await killProcess(child);
}
- untrack();
+ // `killProcess` also resolves on its own 5s timeout, with the tree still
+ // alive. Untracking then would remove the one child `will-quit` still
+ // needs to tree-kill — exactly the leak the registry exists to prevent —
+ // and that survivor still holds the workspace cwd `dispose()` is about to
+ // remove. Leave it registered; `trackE2eTestProcess`'s own exit/error
+ // listeners drop it whenever it does die.
+ if (child.exitCode !== null || child.signalCode !== null) {
+ untrack();
+ }
})();
return stopPromise;
};
@@ -252,6 +343,9 @@ async function startE2eTestRuntimeOnce({
spawnError: () => startError,
portHint,
});
+ // The server owns the port now, so a concurrent allocation only needs the
+ // real bind check to see it is taken.
+ releaseE2eTestPort(port);
logger.info(`Isolated E2E server ready on port ${port}`);
return {
baseUrl,
@@ -263,6 +357,7 @@ async function startE2eTestRuntimeOnce({
};
} catch (error) {
signal?.removeEventListener("abort", onAbort);
+ releaseE2eTestPort(port);
await stop();
throw error;
}
@@ -278,7 +373,7 @@ export async function startE2eTestRuntime(
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
- if (!/EADDRINUSE|address already in use/i.test(message)) throw error;
+ if (!PORT_TAKEN_MESSAGE.test(message)) throw error;
options.onOutput?.(
"[test server] The selected port was taken; retrying with another port…\n",
);
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
index eee04c0723..5117c57247 100644
--- a/src/ipc/services/e2e_test_workspace.test.ts
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -119,6 +119,57 @@ describe("E2E test workspace", () => {
},
);
+ it("refuses a Dyad-managed app whose dependencies aren't installed", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ vi.mocked(getUserDataPath).mockReturnValue(path.join(root, "user-data"));
+ await fs.mkdir(appPath, { recursive: true });
+ await fs.writeFile(path.join(appPath, "package.json"), "{}");
+
+ await expect(createE2eTestWorkspace({ appId: 7, appPath })).rejects.toThrow(
+ /dependencies are not installed/i,
+ );
+ // The partial copy must not survive the refusal.
+ await expect(
+ fs.readdir(path.join(root, "user-data", E2E_TEST_SANDBOX_DIR)),
+ ).resolves.toEqual([]);
+ });
+
+ it("allows a custom-command app to have no node_modules at all", async () => {
+ // Custom install/start commands need not describe a Node project, and the
+ // install command runs inside the sandbox. Refusing here would make the
+ // sandbox structurally impossible for every such app — while the Run
+ // button stays enabled, because the dev-server gate is gone.
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ vi.mocked(getUserDataPath).mockReturnValue(path.join(root, "user-data"));
+ await fs.mkdir(appPath, { recursive: true });
+ await fs.writeFile(path.join(appPath, "main.py"), "print('hi')\n");
+
+ const workspace = await createE2eTestWorkspace({
+ appId: 7,
+ appPath,
+ hasCustomCommands: true,
+ });
+ expect(
+ await fs.readFile(path.join(workspace.workspacePath, "main.py"), "utf8"),
+ ).toBe("print('hi')\n");
+ await workspace.dispose();
+ });
+
+ it("drops artifact paths when retention didn't happen", () => {
+ // Retention is best-effort: when the copy out of the sandbox fails, the
+ // result keeps its verdicts but must not point at a directory that is
+ // about to be deleted.
+ expect(
+ rewriteE2eArtifactPath(
+ path.join("/ws", "test-results", "shot.png"),
+ "/ws",
+ undefined,
+ ),
+ ).toBeUndefined();
+ });
+
it("retains and rewrites screenshot artifacts before disposal", async () => {
const root = await tempRoot();
const workspacePath = path.join(root, "workspace");
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index e818822a99..60538bcdbd 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -69,14 +69,27 @@ function assertOwnedPath(root: string, candidate: string): void {
async function copyNodeModules(
appPath: string,
workspacePath: string,
- signal?: AbortSignal,
- countEntry?: () => void,
-) {
+ {
+ optional = false,
+ signal,
+ countEntry,
+ }: {
+ optional?: boolean;
+ signal?: AbortSignal;
+ countEntry?: () => void;
+ } = {},
+): Promise {
const source = path.join(appPath, "node_modules");
try {
const stat = await fs.stat(source);
if (!stat.isDirectory()) throw new Error("not a directory");
} catch {
+ // An app with its own install and start commands need not be Node-based at
+ // all, and its dependencies need not live in `node_modules`. Its install
+ // command runs inside the sandbox, so a missing tree here is normal rather
+ // than a refusal — refusing would make the sandbox structurally impossible
+ // for every such app.
+ if (optional) return;
// Precondition, not Internal: the user starts the app to fix this, and it
// must not be reported to PostHog as a product exception.
throw new DyadError(
@@ -108,11 +121,17 @@ async function copyNodeModules(
export async function createE2eTestWorkspace({
appId,
appPath,
+ hasCustomCommands = false,
signal,
onProgress,
}: {
appId: number;
appPath: string;
+ /**
+ * The app supplies its own install and start commands, so it may not be a
+ * Node project and a missing `node_modules` is not a reason to refuse.
+ */
+ hasCustomCommands?: boolean;
signal?: AbortSignal;
onProgress?: (message: string) => void;
}): Promise {
@@ -184,8 +203,12 @@ export async function createE2eTestWorkspace({
if (signal?.aborted) throw new Error("Test run stopped.");
const sourceMs = Date.now() - startedAt;
onProgress?.("Cloning installed dependencies into the test workspace…\n");
- await copyNodeModules(appPath, workspacePath, signal, () => {
- dependencyEntries += 1;
+ await copyNodeModules(appPath, workspacePath, {
+ optional: hasCustomCommands,
+ signal,
+ countEntry: () => {
+ dependencyEntries += 1;
+ },
});
sendTelemetryEvent("e2e_test_workspace_created", {
duration_ms: Date.now() - startedAt,
@@ -198,7 +221,17 @@ export async function createE2eTestWorkspace({
});
return { workspacePath, artifactPath, dispose };
} catch (error) {
- await dispose();
+ // Never let cleanup replace the failure it is cleaning up after. Removing a
+ // partially-copied tree can itself fail (EBUSY/EPERM on Windows), and that
+ // error would otherwise bury a well-classified Precondition — "your
+ // dependencies aren't installed" — under an unclassified internal one.
+ try {
+ await dispose();
+ } catch (disposeError) {
+ logger.warn(
+ `Failed to remove a partial E2E test workspace after a setup failure: ${disposeError}`,
+ );
+ }
throw error;
}
}
@@ -224,9 +257,9 @@ export async function retainE2eTestArtifacts({
export function rewriteE2eArtifactPath(
screenshotPath: string | undefined,
workspacePath: string,
- artifactPath: string,
+ artifactPath: string | undefined,
): string | undefined {
- if (!screenshotPath) return undefined;
+ if (!screenshotPath || !artifactPath) return undefined;
const absolute = path.isAbsolute(screenshotPath)
? path.resolve(screenshotPath)
: path.resolve(workspacePath, screenshotPath);
diff --git a/src/ipc/services/isolated_test_db.test.ts b/src/ipc/services/isolated_test_db.test.ts
index a1de7a0419..9eefebf3ec 100644
--- a/src/ipc/services/isolated_test_db.test.ts
+++ b/src/ipc/services/isolated_test_db.test.ts
@@ -324,6 +324,45 @@ describe("prepareIsolatedTestDatabase — Neon happy path", () => {
);
});
+ it("still deletes the branch when the sandbox's own env file can't be restored", async () => {
+ // The recorder keeps a branch whose delete would strand the real project
+ // still pointed at it. Nothing points at this one — the env file is inside
+ // the sandbox, which is deleted moments later — so gating the delete on
+ // that restore would leak a real Neon branch over a file nobody has.
+ mocks.createTempTestBranch.mockResolvedValue({
+ branchId: "test-br",
+ databaseUrl: "postgres://temp",
+ });
+ // A snapshot makes teardown write the file back; the missing directory
+ // makes that write fail the way a real one would.
+ mocks.readEnvFileIfExists.mockResolvedValue("REAL=1\n");
+ mocks.markAndDeleteTempTestBranch.mockResolvedValue(true);
+
+ const prepared = await prepareIsolatedTestDatabase({
+ app: makeApp({ neonProjectId: "proj-1" }),
+ emit,
+ runtimeMode: "host",
+ appPathOverride: "/nonexistent-sandbox-dir/run-1",
+ restartApp: false,
+ });
+
+ emit.mockClear();
+ const result = await prepared.teardown();
+ expect(result.envRestored).toBe(false);
+ expect(result.remoteCleanupCompleted).toBe(true);
+ expect(mocks.markAndDeleteTempTestBranch).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1 }),
+ "test-br",
+ );
+ // And it must not tell the user to go fix a `.env.local` they never had a
+ // problem with.
+ expect(
+ emit.mock.calls.some((call) =>
+ /restore your real database settings/i.test(String(call[0])),
+ ),
+ ).toBe(false);
+ });
+
it("checks the direct dev server instead of the HTML-rewriting proxy", async () => {
mocks.createTempTestBranch.mockResolvedValue({
branchId: "test-br",
diff --git a/src/ipc/services/isolated_test_db.ts b/src/ipc/services/isolated_test_db.ts
index 363ea4d74b..699b229ce6 100644
--- a/src/ipc/services/isolated_test_db.ts
+++ b/src/ipc/services/isolated_test_db.ts
@@ -174,6 +174,10 @@ export async function prepareIsolatedTestDatabase({
}
const appPath = appPathOverride ?? getDyadAppPath(app.path);
+ // The env file this teardown restores lives inside the disposable sandbox,
+ // not in the user's project. Nothing the user can see depends on that restore
+ // succeeding, and the directory is deleted moments later either way.
+ const envIsDisposable = appPathOverride !== undefined;
let envSnapshot: string | null = null;
let envModified = false;
let branchId: string | undefined;
@@ -196,10 +200,16 @@ export async function prepareIsolatedTestDatabase({
logger.error(
`Failed to restore .env.local for app ${app.id}: ${error}`,
);
- emit(
- "Warning: Dyad couldn't restore your real database settings, so the temporary Neon branch was kept tracked for retry. Restore .env.local before running more tests.\n",
- "setup",
- );
+ // Only the recorder's swap can strand the user's real project. Saying
+ // this on the sandbox path would name a file in a directory that is
+ // about to be deleted and tell the user to fix something they never
+ // had a problem with.
+ if (!envIsDisposable) {
+ emit(
+ "Warning: Dyad couldn't restore your real database settings, so the temporary Neon branch was kept tracked for retry. Restore .env.local before running more tests.\n",
+ "setup",
+ );
+ }
}
if (envRestored && restartApp && !options.skipRestart) {
try {
@@ -219,8 +229,12 @@ export async function prepareIsolatedTestDatabase({
// it, and the row's id is what the startup sweep reconciles from. App
// deletion — the one case where that row is about to disappear — handles the
// branch itself, after the deletion commits.
+ //
+ // That reasoning cannot apply when the env file is the sandbox's own copy:
+ // nothing is left pointing at the branch, so gating on the restore there
+ // would only leak a real Neon branch over a file that no longer exists.
let remoteCleanupCompleted = true;
- if (branchId && envRestored) {
+ if (branchId && (envRestored || envIsDisposable)) {
// Shared with the recovery path in `neon_test_branch`: the cleanup-only
// marker is written before the fallible remote delete, so a crash in
// between leaves a row that says the env is real and only the branch is
From 9603db50c0d4611afdf98b1d123865f440e1797f Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Mon, 24 Aug 2026 18:21:48 +0000
Subject: [PATCH 6/9] Address review round 2: readiness budget, port
accounting, artifact timing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A custom app's install step no longer spends the server's readiness budget.
`install && start` is one spawned command, so `pip install -r requirements.txt`,
`bundle install`, `go mod download` or a cold `npm ci` was charged against the
120s server deadline and failed a run whose server was about to come up. Those
runs now get a 15-minute budget, and the timeout message names the budget that
actually applied instead of always saying "2 minutes".
The allocated port is handed back on every exit path. Only the try/catch around
the readiness wait released it, so anything that threw earlier — the pnpm version
probe, a workspace read, `spawn` itself — permanently burned one of the 200 band
ports, and enough failures left the process unable to allocate at all.
A port clash is now a distinct error class matched against the allocated port
number, not a substring of the failure message. That message embeds the last 8KB
of server output, so an app whose dev script also starts a sidecar (Postgres,
Redis, a second worker) logging about *its own* taken port was retried three
times — up to six more minutes — before the real error reached the user.
Retained artifacts are pruned after the new run produces replacements, not when
its workspace is created. Pruning up front destroyed the previous run's
screenshots for a run that then failed during setup, leaving the panel showing
results whose thumbnails silently stopped loading.
Run directory names drop the epoch and shorten the UUID to 12 hex characters.
`/test-sandboxes` is already deeper than the app directory, the Windows
copy is a real one, and long-path support is off by default — so ~50 characters
of pure path depth could push a pnpm tree past MAX_PATH and fail mid-copy with an
opaque setup error.
The Tests panel no longer flashes "Start the app to run tests." while settings
load. `usesSandboxedE2eTests` answers false for absent settings, so a sandbox
user with no preview saw a hard refusal on every mount of the tab.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
---
.../preview_panel/TestsPanel.test.tsx | 19 +++
src/components/preview_panel/TestsPanel.tsx | 7 +-
src/ipc/services/e2e_test_runtime.test.ts | 111 +++++++++++++
src/ipc/services/e2e_test_runtime.ts | 146 ++++++++++++++----
src/ipc/services/e2e_test_workspace.test.ts | 69 +++++++++
src/ipc/services/e2e_test_workspace.ts | 59 ++++---
6 files changed, 362 insertions(+), 49 deletions(-)
diff --git a/src/components/preview_panel/TestsPanel.test.tsx b/src/components/preview_panel/TestsPanel.test.tsx
index 09ff14abee..7c8b2804fd 100644
--- a/src/components/preview_panel/TestsPanel.test.tsx
+++ b/src/components/preview_panel/TestsPanel.test.tsx
@@ -355,6 +355,25 @@ describe("TestsPanel", () => {
).toBe(true);
});
+ it("doesn't refuse the run while settings are still loading", async () => {
+ // `usesSandboxedE2eTests` answers false for absent settings, so the panel
+ // would flash the amber gate banner and a disabled Run button on every
+ // mount — a hard refusal for a state that may not apply at all.
+ mocks.appUrl = null;
+ mocks.settings = undefined as unknown as Record;
+
+ renderPanel();
+
+ expect(
+ (
+ (await screen.findByRole("button", {
+ name: "Run all tests",
+ })) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false);
+ expect(screen.queryByText("Start the app to run tests.")).toBeNull();
+ });
+
describe("stopping a run", () => {
/** Put the panel's app into `phase` as if a run had reached it. */
function setPhase(
diff --git a/src/components/preview_panel/TestsPanel.tsx b/src/components/preview_panel/TestsPanel.tsx
index dc864f2b59..aa29bd61d4 100644
--- a/src/components/preview_panel/TestsPanel.tsx
+++ b/src/components/preview_panel/TestsPanel.tsx
@@ -708,7 +708,12 @@ export function TestsPanel() {
// point of the feature. The fallback path (Docker/cloud runtime, or the
// opt-out) still runs Playwright against the preview and still needs it up.
// Recording is unaffected either way: it drives the live preview.
- const testsNeedDevServer = !usesSandboxedE2eTests(settings);
+ //
+ // While settings are still loading there is nothing to disclose yet:
+ // `usesSandboxedE2eTests` answers false for absent settings, which would
+ // flash the amber "Start the app to run tests." banner and a disabled Run
+ // button on every mount for a state that may not apply at all.
+ const testsNeedDevServer = !!settings && !usesSandboxedE2eTests(settings);
const testRunBlocked = testsNeedDevServer && !devServerRunning;
// Owns the run's whole lifecycle, teardown included. Gates every action that
// must not interleave with it (Run, Record, Delete), because the per-app lock
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
index 6531fbedec..c4b74679ea 100644
--- a/src/ipc/services/e2e_test_runtime.test.ts
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -18,6 +18,7 @@ vi.mock("@/ipc/utils/socket_firewall", async (importOriginal) => {
import {
allocateE2eTestPort,
buildE2eTestStartCommand,
+ e2eServerReadyTimeoutMs,
releaseE2eTestPort,
startE2eTestRuntime,
} from "./e2e_test_runtime";
@@ -211,3 +212,113 @@ describe("startE2eTestRuntime port recovery", () => {
}
}, 60_000);
});
+
+describe("startE2eTestRuntime port accounting", () => {
+ it("hands the port back when start-command construction throws", async () => {
+ // The pnpm version probe runs between the allocation and the try/catch that
+ // used to be the only place releasing the port, so a failure here burned
+ // one of the 200 band ports for the life of the process — and enough of
+ // them left no port to allocate at all.
+ getPnpmMinimumReleaseAgeSupportMock.mockRejectedValue(new Error("probe"));
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-release-"));
+ try {
+ await expect(
+ startE2eTestRuntime({ workspacePath: root }),
+ ).rejects.toThrow(/probe/);
+ const port = await allocateE2eTestPort();
+ try {
+ // Free again — which it only is if the failed run released it.
+ expect(port).toBe(E2E_TEST_SERVER_PORT_START);
+ } finally {
+ releaseE2eTestPort(port);
+ }
+ } finally {
+ mockPnpmAvailable(false);
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ }, 30_000);
+
+ it("does not retry when a sidecar reports a clash on its own port", async () => {
+ // The "exited before becoming ready" error embeds 8KB of server output, so
+ // matching a substring of the whole message turned any sidecar's
+ // EADDRINUSE — Postgres, Redis, a second worker — into three more full
+ // server starts before the real error reached the user.
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-sidecar-"));
+ const attempts = path.join(root, "attempts");
+ fs.writeFileSync(
+ path.join(root, "server.mjs"),
+ [
+ 'import fs from "node:fs";',
+ 'fs.appendFileSync(process.env.DYAD_ATTEMPTS, "x");',
+ // Deliberately NOT the port Dyad allocated.
+ "console.error('listen EADDRINUSE: address already in use 127.0.0.1:5432');",
+ "process.exit(1);",
+ ].join("\n"),
+ );
+ process.env.DYAD_ATTEMPTS = attempts;
+ try {
+ await expect(
+ startE2eTestRuntime({
+ workspacePath: root,
+ installCommand: "true",
+ startCommand: `"${process.execPath}" server.mjs {port}`,
+ }),
+ ).rejects.toThrow(/exited before becoming ready/i);
+ expect(fs.readFileSync(attempts, "utf8")).toBe("x");
+ } finally {
+ delete process.env.DYAD_ATTEMPTS;
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ }, 30_000);
+
+ it("still retries when the clash really is on the allocated port", async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-e2e-clash-"));
+ const attempts = path.join(root, "attempts");
+ fs.writeFileSync(
+ path.join(root, "server.mjs"),
+ [
+ 'import fs from "node:fs";',
+ "const port = Number(process.argv[2]);",
+ 'fs.appendFileSync(process.env.DYAD_ATTEMPTS, "x");',
+ "console.error(`listen EADDRINUSE: address already in use 127.0.0.1:${port}`);",
+ "process.exit(1);",
+ ].join("\n"),
+ );
+ process.env.DYAD_ATTEMPTS = attempts;
+ try {
+ await expect(
+ startE2eTestRuntime({
+ workspacePath: root,
+ installCommand: "true",
+ startCommand: `"${process.execPath}" server.mjs {port}`,
+ }),
+ ).rejects.toThrow(/in use/i);
+ expect(fs.readFileSync(attempts, "utf8")).toBe("xxx");
+ } finally {
+ delete process.env.DYAD_ATTEMPTS;
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ }, 30_000);
+});
+
+describe("e2eServerReadyTimeoutMs", () => {
+ it("gives a custom app's install step room beyond the server budget", () => {
+ // `install && start` is one spawned command, so `pip install -r
+ // requirements.txt`, `bundle install` or a cold `npm ci` spends the
+ // readiness budget — and routinely passes two minutes on a first run.
+ const dyadManaged = e2eServerReadyTimeoutMs({});
+ const custom = e2eServerReadyTimeoutMs({
+ installCommand: "pip install -r requirements.txt",
+ startCommand: "python server.py",
+ });
+ expect(dyadManaged).toBe(120_000);
+ expect(custom).toBeGreaterThan(dyadManaged);
+ });
+
+ it("does not extend the budget for a start command with no install command", () => {
+ // Same rule `getCommand` uses: an app is custom only when both are set.
+ expect(e2eServerReadyTimeoutMs({ startCommand: "python server.py" })).toBe(
+ 120_000,
+ );
+ });
+});
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index 9f5a9311f3..5dbcab4646 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -22,8 +22,56 @@ import {
const logger = log.scope("e2e_test_runtime");
const SERVER_READY_TIMEOUT_MS = 120_000;
+/**
+ * Budget when the spawned command installs before it serves. A custom app's
+ * install step runs inside the same shell command, so it spends the readiness
+ * budget: `pip install -r requirements.txt`, `bundle install`, `go mod
+ * download` or a cold `npm ci` routinely pass two minutes on a first run, and
+ * charging them against the server's own budget would fail a run whose server
+ * was about to come up. The normal preview imposes no deadline at all; this one
+ * exists only so a truly stuck command cannot hang the run forever.
+ */
+const INSTALL_AND_SERVER_READY_TIMEOUT_MS = 900_000;
const SERVER_READY_POLL_MS = 250;
+/**
+ * How long the sandbox server gets to answer. A custom app's install step runs
+ * inside the same shell command as its start command, so it spends this budget
+ * too and needs a far larger one.
+ */
+export function e2eServerReadyTimeoutMs(app: {
+ installCommand?: string | null;
+ startCommand?: string | null;
+}): number {
+ return hasCustomE2eStartCommand(app)
+ ? INSTALL_AND_SERVER_READY_TIMEOUT_MS
+ : SERVER_READY_TIMEOUT_MS;
+}
+
+/**
+ * The dev server can't have this port. Thrown instead of matched by regex on a
+ * message, because the "exited before becoming ready" error embeds the last 8KB
+ * of server output — an app whose dev script also starts a sidecar (Postgres,
+ * Redis, a second worker) that logs about *its own* taken port would otherwise
+ * be retried three times before the real error reached the user.
+ */
+class PortInUseError extends Error {}
+
+/**
+ * Whether some text reports that *this* port is taken. The port number is
+ * required, for the same reason `PortInUseError` exists: a sidecar's clash on a
+ * different port is not this server's problem. Covers Vite's `Port 1234 is in
+ * use, trying another one...` (its default `strictPort: false`, which keeps the
+ * process alive on a port Dyad isn't polling) and Node's `listen EADDRINUSE:
+ * address already in use 127.0.0.1:1234`.
+ */
+function reportsPortInUse(text: string, port: number): boolean {
+ return new RegExp(
+ `port\\s+${port}\\s+is\\s+in\\s+use|(?:EADDRINUSE|address already in use)[^\\n]*[:\\s]${port}\\b`,
+ "i",
+ ).test(text);
+}
+
export interface E2eTestRuntime {
baseUrl: string;
process: ChildProcess;
@@ -190,58 +238,60 @@ function delay(ms: number, signal?: AbortSignal): Promise {
});
}
-/**
- * A dev server that found its port taken and quietly moved to another one.
- * Vite prints this and keeps running (its default is `strictPort: false`), so
- * without matching it the readiness poll would sit on the dead original port
- * for the full two minutes and then report a timeout, when a retry on a fresh
- * port is all that was needed. Matched against the process output rather than a
- * thrown error, because nothing throws in this case.
- */
-const PORT_TAKEN_OUTPUT = /port\s+\d+\s+is\s+in\s+use|address already in use/i;
-/** Errors and output that mean "try another port", for the retry loop below. */
-const PORT_TAKEN_MESSAGE =
- /EADDRINUSE|address already in use|port \d+ is in use/i;
-
async function waitForReady({
baseUrl,
+ port,
process: child,
signal,
outputTail,
spawnError,
portHint,
+ timeoutMs,
}: {
baseUrl: string;
+ port: number;
process: ChildProcess;
signal?: AbortSignal;
outputTail: () => string;
spawnError: () => Error | undefined;
portHint: string;
+ timeoutMs: number;
}): Promise {
- const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
+ const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Test run stopped.");
// Precondition throughout: a server that won't start or won't answer is a
// user/environment problem (a broken start command, a port taken, a build
// error), not a Dyad bug, and must not be reported as a product exception.
+ // A port clash is the exception: the retry loop turns it into a fresh port,
+ // and only a repeat failure reaches the user.
const startError = spawnError();
if (startError) {
+ if (reportsPortInUse(startError.message, port)) {
+ throw new PortInUseError(startError.message);
+ }
throw new DyadError(
`Could not start the isolated test server: ${startError.message}`,
DyadErrorKind.Precondition,
);
}
if (child.exitCode !== null || child.signalCode !== null) {
+ if (reportsPortInUse(outputTail(), port)) {
+ throw new PortInUseError(
+ `The isolated test server exited because port ${port} was already in use.`,
+ );
+ }
throw new DyadError(
`The isolated test server exited before becoming ready.\n${outputTail()}`,
DyadErrorKind.Precondition,
);
}
- if (PORT_TAKEN_OUTPUT.test(outputTail())) {
- // Not a Precondition: the retry loop turns this into a fresh port, and
- // only a repeat failure reaches the user.
- throw new Error(
- `The isolated test server reported its port was already in use.\n${outputTail()}`,
+ if (reportsPortInUse(outputTail(), port)) {
+ // Still running, just not here — Vite's default `strictPort: false` moves
+ // to another port and says so. Without this the poll would sit on the
+ // dead port for the whole budget and then report a timeout.
+ throw new PortInUseError(
+ `The isolated test server moved off port ${port} because it was already in use.`,
);
}
try {
@@ -255,7 +305,9 @@ async function waitForReady({
await delay(SERVER_READY_POLL_MS, signal);
}
throw new DyadError(
- `The isolated test server did not become ready within 2 minutes.${portHint}\n${outputTail()}`,
+ `The isolated test server did not become ready within ${Math.round(
+ timeoutMs / 60_000,
+ )} minutes.${portHint}\n${outputTail()}`,
DyadErrorKind.Precondition,
);
}
@@ -275,7 +327,50 @@ async function startE2eTestRuntimeOnce({
}): Promise {
if (signal?.aborted) throw new Error("Test run stopped.");
const port = await allocateE2eTestPort();
+ // Every exit from here on must hand the port back. Without this, anything
+ // that throws before the try/catch below — a workspace read, the pnpm version
+ // probe, `spawn` itself — permanently burns one of the 200 band ports, and
+ // enough failures leave the process unable to allocate at all.
+ let portReserved = true;
+ const releasePort = () => {
+ if (!portReserved) return;
+ portReserved = false;
+ releaseE2eTestPort(port);
+ };
+ try {
+ return await startServerOnPort({
+ port,
+ workspacePath,
+ installCommand,
+ startCommand,
+ signal,
+ onOutput,
+ onBound: releasePort,
+ });
+ } finally {
+ releasePort();
+ }
+}
+
+async function startServerOnPort({
+ port,
+ workspacePath,
+ installCommand,
+ startCommand,
+ signal,
+ onOutput,
+ onBound,
+}: {
+ port: number;
+ workspacePath: string;
+ installCommand?: string | null;
+ startCommand?: string | null;
+ signal?: AbortSignal;
+ onOutput?: (chunk: string) => void;
+ onBound: () => void;
+}): Promise {
const baseUrl = `http://127.0.0.1:${port}`;
+ const isCustom = hasCustomE2eStartCommand({ installCommand, startCommand });
const { command, env } = await buildE2eTestStartCommand({
workspacePath,
port,
@@ -286,8 +381,7 @@ async function startE2eTestRuntimeOnce({
// `{port}` or PORT. If it ignores both it binds elsewhere and never answers
// here, so name the fix instead of leaving a bare timeout.
const portHint =
- hasCustomE2eStartCommand({ installCommand, startCommand }) &&
- !startCommand!.includes("{port}")
+ isCustom && !startCommand!.includes("{port}")
? ` Your custom start command may be ignoring the PORT environment variable — add {port} to it so Dyad can tell it which port to use.`
: "";
const child = spawn(command, [], {
@@ -337,15 +431,17 @@ async function startE2eTestRuntimeOnce({
try {
await waitForReady({
baseUrl,
+ port,
process: child,
signal,
outputTail: () => tail,
spawnError: () => startError,
portHint,
+ timeoutMs: e2eServerReadyTimeoutMs({ installCommand, startCommand }),
});
// The server owns the port now, so a concurrent allocation only needs the
// real bind check to see it is taken.
- releaseE2eTestPort(port);
+ onBound();
logger.info(`Isolated E2E server ready on port ${port}`);
return {
baseUrl,
@@ -357,7 +453,6 @@ async function startE2eTestRuntimeOnce({
};
} catch (error) {
signal?.removeEventListener("abort", onAbort);
- releaseE2eTestPort(port);
await stop();
throw error;
}
@@ -372,8 +467,7 @@ export async function startE2eTestRuntime(
return await startE2eTestRuntimeOnce(options);
} catch (error) {
lastError = error;
- const message = error instanceof Error ? error.message : String(error);
- if (!PORT_TAKEN_MESSAGE.test(message)) throw error;
+ if (!(error instanceof PortInUseError)) throw error;
options.onOutput?.(
"[test server] The selected port was taken; retrying with another port…\n",
);
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
index 5117c57247..5fbaa32eb9 100644
--- a/src/ipc/services/e2e_test_workspace.test.ts
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -190,6 +190,75 @@ describe("E2E test workspace", () => {
).toBe("png");
});
+ it("keeps the last run's artifacts when a new run never produces any", async () => {
+ // Pruning used to happen when the workspace was created. A run that then
+ // failed during setup left the panel showing the previous run's results
+ // with every screenshot path pointing at a directory that had just been
+ // deleted — thumbnails that silently stop loading.
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+ const previous = path.join(userData, E2E_TEST_ARTIFACT_DIR, "7-oldrun");
+ await fs.mkdir(path.join(previous, "test-results"), { recursive: true });
+ await fs.writeFile(path.join(previous, "test-results", "shot.png"), "png");
+
+ const workspace = await createE2eTestWorkspace({ appId: 7, appPath });
+ expect(
+ await fs.readFile(
+ path.join(previous, "test-results", "shot.png"),
+ "utf8",
+ ),
+ ).toBe("png");
+ await workspace.dispose();
+ });
+
+ it("drops the previous run's artifacts once this run has replacements", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+ const previous = path.join(userData, E2E_TEST_ARTIFACT_DIR, "7-oldrun");
+ await fs.mkdir(previous, { recursive: true });
+ const other = path.join(userData, E2E_TEST_ARTIFACT_DIR, "8-otherapp");
+ await fs.mkdir(other, { recursive: true });
+
+ const workspace = await createE2eTestWorkspace({ appId: 7, appPath });
+ await fs.mkdir(path.join(workspace.workspacePath, "test-results"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(workspace.workspacePath, "test-results", "shot.png"),
+ "png",
+ );
+ await retainE2eTestArtifacts(workspace);
+
+ const remaining = await fs.readdir(
+ path.join(userData, E2E_TEST_ARTIFACT_DIR),
+ );
+ expect(remaining).not.toContain("7-oldrun");
+ // Another app's artifacts are none of this run's business.
+ expect(remaining).toContain("8-otherapp");
+ expect(remaining).toContain(path.basename(workspace.artifactPath));
+ await workspace.dispose();
+ });
+
+ it("keeps run directory names short enough for Windows MAX_PATH", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ vi.mocked(getUserDataPath).mockReturnValue(path.join(root, "user-data"));
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+
+ const workspace = await createE2eTestWorkspace({ appId: 7, appPath });
+ const runName = path.basename(workspace.workspacePath);
+ // `-<12 hex>`; a full epoch + UUID was ~50 characters of pure path
+ // depth on top of a root already deeper than the app directory.
+ expect(runName).toMatch(/^7-[0-9a-f]{12}$/);
+ await workspace.dispose();
+ });
+
it("sweeps abandoned sandboxes without touching a live run", async () => {
const root = await tempRoot();
const appPath = path.join(root, "app");
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index 60538bcdbd..f6b62a2b93 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -143,21 +143,21 @@ export async function createE2eTestWorkspace({
fs.mkdir(sandboxRoot, { recursive: true }),
fs.mkdir(artifactRoot, { recursive: true }),
]);
- const oldArtifacts = await fs.readdir(artifactRoot, { withFileTypes: true });
- await Promise.all(
- oldArtifacts
- .filter(
- (entry) => entry.isDirectory() && entry.name.startsWith(`${appId}-`),
- )
- .map((entry) =>
- fs.rm(path.join(artifactRoot, entry.name), {
- recursive: true,
- force: true,
- }),
- ),
- );
+ // The previous run's artifacts are deliberately NOT pruned here. The panel is
+ // still showing that run's results, and every screenshot path on them points
+ // into the directory this would delete — so a new run that then fails during
+ // setup would leave the user looking at results whose thumbnails silently
+ // stop loading. `retainE2eTestArtifacts` prunes them once this run has
+ // produced replacements.
- const runName = `${appId}-${Date.now()}-${randomUUID()}`;
+ // Kept short on purpose: `/test-sandboxes` is already deeper than
+ // the app directory, on Windows the copy is a real one and long-path support
+ // is off by default, and a pnpm tree
+ // (`node_modules/.pnpm/@/node_modules//…`) that fits under
+ // the app dir can blow past MAX_PATH under a longer root and fail mid-copy.
+ // 12 hex characters of a v4 UUID is ~48 bits of entropy — far more than
+ // enough to separate runs of one app, at a third of the path cost.
+ const runName = `${appId}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const workspacePath = path.join(sandboxRoot, runName);
const artifactPath = path.join(artifactRoot, runName);
assertOwnedPath(sandboxRoot, workspacePath);
@@ -241,17 +241,32 @@ export async function retainE2eTestArtifacts({
artifactPath,
}: Pick): Promise {
const source = path.join(workspacePath, "test-results");
+ let hasArtifacts = true;
try {
- if (!(await fs.stat(source)).isDirectory()) return;
+ hasArtifacts = (await fs.stat(source)).isDirectory();
} catch {
- return;
+ hasArtifacts = false;
}
- await fs.rm(artifactPath, { recursive: true, force: true });
- await fs.mkdir(artifactPath, { recursive: true });
- await fs.cp(source, path.join(artifactPath, "test-results"), {
- recursive: true,
- verbatimSymlinks: false,
- });
+ if (hasArtifacts) {
+ await fs.rm(artifactPath, { recursive: true, force: true });
+ await fs.mkdir(artifactPath, { recursive: true });
+ await fs.cp(source, path.join(artifactPath, "test-results"), {
+ recursive: true,
+ verbatimSymlinks: false,
+ });
+ }
+ // Only now are the previous run's artifacts safe to drop: this run has
+ // finished, so the results on screen are its own and nothing still points at
+ // them. Pruning before the run — where this used to happen — destroyed the
+ // last good screenshots whenever the new run failed during setup.
+ const runName = path.basename(artifactPath);
+ const appId = runDirectoryAppId(runName);
+ if (appId === null) return;
+ await removeRunDirectories(
+ path.dirname(artifactPath),
+ (name) => name !== runName && runDirectoryAppId(name) === appId,
+ "test artifacts",
+ );
}
export function rewriteE2eArtifactPath(
From 227769b70c8010abfb3e54edf5227a12ec61642a Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Mon, 24 Aug 2026 18:49:15 +0000
Subject: [PATCH 7/9] Address review round 3: overlapping runs, crash windows,
wrong-thing copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two reviewer passes over this round; both sets of findings are handled here.
Artifact pruning no longer deletes a concurrent run's screenshots. A second Run
for the same app aborts the first and proceeds without awaiting its teardown, so
both cleanups overlap — and whichever retained second deleted the other's
artifacts before they reached the panel. The prune now skips run directories
`activeWorkspaceNames` still owns, the same check the startup sweep already made
for the same reason. It also runs in a `finally`, so a failed copy no longer
strands the run it replaced with no owner.
Custom commands are spawned as `(install) && (start)`. `&&` binds left-to-right,
so an ungrouped `install && A || B` ran `B` when the *install* failed and
`install && A; B` ran `B` unconditionally — silently re-associating any start
command containing a shell operator.
The port band scan consults `isReservedDyadPort` like the fallback loop already
did. The band sits above every default reserved range, but Dyad's own E2E shards
relocate those: `DYAD_E2E_PORT_BLOCK_INDEX=9` puts a block's proxy sub-range
straight through it, so a sandbox server could take a deterministic proxy port.
A sandboxed run's Neon branch is marked cleanup-only at creation, inside
`createTempTestBranch`, rather than after it returns. Auth provisioning and the
cookie secret sit between those two points with their own retries and backoff; a
crash there left a raw marker that startup recovery read as the recorder's env
swap and "restored" by rewriting the user's real `.env.local` — for a run that
never touched it.
Retained artifacts win over the app path when both match. `userData` can sit
inside the project on a portable or dev install, which made every retained
artifact also look like an app path — skipping the app-id check and then
comparing the run directory name against "test-results", so legitimate
thumbnails silently failed to load.
`sandboxed` is now set when the workspace exists, not when the route is chosen,
so a run whose setup failed no longer offers to clean up a sandbox it never
created. And the cleanup warning names what was actually left behind: the
Supabase path leaks a temporary auth user in the user's real project, which is
not "the isolated test database" and which no startup sweep picks up.
Test fixes: `CancellationBanner`'s i18n mock mapped `cancellationCleaningTestData`
to the *sandbox* wording, so the Supabase test asserted the sandbox string while
exercising the non-sandbox branch — deleting the component's whole `sandboxed`
branch would have left the suite green. Both branches are now covered.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
---
.../chat/CancellationBanner.test.tsx | 23 ++++-
src/ipc/handlers/tests_handlers.test.ts | 37 ++++++++
src/ipc/handlers/tests_handlers.ts | 19 +++-
src/ipc/services/e2e_test_runtime.test.ts | 19 +++-
src/ipc/services/e2e_test_runtime.ts | 13 ++-
src/ipc/services/e2e_test_workspace.test.ts | 67 +++++++++++++
src/ipc/services/e2e_test_workspace.ts | 43 ++++++---
src/ipc/services/isolated_test_db.test.ts | 11 ++-
src/ipc/services/isolated_test_db.ts | 17 ++--
src/ipc/utils/neon_test_branch.test.ts | 16 ++++
src/ipc/utils/neon_test_branch.ts | 13 ++-
src/ipc/utils/test_screenshot.test.ts | 95 +++++++++++++++++++
src/ipc/utils/test_screenshot.ts | 17 +++-
13 files changed, 353 insertions(+), 37 deletions(-)
create mode 100644 src/ipc/utils/test_screenshot.test.ts
diff --git a/src/components/chat/CancellationBanner.test.tsx b/src/components/chat/CancellationBanner.test.tsx
index cfb882aa29..364fdac1c6 100644
--- a/src/components/chat/CancellationBanner.test.tsx
+++ b/src/components/chat/CancellationBanner.test.tsx
@@ -21,6 +21,8 @@ vi.mock("react-i18next", () => ({
cancellationRemovingTestDatabase:
"Removing the temporary test database. This can take a while.",
cancellationCleaningTestData:
+ "Cleaning up the test data from this run.",
+ cancellationCleaningTestSandbox:
"Cleaning up this run's test sandbox and data.",
})[key] ?? key,
}),
@@ -30,6 +32,7 @@ function renderBanner(runState?: {
phase: TestRunPhase;
isolationMode?: TestIsolation["mode"];
source?: "panel" | "agent";
+ sandboxed?: boolean;
}) {
const store = createStore();
if (runState) {
@@ -45,6 +48,7 @@ function renderBanner(runState?: {
isolation: runState.isolationMode
? { mode: runState.isolationMode }
: undefined,
+ sandboxed: runState.sandboxed,
},
],
]),
@@ -95,13 +99,14 @@ describe("CancellationBanner", () => {
expect(screen.queryByText(/Restoring/i)).toBeNull();
});
- it("does not claim a restore on the Supabase path", () => {
+ it("names the sandbox on the Supabase path when the run took one", () => {
// That teardown only deletes the temporary test user and the run's sandbox
// copy — no env swap, no dev-server restart, nothing the user sees.
renderBanner({
phase: "cleaning-up",
isolationMode: "supabase-test-user",
source: "agent",
+ sandboxed: true,
});
expect(
@@ -110,6 +115,22 @@ describe("CancellationBanner", () => {
expect(screen.queryByText(/Restoring/)).toBeNull();
});
+ it("claims no sandbox for a run that never took one", () => {
+ // The fallback path (Docker/cloud runtime, or the opt-out) creates no
+ // workspace, and neither does a run whose setup failed before the copy.
+ renderBanner({
+ phase: "cleaning-up",
+ isolationMode: "supabase-test-user",
+ source: "agent",
+ sandboxed: false,
+ });
+
+ expect(
+ screen.getByText("Cleaning up the test data from this run."),
+ ).toBeTruthy();
+ expect(screen.queryByText(/sandbox/i)).toBeNull();
+ });
+
it("reports the kill before the teardown starts", () => {
renderBanner({ phase: "stopping", source: "agent" });
diff --git a/src/ipc/handlers/tests_handlers.test.ts b/src/ipc/handlers/tests_handlers.test.ts
index 67b5e75e5f..abc121e244 100644
--- a/src/ipc/handlers/tests_handlers.test.ts
+++ b/src/ipc/handlers/tests_handlers.test.ts
@@ -355,6 +355,36 @@ describe("tests handlers", () => {
expect(result.infraError?.message).toMatch(/settings were not changed/i);
});
+ it("names the leftover Supabase test user, not a database", async () => {
+ // The Supabase path leaks a temporary auth user in the user's real
+ // project — no sweep picks that up, and no database was involved.
+ const appId = seedApp("app");
+ harness.db
+ .update(apps)
+ .set({ testingEnabled: true })
+ .where(eq(apps.id, appId))
+ .run();
+ prepareIsolatedTestDatabaseMock.mockResolvedValue({
+ isolation: { mode: "supabase-test-user" },
+ infraError: {
+ message: "Isolation setup stopped before running tests.",
+ },
+ teardown: vi.fn().mockResolvedValue({
+ envRestored: true,
+ remoteCleanupCompleted: false,
+ }),
+ });
+
+ const result = await runAppTestsWithIsolation({
+ event: { sender: {} } as any,
+ appId,
+ source: "panel",
+ });
+
+ expect(result.infraError?.message).toMatch(/temporary test user/i);
+ expect(result.infraError?.message).not.toMatch(/isolated test database/i);
+ });
+
it("stays quiet when only the sandbox env was left unrestored", async () => {
// The sandbox `.env.local` is deleted with the workspace seconds later,
// so `envRestored` says nothing the user needs to hear.
@@ -606,6 +636,13 @@ describe("tests handlers", () => {
expect(result.infraError?.message).toMatch(/registry unreachable/i);
expect(result.results).toEqual([]);
expect(createE2eTestWorkspaceMock).not.toHaveBeenCalled();
+ // No workspace was ever created, so the cleanup copy must not offer to
+ // remove one — `CancellationBanner` and the panel both branch on this.
+ const finished = broadcastToRegisteredWindowsMock.mock.calls
+ .filter(([, channel]) => channel === "tests:run-state")
+ .map(([, , payload]) => payload)
+ .find((payload) => payload.state === "finished");
+ expect(finished?.sandboxed).toBe(false);
});
it("reports a failed sandbox copy as an infra error, not a crash", async () => {
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 0be4efa73a..4fb8d8bb91 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -928,8 +928,15 @@ export async function runAppTestsWithIsolation({
result: RunAppTestsResult,
): RunAppTestsResult => {
if (!isolationCleanupFailed) return result;
+ // Names what was actually left behind. The Neon path leaks a temporary
+ // branch that startup recovery retries; the Supabase path leaks a temporary
+ // auth user in the user's real project, which no sweep picks up — calling
+ // that "the isolated test database" is the same class of wrong-thing copy
+ // this work set out to remove.
const restoreMessage =
- "Dyad couldn't finish cleaning up the isolated test database. Your app settings were not changed; Dyad will retry remote cleanup on next startup.";
+ result.isolation?.mode === "supabase-test-user"
+ ? "Dyad couldn't delete the temporary test user it created in your Supabase project. Your app settings were not changed; you can remove the dyad-test user from Supabase Auth."
+ : "Dyad couldn't finish cleaning up the isolated test database. Your app settings were not changed; Dyad will retry remote cleanup on next startup.";
return {
...result,
// Appended rather than substituted: an isolation-setup failure explains
@@ -993,10 +1000,6 @@ export async function runAppTestsWithIsolation({
neonRefusal:
"Isolated test servers are turned off in Settings, and Dyad won't run Neon tests against your real database. Turn them back on to run tests for this app.",
};
- // Recorded once for this run rather than re-read later: the setting can
- // change while the run is in flight, and the cleanup copy has to describe
- // what this run actually did.
- sandboxed = sandboxUnavailable === null;
if (sandboxUnavailable) {
finalResult = withIsolationCleanupWarning(
await runTestsAgainstNormalPreview({
@@ -1089,6 +1092,12 @@ export async function runAppTestsWithIsolation({
return finalResult;
}
workspace = prepareResult.workspace;
+ // Set here, not when the route was chosen: this flag drives the cleanup
+ // copy, and a run whose setup failed has no sandbox to claim Dyad is
+ // deleting. Recorded once rather than re-read later, because the setting
+ // can change while the run is in flight and the copy has to describe what
+ // this run actually did.
+ sandboxed = true;
// The live test only owns provider/test inputs. It deliberately does not
// claim the normal runtime or runtime-config: its process is run-scoped and
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
index c4b74679ea..d2c9ae4775 100644
--- a/src/ipc/services/e2e_test_runtime.test.ts
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -60,7 +60,7 @@ describe("buildE2eTestStartCommand", () => {
startCommand: "custom-server --listen {port}",
});
expect(command.command).toBe(
- "custom-install && custom-server --listen 45678",
+ "(custom-install) && (custom-server --listen 45678)",
);
});
@@ -75,11 +75,26 @@ describe("buildE2eTestStartCommand", () => {
startCommand: "python server.py",
});
expect(command.command).toBe(
- "pip install -r requirements.txt && python server.py",
+ "(pip install -r requirements.txt) && (python server.py)",
);
expect(command.env.PORT).toBe("45678");
});
+ it("groups each half so a start command's own operators still bind", async () => {
+ // `&&` binds left-to-right, so an ungrouped `install && A || B` runs `B`
+ // when the *install* fails — re-associating the user's command under test
+ // only.
+ const command = await buildE2eTestStartCommand({
+ workspacePath: path.resolve("app"),
+ port: 45678,
+ installCommand: "make deps",
+ startCommand: "./serve.sh || ./fallback.sh",
+ });
+ expect(command.command).toBe(
+ "(make deps) && (./serve.sh || ./fallback.sh)",
+ );
+ });
+
it("ignores a start command that has no matching install command", async () => {
// `getCommand` in app_runtime_service only treats an app as custom when
// both commands are set; the sandbox must agree with the normal preview.
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index 5dbcab4646..188f5b0ae3 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -118,6 +118,11 @@ export async function allocateE2eTestPort(): Promise {
for (let offset = 0; offset < E2E_TEST_SERVER_PORT_RANGE; offset += 1) {
const port = E2E_TEST_SERVER_PORT_START + offset;
if (pendingE2eTestPorts.has(port)) continue;
+ // The band is above every *default* reserved range, but Dyad's own E2E
+ // shards relocate those ranges: `DYAD_E2E_PORT_BLOCK_INDEX=9` puts a
+ // block's proxy sub-range at 51550–52549, straight through this band. The
+ // fallback loop below already asks; the band has to ask too.
+ if (isReservedDyadPort(port)) continue;
if ((await probePort(port)) !== null) {
pendingE2eTestPorts.add(port);
return port;
@@ -185,12 +190,18 @@ export async function buildE2eTestStartCommand({
// non-npm dependency install — so the app would start under the preview and
// fail only under test. The sandbox is a fresh copy, so there is nothing
// else that would have performed it.
+ //
+ // Each half is grouped. `&&` binds left-to-right, so an ungrouped
+ // `install && A || B` runs `B` when the *install* fails, and
+ // `install && A; B` runs `B` unconditionally — silently re-associating any
+ // start command that contains a shell operator. `getDefaultCommand` groups
+ // its own `install && dev` pair the same way.
const trimmedStart = startCommand!.trim();
const start = trimmedStart.includes("{port}")
? trimmedStart.replaceAll("{port}", String(port))
: trimmedStart;
return {
- command: `${installCommand!.trim()} && ${start}`,
+ command: `(${installCommand!.trim()}) && (${start})`,
env: { ...process.env, PORT: String(port) },
};
}
diff --git a/src/ipc/services/e2e_test_workspace.test.ts b/src/ipc/services/e2e_test_workspace.test.ts
index 5fbaa32eb9..908da27b13 100644
--- a/src/ipc/services/e2e_test_workspace.test.ts
+++ b/src/ipc/services/e2e_test_workspace.test.ts
@@ -245,6 +245,73 @@ describe("E2E test workspace", () => {
await workspace.dispose();
});
+ it("does not delete a concurrent run's artifacts", async () => {
+ // A second Run for the same app aborts the first and proceeds without
+ // awaiting its teardown, so both cleanups overlap. Whichever retained
+ // second would otherwise delete the other's screenshots before they ever
+ // reached the panel.
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+
+ const first = await createE2eTestWorkspace({ appId: 7, appPath });
+ const second = await createE2eTestWorkspace({ appId: 7, appPath });
+ await fs.mkdir(path.join(first.artifactPath, "test-results"), {
+ recursive: true,
+ });
+ await fs.writeFile(
+ path.join(first.artifactPath, "test-results", "shot.png"),
+ "png",
+ );
+ // The second run finishes its retention while the first is still live.
+ await fs.mkdir(path.join(second.workspacePath, "test-results"), {
+ recursive: true,
+ });
+ await retainE2eTestArtifacts(second);
+
+ expect(
+ await fs.readFile(
+ path.join(first.artifactPath, "test-results", "shot.png"),
+ "utf8",
+ ),
+ ).toBe("png");
+ await first.dispose();
+ await second.dispose();
+ });
+
+ it("still prunes the run it replaced when the copy fails", async () => {
+ // The caller drops the new paths on a failed copy, so nothing points at
+ // either directory — leaving the old one behind would strand it until the
+ // app is deleted.
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ await fs.mkdir(path.join(appPath, "node_modules"), { recursive: true });
+ const previous = path.join(userData, E2E_TEST_ARTIFACT_DIR, "7-oldrun");
+ await fs.mkdir(previous, { recursive: true });
+
+ const workspace = await createE2eTestWorkspace({ appId: 7, appPath });
+ await fs.mkdir(path.join(workspace.workspacePath, "test-results"), {
+ recursive: true,
+ });
+ // An unreadable source makes the copy throw the way a real EBUSY/ENOSPC
+ // would, without touching the artifact root the prune has to write to.
+ await fs.chmod(path.join(workspace.workspacePath, "test-results"), 0o000);
+
+ try {
+ await expect(retainE2eTestArtifacts(workspace)).rejects.toThrow();
+ expect(
+ await fs.readdir(path.join(userData, E2E_TEST_ARTIFACT_DIR)),
+ ).not.toContain("7-oldrun");
+ } finally {
+ await fs.chmod(path.join(workspace.workspacePath, "test-results"), 0o755);
+ await workspace.dispose();
+ }
+ });
+
it("keeps run directory names short enough for Windows MAX_PATH", async () => {
const root = await tempRoot();
const appPath = path.join(root, "app");
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index f6b62a2b93..943d05987e 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -247,24 +247,43 @@ export async function retainE2eTestArtifacts({
} catch {
hasArtifacts = false;
}
- if (hasArtifacts) {
- await fs.rm(artifactPath, { recursive: true, force: true });
- await fs.mkdir(artifactPath, { recursive: true });
- await fs.cp(source, path.join(artifactPath, "test-results"), {
- recursive: true,
- verbatimSymlinks: false,
- });
+ try {
+ if (hasArtifacts) {
+ await fs.rm(artifactPath, { recursive: true, force: true });
+ await fs.mkdir(artifactPath, { recursive: true });
+ await fs.cp(source, path.join(artifactPath, "test-results"), {
+ recursive: true,
+ verbatimSymlinks: false,
+ });
+ }
+ } finally {
+ // Runs in a `finally` so a failed copy can't strand the run it replaced:
+ // the caller drops the new paths on failure, so nothing points at either
+ // directory, and skipping the prune would leave the old one owner-less
+ // until the app is deleted.
+ await pruneSupersededArtifacts(artifactPath);
}
- // Only now are the previous run's artifacts safe to drop: this run has
- // finished, so the results on screen are its own and nothing still points at
- // them. Pruning before the run — where this used to happen — destroyed the
- // last good screenshots whenever the new run failed during setup.
+}
+
+/**
+ * Drop the app's other retained artifact directories, now that this run has
+ * finished and the results on screen are its own.
+ *
+ * Runs belonging to another in-flight test run are skipped. A second Run for
+ * the same app aborts the first and proceeds without awaiting its teardown, so
+ * two runs' cleanups overlap — and whichever retained second would otherwise
+ * delete the other's screenshots before they ever reached the panel.
+ */
+async function pruneSupersededArtifacts(artifactPath: string): Promise {
const runName = path.basename(artifactPath);
const appId = runDirectoryAppId(runName);
if (appId === null) return;
await removeRunDirectories(
path.dirname(artifactPath),
- (name) => name !== runName && runDirectoryAppId(name) === appId,
+ (name) =>
+ name !== runName &&
+ !activeWorkspaceNames.has(name) &&
+ runDirectoryAppId(name) === appId,
"test artifacts",
);
}
diff --git a/src/ipc/services/isolated_test_db.test.ts b/src/ipc/services/isolated_test_db.test.ts
index 9eefebf3ec..de0a8d1cde 100644
--- a/src/ipc/services/isolated_test_db.test.ts
+++ b/src/ipc/services/isolated_test_db.test.ts
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createTempTestBranch: vi.fn(),
- markTestBranchCleanupOnly: vi.fn().mockResolvedValue(undefined),
// The mark-then-delete tail lives in `neon_test_branch` and is tested there
// (`markAndDeleteTempTestBranch`), including the ordering it depends on. What
// teardown owes it is the branch it actually created — the app row it holds is
@@ -46,7 +45,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("../utils/neon_test_branch", () => ({
createTempTestBranch: mocks.createTempTestBranch,
- markTestBranchCleanupOnly: mocks.markTestBranchCleanupOnly,
markAndDeleteTempTestBranch: mocks.markAndDeleteTempTestBranch,
}));
vi.mock("../utils/neon_test_account", () => ({
@@ -312,9 +310,14 @@ describe("prepareIsolatedTestDatabase — Neon happy path", () => {
connectionUri: "postgres://temp",
}),
);
- expect(mocks.markTestBranchCleanupOnly).toHaveBeenCalledWith(
+ // The marker is asked for at creation, not written afterwards: everything
+ // between (Neon Auth provisioning, the cookie secret, their backoff) takes
+ // seconds, and a crash in that window would leave a raw marker that startup
+ // recovery reads as the recorder's env swap and "restores" by rewriting the
+ // user's real `.env.local`.
+ expect(mocks.createTempTestBranch).toHaveBeenCalledWith(
expect.objectContaining({ id: 1 }),
- "test-br",
+ { cleanupOnly: true },
);
expect(mocks.executeApp).not.toHaveBeenCalled();
await prepared.teardown();
diff --git a/src/ipc/services/isolated_test_db.ts b/src/ipc/services/isolated_test_db.ts
index 699b229ce6..de7683e7b6 100644
--- a/src/ipc/services/isolated_test_db.ts
+++ b/src/ipc/services/isolated_test_db.ts
@@ -5,7 +5,6 @@ import { getDyadAppPath } from "../../paths/paths";
import { apps } from "../../db/schema";
import {
createTempTestBranch,
- markTestBranchCleanupOnly,
markAndDeleteTempTestBranch,
} from "../utils/neon_test_branch";
import { createNeonTestAccount } from "../utils/neon_test_account";
@@ -261,14 +260,16 @@ export async function prepareIsolatedTestDatabase({
envSnapshot = await readEnvFileIfExists({ appPath });
// 2. Create the throwaway branch (off the preview branch, CoW).
- const branch = await createTempTestBranch(app);
+ //
+ // The E2E sandbox never points the real app env at this branch, so it asks
+ // for the cleanup-only marker to be the *first* thing persisted — inside
+ // `createTempTestBranch`, before its own auth provisioning. Writing it
+ // afterwards would leave a window where a crash makes startup recovery
+ // rewrite the user's real `.env.local` for a run that never touched it.
+ const branch = await createTempTestBranch(app, {
+ cleanupOnly: !restartApp,
+ });
branchId = branch.branchId;
- // The E2E sandbox never points the real app env at this branch. Persist the
- // cleanup-only form immediately so crash/startup reconciliation cannot
- // mistake this run for the recorder's real-env swap.
- if (!restartApp) {
- await markTestBranchCleanupOnly(app, branchId);
- }
// 3. Point the app at the throwaway branch. Mark the env as modified before
// the write so a partial failure still triggers a restore in teardown.
diff --git a/src/ipc/utils/neon_test_branch.test.ts b/src/ipc/utils/neon_test_branch.test.ts
index dcff4616eb..08ba22c88d 100644
--- a/src/ipc/utils/neon_test_branch.test.ts
+++ b/src/ipc/utils/neon_test_branch.test.ts
@@ -138,6 +138,22 @@ describe("createTempTestBranch", () => {
});
});
+ it("persists the cleanup-only marker before provisioning, when asked", async () => {
+ // The E2E sandbox never points the real app at this branch. Everything
+ // after this write — Neon Auth provisioning, the cookie secret, their
+ // retries — takes seconds, and a crash in that window would otherwise leave
+ // a RAW marker that `restoreAppFromTestBranch` reads as the recorder's env
+ // swap and "restores" by rewriting the user's real `.env.local`.
+ await createTempTestBranch(makeApp(), { cleanupOnly: true });
+
+ expect(mocks.set).toHaveBeenCalledWith({
+ neonTestBranchId: "dyad-cleanup-only:v1:test-new-branch-id",
+ });
+ expect(mocks.set).not.toHaveBeenCalledWith({
+ neonTestBranchId: "test-new-branch-id",
+ });
+ });
+
it("falls back to the development branch when there is no active branch", async () => {
await createTempTestBranch(makeApp({ neonActiveBranchId: null }));
expect(mocks.createProjectBranch).toHaveBeenCalledWith(
diff --git a/src/ipc/utils/neon_test_branch.ts b/src/ipc/utils/neon_test_branch.ts
index 7ba3b32593..e7007db1e1 100644
--- a/src/ipc/utils/neon_test_branch.ts
+++ b/src/ipc/utils/neon_test_branch.ts
@@ -143,6 +143,7 @@ function resolveAuthBranchType(
*/
export async function createTempTestBranch(
appData: AppRow,
+ { cleanupOnly = false }: { cleanupOnly?: boolean } = {},
): Promise {
const projectId = appData.neonProjectId;
if (!projectId) {
@@ -216,10 +217,20 @@ export async function createTempTestBranch(
// SQLite lock), neither teardown nor reconciliation can find the branch to
// delete it, so remove the branch we just created before rethrowing rather
// than leaking it.
+ //
+ // A caller that will never point the real app at this branch writes the
+ // cleanup-only form HERE, not after this function returns. Everything below —
+ // Neon Auth provisioning, the cookie secret, their retries and backoff — can
+ // take seconds, and a crash or quit inside that window would otherwise leave
+ // a raw marker that startup recovery reads as the recorder's env swap and
+ // "restores" by rewriting the user's real `.env.local`.
+ const marker = cleanupOnly
+ ? `${CLEANUP_ONLY_BRANCH_PREFIX}${branch.id}`
+ : branch.id;
try {
await db
.update(apps)
- .set({ neonTestBranchId: branch.id })
+ .set({ neonTestBranchId: marker })
.where(eq(apps.id, appData.id));
} catch (error) {
await deleteBranchBestEffort(projectId, branch.id);
diff --git a/src/ipc/utils/test_screenshot.test.ts b/src/ipc/utils/test_screenshot.test.ts
new file mode 100644
index 0000000000..171f13759d
--- /dev/null
+++ b/src/ipc/utils/test_screenshot.test.ts
@@ -0,0 +1,95 @@
+// @vitest-environment node
+
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/paths/paths", () => ({ getUserDataPath: vi.fn() }));
+
+import { getUserDataPath } from "@/paths/paths";
+import { E2E_TEST_ARTIFACT_DIR } from "@/ipc/services/e2e_test_workspace";
+import { readTestScreenshotDataUrl } from "./test_screenshot";
+
+const roots: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ roots
+ .splice(0)
+ .map((root) => fs.rm(root, { recursive: true, force: true })),
+ );
+});
+
+async function tempRoot() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-screenshot-"));
+ roots.push(root);
+ return root;
+}
+
+// A 1x1 PNG. The reader sniffs the magic bytes before serving anything.
+const PNG = Buffer.from(
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bfabd40000000049454e44ae426082",
+ "hex",
+);
+
+describe("readTestScreenshotDataUrl", () => {
+ it("serves a retained artifact whose root sits inside the app directory", async () => {
+ // A portable or dev install can put `userData` inside the project, which
+ // makes every retained artifact *also* look like an app path. Reading it as
+ // one skips the app-id check and then compares the run directory name
+ // against "test-results", so the thumbnail silently fails to load.
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(appPath, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ const shot = path.join(
+ userData,
+ E2E_TEST_ARTIFACT_DIR,
+ "7-abc123",
+ "test-results",
+ "spec-fails",
+ "test-failed-1.png",
+ );
+ await fs.mkdir(path.dirname(shot), { recursive: true });
+ await fs.writeFile(shot, PNG);
+
+ await expect(readTestScreenshotDataUrl(appPath, shot, 7)).resolves.toMatch(
+ /^data:image\/png;base64,/,
+ );
+ });
+
+ it("refuses another app's retained artifacts", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ const userData = path.join(root, "user-data");
+ vi.mocked(getUserDataPath).mockReturnValue(userData);
+ const shot = path.join(
+ userData,
+ E2E_TEST_ARTIFACT_DIR,
+ "8-abc123",
+ "test-results",
+ "test-failed-1.png",
+ );
+ await fs.mkdir(path.dirname(shot), { recursive: true });
+ await fs.writeFile(shot, PNG);
+ await fs.mkdir(appPath, { recursive: true });
+
+ await expect(
+ readTestScreenshotDataUrl(appPath, shot, 7),
+ ).resolves.toBeNull();
+ });
+
+ it("refuses a file outside the app's own test-results", async () => {
+ const root = await tempRoot();
+ const appPath = path.join(root, "app");
+ vi.mocked(getUserDataPath).mockReturnValue(path.join(root, "user-data"));
+ const shot = path.join(appPath, "src", "logo.png");
+ await fs.mkdir(path.dirname(shot), { recursive: true });
+ await fs.writeFile(shot, PNG);
+
+ await expect(
+ readTestScreenshotDataUrl(appPath, shot, 7),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/src/ipc/utils/test_screenshot.ts b/src/ipc/utils/test_screenshot.ts
index 0bf810f8b8..27a5f73aac 100644
--- a/src/ipc/utils/test_screenshot.ts
+++ b/src/ipc/utils/test_screenshot.ts
@@ -72,13 +72,24 @@ async function resolveContainedArtifact(
if (!insideApp && !insideArtifacts) {
return null;
}
+ // Artifacts win when both match. `userData` can sit inside the project (a
+ // portable or dev install), which makes every retained artifact *also* look
+ // like an app path — and reading it as one skips the app-id check and then
+ // compares the run directory name against "test-results", so a perfectly
+ // legitimate thumbnail silently fails to load.
+ const useArtifactRoot = insideArtifacts;
// Only serve files under `test-results/`, not anything else in the app. Use
// split (not a string prefix) so a sibling like `test-results-foo/` can't
// slip through.
- const segments = (insideApp ? appRelative : artifactRelative).split(path.sep);
- const testResultsSegment = insideApp ? segments[0] : segments[1];
+ const segments = (useArtifactRoot ? artifactRelative : appRelative).split(
+ path.sep,
+ );
+ // Retained artifacts are namespaced by run directory (`-/`), so
+ // the `test-results` segment is one deeper — and the app id has to match, or
+ // one app could read another's screenshots.
+ const testResultsSegment = useArtifactRoot ? segments[1] : segments[0];
if (
- insideArtifacts &&
+ useArtifactRoot &&
(appId === undefined || !segments[0].startsWith(`${appId}-`))
) {
return null;
From e3b074b6dc9cf17c979f0451615b9715673f19aa Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Mon, 24 Aug 2026 19:11:15 +0000
Subject: [PATCH 8/9] Address review round 4: Supabase cleanup verdicts and
Neon rate limits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Supabase teardown reads `deleteTempTestUser`'s return value, not just the
absence of a throw. That delete is best-effort inside — a 5xx from the Auth
Admin API, or a service-role key fetch that fails, resolves `false` and
deliberately leaves `supabaseTestUserId` on the row for the startup sweep — so
the ordinary failure mode reported a clean teardown while a `dyad-test` user sat
in the user's real project. The Neon sibling already read its verdict this way.
The Supabase setup-failure path carries that verdict forward instead of handing
back `NOOP_TEARDOWN`. A Stop pressed just after the test user was created ran
teardown inside the catch and then answered "nothing left over", reporting a
clean cancellation for a user that had leaked. The Neon path already had
`settledTeardown` for exactly this.
`ensureNeonAuthTrustedOrigin` backs off on 423/429 like every other Neon call in
this flow. Running several specs back to back is the burst that trips the rate
limit, and an unretried clash refused the whole run — including specs that never
touch sign-in, where the same limit previously only degraded auth.
Port exhaustion and an unrecoverable port clash are `DyadErrorKind.Precondition`
rather than bare errors. Every other server-start failure here is already
classified that way "so it must not be reported as a product exception"; these
two were the paths that survived all three retries and landed in telemetry
unclassified.
Last round's Supabase cleanup copy was itself inaccurate: it told the user to go
delete the test user by hand, but `reconcileOrphanTestUsers` sweeps exactly that
at startup, keyed on the id the failed delete deliberately leaves behind. It now
says Dyad will retry, matching the Neon variant.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
---
src/ipc/handlers/tests_handlers.ts | 11 +++--
src/ipc/services/e2e_test_runtime.test.ts | 7 ++-
src/ipc/services/e2e_test_runtime.ts | 17 ++++++-
src/ipc/services/isolated_test_db.test.ts | 57 ++++++++++++++++++++++-
src/ipc/services/isolated_test_db.ts | 30 ++++++++++--
src/ipc/utils/neon_utils.test.ts | 29 ++++++++++++
src/ipc/utils/neon_utils.ts | 23 ++++++---
7 files changed, 155 insertions(+), 19 deletions(-)
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 4fb8d8bb91..89bc13a6d5 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -929,13 +929,14 @@ export async function runAppTestsWithIsolation({
): RunAppTestsResult => {
if (!isolationCleanupFailed) return result;
// Names what was actually left behind. The Neon path leaks a temporary
- // branch that startup recovery retries; the Supabase path leaks a temporary
- // auth user in the user's real project, which no sweep picks up — calling
- // that "the isolated test database" is the same class of wrong-thing copy
- // this work set out to remove.
+ // branch, the Supabase path a temporary auth user in the user's real
+ // project — calling that second one "the isolated test database" is the
+ // same class of wrong-thing copy this work set out to remove. Both are
+ // retried by their own startup sweep (`reconcileOrphanTestBranches`,
+ // `reconcileOrphanTestUsers`), so both say so.
const restoreMessage =
result.isolation?.mode === "supabase-test-user"
- ? "Dyad couldn't delete the temporary test user it created in your Supabase project. Your app settings were not changed; you can remove the dyad-test user from Supabase Auth."
+ ? "Dyad couldn't delete the temporary test user it created in your Supabase project. Your app settings were not changed; Dyad will retry the deletion on next startup."
: "Dyad couldn't finish cleaning up the isolated test database. Your app settings were not changed; Dyad will retry remote cleanup on next startup.";
return {
...result,
diff --git a/src/ipc/services/e2e_test_runtime.test.ts b/src/ipc/services/e2e_test_runtime.test.ts
index d2c9ae4775..67c6265560 100644
--- a/src/ipc/services/e2e_test_runtime.test.ts
+++ b/src/ipc/services/e2e_test_runtime.test.ts
@@ -23,6 +23,7 @@ import {
startE2eTestRuntime,
} from "./e2e_test_runtime";
import { runningApps } from "@/ipc/utils/process_manager";
+import { DyadErrorKind } from "@/errors/dyad_error";
import {
E2E_TEST_SERVER_PORT_RANGE,
E2E_TEST_SERVER_PORT_START,
@@ -301,13 +302,17 @@ describe("startE2eTestRuntime port accounting", () => {
);
process.env.DYAD_ATTEMPTS = attempts;
try {
+ // Precondition, not Internal: three fresh ports all taken means something
+ // else on the machine holds them, which the user acts on — it must not
+ // land in telemetry as an unclassified product exception the way a bare
+ // `Error` would.
await expect(
startE2eTestRuntime({
workspacePath: root,
installCommand: "true",
startCommand: `"${process.execPath}" server.mjs {port}`,
}),
- ).rejects.toThrow(/in use/i);
+ ).rejects.toMatchObject({ kind: DyadErrorKind.Precondition });
expect(fs.readFileSync(attempts, "utf8")).toBe("xxx");
} finally {
delete process.env.DYAD_ATTEMPTS;
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index 188f5b0ae3..a7dfff5753 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -142,7 +142,13 @@ export async function allocateE2eTestPort(): Promise {
return port;
}
}
- throw new Error("Could not allocate a test port.");
+ // Precondition, like every other server-start failure here: the machine has
+ // no free port to give, which is an environment problem the user acts on, not
+ // a Dyad bug to record as a product exception.
+ throw new DyadError(
+ "Dyad couldn't find a free port for the isolated test server. Close some running servers and try again.",
+ DyadErrorKind.Precondition,
+ );
}
/** Hand a port back once its server has bound it (or failed to start). */
@@ -484,5 +490,14 @@ export async function startE2eTestRuntime(
);
}
}
+ // Same reasoning: three fresh ports all found taken means something else on
+ // the machine holds them, not that Dyad malfunctioned. Left as-is when it is
+ // already a classified DyadError (an abort, a Precondition from readiness).
+ if (lastError instanceof PortInUseError) {
+ throw new DyadError(
+ `Dyad couldn't get a free port for the isolated test server: ${lastError.message}`,
+ DyadErrorKind.Precondition,
+ );
+ }
throw lastError;
}
diff --git a/src/ipc/services/isolated_test_db.test.ts b/src/ipc/services/isolated_test_db.test.ts
index de0a8d1cde..fa8d7a3326 100644
--- a/src/ipc/services/isolated_test_db.test.ts
+++ b/src/ipc/services/isolated_test_db.test.ts
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
createNeonTestAccount: vi.fn(),
ensureNeonAuthTrustedOrigin: vi.fn().mockResolvedValue(null),
createTempTestUser: vi.fn(),
- deleteTempTestUser: vi.fn().mockResolvedValue(undefined),
+ deleteTempTestUser: vi.fn().mockResolvedValue(true),
checkRls: vi.fn().mockResolvedValue({ tablesWithoutRls: [] }),
detectLegacyAppKey: vi.fn().mockResolvedValue(undefined),
getPublishableKey: vi.fn(),
@@ -132,6 +132,7 @@ beforeEach(() => {
password: "pw",
projectUrl: "https://sb-1.supabase.co",
});
+ mocks.deleteTempTestUser.mockResolvedValue(true);
mocks.getPublishableKey.mockResolvedValue("anon-key-123");
mocks.createNeonTestAccount.mockResolvedValue({
email: "neon-test@dyad.test",
@@ -637,6 +638,60 @@ describe("prepareIsolatedTestDatabase — auth provisioning", () => {
});
});
+ it("reports a leaked test user when the delete quietly fails", async () => {
+ // `deleteTempTestUser` is best-effort inside: a 5xx from the Auth Admin API
+ // resolves `false` and deliberately leaves the id on the row for the
+ // startup sweep. Reading only the throw reported a clean teardown for a
+ // test user still sitting in the user's real project.
+ mocks.deleteTempTestUser.mockResolvedValue(false);
+
+ const prepared = await prepareIsolatedTestDatabase({
+ app: makeApp({
+ supabaseProjectId: "sb-1",
+ supabaseOrganizationSlug: "org-1",
+ }),
+ emit,
+ runtimeMode: "host",
+ });
+
+ await expect(prepared.teardown()).resolves.toMatchObject({
+ envRestored: true,
+ remoteCleanupCompleted: false,
+ });
+ });
+
+ it("carries the teardown verdict through a setup failure", async () => {
+ // A Stop pressed just after the test user was created runs teardown inside
+ // the catch. Handing back a NOOP teardown afterwards answers "nothing left
+ // over" and reports a clean cancellation for a user that leaked.
+ mocks.deleteTempTestUser.mockResolvedValue(false);
+ const stop = new AbortController();
+ mocks.createTempTestUser.mockImplementation(async () => {
+ stop.abort();
+ return {
+ userId: "user-1",
+ email: "dyad-test+1@dyad.test",
+ password: "pw",
+ projectUrl: "https://sb-1.supabase.co",
+ };
+ });
+
+ const prepared = await prepareIsolatedTestDatabase({
+ app: makeApp({
+ supabaseProjectId: "sb-1",
+ supabaseOrganizationSlug: "org-1",
+ }),
+ emit,
+ runtimeMode: "host",
+ signal: stop.signal,
+ });
+
+ expect(prepared.infraError?.message).toBe("Test run stopped.");
+ await expect(prepared.teardown()).resolves.toMatchObject({
+ remoteCleanupCompleted: false,
+ });
+ });
+
it("continues unauthenticated when the Supabase anon key can't be fetched", async () => {
mocks.getPublishableKey.mockRejectedValue(new Error("no key"));
const prepared = await prepareIsolatedTestDatabase({
diff --git a/src/ipc/services/isolated_test_db.ts b/src/ipc/services/isolated_test_db.ts
index de7683e7b6..e1c4545b7d 100644
--- a/src/ipc/services/isolated_test_db.ts
+++ b/src/ipc/services/isolated_test_db.ts
@@ -433,7 +433,14 @@ async function prepareSupabaseTestUserIsolation({
let remoteCleanupCompleted = true;
if (testUser) {
try {
- await deleteTempTestUser({
+ // The RETURN value, not just the absence of a throw. This delete is
+ // best-effort inside — a 5xx from the Auth Admin API, or a
+ // service-role key fetch that fails, resolves `false` and deliberately
+ // leaves `supabaseTestUserId` on the row for the startup sweep. Reading
+ // only the throw would report a clean teardown for a test user still
+ // sitting in the user's real project. The Neon sibling reads its
+ // verdict the same way.
+ remoteCleanupCompleted = await deleteTempTestUser({
...app,
supabaseTestUserId: testUser.userId,
});
@@ -535,14 +542,29 @@ async function prepareSupabaseTestUserIsolation({
teardown,
};
} catch (error) {
- await teardown();
+ // Keep the verdict. `NOOP_TEARDOWN` answers "nothing left over", which
+ // would report a clean cancellation for a Stop pressed just after the test
+ // user was created and whose delete then failed. The Neon path carries its
+ // verdict forward the same way.
+ let remoteCleanupCompleted = false;
+ try {
+ ({ remoteCleanupCompleted } = await teardown());
+ } catch (teardownError) {
+ logger.error(
+ `Teardown failed during error recovery for app ${app.id}: ${teardownError}`,
+ );
+ }
+ const settledTeardown = async (): Promise => ({
+ envRestored: true,
+ remoteCleanupCompleted,
+ });
// The pre-flight abort check above throws into this catch; a user Stop is
// a deliberate cancellation, not a setup failure.
if (signal?.aborted) {
return {
isolation: { mode: "none", reason: "Test run stopped." },
infraError: { message: "Test run stopped." },
- teardown: NOOP_TEARDOWN,
+ teardown: settledTeardown,
};
}
const message = error instanceof Error ? error.message : String(error);
@@ -557,7 +579,7 @@ async function prepareSupabaseTestUserIsolation({
infraError: {
message: `Couldn't set up an isolated test user, so the run was stopped. Your real data was not touched. Reason: ${message}`,
},
- teardown: NOOP_TEARDOWN,
+ teardown: settledTeardown,
};
}
}
diff --git a/src/ipc/utils/neon_utils.test.ts b/src/ipc/utils/neon_utils.test.ts
index 61fb1f1dc1..250f25eb95 100644
--- a/src/ipc/utils/neon_utils.test.ts
+++ b/src/ipc/utils/neon_utils.test.ts
@@ -331,6 +331,35 @@ describe("ensureNeonAuthTrustedOrigin", () => {
vi.clearAllMocks();
});
+ it("backs off and retries when Neon rate-limits the lookup", async () => {
+ // Running several specs back to back is exactly the burst that trips Neon's
+ // rate limit. Every other Neon call in this flow retries; an unretried
+ // clash here refused the entire run, including specs that never sign in.
+ const rateLimited = Object.assign(new Error("Too many requests"), {
+ response: { status: 429 },
+ });
+ const listBranchNeonAuthTrustedDomains = vi
+ .fn()
+ .mockRejectedValueOnce(rateLimited)
+ .mockResolvedValue({ data: { domains: [] } });
+ const addBranchNeonAuthTrustedDomain = vi
+ .fn()
+ .mockResolvedValue({ data: undefined });
+ mocks.getNeonClient.mockResolvedValue({
+ listBranchNeonAuthTrustedDomains,
+ addBranchNeonAuthTrustedDomain,
+ });
+
+ await expect(
+ ensureNeonAuthTrustedOrigin({
+ projectId: "proj-1",
+ branchId: "br-test",
+ origin: "http://127.0.0.1:52150",
+ }),
+ ).resolves.toBe("http://127.0.0.1:52150");
+ expect(listBranchNeonAuthTrustedDomains).toHaveBeenCalledTimes(2);
+ }, 30_000);
+
it("preserves an HTTP loopback origin exactly", async () => {
const listBranchNeonAuthTrustedDomains = vi.fn().mockResolvedValue({
data: { domains: [] },
diff --git a/src/ipc/utils/neon_utils.ts b/src/ipc/utils/neon_utils.ts
index 993cdd3199..92edcb5e37 100644
--- a/src/ipc/utils/neon_utils.ts
+++ b/src/ipc/utils/neon_utils.ts
@@ -13,6 +13,7 @@ import {
} from "../utils/app_env_var_utils";
import { detectFrameworkType } from "./framework_utils";
import { reconcileTrustedDomains } from "./vercel_neon_sync_helpers";
+import { retryOnLocked } from "./retryOnLocked";
import { getDyadAppPath } from "@/paths/paths";
export type NeonBranchType = "production" | "development";
@@ -414,9 +415,13 @@ export async function ensureNeonAuthTrustedOrigin({
}): Promise {
const trustedOrigin = new URL(origin).origin;
const neonClient = await getNeonClient();
- const existing = await neonClient.listBranchNeonAuthTrustedDomains(
- projectId,
- branchId,
+ // Both calls back off on 423/429 like every other Neon call in the test-run
+ // flow. Running several specs back to back is exactly the burst that trips
+ // the rate limit, and an unretried clash here refuses the whole run — even
+ // one whose specs never touch sign-in.
+ const existing = await retryOnLocked(
+ () => neonClient.listBranchNeonAuthTrustedDomains(projectId, branchId),
+ `List Neon Auth trusted domains for branch ${branchId}`,
);
const alreadyTrusted = (existing.data?.domains ?? []).some(({ domain }) => {
try {
@@ -426,10 +431,14 @@ export async function ensureNeonAuthTrustedOrigin({
}
});
if (alreadyTrusted) return null;
- await neonClient.addBranchNeonAuthTrustedDomain(projectId, branchId, {
- domain: trustedOrigin,
- auth_provider: NeonAuthSupportedAuthProvider.BetterAuth,
- });
+ await retryOnLocked(
+ () =>
+ neonClient.addBranchNeonAuthTrustedDomain(projectId, branchId, {
+ domain: trustedOrigin,
+ auth_provider: NeonAuthSupportedAuthProvider.BetterAuth,
+ }),
+ `Add Neon Auth trusted origin for branch ${branchId}`,
+ );
return trustedOrigin;
}
From f93ca8f7e9de26b457cf7e6ae2017498e86944a7 Mon Sep 17 00:00:00 2001
From: Mohamed Aziz Mejri
Date: Mon, 24 Aug 2026 19:21:10 +0000
Subject: [PATCH 9/9] Address review round 5: settings-loading defaults and
shared owner check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Tests panel derives both of its settings-dependent banners from one
tri-state. `usesSandboxedE2eTests` answers false for absent settings, and the
two banners want opposite defaults while settings load — the Run gate must not
refuse, the Neon disclosure must not promise — so reading the setting directly
in each place made the panel briefly claim sandboxing to a user who had turned
it off. `undefined` now means "not loaded", and each banner compares explicitly.
`runDirectoryAppId` is the single parser both owner checks go through. The
screenshot reader hand-rolled a `startsWith` prefix test while the artifact
prune used the helper; they agree today, and sharing the parser is what keeps
them from drifting.
A child that never got a pid is untracked immediately. There is nothing for
`will-quit` to kill and nothing that could later exit to drop it, so it would
otherwise sit in the process registry for the life of the process.
Also records the invariant the prepare stage's discriminated union rests on: a
`setupError` never carries a workspace, because `createE2eTestWorkspace`
disposes its own partial tree before throwing. That is what makes the caller's
early return safe without a dispose of its own.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
---
.../preview_panel/TestsPanel.test.tsx | 12 ++++++++++++
src/components/preview_panel/TestsPanel.tsx | 18 ++++++++++++------
src/ipc/handlers/tests_handlers.ts | 5 +++++
src/ipc/services/e2e_test_runtime.ts | 10 +++++++++-
src/ipc/services/e2e_test_workspace.ts | 10 ++++++++--
src/ipc/utils/test_screenshot.ts | 7 +++++--
6 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/src/components/preview_panel/TestsPanel.test.tsx b/src/components/preview_panel/TestsPanel.test.tsx
index 7c8b2804fd..cd912afafe 100644
--- a/src/components/preview_panel/TestsPanel.test.tsx
+++ b/src/components/preview_panel/TestsPanel.test.tsx
@@ -472,6 +472,18 @@ describe("TestsPanel", () => {
expect(screen.queryByText(/Your preview keeps running/)).toBeNull();
});
+ it("promises no sandbox while settings are still loading", async () => {
+ // The Run gate treats loading as "sandbox available" so it doesn't refuse
+ // the run; this banner has to key off the same guard, or the panel
+ // briefly promises sandboxing to a user who has it turned off.
+ mocks.app = { id: 1, testingEnabled: true, neonProjectId: "neon-proj" };
+ mocks.settings = undefined as unknown as Record;
+ renderPanel();
+
+ await screen.findByText("signup.spec.ts");
+ expect(screen.queryByText(/Your preview keeps running/)).toBeNull();
+ });
+
it("names the Neon teardown without promising a restore", () => {
// This is the wait that can pass a minute (the branch delete retries with
// backoff). Calling it "Running…" — as the panel used to — reads as a
diff --git a/src/components/preview_panel/TestsPanel.tsx b/src/components/preview_panel/TestsPanel.tsx
index aa29bd61d4..d24b62a09f 100644
--- a/src/components/preview_panel/TestsPanel.tsx
+++ b/src/components/preview_panel/TestsPanel.tsx
@@ -713,8 +713,17 @@ export function TestsPanel() {
// `usesSandboxedE2eTests` answers false for absent settings, which would
// flash the amber "Start the app to run tests." banner and a disabled Run
// button on every mount for a state that may not apply at all.
- const testsNeedDevServer = !!settings && !usesSandboxedE2eTests(settings);
- const testRunBlocked = testsNeedDevServer && !devServerRunning;
+ //
+ // Tri-state on purpose: `undefined` means settings haven't loaded, and that
+ // is neither a refusal nor a promise. `usesSandboxedE2eTests` answers false
+ // for absent settings, so reading it directly would flash the amber gate on
+ // every mount — and the Neon disclosure below has the opposite default, so
+ // reading `!disableSandboxedE2eTests` there would briefly promise sandboxing
+ // to a user who turned it off. One value, two explicit comparisons.
+ const sandboxAvailable = settings
+ ? usesSandboxedE2eTests(settings)
+ : undefined;
+ const testRunBlocked = sandboxAvailable === false && !devServerRunning;
// Owns the run's whole lifecycle, teardown included. Gates every action that
// must not interleave with it (Run, Record, Delete), because the per-app lock
// is still held during `cleaning-up`.
@@ -768,10 +777,7 @@ export function TestsPanel() {
// the alternative a user would assume is "my tests hit my real database" —
// but it must not promise the preview restart the old env-swap path did.
const showNeonSandboxDisclosure =
- specs.length > 0 &&
- !!app?.neonProjectId &&
- (settings?.runtimeMode2 ?? "host") === "host" &&
- !settings?.disableSandboxedE2eTests;
+ specs.length > 0 && !!app?.neonProjectId && sandboxAvailable === true;
// Pop the output drawer when a run starts for the app being viewed. Keyed
// off the global atom's phase transition — not the raw IPC event — so it
diff --git a/src/ipc/handlers/tests_handlers.ts b/src/ipc/handlers/tests_handlers.ts
index 89bc13a6d5..63f7ee3401 100644
--- a/src/ipc/handlers/tests_handlers.ts
+++ b/src/ipc/handlers/tests_handlers.ts
@@ -744,6 +744,11 @@ async function runTestsAgainstNormalPreview({
type E2eTestPrepareResult =
| { installed: boolean; workspace: E2eTestWorkspace }
| { setupError: string };
+// INVARIANT: the two arms are exclusive — a `setupError` never carries a
+// workspace. `createE2eTestWorkspace` disposes its own partially-copied tree
+// before it throws, which is what makes the caller's early return safe to take
+// without a dispose of its own. A future variant that returned both would leak
+// a sandbox directory silently, so it must dispose before returning instead.
export interface RunTestsWithIsolationOptions {
/**
diff --git a/src/ipc/services/e2e_test_runtime.ts b/src/ipc/services/e2e_test_runtime.ts
index a7dfff5753..258ca20226 100644
--- a/src/ipc/services/e2e_test_runtime.ts
+++ b/src/ipc/services/e2e_test_runtime.ts
@@ -436,7 +436,15 @@ async function startServerOnPort({
// and that survivor still holds the workspace cwd `dispose()` is about to
// remove. Leave it registered; `trackE2eTestProcess`'s own exit/error
// listeners drop it whenever it does die.
- if (child.exitCode !== null || child.signalCode !== null) {
+ //
+ // A child with no pid never started, so there is nothing for quit to kill
+ // and nothing that could later exit to drop it: untrack it here or it
+ // sits in the registry for the life of the process.
+ if (
+ child.pid === undefined ||
+ child.exitCode !== null ||
+ child.signalCode !== null
+ ) {
untrack();
}
})();
diff --git a/src/ipc/services/e2e_test_workspace.ts b/src/ipc/services/e2e_test_workspace.ts
index 943d05987e..097a6208b8 100644
--- a/src/ipc/services/e2e_test_workspace.ts
+++ b/src/ipc/services/e2e_test_workspace.ts
@@ -302,8 +302,14 @@ export function rewriteE2eArtifactPath(
return path.join(artifactPath, relative);
}
-/** Run directory names start with `-`; recover the id from one. */
-function runDirectoryAppId(name: string): number | null {
+/**
+ * Run directory names start with `-`; recover the id from one.
+ *
+ * The single parser every owner check goes through — the artifact prune and the
+ * screenshot reader both decide "is this run mine?" from it, and a second,
+ * hand-rolled prefix test in either place is how the two drift apart.
+ */
+export function runDirectoryAppId(name: string): number | null {
const [prefix] = name.split("-");
const appId = Number(prefix);
return prefix !== "" && Number.isInteger(appId) ? appId : null;
diff --git a/src/ipc/utils/test_screenshot.ts b/src/ipc/utils/test_screenshot.ts
index 27a5f73aac..31a0aeb24e 100644
--- a/src/ipc/utils/test_screenshot.ts
+++ b/src/ipc/utils/test_screenshot.ts
@@ -2,7 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import log from "electron-log";
import { getUserDataPath } from "@/paths/paths";
-import { E2E_TEST_ARTIFACT_DIR } from "@/ipc/services/e2e_test_workspace";
+import {
+ E2E_TEST_ARTIFACT_DIR,
+ runDirectoryAppId,
+} from "@/ipc/services/e2e_test_workspace";
const logger = log.scope("test_screenshot");
@@ -90,7 +93,7 @@ async function resolveContainedArtifact(
const testResultsSegment = useArtifactRoot ? segments[1] : segments[0];
if (
useArtifactRoot &&
- (appId === undefined || !segments[0].startsWith(`${appId}-`))
+ (appId === undefined || runDirectoryAppId(segments[0]) !== appId)
) {
return null;
}