diff --git a/.github/workflows/doc-gate.yml b/.github/workflows/doc-gate.yml index 3df485caf..7a5ec4d6b 100644 --- a/.github/workflows/doc-gate.yml +++ b/.github/workflows/doc-gate.yml @@ -35,3 +35,8 @@ jobs: env: BASE_REF: ${{ github.base_ref }} run: python scripts/check_doc_gate.py diff-gate --base "origin/$BASE_REF" + + - name: Managed backend manifest lint + run: | + python -m pip install --quiet pyyaml + python scripts/check_manifests.py managed-lint diff --git a/.gitignore b/.gitignore index ab07259a1..bb7dfe4d3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ data/.setup_complete data/.auth_password data/.auth_sessions data/.auth_local_token +data/mesh_credentials.json data/agents.json data/images/ data/videos/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d49cd09..62e9f94d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio ## [Unreleased] +## [1.0.0-beta.39] - 2026-07-10 + +### Added +- Game Studio can now generate textures and sprites from a text prompt using a ComfyUI backend on a discrete-GPU worker, writing the image straight into the game's file set. On a host with no capable GPU the panel shows a clear "needs a GPU worker" state instead of failing (#1773). +- taOSgo cluster-join now completes the network side: a controller joins the account mesh over the system tailscale against the Headscale server, and the per-host service tokens the join returns are persisted host-locally (owner-only) so publishing and passkey fetches keep working after a join (#1770, #1772). +- Agents post to the coordination bus as themselves through an authenticated send proxy, so a message carries the agent's own identity and cannot be spoofed as another account (#1768). +- The cluster advertises the models a node can serve from its backend manifest, and installing a backend now registers it as a managed, node-local service that can be started, stopped, and health-checked per node (#1756, #1758, #1760, #1762). +- An approved external agent can be granted a least-privilege project-tasks scope to read and drive a single project's task board (claim, close, comment) with its own token, scoped so it can never reach another project (#1774). + +### Fixed +- Backend and worker robustness: the model VRAM check reserves atomically before a load so two loads cannot race the same memory, a malformed backend manifest no longer crashes the worker, and the VRAM guard fails closed rather than open on a probe error (#1725, #1767). +- The RK3588 (RKLLM) install path pins the rkllama server to the verified 1.3.0 reference and guards the fork patches, and a live rkllama port is treated as installed only when it is a managed service (#1755, #1764). +- Fixed six agent-framework catalog manifests that referenced install scripts which did not exist at the repo root (#1694). + ## [1.0.0-beta.38] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 1805c8b67..225ebee0a 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,20 @@ curl -fsSL https://raw.githubusercontent.com/jaylfc/taOS/master/scripts/install- See [docs/mirror-policy.md](docs/mirror-policy.md) for the mirror governance policy, what is mirrored, when it updates, how to verify integrity independently, and how to self-host the mirror for air-gapped deployments. The same policy will extend to RK3576, Raspberry Pi 4, Mac mini / Apple Silicon, and x86 classes as those verified install paths land. +## Agent Framework Install Scripts + +Several agent frameworks ship with dedicated install scripts under `scripts/install-*.sh` that are invoked automatically by the catalog installer when deploying the corresponding framework: + +| Script | Framework | Build toolchain | +|---|---|---| +| `scripts/install-agent-zero.sh` | [Agent Zero](https://github.com/frdel/agent-zero) | Clones repo to `/opt/agent-zero` and installs with pip | +| `scripts/install-deer-flow.sh` | [DeerFlow](https://github.com/bytedance/deer-flow) | Clones repo to `/opt/deer-flow` and provisions with `uv` (Python 3.12) | +| `scripts/install-moltis.sh` | [Moltis](https://github.com/moltis-org/moltis) | Installs via `cargo install` from crates.io (or git tag) | +| `scripts/install-openclaw.sh` | [OpenClaw](https://github.com/openclaw/openclaw) | Clones repo to `/opt/openclaw` and installs with pip | +| `scripts/install-picoclaw.sh` | [PicoClaw](https://github.com/sipeed/picoclaw) | Clones repo to `/opt/picoclaw` and builds with `cmake` | + +Hermes Agent uses `install: {method: pip, package: hermes-agent}` in its catalog manifest (available on PyPI from Nous Research) and does not need a separate install script. + ## TurboQuant KV cache compression **768K context window on a single RTX 3060 (12 GB).** taOS integrates Google's TurboQuant (ICLR 2026) KV cache quantization via TheTom/llama-cpp-turboquant. Unlike weight quantization, which compresses model files, TurboQuant compresses the per-request KV cache -- the per-token memory that scales with context length and is the actual bottleneck on consumer hardware. diff --git a/app-catalog/agents/agent-zero/manifest.yaml b/app-catalog/agents/agent-zero/manifest.yaml index efcf2e396..226e79ba1 100644 --- a/app-catalog/agents/agent-zero/manifest.yaml +++ b/app-catalog/agents/agent-zero/manifest.yaml @@ -14,6 +14,10 @@ requires: install: method: script script: scripts/install-agent-zero.sh + note: | + Clones frdel/agent-zero into /opt/agent-zero and installs with pip. + No PyPI package — the `agent-zero` package on PyPI is an unrelated + voice-agent framework. Agent Zero is clone-and-run only. hardware_tiers: arm-npu-16gb: full diff --git a/app-catalog/agents/deer-flow/manifest.yaml b/app-catalog/agents/deer-flow/manifest.yaml index 73993dd00..fd3eaa30f 100644 --- a/app-catalog/agents/deer-flow/manifest.yaml +++ b/app-catalog/agents/deer-flow/manifest.yaml @@ -14,12 +14,11 @@ requires: install: method: script - script: scripts/install.sh + script: scripts/install-deer-flow.sh note: | Clones bytedance/deer-flow into /opt/deer-flow and provisions the backend - with uv (Python 3.12). Writes a config.yaml pointing DeerFlow at the taOS - LiteLLM proxy and a systemd unit that serves the backend runs API on 8001. - The bridge adapter (deer_flow_adapter.py) lives in the taOS controller. + with uv (Python 3.12). The bridge adapter (deer_flow_adapter.py) lives in + the taOS controller. config_schema: - name: model diff --git a/app-catalog/agents/hermes/manifest.yaml b/app-catalog/agents/hermes/manifest.yaml index 24a377ea8..3c247f1b3 100644 --- a/app-catalog/agents/hermes/manifest.yaml +++ b/app-catalog/agents/hermes/manifest.yaml @@ -13,12 +13,8 @@ requires: python: ">=3.10" install: - method: script - script: scripts/install.sh - note: | - Installs hermes-agent from PyPI inside the agent container. - Configures the Hermes → TAOS bridge adapter (hermes_adapter.py) - and a systemd unit that connects to TAOS channels via SSE. + method: pip + package: hermes-agent config_schema: - name: model diff --git a/app-catalog/agents/openclaw/manifest.yaml b/app-catalog/agents/openclaw/manifest.yaml index 3a638e191..20b8eaa82 100644 --- a/app-catalog/agents/openclaw/manifest.yaml +++ b/app-catalog/agents/openclaw/manifest.yaml @@ -13,7 +13,11 @@ requires: install: method: script - script: scripts/install.sh + script: scripts/install-openclaw.sh + note: | + Clones openclaw/openclaw into /opt/openclaw and installs with pip. + No PyPI package — `openclaw` on PyPI resolves to an unrelated CMDOP + plugin. OpenClaw is clone-and-run only. config_schema: - name: model diff --git a/app-catalog/services/rkllama/manifest.yaml b/app-catalog/services/rkllama/manifest.yaml index ed4627598..942209823 100644 --- a/app-catalog/services/rkllama/manifest.yaml +++ b/app-catalog/services/rkllama/manifest.yaml @@ -31,6 +31,13 @@ lifecycle: backend_type: rkllama default_url: http://localhost:7833 auto_manage: true + # Managed as a systemd service (see scripts/install-rknpu.sh install_systemd_unit). + # taOS manages the unit per node: start/stop/restart default to `systemctl `. + unit: rkllama.service + scope: system + health: + url: "http://localhost:7833/api/tags" + expect: '"models"' keep_alive_minutes: 0 start_cmd: "systemctl start rkllama" stop_cmd: "systemctl stop rkllama" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index df6126e74..afc646bce 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.38", + "version": "1.0.0-beta.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinyagentos-desktop", - "version": "1.0.0-beta.38", + "version": "1.0.0-beta.39", "dependencies": { "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language-data": "^6.5.2", diff --git a/desktop/package.json b/desktop/package.json index e8b9731b9..b02263b7f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "tinyagentos-desktop", "private": true, - "version": "1.0.0-beta.38", + "version": "1.0.0-beta.39", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src/apps/designstudio/DesignView.test.tsx b/desktop/src/apps/designstudio/DesignView.test.tsx new file mode 100644 index 000000000..1c7044521 --- /dev/null +++ b/desktop/src/apps/designstudio/DesignView.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +/* react-konva needs a real (the `canvas` npm package), which is not + * installed in the jsdom test env. Mock the primitives to inert passthroughs so + * DesignView's toolbar / layers / properties logic can be exercised without a + * Konva stage. Because the Stage/Transformer refs are never populated, the + * canvas-only effects (transformer wiring, PNG export) safely no-op. */ +vi.mock("react-konva", () => ({ + Stage: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Layer: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Rect: () => null, + Text: () => null, + Image: () => null, + Ellipse: () => null, + Line: () => null, + Transformer: () => null, +})); + +import { DesignView } from "./DesignView"; +import { DEFAULT_ARTBOARD, type CanvasElement } from "./types"; + +/** DesignView is a controlled component: it hands the next elements array back + * through onElementsChange and re-reads it from props. This host mirrors how + * DesignStudioApp owns that state, and exposes the latest array for assertions. */ +let latest: CanvasElement[] = []; +function Host() { + const [els, setEls] = useState([]); + latest = els; + return ; +} + +function renderView() { + return render(); +} + +beforeAll(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); + +afterEach(() => { + latest = []; + vi.clearAllMocks(); +}); + +describe("DesignView", () => { + it("renders the empty state with export/undo/redo disabled", () => { + renderView(); + expect(screen.getByText(/Add an element or generate with Magic/i)).toBeDefined(); + expect((screen.getByLabelText("Export as PNG") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("Undo") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("Redo") as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText(/No layers yet/i)).toBeDefined(); + }); + + it("keeps the coming-soon Star tile disabled", () => { + renderView(); + expect((screen.getByLabelText("Star") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("Text") as HTMLButtonElement).disabled).toBe(false); + }); + + it("adding a text element updates state and adds a layer, enabling export/undo", () => { + renderView(); + fireEvent.click(screen.getByLabelText("Text")); + + expect(latest).toHaveLength(1); + expect(latest[0].type).toBe("text"); + expect(screen.getByLabelText(/Select layer/i)).toBeDefined(); + expect((screen.getByLabelText("Export as PNG") as HTMLButtonElement).disabled).toBe(false); + expect((screen.getByLabelText("Undo") as HTMLButtonElement).disabled).toBe(false); + }); + + it("adding a shape creates a rect and auto-selects it (properties panel appears)", () => { + renderView(); + fireEvent.click(screen.getByLabelText("Shape")); + + expect(latest[0].type).toBe("rect"); + // Auto-selection surfaces the shape's fill/stroke controls. + expect(screen.getByLabelText("Fill color")).toBeDefined(); + expect(screen.getByText("Rectangle")).toBeDefined(); + }); + + it("adding a circle creates an ellipse", () => { + renderView(); + fireEvent.click(screen.getByLabelText("Circle")); + expect(latest[0].type).toBe("ellipse"); + }); + + it("adding a line creates a line element", () => { + renderView(); + fireEvent.click(screen.getByLabelText("Line")); + expect(latest[0].type).toBe("line"); + }); + + it("undo removes a just-added element and returns to the empty state", () => { + renderView(); + fireEvent.click(screen.getByLabelText("Text")); + expect(latest).toHaveLength(1); + fireEvent.click(screen.getByLabelText("Undo")); + expect(latest).toHaveLength(0); + expect(screen.getByText(/Add an element or generate with Magic/i)).toBeDefined(); + }); + + it("brand swatches are disabled until a fillable element is selected", () => { + renderView(); + expect((screen.getByLabelText("Apply #6b7689") as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByLabelText("Shape")); + expect((screen.getByLabelText("Apply #6b7689") as HTMLButtonElement).disabled).toBe(false); + }); + + it("exposes zoom controls that fit the artboard to the stage", () => { + renderView(); + const zoom = screen.getByLabelText("Zoom level") as HTMLSelectElement; + // The view fits the artboard on mount, so the zoom is a positive percentage. + expect(Number(zoom.value)).toBeGreaterThan(0); + expect(screen.getByLabelText("Zoom in")).toBeDefined(); + expect(screen.getByLabelText("Zoom out")).toBeDefined(); + expect(screen.getByLabelText("Fit to screen")).toBeDefined(); + }); + + it("shows the artboard name and dimensions in the header", () => { + renderView(); + expect(screen.getByText(DEFAULT_ARTBOARD.name)).toBeDefined(); + expect( + screen.getByText(`${DEFAULT_ARTBOARD.width} x ${DEFAULT_ARTBOARD.height}`), + ).toBeDefined(); + }); +}); diff --git a/desktop/src/apps/designstudio/LayersPanel.test.tsx b/desktop/src/apps/designstudio/LayersPanel.test.tsx new file mode 100644 index 000000000..b42a1e8e1 --- /dev/null +++ b/desktop/src/apps/designstudio/LayersPanel.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { LayersPanel } from "./LayersPanel"; +import type { CanvasElement } from "./types"; + +function text(id: string, zIndex: number, over: Partial = {}): CanvasElement { + return { + id, + type: "text", + x: 0, + y: 0, + width: 100, + height: 40, + rotation: 0, + zIndex, + visible: true, + text: "Hello world", + fontSize: 32, + fontFamily: "Inter", + fill: "#fff", + align: "left", + ...over, + } as CanvasElement; +} + +function baseProps() { + return { + onSelect: vi.fn(), + onToggleVisibility: vi.fn(), + onReorder: vi.fn(), + onDelete: vi.fn(), + }; +} + +describe("LayersPanel", () => { + it("shows an empty state with no layers", () => { + render(); + expect(screen.getByText(/No layers yet/i)).toBeDefined(); + }); + + it("lists layers top-most (highest zIndex) first", () => { + const els = [text("low", 0, { text: "Bottom" }), text("high", 5, { text: "Top" })]; + render(); + const labels = screen.getAllByText(/Top|Bottom/).map((n) => n.textContent); + expect(labels[0]).toBe("Top"); + expect(labels[1]).toBe("Bottom"); + }); + + it("labels typed layers by their content", () => { + const els = [ + text("t", 3), + { ...text("r", 2), type: "rect", fill: "#000", stroke: "#fff", strokeWidth: 0 } as CanvasElement, + { ...text("l", 1), type: "line", stroke: "#fff", strokeWidth: 3 } as CanvasElement, + ]; + render(); + expect(screen.getByText("Hello world")).toBeDefined(); + expect(screen.getByText("Rectangle")).toBeDefined(); + expect(screen.getByText("Line")).toBeDefined(); + }); + + it("fires onSelect with the clicked layer id", () => { + const props = baseProps(); + render(); + fireEvent.click(screen.getByLabelText(/Select layer/i)); + expect(props.onSelect).toHaveBeenCalledWith("a"); + }); + + it("marks the selected layer via aria-pressed", () => { + render(); + expect(screen.getByLabelText(/Select layer/i).getAttribute("aria-pressed")).toBe("true"); + }); + + it("disables move-up on the top layer and move-down on the bottom layer", () => { + const els = [text("bottom", 0, { text: "Bottom" }), text("top", 5, { text: "Top" })]; + render(); + // Top row (rendered first) can't move up; bottom row can't move down. + expect((screen.getByLabelText("Move Top up") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("Move Bottom down") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("Move Top down") as HTMLButtonElement).disabled).toBe(false); + }); + + it("fires onReorder with the direction", () => { + const props = baseProps(); + const els = [text("bottom", 0, { text: "Bottom" }), text("top", 5, { text: "Top" })]; + render(); + fireEvent.click(screen.getByLabelText("Move Top down")); + expect(props.onReorder).toHaveBeenCalledWith("top", "down"); + }); + + it("toggles visibility with the correct label per state", () => { + const props = baseProps(); + const els = [text("a", 0), text("b", 1, { visible: false, text: "Hidden" })]; + render(); + fireEvent.click(screen.getByLabelText("Hide Hello world")); + expect(props.onToggleVisibility).toHaveBeenCalledWith("a"); + expect(screen.getByLabelText("Show Hidden")).toBeDefined(); + }); + + it("fires onDelete for the layer", () => { + const props = baseProps(); + render(); + fireEvent.click(screen.getByLabelText("Delete Hello world")); + expect(props.onDelete).toHaveBeenCalledWith("a"); + }); +}); diff --git a/desktop/src/apps/designstudio/LibraryView.test.tsx b/desktop/src/apps/designstudio/LibraryView.test.tsx new file mode 100644 index 000000000..d8447d27a --- /dev/null +++ b/desktop/src/apps/designstudio/LibraryView.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { LibraryView } from "./LibraryView"; + +const DESIGN = { id: "d1", name: "My Poster", updated_at: Math.floor(Date.now() / 1000) }; + +function ok(body: unknown) { + return { ok: true, status: 200, json: () => Promise.resolve(body) } as Response; +} + +describe("LibraryView", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("shows an empty state when there are no saved designs", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(ok([]))) as unknown as typeof fetch); + render(); + await waitFor(() => expect(screen.getByText(/No saved designs yet/i)).toBeDefined()); + }); + + it("renders a saved design and opens it on click", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(ok([DESIGN]))) as unknown as typeof fetch); + const onOpen = vi.fn(); + render(); + await waitFor(() => expect(screen.getByText("My Poster")).toBeDefined()); + fireEvent.click(screen.getByLabelText("Open My Poster")); + expect(onOpen).toHaveBeenCalledWith("d1"); + }); + + it("surfaces a load error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) } as Response)) as unknown as typeof fetch, + ); + render(); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/could not load designs/i)); + }); + + it("delete is backend-confirmed: refetches the list and reports the rename callback", async () => { + let deleted = false; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url === "/api/designs" && method === "GET") { + return Promise.resolve(ok(deleted ? [] : [DESIGN])); + } + if (url === "/api/designs/d1" && method === "DELETE") { + deleted = true; + return Promise.resolve(ok({})); + } + return Promise.resolve(ok({})); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + await waitFor(() => expect(screen.getByText("My Poster")).toBeDefined()); + + fireEvent.click(screen.getByLabelText("Delete My Poster")); + await waitFor(() => expect(screen.queryByText("My Poster")).toBeNull()); + await waitFor(() => expect(screen.getByText(/No saved designs yet/i)).toBeDefined()); + }); + + it("does not delete when the user cancels the confirm", async () => { + const fetchMock = vi.fn(() => Promise.resolve(ok([DESIGN]))); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + await waitFor(() => expect(screen.getByText("My Poster")).toBeDefined()); + + fireEvent.click(screen.getByLabelText("Delete My Poster")); + // No DELETE was issued -- only the initial list GET happened. + expect(fetchMock.mock.calls.every((c) => (c[1]?.method ?? "GET") === "GET")).toBe(true); + }); + + it("renames a design and notifies via onRenamed", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "PUT") return Promise.resolve(ok({ id: "d1", name: "Renamed", content: "{}" })); + return Promise.resolve(ok([DESIGN])); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.spyOn(window, "prompt").mockReturnValue("Renamed"); + const onRenamed = vi.fn(); + + render(); + await waitFor(() => expect(screen.getByText("My Poster")).toBeDefined()); + + fireEvent.click(screen.getByLabelText("Rename My Poster")); + await waitFor(() => expect(onRenamed).toHaveBeenCalledWith("d1", "Renamed")); + }); + + it("skips the rename when the prompt is cancelled or unchanged", async () => { + const fetchMock = vi.fn(() => Promise.resolve(ok([DESIGN]))); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.spyOn(window, "prompt").mockReturnValue(null); + + render(); + await waitFor(() => expect(screen.getByText("My Poster")).toBeDefined()); + + fireEvent.click(screen.getByLabelText("Rename My Poster")); + expect(fetchMock.mock.calls.every((c) => (c[1]?.method ?? "GET") === "GET")).toBe(true); + }); +}); diff --git a/desktop/src/apps/designstudio/MagicView.test.tsx b/desktop/src/apps/designstudio/MagicView.test.tsx new file mode 100644 index 000000000..7e44f0e94 --- /dev/null +++ b/desktop/src/apps/designstudio/MagicView.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MagicView } from "./MagicView"; +import type { GeneratedImage } from "./types"; + +function props(over: Partial[0]> = {}) { + return { + prompt: "", + onPromptChange: vi.fn(), + style: null as string | null, + onStyleChange: vi.fn(), + results: [] as GeneratedImage[], + generating: false, + canGenerate: true, + error: null as string | null, + errorNeedsModel: false, + needsModel: false, + onGenerate: vi.fn(), + onPickModel: vi.fn(), + onUseResult: vi.fn(), + ...over, + }; +} + +describe("MagicView", () => { + it("edits the prompt through onPromptChange", () => { + const p = props(); + render(); + fireEvent.change(screen.getByLabelText("Design prompt"), { target: { value: "a poster" } }); + expect(p.onPromptChange).toHaveBeenCalledWith("a poster"); + }); + + it("disables Generate when canGenerate is false", () => { + render(); + expect((screen.getByRole("button", { name: /Generate/ }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("fires onGenerate when enabled and clicked", () => { + const p = props({ canGenerate: true }); + render(); + fireEvent.click(screen.getByRole("button", { name: /Generate/ })); + expect(p.onGenerate).toHaveBeenCalledTimes(1); + }); + + it("shows the busy label while generating", () => { + render(); + expect(screen.getByText(/Generating\.\.\./)).toBeDefined(); + }); + + it("toggles a style chip on and off", () => { + const p = props({ style: null }); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Bold" })); + expect(p.onStyleChange).toHaveBeenCalledWith("Bold"); + + // When a chip is already active, clicking clears it. + p.onStyleChange.mockClear(); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Bold" })); + expect(p.onStyleChange).toHaveBeenCalledWith(null); + }); + + it("shows the install-a-model prompt and wires Browse models", () => { + const p = props({ needsModel: true }); + render(); + expect(screen.getByText(/Install an image generation model/i)).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Browse models" })); + expect(p.onPickModel).toHaveBeenCalledTimes(1); + }); + + it("renders an error and, when it needs a model, an install action", () => { + const p = props({ error: "boom", errorNeedsModel: true }); + render(); + expect(screen.getByText("boom")).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Install a model" })); + expect(p.onPickModel).toHaveBeenCalledTimes(1); + }); + + it("does not show an install action for a plain error", () => { + render(); + expect(screen.getByText("just a message")).toBeDefined(); + expect(screen.queryByRole("button", { name: "Install a model" })).toBeNull(); + }); + + it("renders results and fires onUseResult with the picked image", () => { + const img: GeneratedImage = { id: "g1", url: "/x.png", prompt: "a cat poster" }; + const p = props({ results: [img] }); + render(); + const tile = screen.getByAltText("a cat poster"); + fireEvent.click(tile); + expect(p.onUseResult).toHaveBeenCalledWith(img); + }); +}); diff --git a/desktop/src/apps/designstudio/PropertiesPanel.test.tsx b/desktop/src/apps/designstudio/PropertiesPanel.test.tsx new file mode 100644 index 000000000..38d3b8305 --- /dev/null +++ b/desktop/src/apps/designstudio/PropertiesPanel.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PropertiesPanel } from "./PropertiesPanel"; +import type { CanvasElement } from "./types"; + +function textEl(over: Partial = {}): CanvasElement { + return { + id: "t", + type: "text", + x: 12.4, + y: 40.6, + width: 100, + height: 40, + rotation: 0, + zIndex: 0, + visible: true, + text: "Hi", + fontSize: 32, + fontFamily: "Inter", + fill: "#ffffff", + align: "left", + ...over, + } as CanvasElement; +} + +function rectEl(over: Partial = {}): CanvasElement { + return { + id: "r", + type: "rect", + x: 0, + y: 0, + width: 50, + height: 60, + rotation: 0, + zIndex: 0, + visible: true, + fill: "#112233", + stroke: "#445566", + strokeWidth: 2, + ...over, + } as CanvasElement; +} + +function lineEl(): CanvasElement { + return { + id: "l", + type: "line", + x: 0, + y: 0, + width: 80, + height: 2, + rotation: 0, + zIndex: 0, + visible: true, + stroke: "#abcdef", + strokeWidth: 3, + } as CanvasElement; +} + +function props(over: Partial[0]> = {}) { + return { + onUpdate: vi.fn(), + onDuplicate: vi.fn(), + onDelete: vi.fn(), + ...over, + }; +} + +describe("PropertiesPanel", () => { + it("prompts to select an element when nothing is selected", () => { + render(); + expect(screen.getByText(/Select an element/i)).toBeDefined(); + }); + + it("shows text controls for a text element and emits font-size updates", () => { + const p = props(); + render(); + const size = screen.getByLabelText("Font size") as HTMLInputElement; + expect(size.value).toBe("32"); + fireEvent.change(size, { target: { value: "48" } }); + expect(p.onUpdate).toHaveBeenCalledWith({ fontSize: 48 }); + }); + + it("emits text color and alignment updates", () => { + const p = props(); + render(); + fireEvent.change(screen.getByLabelText("Text color"), { target: { value: "#ff0000" } }); + expect(p.onUpdate).toHaveBeenCalledWith({ fill: "#ff0000" }); + fireEvent.click(screen.getByLabelText("Align center")); + expect(p.onUpdate).toHaveBeenCalledWith({ align: "center" }); + }); + + it("reflects the active alignment via aria-pressed", () => { + render(); + expect(screen.getByLabelText("Align right").getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByLabelText("Align left").getAttribute("aria-pressed")).toBe("false"); + }); + + it("shows fill/stroke controls for a rect and emits updates", () => { + const p = props(); + render(); + fireEvent.change(screen.getByLabelText("Fill color"), { target: { value: "#000000" } }); + expect(p.onUpdate).toHaveBeenCalledWith({ fill: "#000000" }); + fireEvent.change(screen.getByLabelText("Stroke width"), { target: { value: "5" } }); + expect(p.onUpdate).toHaveBeenCalledWith({ strokeWidth: 5 }); + // Text-only controls must not appear for a shape. + expect(screen.queryByLabelText("Font size")).toBeNull(); + }); + + it("shows only a stroke control for a line (no fill)", () => { + render(); + expect(screen.getByLabelText("Stroke color")).toBeDefined(); + expect(screen.queryByLabelText("Fill color")).toBeNull(); + }); + + it("rounds and displays position and size", () => { + render(); + expect(screen.getByText("X 12")).toBeDefined(); + expect(screen.getByText("Y 41")).toBeDefined(); + expect(screen.getByText("W 100")).toBeDefined(); + expect(screen.getByText("H 40")).toBeDefined(); + }); + + it("wires the duplicate and delete buttons", () => { + const p = props(); + render(); + fireEvent.click(screen.getByLabelText("Duplicate element")); + expect(p.onDuplicate).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByLabelText("Delete element")); + expect(p.onDelete).toHaveBeenCalledTimes(1); + }); + + it("renders the coming-soon Magic chips as disabled", () => { + render(); + const chip = screen.getByRole("button", { name: "Make it bolder" }) as HTMLButtonElement; + expect(chip.disabled).toBe(true); + }); +}); diff --git a/desktop/src/apps/designstudio/TemplatesView.test.tsx b/desktop/src/apps/designstudio/TemplatesView.test.tsx new file mode 100644 index 000000000..e37d70c10 --- /dev/null +++ b/desktop/src/apps/designstudio/TemplatesView.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { TemplatesView } from "./TemplatesView"; + +describe("TemplatesView", () => { + it("renders the template grid and filter pills", () => { + render(); + expect(screen.getByText("Instagram Post")).toBeDefined(); + expect(screen.getByText("Presentation")).toBeDefined(); + expect(screen.getByRole("button", { name: "All" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Logos" })).toBeDefined(); + }); + + it("selecting a template reports its name and dimensions", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Instagram Post")); + expect(onSelect).toHaveBeenCalledWith({ name: "Instagram Post", width: 1080, height: 1080 }); + }); + + it("reports the correct dimensions for a non-square template", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Presentation")); + expect(onSelect).toHaveBeenCalledWith({ name: "Presentation", width: 1920, height: 1080 }); + }); + + it("does not fire selection when only switching filter pills", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Posters" })); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/apps/designstudio/designs-api.test.ts b/desktop/src/apps/designstudio/designs-api.test.ts new file mode 100644 index 000000000..8f36ac47e --- /dev/null +++ b/desktop/src/apps/designstudio/designs-api.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + createDesign, + deleteDesign, + getDesign, + listDesigns, + renameDesign, + updateDesign, +} from "./designs-api"; + +function jsonResponse(body: unknown, ok = true, status = ok ? 200 : 400): Response { + return { + ok, + status, + json: () => Promise.resolve(body), + } as Response; +} + +describe("designs-api", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("listDesigns GETs /api/designs and returns the array", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(jsonResponse([{ id: "d1", name: "A", updated_at: 2 }])), + ); + vi.stubGlobal("fetch", fetchMock); + + const designs = await listDesigns(); + expect(fetchMock).toHaveBeenCalledWith("/api/designs", { credentials: "include" }); + expect(designs).toEqual([{ id: "d1", name: "A", updated_at: 2 }]); + }); + + it("listDesigns throws a friendly error on a failed request", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(jsonResponse({}, false, 500)))); + await expect(listDesigns()).rejects.toThrow(/could not load designs/i); + }); + + it("getDesign fetches a single record by (encoded) id", async () => { + const doc = { id: "d 1", name: "A", content: "{}" }; + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse(doc))); + vi.stubGlobal("fetch", fetchMock); + + const got = await getDesign("d 1"); + expect(fetchMock).toHaveBeenCalledWith("/api/designs/d%201", { credentials: "include" }); + expect(got).toEqual(doc); + }); + + it("getDesign throws when the record can't be opened", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(jsonResponse({}, false, 404)))); + await expect(getDesign("nope")).rejects.toThrow(/could not open design/i); + }); + + it("createDesign POSTs name + content and returns the saved doc", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + expect(input).toBe("/api/designs"); + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)); + expect(body).toEqual({ name: "Poster", content: "{}" }); + return Promise.resolve(jsonResponse({ id: "d1", name: "Poster", content: "{}" })); + }); + vi.stubGlobal("fetch", fetchMock); + + const saved = await createDesign("Poster", "{}"); + expect(saved.id).toBe("d1"); + }); + + it("createDesign surfaces the server's error message", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse({ error: "name is required" }, false, 400))), + ); + await expect(createDesign("", "{}")).rejects.toThrow("name is required"); + }); + + it("createDesign falls back to a generic message when the body has no error", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(jsonResponse({}, false, 413)))); + await expect(createDesign("x", "{}")).rejects.toThrow(/save failed/i); + }); + + it("updateDesign PUTs only the provided fields", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + expect(input).toBe("/api/designs/d1"); + expect(init?.method).toBe("PUT"); + const body = JSON.parse(String(init?.body)); + // name was undefined, so it must be omitted from the payload. + expect(body).toEqual({ content: "{}" }); + return Promise.resolve(jsonResponse({ id: "d1", name: "A", content: "{}" })); + }); + vi.stubGlobal("fetch", fetchMock); + + await updateDesign("d1", undefined, "{}"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("renameDesign PUTs just the name", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + expect(body).toEqual({ name: "New" }); + return Promise.resolve(jsonResponse({ id: "d1", name: "New", content: "{}" })); + }); + vi.stubGlobal("fetch", fetchMock); + + const renamed = await renameDesign("d1", "New"); + expect(renamed.name).toBe("New"); + }); + + it("deleteDesign DELETEs the record", async () => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + expect(input).toBe("/api/designs/d1"); + expect(init?.method).toBe("DELETE"); + return Promise.resolve(jsonResponse({})); + }); + vi.stubGlobal("fetch", fetchMock); + + await deleteDesign("d1"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("deleteDesign throws on failure", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(jsonResponse({}, false, 500)))); + await expect(deleteDesign("d1")).rejects.toThrow(/delete failed/i); + }); +}); diff --git a/desktop/src/apps/designstudio/elementFactory.test.ts b/desktop/src/apps/designstudio/elementFactory.test.ts new file mode 100644 index 000000000..d6882682c --- /dev/null +++ b/desktop/src/apps/designstudio/elementFactory.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { + createImageElement, + createLineElement, + createShapeElement, + createTextElement, + duplicateElement, + nextZIndex, +} from "./elementFactory"; +import type { CanvasElement } from "./types"; + +const AW = 1080; +const AH = 1350; + +describe("nextZIndex", () => { + it("returns 0 for an empty canvas", () => { + expect(nextZIndex([])).toBe(0); + }); + + it("returns one above the current max zIndex", () => { + const els = [ + { zIndex: 0 } as CanvasElement, + { zIndex: 4 } as CanvasElement, + { zIndex: 2 } as CanvasElement, + ]; + expect(nextZIndex(els)).toBe(5); + }); +}); + +describe("createTextElement", () => { + it("creates a centered text element with sane defaults", () => { + const el = createTextElement([], AW, AH); + expect(el.type).toBe("text"); + expect(el.visible).toBe(true); + expect(el.rotation).toBe(0); + expect(el.zIndex).toBe(0); + if (el.type === "text") { + expect(el.fontSize).toBeGreaterThan(0); + expect(el.text.length).toBeGreaterThan(0); + } + // Centered horizontally: left margin + width + right margin == artboard. + expect(el.x).toBe(Math.round((AW - el.width) / 2)); + expect(el.y).toBe(Math.round((AH - el.height) / 2)); + }); + + it("caps text width at 320 for a wide artboard", () => { + const el = createTextElement([], 4000, 4000); + expect(el.width).toBe(320); + }); + + it("stacks zIndex above existing elements", () => { + const first = createTextElement([], AW, AH); + const second = createTextElement([first], AW, AH); + expect(second.zIndex).toBe(1); + }); +}); + +describe("createShapeElement", () => { + it("creates a square rect centered on the artboard", () => { + const el = createShapeElement([], "rect", AW, AH); + expect(el.type).toBe("rect"); + expect(el.width).toBe(el.height); + expect(el.x).toBe(Math.round((AW - el.width) / 2)); + }); + + it("honors the requested kind (ellipse)", () => { + const el = createShapeElement([], "ellipse", AW, AH); + expect(el.type).toBe("ellipse"); + }); + + it("caps size at 220 for a wide artboard", () => { + const el = createShapeElement([], "rect", 4000, 4000); + expect(el.width).toBe(220); + }); +}); + +describe("createLineElement", () => { + it("creates a thin horizontal line", () => { + const el = createLineElement([], AW, AH); + expect(el.type).toBe("line"); + expect(el.height).toBe(2); + expect(el.width).toBeGreaterThan(el.height); + }); +}); + +describe("createImageElement", () => { + it("creates a square image carrying its src and prompt", () => { + const el = createImageElement([], "data:image/png;base64,AA", AW, AH, "a cat"); + expect(el.type).toBe("image"); + expect(el.src).toBe("data:image/png;base64,AA"); + expect(el.prompt).toBe("a cat"); + expect(el.width).toBe(el.height); + }); + + it("leaves the prompt undefined when not supplied", () => { + const el = createImageElement([], "data:x", AW, AH); + expect(el.prompt).toBeUndefined(); + }); +}); + +describe("id uniqueness", () => { + it("every factory produces a distinct id even in the same tick", () => { + const els = [ + createTextElement([], AW, AH), + createShapeElement([], "rect", AW, AH), + createLineElement([], AW, AH), + createImageElement([], "data:x", AW, AH), + ]; + const ids = els.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("ids are prefixed by their type family", () => { + expect(createTextElement([], AW, AH).id).toMatch(/^text-/); + expect(createShapeElement([], "ellipse", AW, AH).id).toMatch(/^ellipse-/); + expect(createLineElement([], AW, AH).id).toMatch(/^line-/); + expect(createImageElement([], "data:x", AW, AH).id).toMatch(/^image-/); + }); +}); + +describe("duplicateElement", () => { + it("offsets the copy by 16px, gives it a fresh id and the top zIndex", () => { + const original = createShapeElement([], "rect", AW, AH); + const existing = [original]; + const copy = duplicateElement(original, existing); + expect(copy.id).not.toBe(original.id); + expect(copy.x).toBe(original.x + 16); + expect(copy.y).toBe(original.y + 16); + expect(copy.zIndex).toBe(nextZIndex(existing)); + // Everything else (fill, size, type) is preserved. + expect(copy.type).toBe(original.type); + expect(copy.width).toBe(original.width); + }); + + it("does not mutate the source element", () => { + const original = createShapeElement([], "rect", AW, AH); + const snapshot = { ...original }; + duplicateElement(original, [original]); + expect(original).toEqual(snapshot); + }); +}); diff --git a/desktop/src/apps/designstudio/types.test.ts b/desktop/src/apps/designstudio/types.test.ts new file mode 100644 index 000000000..c1048cce6 --- /dev/null +++ b/desktop/src/apps/designstudio/types.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_ARTBOARD, + MAGIC_STYLE_CHIPS, + ZOOM_STEPS, + hasFill, + hasStroke, + isValidDesignContent, + type CanvasElement, + type DesignContent, +} from "./types"; + +function textEl(over: Partial = {}): CanvasElement { + return { + id: "t1", + type: "text", + x: 0, + y: 0, + width: 100, + height: 40, + rotation: 0, + zIndex: 0, + visible: true, + text: "Hi", + fontSize: 32, + fontFamily: "Inter", + fill: "#fff", + align: "left", + ...over, + } as CanvasElement; +} + +function rectEl(): CanvasElement { + return { + id: "r1", + type: "rect", + x: 0, + y: 0, + width: 50, + height: 50, + rotation: 0, + zIndex: 1, + visible: true, + fill: "#000", + stroke: "#fff", + strokeWidth: 1, + } as CanvasElement; +} + +function lineEl(): CanvasElement { + return { + id: "l1", + type: "line", + x: 0, + y: 0, + width: 80, + height: 2, + rotation: 0, + zIndex: 2, + visible: true, + stroke: "#fff", + strokeWidth: 3, + } as CanvasElement; +} + +function imageEl(): CanvasElement { + return { + id: "i1", + type: "image", + x: 0, + y: 0, + width: 100, + height: 100, + rotation: 0, + zIndex: 3, + visible: true, + src: "data:image/png;base64,AAAA", + } as CanvasElement; +} + +describe("hasFill", () => { + it("is true for text, rect and ellipse", () => { + expect(hasFill(textEl())).toBe(true); + expect(hasFill(rectEl())).toBe(true); + expect(hasFill({ ...rectEl(), type: "ellipse" } as CanvasElement)).toBe(true); + }); + + it("is false for line and image", () => { + expect(hasFill(lineEl())).toBe(false); + expect(hasFill(imageEl())).toBe(false); + }); +}); + +describe("hasStroke", () => { + it("is true for rect, ellipse and line", () => { + expect(hasStroke(rectEl())).toBe(true); + expect(hasStroke({ ...rectEl(), type: "ellipse" } as CanvasElement)).toBe(true); + expect(hasStroke(lineEl())).toBe(true); + }); + + it("is false for text and image", () => { + expect(hasStroke(textEl())).toBe(false); + expect(hasStroke(imageEl())).toBe(false); + }); +}); + +describe("isValidDesignContent", () => { + const valid: DesignContent = { + artboard: { name: "Poster", width: 1080, height: 1350 }, + elements: [textEl(), rectEl()], + }; + + it("accepts a well-formed design with elements", () => { + expect(isValidDesignContent(valid)).toBe(true); + }); + + it("accepts an empty elements array", () => { + expect(isValidDesignContent({ ...valid, elements: [] })).toBe(true); + }); + + it.each([ + ["null", null], + ["a string", "nope"], + ["a number", 5], + ["undefined", undefined], + ])("rejects %s", (_label, value) => { + expect(isValidDesignContent(value)).toBe(false); + }); + + it("rejects a missing artboard", () => { + expect(isValidDesignContent({ elements: [] })).toBe(false); + }); + + it("rejects an artboard with non-numeric dimensions", () => { + expect( + isValidDesignContent({ artboard: { name: "x", width: "1080", height: 1350 }, elements: [] }), + ).toBe(false); + }); + + it("rejects an artboard without a name", () => { + expect( + isValidDesignContent({ artboard: { width: 1080, height: 1350 }, elements: [] }), + ).toBe(false); + }); + + it("rejects when elements is not an array", () => { + expect(isValidDesignContent({ artboard: valid.artboard, elements: {} })).toBe(false); + }); + + it("rejects an element missing an id or type", () => { + expect( + isValidDesignContent({ artboard: valid.artboard, elements: [{ type: "text" }] }), + ).toBe(false); + expect( + isValidDesignContent({ artboard: valid.artboard, elements: [{ id: "x" }] }), + ).toBe(false); + }); + + it("rejects a non-object element (e.g. null in the array)", () => { + expect(isValidDesignContent({ artboard: valid.artboard, elements: [null] })).toBe(false); + }); +}); + +describe("constants", () => { + it("DEFAULT_ARTBOARD is a valid design's artboard", () => { + expect(isValidDesignContent({ artboard: DEFAULT_ARTBOARD, elements: [] })).toBe(true); + expect(DEFAULT_ARTBOARD.width).toBeGreaterThan(0); + expect(DEFAULT_ARTBOARD.height).toBeGreaterThan(0); + }); + + it("ZOOM_STEPS are ascending and include 100%", () => { + const arr = [...ZOOM_STEPS]; + expect(arr).toContain(1); + expect([...arr].sort((a, b) => a - b)).toEqual(arr); + }); + + it("MAGIC_STYLE_CHIPS is a non-empty unique list", () => { + expect(MAGIC_STYLE_CHIPS.length).toBeGreaterThan(0); + expect(new Set(MAGIC_STYLE_CHIPS).size).toBe(MAGIC_STYLE_CHIPS.length); + }); +}); diff --git a/desktop/src/apps/designstudio/useElementHistory.test.ts b/desktop/src/apps/designstudio/useElementHistory.test.ts new file mode 100644 index 000000000..8b4a79d09 --- /dev/null +++ b/desktop/src/apps/designstudio/useElementHistory.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useElementHistory } from "./useElementHistory"; +import type { CanvasElement } from "./types"; + +function el(id: string, zIndex = 0): CanvasElement { + return { + id, + type: "rect", + x: 0, + y: 0, + width: 10, + height: 10, + rotation: 0, + zIndex, + visible: true, + fill: "#000", + stroke: "#fff", + strokeWidth: 0, + } as CanvasElement; +} + +/** + * The hook records the *previous* elements array on commit, so undo/redo only + * make sense against a `current` value that tracks what was last applied. This + * harness re-renders the hook with the latest applied array, mirroring how + * DesignView feeds `elements` back in from parent state. + */ +function setup(initial: CanvasElement[] = []) { + let current = initial; + const onChange = vi.fn((next: CanvasElement[]) => { + current = next; + }); + const hook = renderHook(({ els }) => useElementHistory(els, onChange), { + initialProps: { els: current }, + }); + const sync = () => hook.rerender({ els: current }); + return { hook, onChange, get current() { return current; }, sync }; +} + +describe("useElementHistory", () => { + it("starts with nothing to undo or redo", () => { + const { hook } = setup(); + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(false); + }); + + it("commit applies the new state and enables undo", () => { + const { hook, onChange, sync } = setup([]); + const next = [el("a")]; + act(() => hook.result.current.commit(next)); + expect(onChange).toHaveBeenLastCalledWith(next); + expect(hook.result.current.canUndo).toBe(true); + expect(hook.result.current.canRedo).toBe(false); + sync(); + }); + + it("undo restores the prior snapshot and enables redo", () => { + const start = [el("a")]; + const { hook, onChange, sync } = setup(start); + + act(() => hook.result.current.commit([el("a"), el("b", 1)])); + sync(); + + act(() => hook.result.current.undo()); + // Undo replays the array as it was *before* the commit. + expect(onChange).toHaveBeenLastCalledWith(start); + expect(hook.result.current.canRedo).toBe(true); + expect(hook.result.current.canUndo).toBe(false); + sync(); + }); + + it("redo re-applies an undone change", () => { + const start = [el("a")]; + const committed = [el("a"), el("b", 1)]; + const { hook, onChange, sync } = setup(start); + + act(() => hook.result.current.commit(committed)); + sync(); + act(() => hook.result.current.undo()); + sync(); + act(() => hook.result.current.redo()); + + expect(onChange).toHaveBeenLastCalledWith(committed); + expect(hook.result.current.canRedo).toBe(false); + expect(hook.result.current.canUndo).toBe(true); + sync(); + }); + + it("a fresh commit clears the redo stack", () => { + const { hook, sync } = setup([el("a")]); + act(() => hook.result.current.commit([el("b")])); + sync(); + act(() => hook.result.current.undo()); + sync(); + expect(hook.result.current.canRedo).toBe(true); + + act(() => hook.result.current.commit([el("c")])); + sync(); + expect(hook.result.current.canRedo).toBe(false); + }); + + it("undo/redo are no-ops when their stacks are empty", () => { + const { hook, onChange } = setup([el("a")]); + act(() => hook.result.current.undo()); + act(() => hook.result.current.redo()); + expect(onChange).not.toHaveBeenCalled(); + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(false); + }); + + it("caps the undo history and still restores the most recent snapshot", () => { + const { hook, onChange, sync } = setup([el("v0")]); + // Push well past the 100-entry cap. + for (let i = 1; i <= 130; i++) { + act(() => hook.result.current.commit([el(`v${i}`)])); + sync(); + } + expect(hook.result.current.canUndo).toBe(true); + + // Undo once returns the immediately-previous applied state, not a + // corrupted/evicted one. + act(() => hook.result.current.undo()); + expect(onChange).toHaveBeenLastCalledWith([el("v129")]); + + // Eviction branch: walk undo to exhaustion. Because the history is capped, + // the oldest snapshots (including the original v0) were evicted, so we can + // never restore v0 and the undo depth is bounded by the cap, not by the 130 + // commits pushed. + let undos = 1; // the undo above + while (hook.result.current.canUndo) { + act(() => hook.result.current.undo()); + undos++; + if (undos > 200) break; // safety: never loop forever if the cap regressed + } + expect(undos).toBeLessThanOrEqual(100); + expect(onChange).not.toHaveBeenLastCalledWith([el("v0")]); + }); +}); diff --git a/desktop/src/apps/designstudio/useHtmlImage.test.ts b/desktop/src/apps/designstudio/useHtmlImage.test.ts new file mode 100644 index 000000000..0371d9871 --- /dev/null +++ b/desktop/src/apps/designstudio/useHtmlImage.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useHtmlImage } from "./useHtmlImage"; + +/** A controllable stand-in for the browser's HTMLImageElement so tests can + * drive the load/error lifecycle deterministically. */ +class FakeImage { + static last: FakeImage | undefined; + crossOrigin = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + private _src = ""; + constructor() { + FakeImage.last = this; + } + set src(v: string) { + this._src = v; + } + get src() { + return this._src; + } +} + +describe("useHtmlImage", () => { + beforeEach(() => { + FakeImage.last = undefined; + vi.stubGlobal("Image", FakeImage as unknown as typeof Image); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("returns no image and no failure for an undefined src", () => { + const { result } = renderHook(() => useHtmlImage(undefined)); + expect(result.current.image).toBeUndefined(); + expect(result.current.failed).toBe(false); + expect(FakeImage.last).toBeUndefined(); + }); + + it("exposes the loaded image once onload fires", () => { + const { result } = renderHook(() => useHtmlImage("data:image/png;base64,AAAA")); + expect(result.current.image).toBeUndefined(); + act(() => FakeImage.last!.onload!()); + expect(result.current.image).toBe(FakeImage.last); + expect(result.current.failed).toBe(false); + }); + + it("sets failed and keeps no image when onerror fires", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { result } = renderHook(() => useHtmlImage("data:broken")); + act(() => FakeImage.last!.onerror!()); + expect(result.current.failed).toBe(true); + expect(result.current.image).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it("requests images anonymously (crossOrigin) so exported canvases stay untainted", () => { + renderHook(() => useHtmlImage("data:x")); + expect(FakeImage.last!.crossOrigin).toBe("anonymous"); + expect(FakeImage.last!.src).toBe("data:x"); + }); + + it("ignores a late onload after the src was cleared (unmount cancellation)", () => { + const { result, rerender } = renderHook(({ src }) => useHtmlImage(src), { + initialProps: { src: "data:first" as string | undefined }, + }); + const first = FakeImage.last!; + // Switch to no src: the effect cleanup marks the first load cancelled. + rerender({ src: undefined }); + act(() => first.onload!()); + expect(result.current.image).toBeUndefined(); + }); +}); diff --git a/desktop/src/apps/gamestudio/AssetsPanel.test.tsx b/desktop/src/apps/gamestudio/AssetsPanel.test.tsx new file mode 100644 index 000000000..429d31e80 --- /dev/null +++ b/desktop/src/apps/gamestudio/AssetsPanel.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { AssetsPanel } from "./AssetsPanel"; + +const TEXTURE_URL = "/api/games/g1/assets/texture"; + +function fetchOnce(body: unknown, ok = true, status = 200) { + return vi.fn((input: RequestInfo | URL) => { + expect(String(input)).toBe(TEXTURE_URL); + return Promise.resolve({ + ok, + status, + json: () => Promise.resolve(body), + } as Response); + }); +} + +describe("AssetsPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the prompt input and a disabled Generate button when empty", () => { + render(); + expect(screen.getByLabelText("Describe the texture or sprite")).toBeDefined(); + expect((screen.getByRole("button", { name: "Generate" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("generates a texture and shows a preview + insert action", async () => { + const onInsert = vi.fn(); + const fetchMock = fetchOnce({ + available: true, + status: "generated", + filename: "texture-1-abcd.png", + path: "/api/games/g1/preview/texture-1-abcd.png", + kind: "texture", + tier: "sdxl", + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + render(); + fireEvent.change(screen.getByLabelText("Describe the texture or sprite"), { + target: { value: "a mossy stone wall" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Generate" })); + + const img = (await screen.findByAltText("Generated texture")) as HTMLImageElement; + expect(img.src).toContain("/api/games/g1/preview/texture-1-abcd.png"); + + // Insert calls the callback with the generated filename. + fireEvent.click(screen.getByRole("button", { name: /Insert into index\.html/ })); + expect(onInsert).toHaveBeenCalledWith("texture-1-abcd.png"); + // Sending the request used the right body. + const sentBody = JSON.parse(String((fetchMock.mock.calls[0]![1] as RequestInit).body)); + expect(sentBody.prompt).toBe("a mossy stone wall"); + expect(sentBody.kind).toBe("texture"); + }); + + it("shows a 'Needs a GPU worker' state when the tier is unavailable", async () => { + const fetchMock = fetchOnce({ available: false, reason: "No GPU or NPU worker available." }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + render(); + fireEvent.change(screen.getByLabelText("Describe the texture or sprite"), { + target: { value: "a texture" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Generate" })); + + await waitFor(() => expect(screen.getByText("Needs a GPU worker")).toBeDefined()); + expect(screen.getByText("No GPU or NPU worker available.")).toBeDefined(); + // No preview / insert button in the unavailable state. + expect(screen.queryByRole("button", { name: /Insert/ })).toBeNull(); + }); + + it("surfaces a backend error", async () => { + const fetchMock = fetchOnce({ error: "Texture generation failed." }, false, 502); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + render(); + fireEvent.change(screen.getByLabelText("Describe the texture or sprite"), { + target: { value: "a texture" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Generate" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Texture generation failed."); + }); + + it("disables insert when no file is open", async () => { + const fetchMock = fetchOnce({ + available: true, + status: "generated", + filename: "texture-1.png", + path: "/api/games/g1/preview/texture-1.png", + kind: "texture", + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + render(); + fireEvent.change(screen.getByLabelText("Describe the texture or sprite"), { + target: { value: "a texture" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Generate" })); + + const insert = (await screen.findByRole("button", { name: /Open a file to insert/ })) as HTMLButtonElement; + expect(insert.disabled).toBe(true); + }); +}); diff --git a/desktop/src/apps/gamestudio/AssetsPanel.tsx b/desktop/src/apps/gamestudio/AssetsPanel.tsx new file mode 100644 index 000000000..ba44a328f --- /dev/null +++ b/desktop/src/apps/gamestudio/AssetsPanel.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { ImagePlus, Loader2, AlertCircle, Sparkles, Check, Cpu } from "lucide-react"; +import { generateTexture, type AssetKind, type TextureResult } from "./game-assets-api"; + +/* ------------------------------------------------------------------ */ +/* AssetsPanel -- AI texture/sprite generation for the Game Studio */ +/* */ +/* Prompt + size/tileable controls drive POST /assets/texture. The */ +/* backend writes the PNG into the game's file set and returns its */ +/* preview path; "Insert" appends a reference to the current file. When */ +/* the backend reports available:false (no GPU/NPU worker) the panel */ +/* shows a clear disabled state rather than a broken Generate button -- */ +/* mirroring the Images Studio tier-gate precedent. */ +/* ------------------------------------------------------------------ */ + +export interface AssetsPanelProps { + gameId: string; + /** The file the generated reference will be appended to (null = no file). */ + activePath: string | null; + /** Append a reference to the generated asset into the current file. */ + onInsert: (filename: string) => void; +} + +const SIZES = [256, 512, 768, 1024] as const; + +export function AssetsPanel({ gameId, activePath, onInsert }: AssetsPanelProps) { + const [prompt, setPrompt] = useState(""); + const [kind, setKind] = useState("texture"); + const [size, setSize] = useState(512); + const [tileable, setTileable] = useState(false); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [inserted, setInserted] = useState(false); + + const unavailable = result?.available === false; + + const handleGenerate = async () => { + const text = prompt.trim(); + if (!text || busy) return; + setBusy(true); + setError(null); + setResult(null); + setInserted(false); + try { + const res = await generateTexture(gameId, { + prompt: text, + width: size, + height: size, + tileable, + kind, + }); + setResult(res); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const handleInsert = () => { + if (!result?.filename || !activePath) return; + onInsert(result.filename); + setInserted(true); + }; + + return ( +
+
+ +

Generate an asset

+
+ +
+ {/* prompt */} + +