-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: isolate E2E test execution in sandboxes #4358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
8cdc7dd
6cad874
10e0442
f6f274a
f298d7e
9603db5
227769b
e3b074b
f93ca8f
dd12902
534b770
379be6a
da9cbbc
b3162e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { useSettings } from "@/hooks/useSettings"; | ||
| import { Label } from "@/components/ui/label"; | ||
| import { Switch } from "@/components/ui/switch"; | ||
|
|
||
| /** | ||
| * Escape hatch for the sandboxed E2E runtime. Stored inverted | ||
| * (`disableSandboxedE2eTests`) so the sandbox stays the default for everyone | ||
| * who never opens this, and only an explicit opt-out falls back to running | ||
| * against the normal preview. | ||
| */ | ||
| export function SandboxedE2eTestsSwitch() { | ||
| const { settings, updateSettings } = useSettings(); | ||
| const enabled = !settings?.disableSandboxedE2eTests; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: While the settings query is loading, Prompt for AI agents |
||
| return ( | ||
| <div className="flex items-center space-x-2"> | ||
| <Switch | ||
| id="enable-sandboxed-e2e-tests" | ||
| aria-label="Run E2E Tests in an Isolated Sandbox" | ||
| checked={enabled} | ||
| onCheckedChange={(checked) => { | ||
| updateSettings({ disableSandboxedE2eTests: !checked }); | ||
| }} | ||
| /> | ||
| <Label htmlFor="enable-sandboxed-e2e-tests"> | ||
| Run E2E Tests in an Isolated Sandbox | ||
| </Label> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,8 @@ const mocks = vi.hoisted(() => ({ | |
| appUrl: "http://localhost:32100" as string | null, | ||
| previewUrl: "http://localhost:32100/" as string | null, | ||
| previewUrlSource: "dyad" as "none" | "dyad" | "app", | ||
| app: { id: 1, testingEnabled: true } as Record<string, unknown>, | ||
| settings: {} as Record<string, unknown>, | ||
| })); | ||
|
|
||
| vi.mock("@/ipc/types", () => ({ | ||
|
|
@@ -52,11 +54,11 @@ vi.mock("@/lib/toast", () => ({ | |
| })); | ||
|
|
||
| vi.mock("@/hooks/useLoadApp", () => ({ | ||
| useLoadApp: () => ({ app: { id: 1, testingEnabled: true } }), | ||
| useLoadApp: () => ({ app: mocks.app }), | ||
| })); | ||
|
|
||
| vi.mock("@/hooks/useSettings", () => ({ | ||
| useSettings: () => ({ settings: {}, updateSettings: vi.fn() }), | ||
| useSettings: () => ({ settings: mocks.settings, updateSettings: vi.fn() }), | ||
| })); | ||
|
|
||
| vi.mock("@/hooks/useRunApp", () => ({ | ||
|
|
@@ -131,6 +133,8 @@ describe("TestsPanel", () => { | |
| mocks.appUrl = "http://localhost:32100"; | ||
| mocks.previewUrl = "http://localhost:32100/"; | ||
| mocks.previewUrlSource = "dyad"; | ||
| mocks.app = { id: 1, testingEnabled: true }; | ||
| mocks.settings = {}; | ||
| mocks.listAppTests.mockResolvedValue({ | ||
| specs: [ | ||
| { | ||
|
|
@@ -314,6 +318,43 @@ describe("TestsPanel", () => { | |
| ).toBe(true); | ||
| }); | ||
|
|
||
| it("runs sandboxed tests without the preview being up", async () => { | ||
| // A sandboxed run serves its own copy of the app on its own port. Requiring | ||
| // the user's preview would block the whole point of the feature — and would | ||
| // contradict the panel's own "your preview keeps running" disclosure. | ||
| mocks.appUrl = null; | ||
|
|
||
| renderPanel(); | ||
|
|
||
| await screen.findByText("signup.spec.ts"); | ||
| expect(screen.queryByText("Start the app to run tests.")).toBeNull(); | ||
| expect( | ||
| ( | ||
| screen.getByRole("button", { | ||
| name: "Run all tests", | ||
| }) as HTMLButtonElement | ||
| ).disabled, | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("still requires the preview when the run is not sandboxed", async () => { | ||
| // The fallback path runs Playwright against the user's preview, so the | ||
| // gate is still correct there. | ||
| mocks.appUrl = null; | ||
| mocks.settings = { disableSandboxedE2eTests: true }; | ||
|
|
||
| renderPanel(); | ||
|
|
||
| expect(await screen.findByText("Start the app to run tests.")).toBeTruthy(); | ||
| expect( | ||
| ( | ||
| screen.getByRole("button", { | ||
| name: "Run all tests", | ||
| }) as HTMLButtonElement | ||
| ).disabled, | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| describe("stopping a run", () => { | ||
| /** Put the panel's app into `phase` as if a run had reached it. */ | ||
| function setPhase( | ||
|
|
@@ -391,42 +432,80 @@ describe("TestsPanel", () => { | |
| expect(mocks.showError).toHaveBeenCalledWith(error); | ||
| }); | ||
|
|
||
| it("names the Neon teardown, which restarts the preview", () => { | ||
| it("tells Neon users their preview keeps its real database", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| // The pre-sandbox copy promised a double preview restart. Nothing | ||
| // restarts any more, so that disclosure would now be a lie. | ||
| mocks.app = { id: 1, testingEnabled: true, neonProjectId: "neon-proj" }; | ||
| renderPanel(); | ||
|
|
||
| expect( | ||
| await screen.findByText(/Your preview keeps running against your real/), | ||
| ).toBeTruthy(); | ||
| expect(screen.queryByText(/restart the preview/i)).toBeNull(); | ||
| }); | ||
|
|
||
| it("drops the sandbox disclosure when the sandbox is turned off", async () => { | ||
| mocks.app = { id: 1, testingEnabled: true, neonProjectId: "neon-proj" }; | ||
| mocks.settings = { disableSandboxedE2eTests: true }; | ||
| renderPanel(); | ||
|
|
||
| await screen.findByText("signup.spec.ts"); | ||
| expect(screen.queryByText(/Your preview keeps running/)).toBeNull(); | ||
| }); | ||
|
|
||
| it("names the Neon teardown without promising a restore", () => { | ||
| // This is the wait that can pass a minute (the branch delete retries with | ||
| // backoff), and it visibly reloads the user's preview. Calling it | ||
| // "Running…" — as the panel used to — reads as a hang. | ||
| // backoff). Calling it "Running…" — as the panel used to — reads as a | ||
| // hang, but the run had its own sandbox and its own server, so nothing | ||
| // of the user's is being put back either. | ||
| const { store } = renderPanel(); | ||
| setPhase(store, { | ||
| phase: "cleaning-up", | ||
| isolation: { mode: "neon-branch" }, | ||
| }); | ||
|
|
||
| expect( | ||
| screen.getByText(/Restoring your database and preview/), | ||
| screen.getByText(/Removing the temporary test database/), | ||
| ).toBeTruthy(); | ||
| expect(screen.queryByText(/Restoring/i)).toBeNull(); | ||
| expect( | ||
| screen.getByRole("button", { name: "Restoring your app" }).textContent, | ||
| ).toContain("Restoring…"); | ||
| screen.getByRole("button", { | ||
| name: "Removing the temporary test database", | ||
| }).textContent, | ||
| ).toContain("Cleaning up…"); | ||
| }); | ||
|
|
||
| it("does not promise a database restore on the Supabase path", () => { | ||
| // That teardown only deletes the temporary test user. It never swaps | ||
| // `.env.local` and never restarts the app, so the Neon copy would lie. | ||
| it("names the sandbox cleanup on the Supabase path", () => { | ||
| // That teardown only deletes the temporary test user and the run's | ||
| // sandbox copy. It never swaps `.env.local` and never restarts the app. | ||
| const { store } = renderPanel(); | ||
| setPhase(store, { | ||
| phase: "cleaning-up", | ||
| isolation: { mode: "supabase-test-user" }, | ||
| sandboxed: true, | ||
| }); | ||
|
|
||
| expect(screen.getByText(/Cleaning up the test data/)).toBeTruthy(); | ||
| expect(screen.queryByText(/Restoring your database/)).toBeNull(); | ||
| expect(screen.getByText(/Cleaning up the test sandbox/)).toBeTruthy(); | ||
| expect(screen.queryByText(/Restoring/i)).toBeNull(); | ||
| expect( | ||
| screen.getByRole("button", { name: "Cleaning up test data" }) | ||
| .textContent, | ||
| ).toContain("Cleaning up…"); | ||
| expect( | ||
| screen.queryByRole("button", { name: "Restoring your app" }), | ||
| ).toBeNull(); | ||
| }); | ||
|
|
||
| it("claims no sandbox when the run never took one", () => { | ||
| // The fallback path (docker/cloud runtime, or the opt-out) creates no | ||
| // workspace, so naming one would be the same inaccurate cleanup copy the | ||
| // Neon "restoring your preview" wording was. | ||
| const { store } = renderPanel(); | ||
| setPhase(store, { | ||
| phase: "cleaning-up", | ||
| isolation: { mode: "supabase-test-user" }, | ||
| sandboxed: false, | ||
| }); | ||
|
|
||
| expect(screen.getByText(/Cleaning up the test data/)).toBeTruthy(); | ||
| expect(screen.queryByText(/test sandbox/)).toBeNull(); | ||
| }); | ||
|
|
||
| it("does not carry a completed run's stop latch into the next run", () => { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.