fix(ui): make environment edit a routed page (#9386)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Instance settings include an Environments section where operators
configure sandbox/SSH/local execution environments, including
interactive custom-image setup sessions with a browser terminal
> - The environment create/edit form was rendered inside a modal dialog,
so pressing Escape anywhere — including inside the embedded SSH terminal
while capturing a snapshot — closed the whole modal and destroyed the
in-progress session
> - Environment editing is a heavyweight, long-lived flow; losing it to
a reflexive Escape keypress is destructive and surprising
> - This pull request converts environment create/edit from a modal into
routed standalone pages, so Escape no longer dismisses the form
> - The benefit is that terminal sessions and half-completed edits
survive Escape, and the flow gets shareable URLs and normal back/forward
navigation

## Linked Issues or Issue Description

No existing public issue; described per the bug report template:

**What happened?**
While editing an environment's sandbox snapshot in the embedded SSH
terminal, pressing Escape (e.g. to exit a mode inside the terminal)
closed the entire environment edit modal, discarding the setup session
and any unsaved form state.

**Expected behavior**
Escape inside the terminal or form should not dismiss the environment
editor. A heavyweight flow like environment configuration should be a
standalone page where Escape behaves as expected within the focused
widget.

**Steps to reproduce**
1. Open Instance settings → Environments and edit a sandbox environment
2. Start a custom image setup session and focus the browser terminal
3. Press Escape
4. The modal closes and the session context is lost

## What Changed

- Converted the environment create/edit dialog in
`CompanyEnvironments.tsx` into routed pages at
`/company/settings/instance/environments/new` and
`/company/settings/instance/environments/:environmentId/edit`
- Registered the new routes in `App.tsx` and wired breadcrumbs for the
list/create/edit states
- Form state now initializes from the route (create vs edit) instead of
dialog open/close state, and successful saves navigate back to the
environments list
- Updated `CompanyEnvironments.test.tsx` and `CompanySettings.test.tsx`
to render through a router with the new routes and assert against the
routed form page instead of a dialog

## Verification

- `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx
ui/src/pages/CompanySettings.test.tsx` — 22/22 passing
- `tsc --noEmit` on the `ui` package — clean
- Behavioral coverage: the updated tests exercise the routed create/edit
pages end to end (open edit via the list, interact with the
setup-session controls on the form page, save navigates back to the
list); with the form no longer in a dialog there is no Escape-close
handler to trigger

## Risks

- Low risk; UI-only routing change. Deep links into the old modal state
do not exist (the modal had no URL), so no redirects are needed
- The edit page resolves the environment from the route param; a
stale/unknown id falls back to the environments list

## Model Used

- Claude (Anthropic), model ID `claude-fable-5` (Fable 5), extended
thinking enabled, agentic tool use via Claude Code

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Cody <cody@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-10 15:36:03 -07:00 committed by GitHub
parent b15115e05b
commit 0f9b1d399c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 369 additions and 340 deletions

View File

@ -103,6 +103,8 @@ function boardRoutes() {
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/instance/environments/new" element={<CompanyEnvironments mode="create" />} />
<Route path="company/settings/instance/environments/:environmentId/edit" element={<CompanyEnvironments mode="edit" />} />
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />

View File

@ -2,6 +2,7 @@
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CompanyEnvironments } from "./CompanyEnvironments";
@ -149,7 +150,7 @@ const mockSecretsApi = vi.hoisted(() => ({
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
selectedCompany: null,
}),
}));
@ -177,7 +178,7 @@ vi.mock("@/api/secrets", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Minimal Radix dialog dependency for jsdom.
// Minimal browser APIs for jsdom.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = class {
observe() {}
@ -262,12 +263,16 @@ function findButton(root: ParentNode, label: string): HTMLButtonElement | undefi
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === label);
}
function editButtons(root: ParentNode): HTMLButtonElement[] {
return Array.from(root.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "Edit");
function findAction(root: ParentNode, label: string): HTMLElement | undefined {
return Array.from(root.querySelectorAll<HTMLElement>("button,a")).find((element) => element.textContent?.trim() === label);
}
function editButtons(root: ParentNode): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>("button,a")).filter((element) => element.textContent?.trim() === "Edit");
}
function click(element: Element | null | undefined) {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
element?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
function setInputValue(input: HTMLInputElement, value: string) {
@ -276,8 +281,26 @@ function setInputValue(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function getOpenDialog(): HTMLElement | null {
return document.body.querySelector("[role='dialog']");
const ENVIRONMENTS_PATH = "/company/settings/instance/environments";
function getEnvironmentFormPage(): HTMLElement | null {
return document.body.querySelector("[data-testid='environment-form-page']");
}
function renderCompanyEnvironments(queryClient: QueryClient, initialPath = ENVIRONMENTS_PATH) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
<TooltipProvider>
<Routes>
<Route path={ENVIRONMENTS_PATH} element={<CompanyEnvironments />} />
<Route path={`${ENVIRONMENTS_PATH}/new`} element={<CompanyEnvironments mode="create" />} />
<Route path={`${ENVIRONMENTS_PATH}/:environmentId/edit`} element={<CompanyEnvironments mode="edit" />} />
</Routes>
</TooltipProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
function createSession(overrides: Record<string, unknown> = {}) {
@ -459,13 +482,7 @@ describe("CompanyEnvironments — test provider button", () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
@ -511,13 +528,7 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
@ -556,13 +567,7 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
@ -582,13 +587,7 @@ describe("CompanyEnvironments — test provider button", () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
@ -613,66 +612,58 @@ describe("CompanyEnvironments — test provider button", () => {
expect(buttons[1].disabled).toBe(true);
});
it("opens the add-environment form in a dialog and closes it on cancel", async () => {
it("opens the add-environment form on a standalone page and closes it on cancel", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => {
findButton(container, "Add environment")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
click(findAction(container, "Add environment"));
});
await flushReact();
expect(getOpenDialog()?.textContent).toContain("Add environment");
await waitForAssertion(() => {
expect(getEnvironmentFormPage()?.textContent).toContain("Add environment");
});
await act(async () => {
findButton(document.body, "Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(getOpenDialog()).toBeNull();
expect(getEnvironmentFormPage()).toBeNull();
});
it("opens the edit form in a dialog with existing values and closes after save", async () => {
it("opens the edit form on a standalone page with existing values and closes after save", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => {
findButton(container, "Edit")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
click(findAction(container, "Edit"));
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog?.textContent).toContain("Edit environment");
await waitForAssertion(() => {
expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment");
});
const page = getEnvironmentFormPage();
expect(document.body.querySelector("[role='dialog']")).toBeNull();
expect(
Array.from(dialog?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"),
Array.from(page?.querySelectorAll("input") ?? []).some((input) => (input as HTMLInputElement).value === "Alpha"),
).toBe(true);
await act(async () => click(findButton(dialog!, "Add variable")));
await act(async () => click(findButton(page!, "Add variable")));
await flushReact();
const variableName = dialog!.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!;
const variableValue = dialog!.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!;
const variableName = page!.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!;
const variableValue = page!.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!;
await act(async () => {
setInputValue(variableName, "API_TOKEN");
setInputValue(variableValue, "draft-token");
@ -692,7 +683,7 @@ describe("CompanyEnvironments — test provider button", () => {
envVars: { API_TOKEN: { type: "plain", value: "draft-token" } },
}),
);
expect(getOpenDialog()).toBeNull();
expect(getEnvironmentFormPage()).toBeNull();
});
it("shows image setup controls only for providers advertising setup and capture support", async () => {
@ -747,40 +738,34 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
// Daytona supports setup + capture → "Configure image" in its config dialog.
// Daytona supports setup + capture -> "Configure image" on its edit page.
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Configure image");
expect(getEnvironmentFormPage()?.textContent).toContain("Configure image");
});
expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
await act(async () => click(findButton(document.body, "Cancel")));
await waitForAssertion(() => expect(getOpenDialog()).toBeNull());
await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeNull());
// E2B does not advertise interactive setup.
await act(async () => click(editButtons(container)[1]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Unsupported provider");
expect(getEnvironmentFormPage()?.textContent).toContain("Unsupported provider");
});
expect(getOpenDialog()?.textContent).not.toContain("Configure image");
expect(getEnvironmentFormPage()?.textContent).not.toContain("Configure image");
await act(async () => click(findButton(document.body, "Cancel")));
await waitForAssertion(() => expect(getOpenDialog()).toBeNull());
await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeNull());
// Provider advertises setup but cannot capture an image.
await act(async () => click(editButtons(container)[2]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Setup capture unavailable");
expect(getEnvironmentFormPage()?.textContent).toContain("Setup capture unavailable");
});
expect(getOpenDialog()?.textContent).not.toContain("Configure image");
expect(getEnvironmentFormPage()?.textContent).not.toContain("Configure image");
});
it("shows a live connect command and removes it after cancellation", async () => {
@ -826,31 +811,25 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain(command);
expect(getEnvironmentFormPage()?.textContent).toContain(command);
});
await act(async () => click(findButton(getOpenDialog()!, "Cancel")));
await act(async () => click(findButton(getEnvironmentFormPage()!, "Cancel")));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Setup cancelled");
expect(getEnvironmentFormPage()?.textContent).toContain("Setup cancelled");
});
expect(mockEnvironmentsApi.cancelCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith(
"session-1",
{ reason: "operator cancelled" },
);
expect(getOpenDialog()?.textContent).not.toContain(command);
expect(getEnvironmentFormPage()?.textContent).not.toContain(command);
});
it("opens an embedded browser terminal automatically while preserving the SSH command fallback", async () => {
@ -872,21 +851,15 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain(command);
expect(getOpenDialog()?.textContent).toContain("Browser terminal");
expect(getOpenDialog()?.textContent).toContain("SSH command fallback");
expect(getEnvironmentFormPage()?.textContent).toContain(command);
expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal");
expect(getEnvironmentFormPage()?.textContent).toContain("SSH command fallback");
expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {});
expect(FakeWebSocket.instances).toHaveLength(1);
});
@ -915,7 +888,7 @@ describe("CompanyEnvironments — test provider button", () => {
FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "ready" }));
FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "output", data: "\u001b[?2004hsetup shell\r\n$ " }));
});
const terminalScreen = getOpenDialog()?.querySelector<HTMLElement>(
const terminalScreen = getEnvironmentFormPage()?.querySelector<HTMLElement>(
"[data-testid='custom-image-terminal-screen-session-1']",
);
await waitForAssertion(() => {
@ -923,7 +896,7 @@ describe("CompanyEnvironments — test provider button", () => {
expect(xtermMocks.terminalInstances[0].writes.join("")).toContain("setup shell");
expect(xtermMocks.terminalInstances[0].focused).toBe(true);
expect(document.activeElement).toBe(terminalScreen);
expect(getOpenDialog()?.textContent).toContain(command);
expect(getEnvironmentFormPage()?.textContent).toContain(command);
});
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({
type: "auth",
@ -940,6 +913,48 @@ describe("CompanyEnvironments — test provider button", () => {
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "input", data: "\r" }));
});
it("keeps the environment edit page open when Escape is pressed in setup terminal", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const command = "ssh sandbox@setup.example.invalid -p 2222";
mockEnvironmentsApi.list.mockResolvedValue([
{ id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } },
]);
mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities());
mockEnvironmentsApi.customImageTemplate.mockResolvedValue({
activeTemplate: null,
activeSession: createSession(),
latestSession: createSession(),
});
mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({
session: createSession(),
connectionPayload: { type: "ssh", command },
});
await act(async () => {
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
let terminalScreen: HTMLElement | null = null;
await waitForAssertion(() => {
terminalScreen = getEnvironmentFormPage()?.querySelector<HTMLElement>(
"[data-testid='custom-image-terminal-screen-session-1']",
) ?? null;
expect(terminalScreen).toBeTruthy();
expect(document.body.querySelector("[role='dialog']")).toBeNull();
});
await act(async () => {
terminalScreen?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
await flushReact();
expect(getEnvironmentFormPage()?.textContent).toContain(command);
expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment");
});
it("does not render connect details when an active session refreshes as expired", async () => {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -976,21 +991,15 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Setup expired");
expect(getEnvironmentFormPage()?.textContent).toContain("Setup expired");
});
expect(getOpenDialog()?.textContent).not.toContain(command);
expect(getEnvironmentFormPage()?.textContent).not.toContain(command);
});
it("shows a setup connection refresh fallback without breaking finish or cancel", async () => {
@ -1008,22 +1017,16 @@ describe("CompanyEnvironments — test provider button", () => {
mockEnvironmentsApi.customImageSetupSession.mockRejectedValue(new Error("proxy unavailable"));
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Setup connection details could not be refreshed.");
expect(getEnvironmentFormPage()?.textContent).toContain("Setup connection details could not be refreshed.");
});
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(findButton(dialog, "Finished")?.disabled).toBe(false);
expect(findButton(dialog, "Cancel")?.disabled).toBe(false);
@ -1051,22 +1054,16 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Browser terminal is not available for this provider connection.");
expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal is not available for this provider connection.");
});
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(findButton(dialog, "Finished")?.disabled).toBe(false);
expect(findButton(dialog, "Cancel")?.disabled).toBe(false);
});
@ -1103,19 +1100,13 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
const dialog = getOpenDialog();
const dialog = getEnvironmentFormPage();
expect(dialog?.textContent).toContain("Active template");
expect(dialog?.textContent).toContain("redacted-template-ref");
expect(dialog?.textContent).not.toContain("id 12345678-90a");
@ -1129,7 +1120,7 @@ describe("CompanyEnvironments — test provider button", () => {
expect(findButton(dialog!, "Disable")).toBeTruthy();
});
await act(async () => click(findButton(getOpenDialog()!, "Refresh")));
await act(async () => click(findButton(getEnvironmentFormPage()!, "Refresh")));
await flushReact();
expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith(
@ -1138,8 +1129,8 @@ describe("CompanyEnvironments — test provider button", () => {
{ templateId: activeTemplateId },
);
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Browser terminal");
expect(getOpenDialog()?.textContent).toContain("SSH command fallback");
expect(getEnvironmentFormPage()?.textContent).toContain("Browser terminal");
expect(getEnvironmentFormPage()?.textContent).toContain("SSH command fallback");
expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {});
expect(FakeWebSocket.instances).toHaveLength(1);
});
@ -1171,28 +1162,22 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(dialog.textContent).toContain("Capturing template");
expect(dialog.textContent).toContain("Capture is in progress.");
expect(findButton(dialog, "Finished")?.disabled).toBe(true);
expect(findButton(dialog, "Cancel")?.disabled).toBe(false);
});
await act(async () => click(findButton(getOpenDialog()!, "Cancel")));
await act(async () => click(findButton(getEnvironmentFormPage()!, "Cancel")));
await waitForAssertion(() => {
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(dialog.textContent).toContain("Active template");
expect(findButton(dialog, "Refresh")).toBeTruthy();
});
@ -1218,19 +1203,13 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(dialog.textContent).toContain("Active template");
expect(dialog.textContent).toContain("Not in use — the environment configuration changed");
});
@ -1251,19 +1230,13 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
const dialog = getOpenDialog()!;
const dialog = getEnvironmentFormPage()!;
expect(dialog.textContent).toContain("Active template");
expect(dialog.textContent).not.toContain("Not in use");
});
@ -1300,30 +1273,24 @@ describe("CompanyEnvironments — test provider button", () => {
});
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root!.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await act(async () => click(editButtons(container)[0]));
await waitForAssertion(() => {
const dialog = getOpenDialog();
const dialog = getEnvironmentFormPage();
expect(dialog?.textContent).toContain("Active template");
expect(findButton(dialog!, "Rollback")).toBeTruthy();
expect(findButton(dialog!, "Disable")).toBeTruthy();
});
await act(async () => click(findButton(getOpenDialog()!, "Rollback")));
await act(async () => click(findButton(getEnvironmentFormPage()!, "Rollback")));
await waitForAssertion(() => {
expect(mockEnvironmentsApi.rollbackCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
});
await act(async () => click(findButton(getOpenDialog()!, "Disable")));
await act(async () => click(findButton(getEnvironmentFormPage()!, "Disable")));
await waitForAssertion(() => {
expect(mockEnvironmentsApi.disableCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
});

View File

@ -5,7 +5,7 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { ArrowLeft, Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTermTerminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
@ -26,14 +26,6 @@ import {
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
EnvironmentVariablesEditor,
type EnvironmentVariablesEditorHandle,
@ -43,6 +35,7 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { Link, useNavigate, useParams } from "@/lib/router";
import { buildSameOriginWebSocketUrl } from "@/lib/websocket-url";
import {
Field,
@ -66,6 +59,18 @@ type EnvironmentFormState = {
envVars: Record<string, EnvBinding>;
};
type CompanyEnvironmentsMode = "list" | "create" | "edit";
type CompanyEnvironmentsProps = {
mode?: CompanyEnvironmentsMode;
};
const ENVIRONMENTS_PATH = "/company/settings/instance/environments";
function environmentEditPath(environmentId: string) {
return `${ENVIRONMENTS_PATH}/${encodeURIComponent(environmentId)}/edit`;
}
function buildEnvironmentPayload(form: EnvironmentFormState) {
return {
name: form.name.trim(),
@ -168,6 +173,48 @@ function readSandboxConfig(environment: Environment) {
};
}
function createEnvironmentFormFromEnvironment(environment: Environment): EnvironmentFormState {
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "ssh",
sshHost: ssh.host,
sshPort: ssh.port,
sshUsername: ssh.username,
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
sshPrivateKey: ssh.privateKey,
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
envVars: environment.envVars ?? {},
};
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
envVars: environment.envVars ?? {},
};
}
return {
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
envVars: environment.envVars ?? {},
};
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
@ -1067,25 +1114,33 @@ function EnvironmentImageTemplatePanel({
);
}
export function CompanyEnvironments() {
export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) {
const { environmentId: routeEnvironmentId } = useParams<{ environmentId?: string }>();
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [environmentDialogOpen, setEnvironmentDialogOpen] = useState(false);
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
const isEnvironmentFormPage = mode === "create" || mode === "edit";
const editingEnvironmentId = mode === "edit" ? routeEnvironmentId ?? null : null;
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
const initializedFormKeyRef = useRef<string | null>(null);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
const [testingEnvironmentId, setTestingEnvironmentId] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
const crumbs = [
{ label: "Settings", href: "/company/settings" },
{ label: "Instance settings", href: "/company/settings/instance/general" },
{ label: "Environments" },
]);
}, [setBreadcrumbs]);
isEnvironmentFormPage
? { label: "Environments", href: ENVIRONMENTS_PATH }
: { label: "Environments" },
];
if (mode === "create") crumbs.push({ label: "Add environment" });
if (mode === "edit") crumbs.push({ label: "Edit environment" });
setBreadcrumbs(crumbs);
}, [isEnvironmentFormPage, mode, setBreadcrumbs]);
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
@ -1105,6 +1160,7 @@ export function CompanyEnvironments() {
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const savedEnvironments = environments ?? [];
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
@ -1135,21 +1191,24 @@ export function CompanyEnvironments() {
return await environmentsApi.update(editingEnvironmentId, body);
}
if (!selectedCompanyId) throw new Error("Select a company to create environments");
return await environmentsApi.create(selectedCompanyId!, body);
},
onSuccess: async (environment) => {
const wasEditing = editingEnvironmentId !== null;
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId!),
});
if (selectedCompanyId) {
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId),
});
}
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.customImageTemplate(environment.id),
});
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
navigate(ENVIRONMENTS_PATH, { replace: true });
pushToast({
title: wasEditing ? "Environment updated" : "Environment created",
body: `${environment.name} is ready.`,
@ -1239,8 +1298,9 @@ export function CompanyEnvironments() {
const draftEnvironmentProbeMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
if (!selectedCompanyId) throw new Error("Select a company to test environments");
const body = buildEnvironmentPayload(form);
return await environmentsApi.probeConfig(selectedCompanyId!, body);
return await environmentsApi.probeConfig(selectedCompanyId, body);
},
onSuccess: (probe) => {
pushToast({
@ -1259,76 +1319,60 @@ export function CompanyEnvironments() {
});
useEffect(() => {
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
setProbeResults({});
setTestingEnvironmentId(null);
}, [selectedCompanyId]);
function handleStartCreateEnvironment() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
setEnvironmentDialogOpen(true);
}
const resetEnvironmentMutation = environmentMutation.reset;
const resetDraftEnvironmentProbeMutation = draftEnvironmentProbeMutation.reset;
function handleEditEnvironment(environment: Environment) {
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
setEditingEnvironmentId(environment.id);
setEnvironmentDialogOpen(true);
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "ssh",
sshHost: ssh.host,
sshPort: ssh.port,
sshUsername: ssh.username,
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
sshPrivateKey: ssh.privateKey,
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
envVars: environment.envVars ?? {},
});
useEffect(() => {
if (!isEnvironmentFormPage) {
initializedFormKeyRef.current = null;
return;
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
envVars: environment.envVars ?? {},
});
const formKey = mode === "create"
? `create:${selectedCompanyId ?? "none"}`
: `edit:${selectedCompanyId ?? "none"}:${editingEnvironmentId ?? "missing"}`;
if (initializedFormKeyRef.current === formKey) return;
resetEnvironmentMutation();
resetDraftEnvironmentProbeMutation();
if (mode === "create") {
setEnvironmentForm(createEmptyEnvironmentForm());
initializedFormKeyRef.current = formKey;
return;
}
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
envVars: environment.envVars ?? {},
});
}
const environment = editingEnvironmentId
? (environments ?? []).find((candidate) => candidate.id === editingEnvironmentId) ?? null
: null;
if (!environment) return;
function closeEnvironmentDialog() {
setEnvironmentForm(createEnvironmentFormFromEnvironment(environment));
initializedFormKeyRef.current = formKey;
}, [
editingEnvironmentId,
environments,
isEnvironmentFormPage,
mode,
resetDraftEnvironmentProbeMutation,
resetEnvironmentMutation,
selectedCompanyId,
]);
function closeEnvironmentForm() {
if (environmentMutation.isPending) return;
setEnvironmentDialogOpen(false);
setEditingEnvironmentId(null);
initializedFormKeyRef.current = null;
setEnvironmentForm(createEmptyEnvironmentForm());
environmentMutation.reset();
draftEnvironmentProbeMutation.reset();
navigate(ENVIRONMENTS_PATH);
}
function flushEnvironmentForm(): EnvironmentFormState {
@ -1395,7 +1439,6 @@ export function CompanyEnvironments() {
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
const savedEnvironments = environments ?? [];
const editingEnvironment = editingEnvironmentId
? savedEnvironments.find((environment) => environment.id === editingEnvironmentId) ?? null
: null;
@ -1426,6 +1469,7 @@ export function CompanyEnvironments() {
return (
<div className="max-w-5xl space-y-6" data-testid="instance-settings-environments-section">
{!isEnvironmentFormPage ? (
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
@ -1453,13 +1497,12 @@ export function CompanyEnvironments() {
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={handleStartCreateEnvironment}>
Add environment
<Button size="sm" asChild>
<Link to={`${ENVIRONMENTS_PATH}/new`}>Add environment</Link>
</Button>
</div>
{savedEnvironments.map((environment) => {
const probe = probeResults[environment.id] ?? null;
const isEditing = editingEnvironmentId === environment.id;
const sandboxProvider = readEnvironmentSandboxProvider(environment);
const sandboxProviderCapability = sandboxProvider
? environmentCapabilities?.sandboxProviders?.[sandboxProvider]
@ -1510,12 +1553,8 @@ export function CompanyEnvironments() {
: "Test provider"}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditEnvironment(environment)}
>
{isEditing ? "Editing" : "Edit"}
<Button size="sm" variant="ghost" asChild>
<Link to={environmentEditPath(environment.id)}>Edit</Link>
</Button>
</div>
</div>
@ -1538,26 +1577,42 @@ export function CompanyEnvironments() {
})}
</div>
</div>
) : null}
<Dialog
open={environmentDialogOpen}
onOpenChange={(open) => {
if (open) {
setEnvironmentDialogOpen(true);
return;
}
closeEnvironmentDialog();
}}
>
<DialogContent className="flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
<DialogTitle>{editingEnvironmentId ? "Edit environment" : "Add environment"}</DialogTitle>
<DialogDescription>
{isEnvironmentFormPage && mode === "edit" && environments === undefined ? (
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Loading environment...
</div>
) : null}
{isEnvironmentFormPage && mode === "edit" && environments !== undefined && !editingEnvironment ? (
<div className="space-y-3 rounded-md border border-border px-4 py-4 text-sm">
<div className="font-medium">Environment not found</div>
<div className="text-muted-foreground">The environment may have been removed or is not available in this company.</div>
<Button size="sm" variant="outline" asChild>
<Link to={ENVIRONMENTS_PATH}>Back to environments</Link>
</Button>
</div>
) : null}
{isEnvironmentFormPage && (mode === "create" || editingEnvironment) ? (
<div className="rounded-md border border-border bg-background" data-testid="environment-form-page">
<div className="border-b border-border/60 px-6 pb-4 pt-6">
<div className="mb-4">
<Button size="sm" variant="ghost" asChild>
<Link to={ENVIRONMENTS_PATH}>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Environments
</Link>
</Button>
</div>
<h1 className="text-lg font-semibold">{editingEnvironmentId ? "Edit environment" : "Add environment"}</h1>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Configure a reusable execution target for your agents. Saved changes affect future runs; Paperclip may start fresh sessions or sandbox leases after environment config changes.
</DialogDescription>
</DialogHeader>
</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className="px-6 py-4">
<div className="space-y-4">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
@ -1798,10 +1853,10 @@ export function CompanyEnvironments() {
</div>
</div>
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 bg-background px-6 py-4">
<Button
variant="outline"
onClick={closeEnvironmentDialog}
onClick={closeEnvironmentForm}
disabled={environmentMutation.isPending}
>
Cancel
@ -1827,9 +1882,9 @@ export function CompanyEnvironments() {
? "Save environment"
: "Create environment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
) : null}
</div>
);
}

View File

@ -3,6 +3,7 @@
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyEnvironments } from "./CompanyEnvironments";
@ -32,6 +33,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
}));
@ -84,14 +86,7 @@ vi.mock("../context/ToastContext", () => ({
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
selectedCompany: {
id: "company-1",
name: "Paperclip",
description: null,
brandColor: null,
logoUrl: null,
issuePrefix: "PAP",
},
selectedCompany: null,
selectedCompanyId: "company-1",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
@ -122,8 +117,48 @@ async function flushReact() {
});
}
function getOpenDialog(): HTMLElement | null {
return document.body.querySelector("[role='dialog']");
async function waitForAssertion(assertion: () => void) {
let lastError: unknown;
for (let i = 0; i < 20; i += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
const ENVIRONMENTS_PATH = "/company/settings/instance/environments";
function getEnvironmentFormPage(): HTMLElement | null {
return document.body.querySelector("[data-testid='environment-form-page']");
}
function findAction(root: ParentNode, label: string): HTMLElement | undefined {
return Array.from(root.querySelectorAll<HTMLElement>("button,a")).find((element) => element.textContent?.trim() === label);
}
function click(element: Element | null | undefined) {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
function renderCompanyEnvironments(queryClient: QueryClient, initialPath = ENVIRONMENTS_PATH) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
<TooltipProvider>
<Routes>
<Route path={ENVIRONMENTS_PATH} element={<CompanyEnvironments />} />
<Route path={`${ENVIRONMENTS_PATH}/new`} element={<CompanyEnvironments mode="create" />} />
<Route path={`${ENVIRONMENTS_PATH}/:environmentId/edit`} element={<CompanyEnvironments mode="edit" />} />
</Routes>
</TooltipProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
describe("CompanyEnvironments", () => {
@ -136,6 +171,7 @@ describe("CompanyEnvironments", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableEnvironments: true,
});
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockEnvironmentsApi.list.mockResolvedValue([]);
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities(AGENT_ADAPTER_TYPES),
@ -164,13 +200,7 @@ describe("CompanyEnvironments", () => {
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await flushReact();
@ -208,29 +238,20 @@ describe("CompanyEnvironments", () => {
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await flushReact();
const addEnvironmentButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Add environment",
);
const addEnvironmentButton = findAction(container, "Add environment");
expect(addEnvironmentButton).toBeTruthy();
await act(async () => {
addEnvironmentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
click(addEnvironmentButton);
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy());
const dialog = getEnvironmentFormPage();
const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? [])
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
@ -268,28 +289,20 @@ describe("CompanyEnvironments", () => {
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await flushReact();
const editButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Edit");
const editButton = findAction(container, "Edit");
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
click(editButton);
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy());
const dialog = getEnvironmentFormPage();
const driverSelect = Array.from(dialog?.querySelectorAll("select") ?? [])
.find((select) => Array.from(select.options).some((option) => option.value === "ssh")) as
@ -350,30 +363,22 @@ describe("CompanyEnvironments", () => {
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
root.render(renderCompanyEnvironments(queryClient));
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Secure Sandbox");
const editButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Edit");
const editButton = findAction(container, "Edit");
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
click(editButton);
});
await flushReact();
const dialog = getOpenDialog();
expect(dialog).toBeTruthy();
await waitForAssertion(() => expect(getEnvironmentFormPage()).toBeTruthy());
const dialog = getEnvironmentFormPage();
const providerSelect = Array.from(dialog?.querySelectorAll("select") ?? []).find((select) =>
Array.from(select.options).some((option) => option.value === "secure-plugin"),