Skip to content
Open
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
2 changes: 1 addition & 1 deletion front/lib/api/files/content_validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { normalizeError } from "@app/types/shared/utils/error_utils";
import * as ts from "typescript";

export interface ValidationWarning {
type: "tailwind" | "typescript";
type: "frame_function" | "tailwind" | "typescript";
message: string;
oldString?: string;
suggestion?: string;
Expand Down
13 changes: 12 additions & 1 deletion front/lib/api/frames/build_and_publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
publishFramePublication,
} from "@app/lib/api/frames/publication_storage";
import { withStagedFrameSource } from "@app/lib/api/frames/source_staging";
import { collectFrameFunctionWarnings } from "@app/lib/api/frames/validate_frame_functions";
import { ensureConversationSandboxReadyWithScope } from "@app/lib/api/sandbox/lifecycle";
import { buildSandboxFunctionOnReadySandbox } from "@app/lib/api/sandbox_functions/build_on_sandbox";
import { SandboxFunctionError } from "@app/lib/api/sandbox_functions/errors";
Expand Down Expand Up @@ -215,7 +216,17 @@ export async function validateFramePublication(
return contracts;
}

return new Ok({ warnings: collectFrameTailwindWarnings(sourceFiles) });
const functionWarnings = await collectFrameFunctionWarnings({
functions: contracts.value.functions,
sourceFiles,
});

return new Ok({
warnings: [
...collectFrameTailwindWarnings(sourceFiles),
...functionWarnings,
],
});
}

/**
Expand Down
304 changes: 304 additions & 0 deletions front/lib/api/frames/validate_frame_functions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
// @vitest-environment node

import { collectFrameFunctionWarnings } from "@app/lib/api/frames/validate_frame_functions";
import type { JSONSchema7 as JSONSchema } from "json-schema";
import { describe, expect, it } from "vitest";

const ADD_TASK_INPUT_SCHEMA: JSONSchema = {
additionalProperties: false,
properties: {
done: { type: "boolean" },
title: { type: "string" },
},
required: ["title"],
type: "object",
};

const LIST_TASKS_INPUT_SCHEMA: JSONSchema = {
additionalProperties: false,
properties: {},
type: "object",
};

const FUNCTIONS = [
{ inputSchema: ADD_TASK_INPUT_SCHEMA, name: "add-task" },
{ inputSchema: LIST_TASKS_INPUT_SCHEMA, name: "list-tasks" },
];

function sourceFiles(
files: Record<string, string>
): { content: Buffer; relativePath: string }[] {
return Object.entries(files).map(([relativePath, content]) => ({
content: Buffer.from(content, "utf8"),
relativePath,
}));
}

async function warningsFor(
files: Record<string, string>,
functions = FUNCTIONS
) {
return collectFrameFunctionWarnings({
functions,
sourceFiles: sourceFiles(files),
});
}

describe("collectFrameFunctionWarnings", () => {
it("returns nothing when the UI calls no function hook", async () => {
const warnings = await warningsFor({
"index.tsx": `export default function App() { return <div>Hi</div>; }`,
});

expect(warnings).toEqual([]);
});

it("returns nothing for a declared reference with a matching input", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
const { data } = usePodFunction("add-task", { title: "Write tests" });
return <div>{JSON.stringify(data)}</div>;
}
`,
});

expect(warnings).toEqual([]);
});

it("flags a reference the manifest does not declare", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
usePodFunction("add-tasks", { title: "Typo" });
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].type).toBe("frame_function");
expect(warnings[0].message).toContain("index.tsx:5:18");
expect(warnings[0].message).toContain("'add-tasks'");
expect(warnings[0].suggestion).toBe(
"Declared functions: 'add-task', 'list-tasks'."
);
});

it("flags a Pod-style reference and suggests the bare name", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
usePodFunction("spc_1234/add-task", { title: "Write tests" });
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("<podId>/<slug>");
expect(warnings[0].suggestion).toBe("Use 'add-task'.");
});

it("flags an input that does not match the declared contract", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
usePodFunction("add-task", { titel: "Misspelled property" });
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].type).toBe("frame_function");
expect(warnings[0].message).toContain("index.tsx:5:32");
expect(warnings[0].message).toContain(
"'titel' does not exist in type 'Input'. Did you mean to write 'title'?"
);
});

it("flags a missing required input property", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
usePodFunction("add-task", { done: false });
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("title");
});

it("flags a mutation triggered with a mismatched input", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunctionMutation } from "@dust/react-hooks";

export default function App() {
const { trigger } = usePodFunctionMutation("add-task");
return <button onClick={() => trigger({ title: 42 })}>Add</button>;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("index.tsx:6:43");
});

it("returns nothing for a mutation triggered with a matching input", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunctionMutation } from "@dust/react-hooks";

export default function App() {
const { trigger } = usePodFunctionMutation("add-task");
return <button onClick={() => trigger({ title: "ok" })}>Add</button>;
}
`,
});

expect(warnings).toEqual([]);
});

it("follows an aliased import", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction as useFrameFunction } from "@dust/react-hooks";

export default function App() {
useFrameFunction("nope", {});
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("'nope'");
});

it("follows a namespace import", async () => {
const warnings = await warningsFor({
"index.tsx": `
import * as hooks from "@dust/react-hooks";

export default function App() {
hooks.usePodFunction("nope", {});
return null;
}
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("'nope'");
});

it("skips a computed reference", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App({ name }: { name: string }) {
usePodFunction(name, { anything: true });
return null;
}
`,
});

expect(warnings).toEqual([]);
});

it("checks a hook call in an imported component", async () => {
const warnings = await warningsFor({
"components/TaskList.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export function TaskList() {
usePodFunction("add-task", { title: false });
return null;
}
`,
"index.tsx": `
import { TaskList } from "./components/TaskList";

export default function App() { return <TaskList />; }
`,
});

expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain("components/TaskList.tsx");
});

it("reports the reference when the manifest declares no functions", async () => {
const warnings = await warningsFor(
{
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
usePodFunction("add-task", { title: "No manifest entry" });
return null;
}
`,
},
[]
);

// An empty contract map would make `keyof` never and reject the input too; only the reference
// is reported.
expect(warnings).toHaveLength(1);
expect(warnings[0].suggestion).toBe(
"This Frame's manifest declares no functions."
);
});

it("caps the reported warnings", async () => {
const calls = Array.from(
{ length: 7 },
(_, index) => ` usePodFunction("missing-${index}", {});`
).join("\n");
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
${calls}
return null;
}
`,
});

expect(warnings).toHaveLength(6);
expect(warnings[5].message).toBe(
"2 more Frame function warning(s) not shown."
);
});

it("ignores type errors outside the function inputs", async () => {
const warnings = await warningsFor({
"index.tsx": `
import { usePodFunction } from "@dust/react-hooks";

export default function App() {
const broken: number = "not a number";
usePodFunction("add-task", { title: "fine" });
return <div>{broken}</div>;
}
`,
});

expect(warnings).toEqual([]);
});
});
Loading
Loading