test(canvas): unit tests for useTemplateDeploy (#2071)

[Molecule-Platform-Evolvement-Manager]

Closes the first item from #2071 (Canvas test gaps follow-up):
adds behavioural coverage for the shared template-deploy hook that
both TemplatePalette (sidebar) and EmptyState (welcome grid) drive.

10 cases across 4 buckets:

**Happy path (4):**
- preflight ok → POST /workspaces → onDeployed fires with new id
- caller-supplied canvasCoords flows into the POST body
- default coords fall in [100,500) × [100,400) when canvasCoords omitted
- template.runtime is preferred over the resolveRuntime fallback
  (locks the deduped-fallback table contract added in #2061)

**Preflight failures (2):**
- network throw sets error AND clears `deploying` (regression test
  for the "stranded button" bug called out in the SUT's inline
  comment — drop the try block and you'll fail this test)
- not-ok-with-missing-keys opens the modal without firing POST

**Modal lifecycle (2):**
- 'keys added' click retries POST without re-running preflight
  (verifies the executeDeploy / deploy split — preflight call count
  stays at 1, POST count goes to 1)
- 'cancel' click closes modal without firing POST

**POST failures (2):**
- Error rejection surfaces the message
- non-Error rejection surfaces the "Deploy failed" fallback

Mocks `@/lib/api`, `@/lib/deploy-preflight`, and `@/components/MissingKeysModal`
(stand-in component exposes the two callbacks as test-id buttons —
the real radix modal is irrelevant to this hook's behavior). Test
file follows the `vi.hoisted` + import-after-mocks pattern from
`canvas/src/app/__tests__/orgs-page.test.tsx`.

## Test plan

- [x] All 10 cases pass locally (`vitest run useTemplateDeploy.test.tsx`)
- [x] No changes to the SUT — pure additive coverage
- [ ] CI green

Follow-ups for the rest of #2071 (separate PRs):
- A2AEdge rendering + click-to-select-source
- OrgCancelButton cancel flow + optimistic state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-26 14:17:35 -07:00
parent 84c3206e39
commit a9d2d46682

View File

@ -0,0 +1,316 @@
// @vitest-environment jsdom
/**
* Tests for useTemplateDeploy the shared preflight + POST + modal
* hook used by TemplatePalette (sidebar) and EmptyState (welcome grid).
*
* Behavioural coverage for the three flows the hook owns:
* 1. Happy path: preflight ok POST /workspaces onDeployed fires
* 2. Preflight errors: network throw vs not-ok-with-missing-keys
* (different code paths the throw must NOT strand `deploying`,
* see the inline comment in the SUT for the prior bug)
* 3. Modal lifecycle: keys-added retries POST without re-running
* preflight; cancel closes without POST
*
* Issue: #2071 (Canvas test gaps follow-up).
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from "vitest";
import { act, render, cleanup, screen, fireEvent } from "@testing-library/react";
import { renderHook } from "@testing-library/react";
import type { Template } from "@/lib/deploy-preflight";
// ── Hoisted mocks ────────────────────────────────────────────────────────────
const { mockApiPost, mockCheckDeploySecrets, mockResolveRuntime } = vi.hoisted(
() => ({
mockApiPost: vi.fn(),
mockCheckDeploySecrets: vi.fn(),
mockResolveRuntime: vi.fn(),
}),
);
vi.mock("@/lib/api", () => ({
api: { post: mockApiPost },
}));
vi.mock("@/lib/deploy-preflight", async () => {
// Re-export the real types; only swap the runtime functions.
const actual = await vi.importActual<
typeof import("@/lib/deploy-preflight")
>("@/lib/deploy-preflight");
return {
...actual,
checkDeploySecrets: mockCheckDeploySecrets,
resolveRuntime: mockResolveRuntime,
};
});
// MissingKeysModal: render a minimal stand-in that exposes the two
// callbacks the hook wires up. The real modal pulls in radix + the
// secrets store, neither of which is relevant to this hook's behavior.
vi.mock("@/components/MissingKeysModal", () => ({
MissingKeysModal: (props: {
open: boolean;
onKeysAdded: () => void;
onCancel: () => void;
}) =>
props.open ? (
<div data-testid="missing-keys-modal">
<button data-testid="modal-keys-added" onClick={props.onKeysAdded}>
keys added
</button>
<button data-testid="modal-cancel" onClick={props.onCancel}>
cancel
</button>
</div>
) : null,
}));
// Import the hook AFTER the mocks are declared.
import { useTemplateDeploy } from "../useTemplateDeploy";
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeTemplate(over: Partial<Template> = {}): Template {
return {
id: "claude-code-default",
name: "Claude Code",
description: "",
tier: 1,
model: "claude-sonnet-4-5",
skills: [],
skill_count: 0,
runtime: "claude-code",
models: [],
required_env: [],
...over,
};
}
beforeEach(() => {
mockApiPost.mockReset();
mockCheckDeploySecrets.mockReset();
mockResolveRuntime.mockReset();
// Default: identity-mapped runtime, preflight passes.
mockResolveRuntime.mockImplementation((id: string) => id);
mockCheckDeploySecrets.mockResolvedValue({
ok: true,
missingKeys: [],
providers: [],
runtime: "claude-code",
});
mockApiPost.mockResolvedValue({ id: "ws-new" });
});
afterEach(() => {
cleanup();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("useTemplateDeploy — happy path", () => {
it("preflight ok → POST /workspaces → onDeployed fires with new id", async () => {
const onDeployed = vi.fn();
const { result } = renderHook(() => useTemplateDeploy({ onDeployed }));
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
name: "Claude Code",
template: "claude-code-default",
tier: 1,
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
expect(result.current.deploying).toBeNull();
expect(result.current.error).toBeNull();
});
it("uses caller-supplied canvasCoords when provided", async () => {
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
const { result } = renderHook(() => useTemplateDeploy({ canvasCoords }));
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(canvasCoords).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({ canvas: { x: 42, y: 99 } }),
);
});
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
const { result } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
canvas: { x: number; y: number };
};
expect(body.canvas.x).toBeGreaterThanOrEqual(100);
expect(body.canvas.x).toBeLessThan(500);
expect(body.canvas.y).toBeGreaterThanOrEqual(100);
expect(body.canvas.y).toBeLessThan(400);
});
it("prefers template.runtime over resolveRuntime fallback", async () => {
const { result } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(
makeTemplate({ runtime: "hermes", id: "some-id" }),
);
});
expect(mockResolveRuntime).not.toHaveBeenCalled();
expect(mockCheckDeploySecrets).toHaveBeenCalledWith(
expect.objectContaining({ runtime: "hermes" }),
);
});
});
describe("useTemplateDeploy — preflight failure modes", () => {
it("preflight throw sets error and clears deploying (no stranded button)", async () => {
mockCheckDeploySecrets.mockRejectedValueOnce(new Error("network down"));
const { result } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(result.current.error).toBe("network down");
expect(result.current.deploying).toBeNull();
expect(mockApiPost).not.toHaveBeenCalled();
});
it("preflight not-ok opens the modal without firing POST", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: false,
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
});
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
expect(mockApiPost).not.toHaveBeenCalled();
expect(onDeployed).not.toHaveBeenCalled();
expect(result.current.deploying).toBeNull();
});
});
describe("useTemplateDeploy — modal lifecycle", () => {
it("'keys added' retries POST without re-running preflight", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: false,
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
});
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
rerender();
render(<>{result.current.modal}</>);
// Click "keys added" — the hook should retry via executeDeploy
// (which does NOT call preflight again).
await act(async () => {
fireEvent.click(screen.getByTestId("modal-keys-added"));
// Let the fire-and-forget executeDeploy promise resolve.
await Promise.resolve();
await Promise.resolve();
});
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1); // still 1, not 2
expect(mockApiPost).toHaveBeenCalledTimes(1);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
});
it("'cancel' closes the modal without firing POST", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: false,
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
rerender();
const { rerender: renderRerender } = render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByTestId("modal-cancel"));
});
rerender();
renderRerender(<>{result.current.modal}</>);
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
expect(mockApiPost).not.toHaveBeenCalled();
});
});
describe("useTemplateDeploy — POST failure", () => {
it("POST rejection sets error and clears deploying", async () => {
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
const onDeployed = vi.fn();
const { result } = renderHook(() => useTemplateDeploy({ onDeployed }));
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(result.current.error).toBe("server 500");
expect(result.current.deploying).toBeNull();
expect(onDeployed).not.toHaveBeenCalled();
});
it("non-Error rejection still surfaces a message (defensive)", async () => {
mockApiPost.mockRejectedValueOnce("plain string");
const { result } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(result.current.error).toBe("Deploy failed");
expect(result.current.deploying).toBeNull();
});
});