Skip to content

feat: isolate E2E test execution in sandboxes - #4358

Draft
azizmejri1 wants to merge 9 commits into
dyad-sh:mainfrom
azizmejri1:feat/sandbox-e2e-test-runtime
Draft

feat: isolate E2E test execution in sandboxes#4358
azizmejri1 wants to merge 9 commits into
dyad-sh:mainfrom
azizmejri1:feat/sandbox-e2e-test-runtime

Conversation

@azizmejri1

@azizmejri1 azizmejri1 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

What this changes

E2E test runs no longer execute against the user's real working tree, real preview, or real database. Each run gets:

  • a disposable workspace under <userData>/test-sandboxes β€” the app's current source (tracked and untracked) plus a copy-on-write clone of its node_modules;
  • a run-scoped dev server on its own port, never registered in runningApps, so the normal preview keeps its PID, URL and real branch;
  • data isolation that only ever writes inside that workspace β€” a throwaway Neon branch, a Supabase RLS-scoped test user, or nothing for a no-database app.

The real .env.local is byte-identical before and after, and the preview is never restarted.

Behavior changes worth calling out

Neon apps can no longer run tests in Docker or cloud runtime. Previously prepareIsolatedTestDatabase returned mode: "none" with the disclosure "Isolated test data isn't available in docker runtime yet β€” tests run against your current data", and the run proceeded against the user's real Neon database. The sandbox is host-only for now, so there is no throwaway branch to point a Docker/cloud app at, and this now fails closed with an explanatory error instead. This is intentional and is the plan's "for Neon specifically, never degrade to the normal preview/real database" applied to the runtime-mode case β€” but it is a capability removal for Docker/cloud users with a Neon app, not a side effect. Supabase and no-database apps on those runtimes keep working: they run against the normal preview with the missing runtime isolation disclosed on the result.

Sandboxing can be turned off. Settings β†’ Workflow β†’ Run E2E Tests in an Isolated Sandbox is on by default. COPYFILE_FICLONE only clones on reflink-capable filesystems, so on ext4 and Windows the per-run snapshot is a real copy; turning the setting off routes through the same non-sandboxed path as Docker/cloud (and still refuses Neon apps). Copy/dispose durations and entry counts are reported as telemetry so the cost on non-reflink filesystems is measurable.

Sandboxed runs no longer require the preview to be running. The sandbox serves the app itself, so the Tests panel's "Start the app to run tests." gate and the agent's equivalent refusal now apply only to the non-sandboxed path. Recording still requires the preview β€” it drives the live one.

Test artifacts are now swept. Retained screenshots and traces under <userData>/test-artifacts are pruned at startup when their app no longer exists, and dropped when an app is deleted. Because they live outside the app directory, the agent's read_file cannot reach them, so a failing sandboxed run gets Playwright's page snapshot inlined into the tool result instead of a path.

Review in cubic

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 7 inline finding(s).

Comment thread src/ipc/handlers/tests_handlers.ts Outdated
Comment thread src/ipc/handlers/tests_handlers.ts
Comment thread src/ipc/services/e2e_test_workspace.ts Outdated
Comment thread src/ipc/services/e2e_test_runtime.ts Outdated
Comment thread src/ipc/services/e2e_test_runtime.ts Outdated
Comment thread src/ipc/handlers/tests_handlers.ts Outdated
Comment thread src/ipc/handlers/tests_handlers.ts
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

This is a well-structured refactor: the sandbox workspace, run-scoped runtime, artifact promotion, and the HTTP-preserving Neon trusted-origin helper are all sensibly separated and reasonably tested, and the recorder path is left genuinely untouched. Two problems in the new orchestration are merge-blocking, and both have mechanical fixes.

The most serious one is stage ordering. The refactor moved ensurePlaywrightBootstrap and the full workspace snapshot into a new coordinator stage that runs before the testingEnabled and runtimeMode guards, which still live inside the second stage. ensurePlaywrightBootstrap is not a read-only check β€” it installs @playwright/test into the user's real app, writes Dyad's config, adds ignore entries, and can download a browser (5-minute install timeout). So a tests:run on an app with testing disabled, or on a Docker/cloud-runtime app, now mutates the real project and copies the entire app plus node_modules before returning "Testing isn't enabled for this app" or "Isolated E2E test servers currently require host runtime." Before this PR both refusals were cheap and side-effect-free.

The second is quit-time process cleanup. stopAllAppTestsSync only calls controller.abort(), and the runtime's abort listener routes into the async killProcess β†’ tree-kill path. will-quit does not await async work, which is exactly why the existing stopAllAppsSync uses killProcessTreeSync. Sandbox dev servers therefore survive Dyad quitting; the next launch's reconcileOrphanE2eTestWorkspaces then tries to rm -rf a tree that a live process still has open (EBUSY on Windows).

Everything else below is informational. Note that the plan document committed with this PR describes several Phase 1/4 items (copy progress telemetry, staged coordination with startup reconciliation, feature-flag rollout) that the implementation does not yet include; the plan's own "Status" section acknowledges partial delivery, so this is scope rather than a defect.

The diff was provided in full (diffTruncated: false), so confidence in the findings is good. I verified the base-branch behaviour of stopAllAppsSync, getDefaultCommand, ensurePlaywrightBootstrap, restoreAppFromTestBranch, and the coordinator's deletion fence directly against the checked-out main.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/ipc/handlers/tests_handlers.ts:795 Playwright bootstrap and workspace copy run before the run guards
πŸ”΄ HIGH src/ipc/handlers/tests_handlers.ts:204 Quit path only aborts, leaking sandbox server processes
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:188 Startup sandbox sweep is uncoordinated and can delete a live run
🟑 MEDIUM src/ipc/services/e2e_test_runtime.ts:53 Custom start commands get an unconditional -- --port suffix
🟑 MEDIUM src/ipc/services/e2e_test_runtime.ts:57 pnpm chosen by lockfile alone, ignoring pnpm availability
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:857 Docker and cloud runtime users lose E2E testing entirely
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:355 first_run telemetry is now always false for panel runs
🟒 Low Priority Notes (8 items)
  • Bootstrap failures change shape - ensurePlaywrightBootstrap used to be wrapped in a try/catch inside runAppTestsCore that returned a friendly infraError in the result. In the new stage-1 call it is uncaught, so it propagates through the outer catch and rejects the IPC invocation instead of resolving with an inline error the panel can render next to the streamed setup output. (src/ipc/handlers/tests_handlers.ts)
  • Precondition error is not a DyadError - copyNodeModules throws a plain Error for "The app's dependencies are not installed." That is an expected user precondition, but runAppTestsWithIsolation's outer catch wraps non-Dyad errors as DyadErrorKind.Internal, so it will be counted as a product exception. DyadErrorKind.Precondition matches rules/dyad-errors.md. (src/ipc/services/e2e_test_workspace.ts)
  • Test temp dir lands in the repo - buildE2eTestStartCommand's pnpm test creates fs.mkdtempSync(path.join(process.cwd(), ".e2e-runtime-test-")), writing into the repository root; the sibling test in the same file correctly uses os.tmpdir(). (src/ipc/services/e2e_test_runtime.test.ts)
  • Trusted-origin helper duplicates its neighbour - ensureNeonAuthTrustedOrigin is a near copy of ensureNeonAuthTrustedDomain differing only in normalization. Parameterizing the existing function would keep the list/compare/add logic in one place. (src/ipc/utils/neon_utils.ts)
  • Retained artifacts are never reclaimed for deleted apps - test-artifacts/<appId>-* is pruned only at the start of that same app's next run. Deleting an app (or never running its tests again) leaves the directory on disk forever, and startup reconciliation only sweeps test-sandboxes. (src/ipc/services/e2e_test_workspace.ts)
  • Previous artifacts are dropped before the new run can succeed - createE2eTestWorkspace deletes the prior run's artifacts up front, so a run that fails during copy or server startup leaves the still-displayed previous results with broken screenshot thumbnails. (src/ipc/services/e2e_test_workspace.ts)
  • Real .env.local is copied outside the project - The snapshot copies the app's environment files into <userData>/test-sandboxes/.... Disposal removes them, but after a crash they persist until the next launch's sweep. Worth confirming the sandbox root's directory mode is user-only on each platform. (src/ipc/services/e2e_test_workspace.ts)
  • Copy progress is a single line - COPYFILE_FICLONE silently falls back to a full copy on ext4 and is skipped entirely on Windows, so node_modules cloning can take a long time behind one static "Cloning installed dependencies…" message. The plan's Phase 1 called for a file-count/bytes indicator after 500 ms. (src/ipc/services/e2e_test_workspace.ts)

Generated by Dyadbot persona-based code review

@github-actions github-actions Bot added the needs-human:review-issue ai agent flagged an issue that requires human review label Aug 22, 2026
- 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 <userData>/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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 4/5

All seven review threads were addressed with code changes in 6cad874 and the full unit suite passes (6365 tests), but the sandboxed runtime and the restored Docker/cloud fallback are both exercised only through mocks here β€” a real E2E run on a non-host runtime would raise this to 5.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Playwright bootstrap and the workspace snapshot ran before the testingEnabled / runtime-mode guards, so a refused run still mutated the user's real project and copied gigabytes Both guards now run before the prepare-e2e-test-workspace stage; the testingEnabled check is also kept inside the run claim, since the two stages take separate claims. New test asserts neither ensurePlaywrightBootstrap nor createE2eTestWorkspace is called on refusal View
Docker and cloud runtime users lost E2E testing entirely to a hard refusal Restored the pre-sandbox path (bootstrap + normal preview) for those runtimes with the missing runtime isolation disclosed via isolation.reason. The refusal is now scoped to Neon apps, whose only alternative would be the user's real database. Per Principle #1: Backend-Flexible (runtime mode is a backend choice; a feature that works on only one is too coupled) and Principle #4: Transparent Over Magical (disclose the gap rather than silently block) View
Quit only aborted the controllers, leaking sandbox server processes that held their port and sandbox cwd New e2e_test_process_registry tracks run-scoped children; stopAllAppTestsSync now killProcessTreeSynces each live pid, mirroring stopAllAppsSync. The Playwright runner is registered too via spawnStreaming's onProcess hook, since it shares the sandbox cwd View
The startup sandbox sweep removed the whole test-sandboxes root and could delete a live run mid-copy reconcileOrphanE2eTestWorkspaces now deletes run directories individually and skips names registered by an in-flight run; the claim is taken before the first byte is copied View
Custom start commands got an unconditional -- --port suffix, and "custom" was decided differently from the normal runtime Custom commands now run verbatim (with {port} substitution when present) relying on the PORT env var, and hasCustomE2eStartCommand mirrors getCommand's both-commands-set condition. A command that respects neither now fails with a {port} hint instead of a bare timeout β€” Principle #4: Transparent Over Magical View
pnpm was chosen from the lockfile alone, ignoring whether pnpm is usable Now uses getPackageManagerSignal + choosePackageManagerFromSignal with getPnpmMinimumReleaseAgeSupport, so the sandbox and the normal preview always agree β€” including the npm fallback when pnpm is missing or too old View
first_run telemetry was always false for panel/agent runs The stage-1 bootstrap's installed flag is threaded into runAppTestsCore as bootstrapInstalled; a new test asserts first_run: true reaches sendTelemetryEvent View
Product Principle Suggestions

The following suggestions could improve rules/product-principles.md to help resolve ambiguous cases in the future:

  • Principle Gif for READMEΒ #1: Backend-Flexible: "Add runtime modes (host / Docker / cloud) to the list of swappable backends alongside LLM providers, database backends, and deployment targets, and state whether a feature may ship host-only as a first phase β€” and if so, whether the other runtimes must keep a degraded-but-disclosed path rather than being blocked."
  • Principle Does it work for Intel Macs?Β #4: Transparent Over Magical: "Add guidance for the fail-closed-vs-disclose trade-off: when a safety guarantee (e.g. database isolation) can't be provided, say when to refuse outright and when to run with the gap disclosed in the UI. The current text covers approval gates and error surfacing but not degraded-capability paths."

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 8 inline finding(s).

Comment thread src/atoms/testRuntimeAtoms.ts
Comment thread src/ipc/handlers/tests_handlers.ts
Comment thread src/ipc/services/e2e_test_workspace.ts
Comment thread src/ipc/handlers/tests_handlers.ts
Comment thread src/ipc/services/e2e_test_workspace.ts
Comment thread src/ipc/services/e2e_test_workspace.ts
Comment thread src/ipc/services/e2e_test_runtime.ts Outdated
Comment thread src/ipc/handlers/tests_handlers.ts
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: auto-fix

The main-process architecture here is strong: staged coordinator claims, a run-scoped process registry with a synchronous quit path, reflink-based snapshotting, containment assertions on disposal, and an orphan sweep that respects live runs. The unit-test coverage for the new services is genuinely good, and the Neon cleanup-only marker is threaded correctly so crash recovery can't mistake a sandbox run for the recorder's env swap.

The blocking problem is that the renderer was never migrated. The PR's headline promise β€” the normal preview is never stopped, restarted, or repointed β€” is contradicted by three user-facing strings that this PR leaves untouched and that are gated on exactly the path it changed.

The patch was complete (not truncated), so confidence on the diff itself is high. Where a finding depends on unchanged files (TestsPanel.tsx, CancellationBanner.tsx, app_operation_coordinator.ts, app_runtime_service.ts, neon_test_branch.ts), I read those from the repo to confirm.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/atoms/testRuntimeAtoms.ts:47 Tests panel still says Dyad restores the preview and database
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:902 Cleanup warning keys off sandbox env restore, not branch deletion
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:101 Stop during sandbox setup is reported as an internal error
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:1187 Sandbox deletion blocks the terminal result with no progress state
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:80 Every host run copies node_modules with no flag or telemetry
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:109 Retained test artifacts are never swept from user data
🟑 MEDIUM src/ipc/services/e2e_test_runtime.ts:114 Readiness poll leaks an abort listener per iteration
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:658 Neon apps can no longer run tests in docker or cloud runtime
🟒 Low Priority Notes (6 items)
  • Dev-server gate is now unnecessary and misleading - The panel disables Run behind devServerRunning and shows "Start the app to run tests.", and the agent tool's guardDevServerRunning refuses with "The app's dev server isn't running, so the tests can't execute." Neither is true on the sandbox path β€” the run brings up its own server. The gate is still safe (the snapshot needs node_modules, which bootstrap guarantees), but it withholds the feature's main benefit and the copy is now wrong. (src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts)
  • Failure text carries sandbox absolute paths - parsePlaywrightReport relativizes file against the sandbox (correct), but error/stack text is passed through verbatim, so users and the local agent now see <userData>/test-sandboxes/<appId>-…/… paths pointing at a directory that is deleted moments later. Consider stripping the workspace prefix from error text alongside the screenshot rewrite. (src/ipc/handlers/tests_handlers.ts)
  • Runtime tests write temp dirs into the repo working directory - Two cases use fs.mkdtempSync(path.join(process.cwd(), ".e2e-runtime-test-")) instead of os.tmpdir() (which the same file uses elsewhere). They clean up in finally, but a hard kill leaves untracked .e2e-runtime-test-* directories in the checkout. (src/ipc/services/e2e_test_runtime.test.ts)
  • Vacuous assertion in the sweep test - "sweeps abandoned sandboxes without touching a live run" ends with await live.dispose() and then asserts fs.stat(live.workspacePath) rejects. dispose() already removed it, so the second reconcileOrphanE2eTestWorkspaces() call is not actually verified to do anything. Assert on the sandbox root listing, or drop the run from activeWorkspaceNames without disposing. (src/ipc/services/e2e_test_workspace.test.ts)
  • No packaged Playwright coverage for the headline behavior - The plan's test plan calls for one broad E2E spec proving the normal preview keeps its PID/URL and .env.local while a sandboxed run executes. The unit/integration tests here all mock the workspace or the runtime, so nothing exercises "both servers alive at once" end to end. (plans/sandboxed-e2e-test-runtime.md)
  • Checked-in plan overstates completion - The document says "Status: Implemented for host-runtime test execution", but Phase 4 items it lists as required (temporary feature flag, and the app relocation / provider unlink / shutdown coordination audits) are not in this PR. Only deleteAppById gained an endTestsForApp hook. Worth trimming the status line or the phase list so the doc doesn't drift. (plans/sandboxed-e2e-test-runtime.md)

Generated by Dyadbot persona-based code review

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 4/5

The eight new threads are all addressed in 10e0442 with the full unit suite green (6375 tests), and the biggest gaps this round β€” UI copy that asserted the opposite of what the code does, and a cleanup warning that could never fire β€” are closed; still 4 rather than 5 because the sandbox path and the restored non-sandboxed path are exercised only through mocks, and the copy telemetry has no field data yet on non-reflink filesystems.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Tests panel and cancellation banner still said Dyad restores the preview and database β€” on exactly the path the PR changed All four strings replaced, cancellationRestoringTestApp renamed to cancellationRemovingTestDatabase across all five locales, and the Neon banner rewritten to "your preview keeps running against your real one" rather than deleted β€” per Principle #4: Transparent Over Magical, "do my tests hit my real database?" is the question that banner exists to answer. Panel and banner tests moved with them and now assert no "Restoring" copy survives View
Cleanup warning keyed off sandbox env restore, so it never fired when a Neon branch actually leaked New TeardownResult.remoteCleanupCompleted, reported by markAndDeleteTempTestBranch (and by the Supabase test-user delete, which had the same problem). Both E2E paths key off it; the recorder keeps envRestored, which still means what it says there View
Stop during sandbox setup surfaced as an internal product exception and an amber error banner runAppTestsWithIsolation now returns the same { infraError: "Test run stopped." } shape the in-run Stop path produces when the controller is aborted, instead of rejecting the IPC call View
Sandbox deletion blocked the terminal result with no progress state cleaning-up is now emitted before disposal for every isolation mode, not just when there was provider state to remove. The old "stays quiet with no isolation" test was written when disposal didn't exist; it now covers the non-sandboxed path, where the premise still holds View
Every host run copies node_modules with no flag or telemetry Added e2e_test_workspace_created / _disposed telemetry (durations, entry counts off the existing fs.cp filter, reflink_requested, platform; no absolute paths) and a Settings β†’ Workflow toggle, on by default. Turning it off reuses the non-sandboxed path and still refuses Neon β€” the plan's "fail closed rather than silently use the legacy runtime". Per Principle #3: Intuitive But Power-User Friendly: default effortless, escape hatch for the user hitting a multi-minute copy on ext4 or Windows View
Retained test artifacts were never swept from user data Startup reconciliation now prunes artifact directories whose appId prefix has no matching row (via an injected knownAppIds set, so the service stays db-free and testable), and deleteAppById drops the app's artifacts next to endTestsForApp View
Readiness poll leaked an abort listener per iteration delay now removes its listener on the normal timer path β€” { once: true } only cleans up when the listener fires, so a 15-second cold start was attaching ~60 of them and tripping MaxListenersExceededWarning View
Neon apps can no longer run tests in Docker or cloud runtime β€” wanted an explicit product decision Confirmed intended and now called out in the PR description as a capability removal, with the before/after spelled out. Not adding an opt-in: per Principle #4, an approval gate covers a consequential action the user chose, whereas this would be standing permission for every future run to write to a live database, with silent data loss as the failure mode. The escape hatch Principle #3 asks for is the sandbox toggle, which changes how tests run rather than what they may destroy View
Product Principle Suggestions

The following suggestions could improve rules/product-principles.md to help resolve ambiguous cases in the future:

  • Principle Does it work for Intel Macs?Β #4: Transparent Over Magical: "Add a rule that user-facing copy describing what Dyad does to the user's files, processes, or data is part of the behavior, not documentation of it: a change to that behavior is incomplete until the strings β€” in every locale β€” and their tests move with it."
  • Principle Does it work for Intel Macs?Β #4: Transparent Over Magical: "Distinguish an approval gate (the user consents to one consequential action they just asked for) from a standing opt-out (the user disables a safety property for all future actions). State when a capability may be removed outright rather than offered behind a confirmation."
  • Principle Infinite Loop: Checking Node.js setup...Β #2: Productionizable: "Add guidance on shipping a change with a per-run cost that varies by platform: when telemetry and a user-facing off switch are required before rollout, versus when the new path may ship unconditionally."

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 5 inline finding(s).

Comment thread src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts Outdated
Comment thread src/components/preview_panel/TestsPanel.tsx
Comment thread src/components/preview_panel/TestsPanel.tsx Outdated
Comment thread src/ipc/services/e2e_test_workspace.ts Outdated
Comment thread src/ipc/handlers/tests_handlers.ts
@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: β›” NO - Do NOT merge
Recommendation: human-review

The architecture here is careful and well-tested: bootstrap-before-snapshot, staged coordinator claims, a run-scoped process registry killed synchronously on quit, containment checks on every path removal, a startup sweep that skips live runs, and remoteCleanupCompleted replacing the now-meaningless envRestored signal on the E2E path. The full diff was available (not truncated). One regression blocks merge: the agent's failure-diagnosis artifacts now live outside the app directory and the tool hands the model paths read_file will refuse.

Issues Summary

Severity File Issue
πŸ”΄ HIGH src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts:409 Agent failure artifacts point outside the app and can't be read
🟑 MEDIUM src/components/preview_panel/TestsPanel.tsx:1556 Sandbox disclosure contradicts the still-active dev-server gate
🟑 MEDIUM src/components/preview_panel/TestsPanel.tsx:1464 Cleanup copy claims a sandbox even when none was created
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:79 Expected setup failures thrown as plain Error, reported as Internal
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:999 Snapshot stage drops allowCompatibleQueueBypass

On the HIGH: rewriteResultArtifactPaths moves screenshot paths into <userData>/test-artifacts/<runId>/…, but attachFailureArtifacts still does path.relative(ctx.appPath, shot.screenshotPath). That produces a ../../../.. traversal path (or a cross-drive absolute path on Windows), which the tool then presents as Page snapshot: … ← read this first with read_file. read_file routes through safeJoin, which throws DyadErrorKind.Validation for anything escaping the app directory, so every failing sandboxed agent run costs a wasted read and loses the page snapshot. The screenshot image itself still attaches (readTestScreenshotDataUrl was correctly taught about the artifact root and the <appId>- prefix), so the agent is degraded rather than blind. It is marked human-review only because the fix involves a small design choice β€” retain the artifacts somewhere the agent can read, or suppress the unreadable path β€” not because the code change is hard.

Verified as sound (no action needed): app deletion aborts and awaits the in-flight run before the row is deleted, and beginAppDeletion rejects new coordinator admissions synchronously, so endTestsForApp cannot deadlock between the two stages; the workspace is disposed in the outer finally on every exit including the new abort early-return; the sandbox mirrors executeApp's package-manager selection and env construction; markTestBranchCleanupOnly is written before the branch is used so crash recovery cannot mistake a sandbox run for the recorder's real-env swap; ensureNeonAuthTrustedOrigin preserves the HTTP scheme and targets only the throwaway branch; the screenshot IPC containment guard is tightened correctly; all five locale files were updated; modifiesState: true on run_tests is preserved.

🟒 Low Priority Notes (7 items)
  • Previous run's artifacts are deleted before the new run produces any - createE2eTestWorkspace prunes <appId>-* artifact directories at the very start of a run, so a run that then fails during setup leaves the panel showing the prior results with no readable thumbnails behind them. Pruning after retainE2eTestArtifacts would keep the last good set intact. (src/ipc/services/e2e_test_workspace.ts)
  • Windows sandbox disposal has no retry - fs.rm(workspacePath, { recursive: true, force: true }) without maxRetries/retryDelay is a known EBUSY/ENOTEMPTY source on Windows when a dev-server child is still exiting. It degrades safely (logged, swept at next startup), but a couple of retries would avoid the leak. (src/ipc/services/e2e_test_workspace.ts)
  • Readiness probe accepts any listener on the allocated port - Vite/Next auto-increment their port when the requested one is taken instead of failing with EADDRINUSE, so the bounded retry wouldn't trigger; the probe would either hit whatever else owns that port or wait the full two minutes. The bind race is narrow, so this is a tail case, not a likely one. (src/ipc/services/e2e_test_runtime.ts)
  • Docker/cloud Neon apps lose E2E testing entirely - previously they ran with a "tests run against your current data" disclosure; they are now refused outright. This is a deliberate and safer fail-closed choice, but it is a user-visible capability removal that deserves a release note (the PR description is empty). (src/ipc/handlers/tests_handlers.ts)
  • Sandboxes and artifacts live under userData - on Windows that is roaming %APPDATA%, so multi-gigabyte node_modules clones can land in a roamed profile in managed environments. A local/temp root would avoid that; the plan's reasoning for userData is about cleanup recognizability, which a fixed local root also satisfies. (src/ipc/services/e2e_test_workspace.ts)
  • test_screenshot.ts pulls a service module in for one constant - importing E2E_TEST_ARTIFACT_DIR from e2e_test_workspace drags electron-log and telemetry into the agent's screenshot path. A shared constants module would keep that boundary thin. (src/ipc/utils/test_screenshot.ts)
  • No packaged E2E spec for the new flow - the committed plan's test plan calls for one broad packaged spec asserting the preview keeps its PID/URL and .env.local while the sandbox runs. Unit and Vitest coverage is genuinely thorough, but the cross-process guarantee is only asserted indirectly. (plans/sandboxed-e2e-test-runtime.md)

Generated by Dyadbot persona-based code review

- Stop handing the agent an unreadable artifact path. A sandboxed run retains
  error-context.md under <userData>/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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb
@azizmejri1

Copy link
Copy Markdown
Collaborator Author

πŸ€– Claude Code Review Summary

PR Confidence: 4/5

The five new threads are addressed in f6f274a with the full unit suite green (6381 tests), and this round closed two things that would have been felt immediately β€” the agent's first diagnostic step failing on every sandboxed failure, and the dev-server gate blocking the feature's main benefit; still 4 because both the sandboxed path and the restored fallback are covered by mocks rather than a real end-to-end run, and the copy telemetry has no field data yet on non-reflink filesystems.

Unresolved Threads

No unresolved threads

Resolved Threads

Issue Rationale Link
Agent failure artifacts pointed outside the app, so read_file rejected the page snapshot the tool tells the model to read first The artifact's reachability is now checked with the same rule safeJoin applies; out-of-app snapshots are read via a new readTestErrorContext and inlined (24 KB cap) instead of named by a ../../.. path, which is no longer printed. Chose inlining over dropping the line per Principle #4: Transparent Over Magical β€” the snapshot is the artifact that explains the failure, and removing the pointer without replacing the content would leave the agent worse off than before the sandbox existed. The screenshot reader's containment guards were extracted so both readers share them View
The sandbox disclosure contradicted a still-active dev-server gate New shared usesSandboxedE2eTests helper drives the panel banner, Run/Retry state, the agent's guardDevServerRunning, its tool description, and the main-process routing β€” so the renderer's gate and main's decision can't drift. Recording deliberately still requires the preview, since it drives the live one View
Cleanup copy claimed a sandbox on the fallback path, which never creates one sandboxed is now threaded through the run-state payload (recorded once when the run picks its path, since the setting can change mid-run) rather than sniffed from the isolation reason. cancellationCleaningTestData restored to its original wording with a separate cancellationCleaningTestSandbox key, in all five locales. This was my own regression from the previous round β€” same class of inaccuracy as the "Restoring your preview" copy it replaced View
Expected setup failures thrown as plain Error, reported to PostHog as Internal Dependencies-not-installed and the three server-readiness failures now throw DyadError with DyadErrorKind.Precondition, which rules/dyad-errors.md filters from telemetry. The existing catch already rethrows a classified DyadError unchanged, so they reach telemetry correctly. Abort throws stay plain β€” the previous round's signal.aborted branch converts them before they get there View
Snapshot stage dropped allowCompatibleQueueBypass Confirmed an oversight and added. Splitting the claim in two was about releasing the working tree earlier, not about making the snapshot a harder barrier than the single claim it replaced. The coordinator-claim test now asserts the flag on both stages so a future split can't drop it silently View
Product Principle Suggestions

The following suggestions could improve rules/product-principles.md to help resolve ambiguous cases in the future:

  • Principle Add supabase supportΒ #5: Bridge, Don't Replace: "Add guidance for when Dyad stops depending on one of the user's running processes: state that every precondition guarding the old dependency β€” UI gates, agent refusals, tool descriptions β€” must be re-evaluated in the same change, since a stale gate silently withholds the capability the change was meant to add."
  • Principle Does it work for Intel Macs?Β #4: Transparent Over Magical: "Add a rule for artifacts Dyad hands to the agent or the user: a path is only useful if the recipient can open it. When a file moves outside the reachable root, inline the content or say it's unavailable β€” never emit a path that will only fail."

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01JhzboMdTdVq829U81ZCiNb

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 4 inline finding(s).

let dependencyEntries = 0;
const startedAt = Date.now();
try {
await fs.cp(appPath, workspacePath, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Sandbox copy failures are reported as internal product exceptions

copyNodeModules deliberately throws a DyadError with DyadErrorKind.Precondition for the "dependencies not installed" case, but the two fs.cp calls themselves let raw Node errors escape. runAppTestsWithIsolation's catch wraps any non-DyadError in new DyadError(message, DyadErrorKind.Internal), so the most likely real-world failures of a per-run app + node_modules snapshot β€” ENOSPC on a full disk, EPERM/EBUSY on Windows when a file is locked by the dev server or antivirus, EACCES β€” get counted as unclassified Dyad product exceptions instead of user/environment preconditions. This is the same class of misclassification the comment immediately above the fs.stat guard exists to prevent.

πŸ’‘ Suggestion: Wrap both fs.cp calls (source tree and node_modules) and translate ENOSPC/EPERM/EBUSY/EACCES into a DyadError with a user-facing kind (e.g. Precondition) and an actionable message, letting only genuinely unexpected errors fall through as Internal.

{
appId,
operation: "prepare-e2e-test-workspace",
resources: [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Snapshot stage does not claim runtime-config, so .env.local can be rewritten mid-copy

The prepare-e2e-test-workspace stage claims app-path (read), repository-ref (read), repository-worktree and test-files, but not runtime-config β€” the resource that guards .env.local. restoreAppFromTestBranch (neon_test_branch.ts:524) claims app-path (read), provider, runtime and runtime-config, so it does not conflict with the snapshot stage and can rewrite the real .env.local while createE2eTestWorkspace is copying the app directory. The pre-sandbox code held runtime/runtime-config for the entire run precisely to keep startup reconciliation from interleaving, and app_handlers.ts:988 shows the repo's own precedent: copy-app takes readAppResource("runtime-config") because it copies the app directory. A run started shortly after launch (or concurrently with a Run press that triggers ensureAppOffTestBranch) can therefore snapshot a torn or temporary-branch .env.local into the sandbox.

πŸ’‘ Suggestion: Add readAppResource("runtime-config") to the prepare-e2e-test-workspace resource list so env-file writers cannot interleave with the snapshot, mirroring the copy-app operation.

}): Promise<E2eTestRuntime> {
if (signal?.aborted) throw new Error("Test run stopped.");
const port = await allocateE2eTestPort();
const baseUrl = `http://127.0.0.1:${port}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Test server URL is assumed rather than observed, so a mismatch costs a 2-minute dead wait

baseUrl is hardcoded to http://127.0.0.1: and both the readiness probe and Playwright's baseURL use it, but the child is only ever told the port (via --port or PORT) and never the host. If the framework binds somewhere else β€” a dev script that hardcodes its own --host/--port, Vite falling back to a different port when the allocated one is taken between the probe bind and the spawn, or localhost resolving to ::1 rather than 127.0.0.1 β€” the probe can never succeed and the run sits for the full SERVER_READY_TIMEOUT_MS before failing. The normal preview avoids this by parsing the URL the dev server actually prints (app_runtime_service.ts:801). The portHint only fires for custom start commands, so a Dyad-managed app gets a bare 2-minute timeout. I have not reproduced this, but the divergence from the normal-preview path is real.

πŸ’‘ Suggestion: Either pass an explicit host to the managed start commands (e.g. --host 127.0.0.1) and/or parse the dev server's printed URL from the streamed output as the normal runtime does, falling back to the assumed URL only when nothing is printed.

)}

{!isRunning && showNeonRestartDisclosure && (
{!isRunning && showNeonSandboxDisclosure && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Neon apps that cannot run tests get no disclosure until Run fails

When the sandbox is unavailable (Docker/cloud runtime, or the user's opt-out), a Neon app is now refused outright β€” a capability removal the PR description calls out. The panel gives no signal: showNeonSandboxDisclosure is false in exactly those configurations, so the teal banner disappears, and the Run button is enabled as soon as the preview is up. The user only learns tests cannot run at all by pressing Run and reading the resulting infra error. A Neon user who toggles the setting off to make runs faster is left with a Run button that always errors.

πŸ’‘ Suggestion: When app.neonProjectId is set and usesSandboxedE2eTests(settings) is false, show a warning banner naming the reason (docker/cloud runtime or the Settings opt-out) and disable Run, instead of only surfacing the refusal after the run is attempted.

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

This is a large, unusually well-documented change: E2E runs now snapshot the app into <userData>/test-sandboxes/<appId>-<runId>, start a run-scoped dev server outside runningApps, point Playwright at it explicitly, promote artifacts before disposal, and fail closed for Neon when the sandbox isn't available. The safety-critical properties hold up on review β€” the real .env.local is no longer rewritten on the E2E path, runningApps/the app-run actor are untouched, the Neon branch is marked cleanup-only up front so crash recovery cannot mistake it for the recorder's env swap, and the fallback path still serializes against startup reconciliation on provider. Error classification is mostly careful (DyadErrorKind.Precondition on the server-readiness and missing-dependency paths, Stop returning a structured result rather than rejecting the IPC call), path containment in test_screenshot.ts is tightened rather than loosened, and SandboxedE2eTestsSwitch follows the existing Base UI switch pattern exactly. Test coverage is genuinely good, including the ordering assertion that origin authorization happens between server start and Playwright.

I found no HIGH issues. Four MEDIUM items are worth a look before or after merge; none of them break the main flow.

Two notes on confidence: the context reports diffTruncated: true and lists 38 of the PR's 39 changed files, so one file was not available to me β€” every file I did review had a complete, untruncated patch. And the 127.0.0.1 item below is a reasoned divergence from the normal-preview path rather than something I reproduced; I've flagged the uncertainty in the finding itself.

Issues Summary

Severity File Issue
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:171 Sandbox copy failures are reported as internal product exceptions
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:1012 Snapshot stage does not claim runtime-config, so .env.local can be rewritten mid-copy
🟑 MEDIUM src/ipc/services/e2e_test_runtime.ts:195 Test server URL is assumed rather than observed, so a mismatch costs a 2-minute dead wait
🟑 MEDIUM src/components/preview_panel/TestsPanel.tsx:1563 Neon apps that cannot run tests get no disclosure until Run fails
🟒 Low Priority Notes (7 items)
  • Cleanup label contradicts its own aria-label - For a sandboxed Supabase run the visible copy reads "Cleaning up the test sandbox…" while the Stop button's aria-label is still "Cleaning up test data"; the new test asserts the old label. Screen-reader users get a different message than sighted ones. (src/components/preview_panel/TestsPanel.tsx)
  • Workspace disposal reuses the provider isolation label - The outer finally calls emitProgress("cleaning-up", finalResult.isolation), so while the sandbox directory is being deleted a Neon run still displays "Removing the temporary test database…". Passing undefined (or a distinct marker) for that phase would name the actual work. (src/ipc/handlers/tests_handlers.ts)
  • Sandbox predicate duplicated instead of reused - showNeonSandboxDisclosure re-implements usesSandboxedE2eTests inline (runtimeMode2 === "host" && !disableSandboxedE2eTests) in a file that already imports the shared helper. Identical today, but two copies of one rule drift. (src/components/preview_panel/TestsPanel.tsx)
  • Custom install commands are skipped in the sandbox - hasCustomE2eStartCommand correctly mirrors getCommand's both-commands rule, but the sandbox then runs only startCommand, whereas the normal preview runs installCommand && startCommand. Apps whose install step does codegen (prisma generate, protobuf, etc.) rely on artifacts that the node_modules clone may not carry. Worth a comment at minimum. (src/ipc/services/e2e_test_runtime.ts)
  • Fallback path's hardcoded runtimeMode: "host" depends on a cross-function invariant - It is correct today only because the Neon refusal above makes prepareIsolatedTestDatabase's runtime-mode branch unreachable. If that guard or the provider ordering ever changes, a Docker-runtime Neon app would swap the real .env.local. The comment documents it, but forwarding the real runtimeMode would make it safe by construction. (src/ipc/handlers/tests_handlers.ts)
  • Prior artifacts are pruned before the new run produces replacements - createE2eTestWorkspace deletes every <appId>-* artifact directory at snapshot time. Because a partial (single-test or grep) run keeps prev.results wholesale, previously-displayed failure screenshots become unavailable even if the new run never gets that far. The panel degrades gracefully to "Screenshot unavailable", and Playwright already cleared its own output dir per run, so this is close to pre-existing behavior β€” but pruning after retainE2eTestArtifacts would be strictly better. (src/ipc/services/e2e_test_workspace.ts)
  • Untested paths the plan called out - The workspace suite does not cover cancellation removing a partial workspace, reflink failure falling back to an ordinary copy, or assertOwnedPath refusing an out-of-root disposal β€” all named in the plan's own unit-test list. The code handles the first and third; only the tests are missing. (src/ipc/services/e2e_test_workspace.test.ts)

Generated by Dyadbot persona-based code review

azizmejri1 and others added 2 commits August 24, 2026 18:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
…iming

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.
`<userData>/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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
azizmejri1 and others added 3 commits August 24, 2026 18:49
…copy

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEzQQKdVCw3fuqd8WdAh3d

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 3 inline finding(s).

const runName = path.basename(artifactPath);
const appId = runDirectoryAppId(runName);
if (appId === null) return;
await removeRunDirectories(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Artifact prune deletes screenshots still shown for other specs

retainE2eTestArtifacts always calls pruneSupersededArtifacts, which removes every other retained run directory for the app. But the panel does not clear every spec's results on a single-file re-run: applyTestRunStartedAtom only filters out the targeted files and keeps prev.results for the rest. After a full run leaves failures in specs A and B and the user re-runs only spec A, spec B's result row is still on screen with a screenshotPath that points into the just-deleted directory, so its thumbnail silently fails to load. The comment on pruneSupersededArtifacts assumes 'the results on screen are its own', which only holds for an all-specs run.

πŸ’‘ Suggestion: Prune only when the run covered every spec (or keep the previous run's directory until its results are actually replaced), and have the panel drop screenshot paths it knows were pruned.

? trimmedStart.replaceAll("{port}", String(port))
: trimmedStart;
return {
command: `(${installCommand!.trim()}) && (${start})`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Sandbox re-runs the custom install command on every test run

For an app with both installCommand and startCommand set, buildE2eTestStartCommand emits (install) && (start) inside the sandbox even though createE2eTestWorkspace already cloned node_modules into that workspace. For a Node app whose install command is npm ci this deletes the freshly cloned tree and reinstalls from scratch on every single test run, on top of the full source+dependency copy; that is also why the readiness budget had to be raised to 15 minutes. It contradicts this PR's own plan document, which lists 'Do not install dependencies independently inside every sandbox' as a non-goal and 'prohibit sandbox installs' as a risk mitigation.

πŸ’‘ Suggestion: Skip the install half when copyNodeModules succeeded (run it only when the dependency tree was absent), or gate the re-install behind an explicit per-app opt-in.

const sandboxAvailable = settings
? usesSandboxedE2eTests(settings)
: undefined;
const testRunBlocked = sandboxAvailable === false && !devServerRunning;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Run stays enabled while settings load for opted-out users

testRunBlocked is only true when sandboxAvailable === false, so while settings are still loading (sandboxAvailable === undefined) the Run/Retry/per-spec buttons stay enabled and the amber 'Start the app to run tests.' gate is suppressed. For a user who turned the sandbox off (or is on docker/cloud runtime) with no dev server running, that first render invites a click that the main process then rejects with 'Start the app before running tests', replacing a local, actionable gate with a run-failure banner. The Neon disclosure has the same undefined hole in the opposite direction.

πŸ’‘ Suggestion: Treat the loading state as blocked-but-quiet (disable the run affordances until settings resolve, without rendering the amber banner), or render a skeleton/disabled state for the panel actions while settings are pending.

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

Large, well-structured change (41 files, ~4.7k additions). All 41 changed files are present in the review context with complete, untruncated per-file patches β€” only the aggregate diff blob is truncated β€” so confidence in the file-level review is high.

The main/renderer boundary looks sound: the new services live entirely in main, the only new IPC surface is an added appId on tests:screenshot, and resolveContainedArtifact keeps the symlink-resolved containment guard while adding an app-id check so one app can't read another's retained artifacts. Expected user/environment failures are classified as DyadError with DyadErrorKind.Precondition (missing dependencies, no free port, server never became ready), and abort paths are folded into structured infraError results instead of rejecting the IPC call. run_tests keeps modifiesState: true. The new settings switch uses the repo's Base UI wrappers and the same useSettings pattern as its siblings, all five locale files were updated, and disableSandboxedE2eTests is a user setting, so no Drizzle migration is needed. Coordination was checked: restoreAppFromTestBranch and the sandboxed run stage both claim provider, so writing the cleanup-only Neon marker at branch creation can't be raced into deleting a branch a live run is still using, and beginAppDeletion rejects rather than blocks, so the new endTestsForApp await in deleteAppById can't deadlock.

The issues below are all non-blocking.

Issues Summary

Severity File Issue
🟑 MEDIUM src/ipc/services/e2e_test_workspace.ts:281 Artifact prune deletes screenshots still shown for other specs
🟑 MEDIUM src/ipc/services/e2e_test_runtime.ts:210 Sandbox re-runs the custom install command on every test run
🟑 MEDIUM src/components/preview_panel/TestsPanel.tsx:726 Run stays enabled while settings load for opted-out users
🟒 Low Priority Notes (6 items)
  • Switch flashes ON while settings load - enabled = !settings?.disableSandboxedE2eTests reads true before settings resolve, so a user who turned sandboxing off sees the toggle on and then flip. This is exactly the "briefly promise sandboxing to a user who turned it off" case the Tests panel comment reasons about, but the switch itself has no tri-state. (src/components/SandboxedE2eTestsSwitch.tsx)
  • No progress during the copy - Phase 1 of the plan called for a soft progress threshold (file count/bytes after 500 ms). The implementation emits two static lines, so on ext4/Windows a multi-gigabyte copy is a silent multi-minute wait in the setup phase. Entry counts are already tracked for telemetry and could feed the same output channel. (src/ipc/services/e2e_test_workspace.ts)
  • Absolute symlinks survive into the sandbox - verbatimSymlinks: true preserves a symlink whose target is an absolute path back into the real app, so a test writing through one would escape the sandbox boundary the feature exists to create. Rare, but it's the one hole in the "cannot mutate real source" exit criterion. (src/ipc/services/e2e_test_workspace.ts)
  • Retained artifacts have no size cap - <userData>/test-artifacts/<appId>-<id> holds full Playwright traces and is only replaced by the next run of the same app, deleted with the app, or swept at startup for apps that no longer exist. There is no UI surface showing the space in use. (src/ipc/services/e2e_test_workspace.ts)
  • Record affordance loses its banner - the amber "Start the app to run tests." banner (which carried a Start button) is now gated on testRunBlocked, so a sandbox user with the preview down sees no banner while Record is still disabled and relies on recordButtonTitle alone. (src/components/preview_panel/TestsPanel.tsx)
  • Port-binding unit tests may be flaky on shared CI - e2e_test_runtime.test.ts spawns real child processes, binds real ports, and asserts port === E2E_TEST_SERVER_PORT_START; anything else on the runner holding 52150 fails the test rather than exercising the fallback the code was written for. (src/ipc/services/e2e_test_runtime.test.ts)

Generated by Dyadbot persona-based code review

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Playwright Test Results

❌ Some tests failed

OS Passed Failed Flaky Skipped
🍎 macOS 290 3 1 12

Summary: 290 passed, 3 failed, 1 flaky, 12 skipped

Failed Tests

🍎 macOS

  • local_agent_advanced.spec.ts > local-agent - mention apps
    • Error: expect(string).toMatchSnapshot(expected) failed
  • local_agent_auto.spec.ts > local-agent - auto model
    • Error: expect(string).toMatchSnapshot(expected) failed
  • local_agent_explore_code.spec.ts > local-agent - sub-agent tools replace root explore_code
    • Error: expect(string).toMatchSnapshot(expected) failed

πŸ“‹ Re-run Failing Tests (macOS)

Copy and paste to re-run all failing spec files locally:

npm run e2e \
  e2e-tests/local_agent_advanced.spec.ts \
  e2e-tests/local_agent_auto.spec.ts \
  e2e-tests/local_agent_explore_code.spec.ts

⚠️ Flaky Tests

🍎 macOS

  • chat_tabs.spec.ts > group by app: new chat joins its app's group (grouping sticks) (passed after 1 retry)

πŸ“Š View full report

@azizmejri1
azizmejri1 marked this pull request as ready for review August 25, 2026 10:46
@azizmejri1
azizmejri1 requested a review from a team August 25, 2026 10:46

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f93ca8f7e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with πŸ‘.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pages/settings.tsx
Comment on lines +533 to +534
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop promising that sandboxed tests never touch real data

For Supabase apps, this guarantee is false: prepareSupabaseTestUserIsolation deliberately uses the real Supabase project under RLS, and when no organization is connected it returns mode: "none" and proceeds against the current data (isolated_test_db.ts:127-130, 418-425). If RLS is absent or that setup is unavailable, destructive tests can therefore modify real data despite this setting explicitly promising otherwise; qualify the text to distinguish filesystem/runtime isolation from Supabase data isolation.

Useful? React with πŸ‘Β / πŸ‘Ž.

Comment on lines +1330 to +1334
await workspace.dispose();
} catch (error) {
logger.error(
`Failed to remove isolated test workspace for app ${appId}: ${error}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface failed sandbox deletion instead of silently leaking it

When workspace removal failsβ€”for example with EBUSY/EPERM on Windows while a child or antivirus still holds the copied treeβ€”this catch only logs the error and the run still finishes normally. Because the workspace is also removed from activeWorkspaceNames, no retry occurs until a future application startup, while every subsequent run creates another potentially multi-gigabyte node_modules copy; repeated runs in the same session can silently consume disk space, so retain/retry the orphan or report the cleanup failure to the user.

Useful? React with πŸ‘Β / πŸ‘Ž.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 41 files

Confidence score: 2/5

  • src/pages/settings.tsx may overstate sandbox protection: Supabase sandbox runs still use the existing project, and mode: "none" provides no test-user isolation. Clarify the disclosure so filesystem/runtime isolation is not confused with provider-data isolation.
  • src/ipc/services/e2e_test_workspace.ts preserves source symlinks that point outside appPath, allowing test writes to reach the real filesystem. Validate symlink targets during copying and reject or rewrite links that escape the workspace.
  • src/ipc/handlers/tests_handlers.ts logs a failed workspace.dispose() but removes the workspace from active reconciliation, so a run can appear successful while cleanup remains incomplete. Keep failed workspaces registered for retry and surface the cleanup failure.
  • src/components/SandboxedE2eTestsSwitch.tsx renders the switch enabled while settings are loading, even when disableSandboxedE2eTests is explicitly true. Avoid showing an enabled state until the setting is known, so the UI does not briefly promise sandboxing incorrectly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid β€” if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/components/preview_panel/TestsPanel.test.tsx">

<violation number="1" location="src/components/preview_panel/TestsPanel.test.tsx:454">
P3: These three disclosure tests β€” "tells Neon users their preview keeps its real database", "drops the sandbox disclosure when the sandbox is turned off", and "promises no sandbox while settings are still loading" β€” live inside `describe("stopping a run")`, but none of them touches stopping, the stop button, or the `cleaning-up` phase. They verify run-gate/disclosure rendering and belong at the top level alongside "runs sandboxed tests without the preview being up" and "still requires the preview...", not buried in the stopping block.</violation>
</file>

<file name="src/ipc/services/e2e_test_workspace.test.ts">

<violation number="1" location="src/ipc/services/e2e_test_workspace.test.ts:302">
P3: This test simulates a failed artifact copy with `chmod 0o000`, which only makes the directory unreadable for a non-root POSIX user. When the suite runs as root (common in the container CI this PR targets) or on Windows, `fs.chmod(0o000)` does not enforce read access, so `fs.cp` in `retainE2eTestArtifacts` succeeds and the `rejects.toThrow()` assertion fails, flaking the suite. The neighboring pnpm-realpath test is correctly gated with `it.runIf(process.platform !== "win32")`, but this one is not. Use a failure mode that is portable (e.g. a read-only artifact destination on ext4 is unreliable as well; prefer a source that is guaranteed unreadable across platforms, or gate the test to POSIX non-root) so the copy-failure branch stays covered without depending on the CI user and platform.</violation>
</file>

<file name="src/components/SandboxedE2eTestsSwitch.tsx">

<violation number="1" location="src/components/SandboxedE2eTestsSwitch.tsx:13">
P2: While the settings query is loading, `settings` is null, so `enabled` evaluates to `!undefined` and the switch renders ON even for users who have explicitly set `disableSandboxedE2eTests: true`. This briefly promises sandboxing that the user turned off (the same inverted-read footgun `TestsPanel.tsx` already guards against), and a toggle during that window writes a value derived from the not-yet-loaded state. Disable the switch until settings have loaded.</violation>
</file>

<file name="src/ipc/services/e2e_test_workspace.ts">

<violation number="1" location="src/ipc/services/e2e_test_workspace.ts:190">
P1: If the app source contains a symlink to a path outside `appPath`, the sandbox preserves that link and test writes can still reach the real filesystem. Validate symlink targets during copy and reject or rewrite links that escape the app root before creating the workspace.</violation>

<violation number="2" location="src/ipc/services/e2e_test_workspace.ts:315">
P2: `runDirectoryAppId` currently treats bare numeric names as valid run directories, even though run names are defined as `<appId>-...`. Require the hyphenated prefix format so non-run directories are not misclassified for cleanup or artifact ownership checks.</violation>
</file>

<file name="plans/sandboxed-e2e-test-runtime.md">

<violation number="1" location="plans/sandboxed-e2e-test-runtime.md:321">
P2: The plan fixes the workspace root at `<userData>/test-sandboxes/<appId>/<runId>` and scopes startup reconciliation ("remove abandoned directories only beneath the recognized `test-sandboxes` root") to that root, but never specifies where `E2eTestWorkspace.artifactPath` lives or how the retained artifact directory is administered. Retained artifacts are promoted out of the sandbox for the result UI, so they need the same bounded root, app-gone/run pruning, and canonical-containment guard the sandbox root gets; otherwise implementers can diverge and stranded artifact directories accumulate. State the artifact root (e.g. `<userData>/test-artifacts/<appId>/<runId>`), include it in startup reconciliation, and reuse the ownership/containment check for it.</violation>
</file>

<file name="src/ipc/handlers/tests_handlers.ts">

<violation number="1" location="src/ipc/handlers/tests_handlers.ts:727">
P2: When `workspace.dispose()` fails, this catch only logs the error, so the run reports success after removing the workspace from active reconciliation. Keep failed workspaces registered for retry and surface the cleanup failure in the terminal state before allowing subsequent runs to accumulate orphaned copies.</violation>
</file>

<file name="src/pages/settings.tsx">

<violation number="1" location="src/pages/settings.tsx:534">
P1: Qualify this disclosure: Supabase sandbox runs still target the existing project, and the `mode: "none"` fallback can run without test-user isolation. Distinguish filesystem/runtime isolation from provider data isolation instead of promising that real Supabase data is never touched.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

let dependencyEntries = 0;
const startedAt = Date.now();
try {
await fs.cp(appPath, workspacePath, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: If the app source contains a symlink to a path outside appPath, the sandbox preserves that link and test writes can still reach the real filesystem. Validate symlink targets during copy and reject or rewrite links that escape the app root before creating the workspace.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/ipc/services/e2e_test_workspace.ts, line 190:

<comment>If the app source contains a symlink to a path outside `appPath`, the sandbox preserves that link and test writes can still reach the real filesystem. Validate symlink targets during copy and reject or rewrite links that escape the app root before creating the workspace.</comment>

<file context>
@@ -0,0 +1,389 @@
+  let dependencyEntries = 0;
+  const startedAt = Date.now();
+  try {
+    await fs.cp(appPath, workspacePath, {
+      recursive: true,
+      verbatimSymlinks: true,
</file context>

Comment thread src/pages/settings.tsx
Comment on lines +534 to +535
so tests never touch your preview or your real database. Turn this off
if copying your dependencies makes runs slow β€” tests then run against

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Qualify this disclosure: Supabase sandbox runs still target the existing project, and the mode: "none" fallback can run without test-user isolation. Distinguish filesystem/runtime isolation from provider data isolation instead of promising that real Supabase data is never touched.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/pages/settings.tsx, line 534:

<comment>Qualify this disclosure: Supabase sandbox runs still target the existing project, and the `mode: "none"` fallback can run without test-user isolation. Distinguish filesystem/runtime isolation from provider data isolation instead of promising that real Supabase data is never touched.</comment>

<file context>
@@ -526,6 +527,17 @@ export function WorkflowSettings() {
+        <SandboxedE2eTestsSwitch />
+        <p className={hint}>
+          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
</file context>
Suggested change
so tests never touch your preview or your real database. Turn this off
if copying your dependencies makes runs slow β€” tests then run against
so tests never touch your preview. Neon and no-database runs keep data
isolated from the real environment; Supabase runs use the existing
project and depend on RLS-scoped test-user isolation, so this setting
does not guarantee that real Supabase data is untouched. Turn this off
if copying your dependencies makes runs slow β€” tests then run against

*/
export function SandboxedE2eTestsSwitch() {
const { settings, updateSettings } = useSettings();
const enabled = !settings?.disableSandboxedE2eTests;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: While the settings query is loading, settings is null, so enabled evaluates to !undefined and the switch renders ON even for users who have explicitly set disableSandboxedE2eTests: true. This briefly promises sandboxing that the user turned off (the same inverted-read footgun TestsPanel.tsx already guards against), and a toggle during that window writes a value derived from the not-yet-loaded state. Disable the switch until settings have loaded.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/components/SandboxedE2eTestsSwitch.tsx, line 13:

<comment>While the settings query is loading, `settings` is null, so `enabled` evaluates to `!undefined` and the switch renders ON even for users who have explicitly set `disableSandboxedE2eTests: true`. This briefly promises sandboxing that the user turned off (the same inverted-read footgun `TestsPanel.tsx` already guards against), and a toggle during that window writes a value derived from the not-yet-loaded state. Disable the switch until settings have loaded.</comment>

<file context>
@@ -0,0 +1,29 @@
+ */
+export function SandboxedE2eTestsSwitch() {
+  const { settings, updateSettings } = useSettings();
+  const enabled = !settings?.disableSandboxedE2eTests;
+  return (
+    <div className="flex items-center space-x-2">
</file context>

export function runDirectoryAppId(name: string): number | null {
const [prefix] = name.split("-");
const appId = Number(prefix);
return prefix !== "" && Number.isInteger(appId) ? appId : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: runDirectoryAppId currently treats bare numeric names as valid run directories, even though run names are defined as <appId>-.... Require the hyphenated prefix format so non-run directories are not misclassified for cleanup or artifact ownership checks.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/ipc/services/e2e_test_workspace.ts, line 315:

<comment>`runDirectoryAppId` currently treats bare numeric names as valid run directories, even though run names are defined as `<appId>-...`. Require the hyphenated prefix format so non-run directories are not misclassified for cleanup or artifact ownership checks.</comment>

<file context>
@@ -0,0 +1,389 @@
+export function runDirectoryAppId(name: string): number | null {
+  const [prefix] = name.split("-");
+  const appId = Number(prefix);
+  return prefix !== "" && Number.isInteger(appId) ? appId : null;
+}
+
</file context>
Suggested change
return prefix !== "" && Number.isInteger(appId) ? appId : null;
return /^\d+-/.test(name) ? Number(prefix) : null;


On Dyad startup:

- remove abandoned directories only beneath the recognized

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The plan fixes the workspace root at <userData>/test-sandboxes/<appId>/<runId> and scopes startup reconciliation ("remove abandoned directories only beneath the recognized test-sandboxes root") to that root, but never specifies where E2eTestWorkspace.artifactPath lives or how the retained artifact directory is administered. Retained artifacts are promoted out of the sandbox for the result UI, so they need the same bounded root, app-gone/run pruning, and canonical-containment guard the sandbox root gets; otherwise implementers can diverge and stranded artifact directories accumulate. State the artifact root (e.g. <userData>/test-artifacts/<appId>/<runId>), include it in startup reconciliation, and reuse the ownership/containment check for it.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At plans/sandboxed-e2e-test-runtime.md, line 321:

<comment>The plan fixes the workspace root at `<userData>/test-sandboxes/<appId>/<runId>` and scopes startup reconciliation ("remove abandoned directories only beneath the recognized `test-sandboxes` root") to that root, but never specifies where `E2eTestWorkspace.artifactPath` lives or how the retained artifact directory is administered. Retained artifacts are promoted out of the sandbox for the result UI, so they need the same bounded root, app-gone/run pruning, and canonical-containment guard the sandbox root gets; otherwise implementers can diverge and stranded artifact directories accumulate. State the artifact root (e.g. `<userData>/test-artifacts/<appId>/<runId>`), include it in startup reconciliation, and reuse the ownership/containment check for it.</comment>

<file context>
@@ -0,0 +1,489 @@
+
+On Dyad startup:
+
+- remove abandoned directories only beneath the recognized
+  `test-sandboxes` root;
+- never follow directory links during recursive cleanup;
</file context>

!(await prepared.teardown()).remoteCleanupCompleted,
);
} catch (error) {
logger.error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When workspace.dispose() fails, this catch only logs the error, so the run reports success after removing the workspace from active reconciliation. Keep failed workspaces registered for retry and surface the cleanup failure in the terminal state before allowing subsequent runs to accumulate orphaned copies.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/ipc/handlers/tests_handlers.ts, line 727:

<comment>When `workspace.dispose()` fails, this catch only logs the error, so the run reports success after removing the workspace from active reconciliation. Keep failed workspaces registered for retry and surface the cleanup failure in the terminal state before allowing subsequent runs to accumulate orphaned copies.</comment>

<file context>
@@ -515,6 +601,155 @@ export async function runAppTestsCore({
+              !(await prepared.teardown()).remoteCleanupCompleted,
+            );
+          } catch (error) {
+            logger.error(
+              `Failed to tear down isolated test environment for app ${appId}: ${error}`,
+            );
</file context>

});

it("names the Neon teardown, which restarts the preview", () => {
it("tells Neon users their preview keeps its real database", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: These three disclosure tests β€” "tells Neon users their preview keeps its real database", "drops the sandbox disclosure when the sandbox is turned off", and "promises no sandbox while settings are still loading" β€” live inside describe("stopping a run"), but none of them touches stopping, the stop button, or the cleaning-up phase. They verify run-gate/disclosure rendering and belong at the top level alongside "runs sandboxed tests without the preview being up" and "still requires the preview...", not buried in the stopping block.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/components/preview_panel/TestsPanel.test.tsx, line 454:

<comment>These three disclosure tests β€” "tells Neon users their preview keeps its real database", "drops the sandbox disclosure when the sandbox is turned off", and "promises no sandbox while settings are still loading" β€” live inside `describe("stopping a run")`, but none of them touches stopping, the stop button, or the `cleaning-up` phase. They verify run-gate/disclosure rendering and belong at the top level alongside "runs sandboxed tests without the preview being up" and "still requires the preview...", not buried in the stopping block.</comment>

<file context>
@@ -391,42 +451,92 @@ describe("TestsPanel", () => {
     });
 
-    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.
</file context>

});
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test simulates a failed artifact copy with chmod 0o000, which only makes the directory unreadable for a non-root POSIX user. When the suite runs as root (common in the container CI this PR targets) or on Windows, fs.chmod(0o000) does not enforce read access, so fs.cp in retainE2eTestArtifacts succeeds and the rejects.toThrow() assertion fails, flaking the suite. The neighboring pnpm-realpath test is correctly gated with it.runIf(process.platform !== "win32"), but this one is not. Use a failure mode that is portable (e.g. a read-only artifact destination on ext4 is unreliable as well; prefer a source that is guaranteed unreadable across platforms, or gate the test to POSIX non-root) so the copy-failure branch stays covered without depending on the CI user and platform.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At src/ipc/services/e2e_test_workspace.test.ts, line 302:

<comment>This test simulates a failed artifact copy with `chmod 0o000`, which only makes the directory unreadable for a non-root POSIX user. When the suite runs as root (common in the container CI this PR targets) or on Windows, `fs.chmod(0o000)` does not enforce read access, so `fs.cp` in `retainE2eTestArtifacts` succeeds and the `rejects.toThrow()` assertion fails, flaking the suite. The neighboring pnpm-realpath test is correctly gated with `it.runIf(process.platform !== "win32")`, but this one is not. Use a failure mode that is portable (e.g. a read-only artifact destination on ext4 is unreliable as well; prefer a source that is guaranteed unreadable across platforms, or gate the test to POSIX non-root) so the copy-failure branch stays covered without depending on the CI user and platform.</comment>

<file context>
@@ -0,0 +1,424 @@
+    });
+    // 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 {
</file context>

@dyad-assistant dyad-assistant Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude review: 2 inline finding(s).

// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

Supabase test-user leak is reported as a database cleanup failure

The cleanup warning picks its wording from result.isolation?.mode === "supabase-test-user", but the Supabase setup-failure path in prepareSupabaseTestUserIsolation returns isolation: { mode: "none", reason: "Couldn't set up an isolated Supabase test user." } while the new settledTeardown it now hands back can carry remoteCleanupCompleted: false. In that case the else branch fires and the user is told "Dyad couldn't finish cleaning up the isolated test database ... Dyad will retry remote cleanup on next startup" for an app that has no isolated database at all β€” what actually leaked is a temporary auth user in their real Supabase project, swept by reconcileOrphanTestUsers. This is the same wrong-thing cleanup copy the PR set out to remove, and the new test that guards it only covers the mode: "supabase-test-user" case.

πŸ’‘ Suggestion: Key the message off something that survives the failure path β€” e.g. carry the provider on the isolation object even for the mode: "none" failure returns, or select the wording from app.supabaseProjectId/app.neonProjectId rather than from isolation.mode. Add a case covering a Supabase setup failure whose user delete fails.

(settings?.runtimeMode2 ?? "host") === "host";
// 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" β€”

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 MEDIUM

No Run gate or disclosure for Neon apps when the sandbox is off

showNeonSandboxDisclosure now requires sandboxAvailable === true, and testRunBlocked only considers the dev server. For a Neon app on Docker/cloud runtime, or with disableSandboxedE2eTests set, the panel therefore shows no disclosure at all and renders Run fully enabled β€” but the main process refuses the run outright ("Dyad won't run Neon tests against your real database"). That refusal is a deliberate capability removal relative to the previous behaviour, and the only place it is disclosed up front is the Settings hint the user may never open. The panel is where the user presses Run, and it currently promises an action it will reject.

πŸ’‘ Suggestion: Add a Neon-specific branch: when sandboxAvailable === false and app?.neonProjectId is set, disable Run (fold it into testRunBlocked) and show a banner naming the refusal and the fix (switch to host runtime / re-enable the sandbox setting).

@dyad-assistant

Copy link
Copy Markdown
Contributor

πŸ” Dyadbot Code Review Summary

Verdict: πŸ€” NOT SURE - Potential issues
Recommendation: ready

Reviewed all 41 changed files. The aggregate diff blob in the context is marked truncated, but every per-file patch is complete (patchTruncated: false), so coverage of the change is full.

This is a large, unusually well-reasoned change. The main/renderer boundary is preserved (no new broad filesystem or process exposure), the new tests:screenshot artifact reader keeps its symlink/extension/containment guards and adds a correct per-app ownership check for the new out-of-app artifact root, setup failures are deliberately converted into structured infraError results with DyadError/DyadErrorKind.Precondition on the throwing paths, the new Base UI switch matches the existing TestingForNewAppsSwitch pattern, modifiesState: true is preserved on the agent tool, and there are no schema changes (so no Drizzle migration is required). Port allocation, run-scoped process tracking, cleanup ordering (Playwright β†’ server β†’ provider β†’ workspace) and the startup orphan sweep are all handled carefully.

Two MEDIUM issues below. Neither blocks merge.

Issues Summary

Severity File Issue
🟑 MEDIUM src/ipc/handlers/tests_handlers.ts:941 Supabase test-user leak is reported as a database cleanup failure
🟑 MEDIUM src/components/preview_panel/TestsPanel.tsx:777 No Run gate or disclosure for Neon apps when the sandbox is off
🟒 Low Priority Notes (6 items)
  • Switch flashes the wrong state while settings load - const enabled = !settings?.disableSandboxedE2eTests evaluates to true before settings resolve, so a user who turned sandboxing off briefly sees the toggle in the ON position. TestsPanel deliberately uses a tri-state (sandboxAvailable === undefined) for exactly this reason; the switch could do the same. (src/components/SandboxedE2eTestsSwitch.tsx)

  • Contradictory artifact copy when nothing can be attached - When both dataUrl and inlineSnapshot are null the model is told Page snapshot: unavailable for this run. and, on the next line, Screenshot: could NOT be attached as an image β€” rely on the page snapshot instead. (src/pro/main/ipc/handlers/local_agent/tools/run_tests.ts)

  • Test-server start failures take the "unexpected throw" path - startE2eTestRuntime rejections escape the run-app-tests coordinator callback and are rethrown from runAppTestsWithIsolation, unlike the bootstrap and workspace-copy failures the PR explicitly converts to infraError results. Both consumers recover (the panel maps the rejection to runError, the agent tool catches and reports an uncounted infra outcome) and the errors are classified DyadErrorKind.Precondition, so impact is limited β€” but a sandbox dev server that fails to boot is now one of the most likely failure modes and is handled less consistently than its siblings. (src/ipc/handlers/tests_handlers.ts)

  • Stale comment about overlapping run cleanups - pruneSupersededArtifacts's doc comment says "A second Run for the same app aborts the first and proceeds without awaiting its teardown", but runAppTestsWithIsolation does await prior.done before starting. The activeWorkspaceNames guard is still good defence; the rationale is just no longer accurate. (src/ipc/services/e2e_test_workspace.ts)

  • Two spellings of "this is the sandbox" - prepareIsolatedTestDatabase derives envIsDisposable from appPathOverride !== undefined but derives the durable cleanupOnly marker from !restartApp. They always agree today because prepareE2eTestDataIsolation sets both, but a future caller that sets only one would silently get the recorder's env-restoration recovery semantics for a sandbox run. (src/ipc/services/isolated_test_db.ts)

  • No packaged Playwright/Electron spec for the new path - Unit and Vitest integration coverage is genuinely thorough (workspace, runtime, process registry, screenshot reader, handler orchestration), but the plan added in this PR designates a broad packaged E2E test β€” preview PID/URL and .env.local unchanged during a sandboxed run β€” as the acceptance check, and no e2e-tests/ spec is included. (plans/sandboxed-e2e-test-runtime.md)


Generated by Dyadbot persona-based code review

@azizmejri1
azizmejri1 marked this pull request as draft August 25, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-human:review-issue ai agent flagged an issue that requires human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant