From 8800a2465472d463d9052df4f9cd3bb4d5994a2d Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Tue, 12 May 2026 00:33:56 +0000 Subject: [PATCH] test(canvas): AttachmentLightbox 18 cases + test(platform): buildBundleConfigFiles + nilIfEmpty 11 cases (closes #598, #592) Co-Authored-By: Claude Opus 4.7 --- .../__tests__/AttachmentLightbox.test.tsx | 245 ++++++++++++++++++ .../internal/bundle/importer_test.go | 167 ++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/AttachmentLightbox.test.tsx create mode 100644 workspace-server/internal/bundle/importer_test.go diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentLightbox.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentLightbox.test.tsx new file mode 100644 index 00000000..713c5104 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentLightbox.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom +/** + * Tests for AttachmentLightbox — shared fullscreen modal for image/PDF + * fullscreen viewing. + * + * Covers: open/close rendering, backdrop click-to-close, Esc key close, + * role/dialog + aria attributes, close button, prefers-reduced-motion. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AttachmentLightbox } from "../AttachmentLightbox"; + +afterEach(cleanup); + +describe("AttachmentLightbox", () => { + describe("renders nothing when closed", () => { + it("returns null when open=false", () => { + const { container } = render( + + test + + ); + expect(container.textContent).toBe(""); + }); + }); + + describe("renders modal when open", () => { + it("renders the dialog when open=true", () => { + render( + + test + + ); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("renders the provided children", () => { + render( + + + + ); + expect(document.querySelector("embed")).toBeTruthy(); + }); + + it("has aria-modal=true", () => { + render( + + x + + ); + expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true"); + }); + + it("uses the provided ariaLabel", () => { + render( + + x + + ); + expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("My document"); + }); + + it("renders the close button", () => { + render( + + x + + ); + expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy(); + }); + + it("close button renders an SVG icon", () => { + render( + + x + + ); + const btn = screen.getByRole("button", { name: /close preview/i }); + expect(btn.querySelector("svg")).toBeTruthy(); + }); + }); + + describe("Esc to close", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("calls onClose when Escape is pressed", () => { + const onClose = vi.fn(); + render( + + x + + ); + + act(() => { + fireEvent.keyDown(document, { key: "Escape" }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose for non-Escape keys", () => { + const onClose = vi.fn(); + render( + + x + + ); + + act(() => { + fireEvent.keyDown(document, { key: "Enter" }); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not call onClose when closed (open=false)", () => { + const onClose = vi.fn(); + render( + + x + + ); + + act(() => { + fireEvent.keyDown(document, { key: "Escape" }); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe("backdrop click to close", () => { + it("calls onClose when backdrop is clicked", () => { + const onClose = vi.fn(); + render( + + x + + ); + + const dialog = screen.getByRole("dialog"); + fireEvent.click(dialog); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose when content area is clicked", () => { + const onClose = vi.fn(); + render( + + x + + ); + + // The content is nested inside the dialog — clicking the inner content + // div should not close because it has stopPropagation + const content = document.querySelector(".max-w-\\[95vw\\]") as HTMLElement; + if (content) { + fireEvent.click(content); + } + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not call onClose when close button is clicked", () => { + const onClose = vi.fn(); + render( + + x + + ); + + fireEvent.click(screen.getByRole("button", { name: /close preview/i })); + + // onClose is NOT called for button click — the button's onClick handles + // close directly. Only backdrop click triggers onClose. + // (The component does not call onClose from the button; it calls setOpen(false) + // Actually, looking at the component: onClick={onClose} on the button too. + // So this test should expect onClose to be called. + // Wait — the close button's onClick calls onClose, and backdrop also calls onClose. + // Both should call onClose. + // Let me update this test. + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("a11y", () => { + it("dialog has role=dialog", () => { + render( + + x + + ); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("close button has accessible name", () => { + render( + + x + + ); + expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy(); + }); + + it("dialog has aria-label matching the provided label", () => { + render( + + report + + ); + expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("Quarterly Report Q1 2026"); + }); + }); + + describe("motion", () => { + it("backdrop applies motion-reduce class for reduced motion preference", () => { + render( + + x + + ); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("motion-reduce"); + }); + + it("backdrop has transition-opacity for normal motion preference", () => { + render( + + x + + ); + const dialog = screen.getByRole("dialog"); + expect(dialog.className).toContain("transition-opacity"); + }); + }); +}); diff --git a/workspace-server/internal/bundle/importer_test.go b/workspace-server/internal/bundle/importer_test.go new file mode 100644 index 00000000..0c07d008 --- /dev/null +++ b/workspace-server/internal/bundle/importer_test.go @@ -0,0 +1,167 @@ +package bundle + +import ( + "testing" +) + +func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) { + b := &Bundle{} + files := buildBundleConfigFiles(b) + if len(files) != 0 { + t.Errorf("empty bundle: want 0 files, got %d", len(files)) + } +} + +func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) { + b := &Bundle{ + SystemPrompt: "You are a helpful assistant.", + } + files := buildBundleConfigFiles(b) + if n := len(files); n != 1 { + t.Fatalf("system-prompt only: want 1 file, got %d", n) + } + if content, ok := files["system-prompt.md"]; !ok { + t.Fatal("missing system-prompt.md") + } else if string(content) != "You are a helpful assistant." { + t.Errorf("system-prompt content: got %q", string(content)) + } +} + +func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) { + b := &Bundle{ + Prompts: map[string]string{ + "config.yaml": "runtime: langgraph\ntier: 2\n", + }, + } + files := buildBundleConfigFiles(b) + if n := len(files); n != 1 { + t.Fatalf("config.yaml only: want 1 file, got %d", n) + } + if content, ok := files["config.yaml"]; !ok { + t.Fatal("missing config.yaml") + } else if string(content) != "runtime: langgraph\ntier: 2\n" { + t.Errorf("config.yaml content: got %q", string(content)) + } +} + +func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) { + b := &Bundle{ + SystemPrompt: "Be concise.", + Prompts: map[string]string{ + "config.yaml": "runtime: langgraph\n", + }, + } + files := buildBundleConfigFiles(b) + if n := len(files); n != 2 { + t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n) + } + if _, ok := files["system-prompt.md"]; !ok { + t.Error("missing system-prompt.md") + } + if _, ok := files["config.yaml"]; !ok { + t.Error("missing config.yaml") + } +} + +func TestBuildBundleConfigFiles_Skills(t *testing.T) { + b := &Bundle{ + Skills: []BundleSkill{ + { + ID: "web-search", + Files: map[string]string{"readme.md": "# Web Search\n"}, + }, + { + ID: "code-interpreter", + Files: map[string]string{"readme.md": "# Code Interpreter\n"}, + }, + }, + } + files := buildBundleConfigFiles(b) + // 2 skills × 1 file each = 2 files + if n := len(files); n != 2 { + t.Fatalf("skills: want 2 files, got %d", n) + } + if _, ok := files["skills/web-search/readme.md"]; !ok { + t.Error("missing skills/web-search/readme.md") + } + if _, ok := files["skills/code-interpreter/readme.md"]; !ok { + t.Error("missing skills/code-interpreter/readme.md") + } +} + +func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) { + b := &Bundle{ + Skills: []BundleSkill{ + { + ID: "multi-file", + Files: map[string]string{ + "readme.md": "# Multi", + "instructions.txt": "Step 1, Step 2", + }, + }, + }, + } + files := buildBundleConfigFiles(b) + if n := len(files); n != 2 { + t.Fatalf("skill with sub-paths: want 2 files, got %d", n) + } + if _, ok := files["skills/multi-file/readme.md"]; !ok { + t.Error("missing skills/multi-file/readme.md") + } + if _, ok := files["skills/multi-file/instructions.txt"]; !ok { + t.Error("missing skills/multi-file/instructions.txt") + } +} + +func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) { + b := &Bundle{ + SystemPrompt: "", + Prompts: map[string]string{ + "config.yaml": "runtime: langgraph\n", + }, + } + files := buildBundleConfigFiles(b) + // Empty system-prompt should not produce a file + if n := len(files); n != 1 { + t.Errorf("empty system-prompt: want 1 file, got %d", n) + } +} + +func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) { + b := &Bundle{ + Prompts: map[string]string{}, + } + files := buildBundleConfigFiles(b) + if n := len(files); n != 0 { + t.Errorf("empty prompts map: want 0 files, got %d", n) + } +} + +// nilIfEmpty + +func TestNilIfEmpty_EmptyString(t *testing.T) { + got := nilIfEmpty("") + if got != nil { + t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got) + } +} + +func TestNilIfEmpty_NonEmptyString(t *testing.T) { + got := nilIfEmpty("hello") + if got == nil { + t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil") + } + if s, ok := got.(string); !ok || s != "hello" { + t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got) + } +} + +func TestNilIfEmpty_Whitespace(t *testing.T) { + got := nilIfEmpty(" ") + if got == nil { + t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)") + } + if s, ok := got.(string); !ok || s != " " { + t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got) + } +}