diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index ba92c4e5..dcc68434 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -56,20 +56,25 @@ const manifest: PaperclipPluginManifestV1 = { "Optional Daytona language hint for direct code execution. If omitted, Daytona uses its default runtime.", }, cpu: { - type: "number", + type: "integer", description: "Optional CPU allocation in cores.", + minimum: 1, }, memory: { - type: "number", - description: "Optional memory allocation in GiB.", + type: "integer", + description: + "Optional memory allocation in GiB. Leave unset to use Daytona defaults; supported sandbox sizes are 1, 2, 4, and 8 GiB.", + enum: [1, 2, 4, 8], }, disk: { - type: "number", + type: "integer", description: "Optional disk allocation in GiB.", + minimum: 1, }, gpu: { - type: "number", + type: "integer", description: "Optional GPU allocation in units.", + minimum: 1, }, timeoutMs: { type: "number", diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 389426a4..a9f00229 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -19,6 +19,7 @@ vi.mock("@daytonaio/sdk", () => ({ })); import plugin from "./plugin.js"; +import manifest from "./manifest.js"; function createMockSandbox(overrides: { id?: string; @@ -497,3 +498,24 @@ describe("Daytona sandbox provider plugin", () => { }); }); }); + +describe("daytona manifest memory config", () => { + const memorySchema = ( + manifest.environmentDrivers?.[0]?.configSchema as { + properties?: Record; + required?: string[]; + } + ); + + it("offers memory as a fixed dropdown of supported sandbox sizes", () => { + expect(memorySchema.properties?.memory?.enum).toEqual([1, 2, 4, 8]); + }); + + it("excludes 0 — an invalid Daytona memory configuration", () => { + expect(memorySchema.properties?.memory?.enum).not.toContain(0); + }); + + it("keeps memory optional so the blank/default selection stays valid", () => { + expect(memorySchema.required ?? []).not.toContain("memory"); + }); +}); diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx index 755acc61..b9d6c9d3 100644 --- a/ui/src/components/JsonSchemaForm.test.tsx +++ b/ui/src/components/JsonSchemaForm.test.tsx @@ -8,6 +8,24 @@ import { JsonSchemaForm, getDefaultValues } from "./JsonSchemaForm"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// Radix Select relies on PointerEvent, pointer capture, and ResizeObserver, +// none of which jsdom implements. Stub them so the dropdown can open in tests. +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} +if (typeof Element !== "undefined" && !Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; +} +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ResizeObserver = (globalThis as any).ResizeObserver ?? ResizeObserverStub; + // SecretBindingPicker pulls in CompanyContext + react-query. Stub it so we can // exercise SecretField in isolation. The stub renders a select with the same // onChange contract as the real picker. @@ -358,6 +376,7 @@ describe("JsonSchemaForm secret-ref rendering", () => { sshPort: { type: "number", default: 22 }, cpu: { type: "number" }, memory: { type: "string" }, + size: { type: "string", enum: ["small", "large"] }, reuseLease: { type: "boolean", default: false }, tags: { type: "array", items: { type: "string" } }, }, @@ -373,6 +392,42 @@ describe("JsonSchemaForm secret-ref rendering", () => { expect("apiKey" in defaults).toBe(false); expect("cpu" in defaults).toBe(false); expect("memory" in defaults).toBe(false); + expect("size" in defaults).toBe(false); + }); + + it("renders datalist suggestions for numeric fields when examples are present", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + />, + ); + }); + + const input = container.querySelector('input[type="number"]'); + // The "/" in the field path is sanitized so the id is a valid CSS/HTML identifier. + expect(input?.getAttribute("list")).toBe("-memory-suggestions"); + expect(container.querySelector("datalist")?.getAttribute("id")).toBe("-memory-suggestions"); + const options = Array.from(container.querySelectorAll("datalist option")).map((option) => + option.getAttribute("value"), + ); + expect(options).toEqual(["1", "2", "4", "8"]); + + await act(async () => { + root.unmount(); + }); }); it("keeps the password fallback for short raw values", async () => { @@ -407,3 +462,142 @@ describe("JsonSchemaForm secret-ref rendering", () => { }); }); }); + +describe("JsonSchemaForm enum rendering", () => { + let container: HTMLDivElement; + + const numericEnumSchema = { + type: "object" as const, + properties: { + memory: { + type: "integer" as const, + enum: [1, 2, 4, 8], + }, + }, + }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function openSelect() { + const trigger = container.querySelector('[role="combobox"]'); + expect(trigger).not.toBeNull(); + await act(async () => { + trigger!.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, button: 0 }), + ); + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + + function optionByLabel(label: string): Element | undefined { + return Array.from(document.querySelectorAll('[role="option"]')).find( + (option) => option.textContent?.trim() === label, + ); + } + + it("renders an optional numeric enum as a dropdown with a blank row and no 0", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} />, + ); + }); + + await openSelect(); + + const labels = Array.from(document.querySelectorAll('[role="option"]')).map( + (option) => option.textContent?.trim(), + ); + // A blank "None" row is offered so the user can express "not configured". + expect(labels).toContain("None"); + expect(labels).toEqual(expect.arrayContaining(["1", "2", "4", "8"])); + // 0 is not a valid Daytona memory size and must never appear. + expect(labels).not.toContain("0"); + + await act(async () => { + root.unmount(); + }); + }); + + it("selects the blank row by default when no value is configured", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} />, + ); + }); + + await openSelect(); + + const noneOption = optionByLabel("None"); + expect(noneOption).toBeTruthy(); + // Radix marks the active selection with aria-selected / data-state checked. + expect(noneOption?.getAttribute("aria-selected")).toBe("true"); + + await act(async () => { + root.unmount(); + }); + }); + + it("coerces the selected numeric enum value back to a number", async () => { + const onChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await openSelect(); + + await act(async () => { + optionByLabel("2")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // Number, not the string "2", so server-side integer validation passes. + expect(onChange).toHaveBeenCalledWith({ memory: 2 }); + + await act(async () => { + root.unmount(); + }); + }); + + it("maps the blank row back to an unset (undefined) value", async () => { + const onChange = vi.fn(); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + + await openSelect(); + + await act(async () => { + optionByLabel("None")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith({ memory: undefined }); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index b9f18521..c6aa30b0 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -47,6 +47,7 @@ export interface JsonSchemaNode { description?: string; default?: unknown; enum?: unknown[]; + examples?: unknown[]; const?: unknown; format?: string; @@ -155,7 +156,7 @@ export function getDefaultForSchema(schema: JsonSchemaNode): unknown { case "boolean": return false; case "enum": - return schema.enum?.[0] ?? ""; + return undefined; case "array": return []; case "object": { @@ -438,6 +439,13 @@ const BooleanField = React.memo(({ BooleanField.displayName = "BooleanField"; +/** + * Sentinel value for the "not configured" row of an optional enum select. + * Radix `Select` forbids an empty-string item value, so we map the unset state + * onto this sentinel and translate it back to `undefined` on change. + */ +const ENUM_UNSET_VALUE = "__paperclip_unset__"; + /** * Specialized field for enum (select) values. */ @@ -459,32 +467,63 @@ const EnumField = React.memo(({ description?: string; error?: string; options: unknown[]; -}) => ( - - - -)); + + + ); +}); EnumField.displayName = "EnumField"; @@ -689,6 +728,7 @@ SecretField.displayName = "SecretField"; * Specialized field for numeric (number/integer) values. */ const NumberField = React.memo(({ + id, value, onChange, disabled, @@ -698,7 +738,11 @@ const NumberField = React.memo(({ error, defaultValue, type, + minimum, + maximum, + suggestions, }: { + id: string; value: unknown; onChange: (val: unknown) => void; disabled: boolean; @@ -708,28 +752,47 @@ const NumberField = React.memo(({ error?: string; defaultValue?: unknown; type: "number" | "integer"; -}) => ( - - { - const val = e.target.value; - onChange(val === "" ? undefined : Number(val)); - }} - placeholder={String(defaultValue ?? "")} + minimum?: number; + maximum?: number; + suggestions?: unknown[]; +}) => { + const hasSuggestions = Array.isArray(suggestions) && suggestions.length > 0; + // Sanitize the path-based id so it is a valid CSS/HTML identifier (paths can contain "/"). + const listId = hasSuggestions ? `${id.replace(/[^a-zA-Z0-9_-]/g, "-")}-suggestions` : undefined; + return ( + - -)); + > + { + const val = e.target.value; + const trimmed = val.trim(); + onChange(trimmed === "" ? undefined : Number(trimmed)); + }} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + aria-invalid={!!error} + /> + {listId ? ( + + {suggestions!.map((suggestion) => ( + + ) : null} + + ); +}); NumberField.displayName = "NumberField"; @@ -1044,6 +1107,7 @@ const FormField = React.memo(({ case "integer": return ( );