Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions apps/web/src/components/app/agent-surfaces/form-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { describe, expect, it } from "vitest";

import {
defaultFormValues,
mergeFormValues,
validateFormValues,
} from "./form-validation";
import type { FormField } from "./types";

describe("defaultFormValues", () => {
it("defaults a checkbox to false when no defaultValue is set", () => {
const fields: FormField[] = [
{ id: "agree", type: "checkbox", label: "Agree" },
];
expect(defaultFormValues(fields)).toEqual({ agree: false });
});

it("uses a checkbox's authored defaultValue", () => {
const fields: FormField[] = [
{ id: "agree", type: "checkbox", label: "Agree", defaultValue: true },
];
expect(defaultFormValues(fields)).toEqual({ agree: true });
});

it("defaults a number to null when no defaultValue is set", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count" },
];
expect(defaultFormValues(fields)).toEqual({ count: null });
});

it("uses a number's authored defaultValue", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", defaultValue: 5 },
];
expect(defaultFormValues(fields)).toEqual({ count: 5 });
});

it("defaults a multi-select to an empty array", () => {
const fields: FormField[] = [
{
id: "tags",
type: "select",
label: "Tags",
multiple: true,
options: [{ value: "a", label: "A" }],
},
];
expect(defaultFormValues(fields)).toEqual({ tags: [] });
});

it("uses a multi-select's authored defaultValue array", () => {
const fields: FormField[] = [
{
id: "tags",
type: "select",
label: "Tags",
multiple: true,
options: [{ value: "a", label: "A" }],
defaultValue: ["a"],
},
];
expect(defaultFormValues(fields)).toEqual({ tags: ["a"] });
});

it("defaults a single-select to an empty string, not an array", () => {
const fields: FormField[] = [
{
id: "size",
type: "select",
label: "Size",
options: [{ value: "s", label: "Small" }],
},
];
expect(defaultFormValues(fields)).toEqual({ size: "" });
});

it("defaults text, textarea and radio fields to an empty string", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name" },
{ id: "notes", type: "textarea", label: "Notes" },
{
id: "choice",
type: "radio",
label: "Choice",
options: [{ value: "x", label: "X" }],
},
];
expect(defaultFormValues(fields)).toEqual({
name: "",
notes: "",
choice: "",
});
});

it("uses a text field's authored defaultValue", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", defaultValue: "Ada" },
];
expect(defaultFormValues(fields)).toEqual({ name: "Ada" });
});
});

describe("mergeFormValues", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name" },
{ id: "agree", type: "checkbox", label: "Agree" },
];

it("falls back to defaults when there is no draft", () => {
expect(mergeFormValues(fields, null)).toEqual({
name: "",
agree: false,
});
});

it("overlays draft values onto the defaults", () => {
expect(mergeFormValues(fields, { name: "Ada" })).toEqual({
name: "Ada",
agree: false,
});
});

it("lets a draft override a field's authored default, not just its fallback", () => {
const withDefaults: FormField[] = [
{ id: "name", type: "text", label: "Name", defaultValue: "Ada" },
{ id: "agree", type: "checkbox", label: "Agree", defaultValue: true },
];
expect(
mergeFormValues(withDefaults, { name: "Grace", agree: false })
).toEqual({ name: "Grace", agree: false });
});

it("does not mutate the passed-in draft", () => {
const draft = { name: "Ada" };
mergeFormValues(fields, draft);
expect(draft).toEqual({ name: "Ada" });
});
});

describe("validateFormValues", () => {
it("returns no errors when there are no fields", () => {
expect(validateFormValues([], {})).toEqual({});
});

it("flags a missing required text value", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", required: true },
];
expect(validateFormValues(fields, { name: "" })).toEqual({
name: "Required",
});
});

it("does not flag a filled required text value", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", required: true },
];
expect(validateFormValues(fields, { name: "Ada" })).toEqual({});
});

it("does not flag a non-required, empty value", () => {
const fields: FormField[] = [{ id: "name", type: "text", label: "Name" }];
expect(validateFormValues(fields, { name: "" })).toEqual({});
});

it("treats an unchecked required checkbox as empty", () => {
const fields: FormField[] = [
{ id: "agree", type: "checkbox", label: "Agree", required: true },
];
expect(validateFormValues(fields, { agree: false })).toEqual({
agree: "Required",
});
});

it("accepts a checked required checkbox", () => {
const fields: FormField[] = [
{ id: "agree", type: "checkbox", label: "Agree", required: true },
];
expect(validateFormValues(fields, { agree: true })).toEqual({});
});

it("treats an empty required multi-select array as empty", () => {
const fields: FormField[] = [
{
id: "tags",
type: "select",
label: "Tags",
multiple: true,
required: true,
options: [{ value: "a", label: "A" }],
},
];
expect(validateFormValues(fields, { tags: [] })).toEqual({
tags: "Required",
});
});

it("accepts a non-empty required multi-select array", () => {
const fields: FormField[] = [
{
id: "tags",
type: "select",
label: "Tags",
multiple: true,
required: true,
options: [{ value: "a", label: "A" }],
},
];
expect(validateFormValues(fields, { tags: ["a"] })).toEqual({});
});

it("rejects text shorter than minLength", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", minLength: 3 },
];
expect(validateFormValues(fields, { name: "ab" })).toEqual({
name: "Must be at least 3 characters",
});
});

it("applies the same minLength check to textarea fields", () => {
const fields: FormField[] = [
{ id: "notes", type: "textarea", label: "Notes", minLength: 3 },
];
expect(validateFormValues(fields, { notes: "ab" })).toEqual({
notes: "Must be at least 3 characters",
});
});

it("accepts text at exactly minLength", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", minLength: 3 },
];
expect(validateFormValues(fields, { name: "abc" })).toEqual({});
});

it("rejects text longer than maxLength", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", maxLength: 3 },
];
expect(validateFormValues(fields, { name: "abcd" })).toEqual({
name: "Must be at most 3 characters",
});
});

it("accepts text at exactly maxLength", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", maxLength: 3 },
];
expect(validateFormValues(fields, { name: "abc" })).toEqual({});
});

it("does not apply length checks to an empty optional value", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", minLength: 3 },
];
expect(validateFormValues(fields, { name: "" })).toEqual({});
});

it("rejects a number below min", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", min: 5 },
];
expect(validateFormValues(fields, { count: 4 })).toEqual({
count: "Must be at least 5",
});
});

it("accepts a number at exactly min", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", min: 5 },
];
expect(validateFormValues(fields, { count: 5 })).toEqual({});
});

it("rejects a number above max", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", min: 0, max: 10 },
];
expect(validateFormValues(fields, { count: 11 })).toEqual({
count: "Must be at most 10",
});
});

it("accepts a number at exactly max", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", max: 10 },
];
expect(validateFormValues(fields, { count: 10 })).toEqual({});
});

it("does not apply number range checks to a null value", () => {
const fields: FormField[] = [
{ id: "count", type: "number", label: "Count", min: 5 },
];
expect(validateFormValues(fields, { count: null })).toEqual({});
});

it("validates independent fields independently", () => {
const fields: FormField[] = [
{ id: "name", type: "text", label: "Name", required: true },
{ id: "count", type: "number", label: "Count", min: 5 },
];
expect(validateFormValues(fields, { name: "Ada", count: 1 })).toEqual({
count: "Must be at least 5",
});
});
});
35 changes: 35 additions & 0 deletions apps/web/src/components/app/agent-surfaces/surface-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";

import { isAllowedSurfaceUrl } from "./surface-url";

describe("isAllowedSurfaceUrl", () => {
it("allows http URLs", () => {
expect(isAllowedSurfaceUrl("http://example.com")).toBe(true);
});

it("allows https URLs", () => {
expect(isAllowedSurfaceUrl("https://example.com/path?q=1")).toBe(true);
});

it("allows mailto URLs", () => {
expect(isAllowedSurfaceUrl("mailto:agent@example.com")).toBe(true);
});

it("rejects javascript: URLs", () => {
expect(isAllowedSurfaceUrl("javascript:alert(1)")).toBe(false);
});

it("rejects data: URLs", () => {
expect(isAllowedSurfaceUrl("data:text/html,<script>1</script>")).toBe(
false
);
});

it("rejects file: URLs", () => {
expect(isAllowedSurfaceUrl("file:///etc/passwd")).toBe(false);
});

it("rejects strings that aren't valid URLs", () => {
expect(isAllowedSurfaceUrl("not a url")).toBe(false);
});
});
9 changes: 9 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
packages:
- "apps/*"
- "packages/*"
# pnpm 10+ moved `shamefully-hoist` (.npmrc) and `pnpm.onlyBuiltDependencies`
# (package.json) here. Both settings are still declared in their old spots
# above for pnpm 9, which CI pins (see .github/workflows/ci.yml) and which
# still reads them there. Keep all three in sync if the dependency list changes.
shamefullyHoist: true
allowBuilds:
esbuild: true
sharp: true
workerd: true
Loading