fix(canvas): dark zinc disabled button, 6 failing tests, case-insensitive icon lookup
Design fixes:
- PricingTable.tsx: replace non-zinc disabled:bg-blue-900 with
bg-zinc-700/text-zinc-500, keeping all states within the dark zinc
palette (zinc-900 bg, zinc-800 surfaces, zinc-700 borders).
Test fixes:
- PurchaseSuccessModal.test.tsx: replace setTimeout(0) anti-pattern under
vi.useFakeTimers() — act() does not advance fake timers, causing 5000ms
timeouts. Use vi.advanceTimersByTime(10) to flush render effects without
triggering the 5s auto-dismiss. 18/18 tests now pass.
- OnboardingWizard.test.tsx: replace stateless mock with
useSyncExternalStore bridge + subscriber set so React re-renders when
mockStoreState is mutated; fix second-render unmount ordering. 13/13 pass.
- yaml-utils.ts: emit tools: [] key unconditionally (matching skills
behaviour); test expectation was correct, implementation was wrong. 36/36.
- tabs/chat/types.ts createMessage: conditional { attachments } spread
avoids undefined key in Object.keys(); Object.freeze() the returned
object so mutation-guards in tests pass.
- tabs/FilesTab/tree.ts getIcon: normalize extracted extension to
lowercase so data.JSON matches the .json entry in FILE_ICONS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8d536e99e2
commit
36ed7b4590
@ -130,7 +130,7 @@ function PlanCard({
|
||||
disabled={loading}
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
plan.highlighted
|
||||
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
|
||||
? "bg-accent-strong text-white hover:bg-accent disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -6,11 +6,10 @@
|
||||
* button, localStorage persistence, progress bar width, step navigation,
|
||||
* auto-advance from welcome→api-key on nodes change, aria-live region.
|
||||
*/
|
||||
import React from "react";
|
||||
import React, { useSyncExternalStore } from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
const mockStoreState = {
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
@ -20,11 +19,30 @@ const mockStoreState = {
|
||||
setPanelTab: vi.fn(),
|
||||
};
|
||||
|
||||
// Subscribers set so we can notify them when mockStoreState changes.
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
/** Call after mutating mockStoreState to trigger React re-renders. */
|
||||
function notifySubscribers() {
|
||||
subscribers.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function createMockUseCanvasStore<T>(sel: (s: typeof mockStoreState) => T): T {
|
||||
return useSyncExternalStore<T>(
|
||||
(onStoreChange) => {
|
||||
const sub = () => onStoreChange();
|
||||
subscribers.add(sub);
|
||||
return () => { subscribers.delete(sub); };
|
||||
},
|
||||
() => sel(mockStoreState as typeof mockStoreState),
|
||||
() => sel(mockStoreState as typeof mockStoreState),
|
||||
);
|
||||
}
|
||||
// Attach getState as a static property — matches Zustand's API surface.
|
||||
(createMockUseCanvasStore as unknown as { getState: () => typeof mockStoreState }).getState = () => mockStoreState;
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
),
|
||||
useCanvasStore: createMockUseCanvasStore,
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "molecule-onboarding-complete";
|
||||
@ -51,6 +69,8 @@ afterEach(() => {
|
||||
mockStoreState.panelTab = "chat";
|
||||
mockStoreState.agentMessages = {};
|
||||
mockStoreState.setPanelTab = vi.fn();
|
||||
// Clear useSyncExternalStore subscribers so each test starts clean.
|
||||
subscribers.clear();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
@ -142,16 +162,21 @@ describe("OnboardingWizard — auto-advance", () => {
|
||||
it("auto-advances from welcome to api-key when nodes appear", async () => {
|
||||
const { unmount } = render(<OnboardingWizard />);
|
||||
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
|
||||
unmount(); // remove first instance before testing auto-advance
|
||||
|
||||
// Simulate a node being added to the store and re-render
|
||||
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
|
||||
// Simulate a node being added to the store and re-render.
|
||||
// act() flushes the useSyncExternalStore subscription + React state update
|
||||
// so the component sees the new nodes before waitFor polls the DOM.
|
||||
await act(async () => {
|
||||
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
|
||||
notifySubscribers();
|
||||
});
|
||||
render(<OnboardingWizard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Set your API key")).toBeTruthy();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -6,163 +6,169 @@
|
||||
* portal rendering, item name from &item=, auto-dismiss after 5s,
|
||||
* manual dismiss, backdrop click close, Escape key close, URL stripping,
|
||||
* focus management.
|
||||
*
|
||||
* JSDOM quirks worked around:
|
||||
* A) window.location.href assignment drops query params → tracked in
|
||||
* currentUrl variable, mocked window.location as accessor.
|
||||
* B) history.replaceState/pushState throws SecurityError on same-URL →
|
||||
* mocked both methods to just update currentUrl (bypasses real history).
|
||||
* C) Fake timers: vi.useFakeTimers() + vi.advanceTimersByTime(10) inside
|
||||
* act() flushes render effects without firing the 5000ms auto-dismiss.
|
||||
* The setTimeout(0) anti-pattern does NOT work under fake timers.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, afterAll, beforeEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
// ─── URL state ────────────────────────────────────────────────────────────────
|
||||
|
||||
function pushUrl(url: string) {
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
function replaceUrl(url: string) {
|
||||
window.history.replaceState({}, "", url);
|
||||
let currentUrl = "http://localhost/";
|
||||
|
||||
function setUrl(url: string) {
|
||||
currentUrl = url;
|
||||
}
|
||||
|
||||
// ─── Global mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
let replaceStateMock: ReturnType<typeof vi.spyOn>;
|
||||
let pushStateMock: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(() => {
|
||||
replaceStateMock = vi.spyOn(window.history, "replaceState").mockImplementation(
|
||||
(_s, _u, url) => { if (url) currentUrl = String(url); }
|
||||
);
|
||||
pushStateMock = vi.spyOn(window.history, "pushState").mockImplementation(
|
||||
(_s, _u, url) => { if (url) currentUrl = String(url); }
|
||||
);
|
||||
// Mock window.location as a getter so `new URL(window.location.href)` always
|
||||
// reads the live currentUrl value, not a snapshot made at setup time.
|
||||
Object.defineProperty(window, "location", {
|
||||
get() { return new URL(currentUrl); },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
replaceStateMock?.mockRestore();
|
||||
pushStateMock?.mockRestore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUrl = "http://localhost/";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PurchaseSuccessModal — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
beforeEach(() => { vi.useRealTimers(); });
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("renders nothing when URL has no purchase_success param", () => {
|
||||
replaceUrl("http://localhost/");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing on a plain URL", () => {
|
||||
replaceUrl("http://localhost/dashboard?foo=bar");
|
||||
setUrl("http://localhost/dashboard?foo=bar");
|
||||
render(<PurchaseSuccessModal />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=1 is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
setUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
// useEffect fires after mount
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the dialog when ?purchase_success=true is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=true");
|
||||
setUrl("http://localhost/?purchase_success=true");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a portal attached to document.body", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
setUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the item name when &item= is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
|
||||
setUrl("http://localhost/?purchase_success=1&item=MyAgent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.getByText("MyAgent")).toBeTruthy();
|
||||
expect(screen.getByText("Purchase successful")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'Your new agent' when no item param is present", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1");
|
||||
setUrl("http://localhost/?purchase_success=1");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.getByText("Your new agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("decodes URI-encoded item names", async () => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
|
||||
setUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — dismiss", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
setUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("closes the dialog when the close button is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
// advanceTimersByTime(10) flushes the initial render effects (useEffect
|
||||
// sets open=true, schedules the 5000ms auto-dismiss) WITHOUT firing it.
|
||||
// This avoids the setTimeout(0) anti-pattern under vi.useFakeTimers().
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the dialog when the backdrop is clicked", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click the backdrop (the full-screen overlay div)
|
||||
const backdrop = document.body.querySelector('[aria-hidden="true"]');
|
||||
if (backdrop) fireEvent.click(backdrop);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-dismisses after 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
// Advance 5 seconds
|
||||
// Advance the 5000ms auto-dismiss timer (scheduled in 2nd useEffect).
|
||||
// Use advanceTimersByTime which is safe within act.
|
||||
act(() => { vi.advanceTimersByTime(5000); });
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
@ -170,11 +176,8 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
|
||||
it("does not auto-dismiss before 5 seconds", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4900); });
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
@ -183,20 +186,14 @@ describe("PurchaseSuccessModal — dismiss", () => {
|
||||
|
||||
describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
setUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("strips purchase_success and item params from the URL on mount", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
const url = new URL(window.location.href);
|
||||
expect(url.searchParams.get("purchase_success")).toBeNull();
|
||||
expect(url.searchParams.get("item")).toBeNull();
|
||||
@ -205,38 +202,28 @@ describe("PurchaseSuccessModal — URL stripping", () => {
|
||||
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
|
||||
const replaceSpy = vi.spyOn(window.history, "replaceState");
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
expect(replaceSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PurchaseSuccessModal — accessibility", () => {
|
||||
beforeEach(() => {
|
||||
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
setUrl("http://localhost/?purchase_success=1&item=TestItem");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("has aria-modal=true on the dialog", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-labelledby pointing to the title", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledby = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledby).toBeTruthy();
|
||||
@ -246,10 +233,9 @@ describe("PurchaseSuccessModal — accessibility", () => {
|
||||
|
||||
it("moves focus to the close button on open", async () => {
|
||||
render(<PurchaseSuccessModal />);
|
||||
await act(async () => {
|
||||
// Two rAFs for focus: one from the effect, one from the RAF wrapper
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(10); });
|
||||
// Focus is set via requestAnimationFrame. rAF fires synchronously under
|
||||
// fake timers when advanceTimersByTime is called; no need for runAllTimers.
|
||||
expect(document.activeElement?.textContent).toMatch(/close/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
|
||||
|
||||
export function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + path.split(".").pop();
|
||||
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@ -26,13 +26,15 @@ export function createMessage(
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): ChatMessage {
|
||||
return {
|
||||
return Object.freeze({
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
// Conditional spread avoids `attachments: undefined` appearing in
|
||||
// Object.keys() when no attachments are provided.
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
|
||||
Loading…
Reference in New Issue
Block a user