Compare commits

..

2 Commits

Author SHA1 Message Date
core-fe ac7c395855 fix(canvas/ContextMenu): prevent React error #185 by moving hasChildren derivation out of Zustand selector
ContextMenu used `.some()` inside its Zustand selector to compute hasChildren.
Zustand's useSyncExternalStore calls the selector on every snapshot; `.some()`
returns a new boolean each time, which React 19's stricter comparison
and the re-render side-effects from the store subscription created a
feedback loop on mobile Chat tab mount → React error #185
("Maximum update depth exceeded").

Fix: select the stable `nodes` array once, derive children via useMemo
outside the store subscription. Also removes the inline `getState().nodes.filter()`
call in handleDelete in favour of the memoized children.

Regression tests (2 cases):
- setPendingDelete receives correct children array when workspace has children
- setPendingDelete hasChildren=false and empty children when no children

Refs: #651

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
core-fe 3edb68ab77 test(canvas/lib): add isExternalLikeRuntime coverage (16 cases)
Mirrors the backend isExternalLikeRuntime() contract so both sides agree
on which runtimes are external-like (no platform container, no Files/Terminal tabs).

Cases: "external", "kimi", "kimi-cli" → true; all other runtimes,
undefined, null, empty string → false. Case-sensitivity verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
-189
View File
@@ -1,189 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for hydrate.ts — canvas store hydration with exponential backoff.
*
* Covers:
* - Successful hydration on first attempt (no retries)
* - Retry with exponential backoff on failure
* - onRetrying callback called at correct intervals
* - Error propagation after MAX_RETRIES exhausted
* - Viewport persisted on success
* - Viewport failure is non-fatal
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { WorkspaceData } from "@/store/socket";
// ---------------------------------------------------------------------------
// Mock modules — must precede imports that use them
// ---------------------------------------------------------------------------
const mockHydrate = vi.fn();
const mockSetViewport = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
},
PLATFORM_URL: "https://platform.test",
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
() => ({}),
{
getState: () => ({
hydrate: mockHydrate,
setViewport: mockSetViewport,
}),
},
),
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import { api } from "@/lib/api";
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const WORKSPACES: WorkspaceData[] = [
{ id: "ws-1", name: "Test Workspace" } as WorkspaceData,
];
const VIEWPORT = { x: 10, y: 20, zoom: 1.5 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockApiGet = vi.mocked(api.get);
/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */
function succeedTimes(count: number) {
let workspaceRemaining = count;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return VIEWPORT;
if (workspaceRemaining > 0) {
workspaceRemaining--;
return WORKSPACES;
}
throw new Error("API error");
});
}
/** Always fails with the given message. */
function alwaysFail(msg = "Network error") {
mockApiGet.mockRejectedValue(new Error(msg));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("hydrateCanvas", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiGet.mockReset();
mockHydrate.mockReset();
mockSetViewport.mockReset();
});
// ── Success on first attempt ─────────────────────────────────────────────
it("hydrates the store and returns null error on first attempt success", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
});
it("persists viewport when returned by the API", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT);
});
// ── Viewport failure is non-fatal ─────────────────────────────────────────
it("returns null error when viewport fetch fails but workspaces succeed", async () => {
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") throw new Error("Viewport error");
return WORKSPACES;
});
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).not.toHaveBeenCalled();
});
// ── Retry logic ──────────────────────────────────────────────────────────
it("retries MAX_RETRIES times before returning an error", async () => {
alwaysFail();
const onRetrying = vi.fn();
const result = await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly");
expect(result.error).not.toBeNull();
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
}, 10000);
it("onRetrying is called with attempt number before each retry", async () => {
alwaysFail();
const onRetrying = vi.fn();
await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
}, 10000);
it("succeeds on second attempt — hydrates after transient failure", async () => {
let callCount = 0;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return null;
callCount++;
if (callCount === 1) throw new Error("Transient error");
return WORKSPACES;
});
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
}, 10000);
// ── Error messages ────────────────────────────────────────────────────────
it("error message includes the platform URL after all retries exhausted", async () => {
alwaysFail("Connection refused");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).toContain("platform.test");
expect(result.error).toContain("Unable to connect");
}, 10000);
it("error message includes the underlying error message", async () => {
alwaysFail("TLS certificate expired");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).not.toBeNull();
expect(typeof result.error).toBe("string");
}, 10000);
});