Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
908a9f2
feat: add variant field to RecipeDefinition and recipe-v1/v2/v3 PanelIds
aaronbrezel May 6, 2026
f78b398
feat: export buildRunTemplate; add V1/V2/V3 recipe entries
aaronbrezel May 6, 2026
e974b73
feat: route RecipesListPanel to recipe-v1/v2/v3 based on variant field
aaronbrezel May 6, 2026
3c81638
feat: add RecipeV1Panel (4-button variant)
aaronbrezel May 6, 2026
83bfc38
fix: narrow preppedRunConfig to RunConfig in RecipeV1Panel
aaronbrezel May 6, 2026
baa3c8f
feat: add RecipeV2Panel (2-button auto-prep variant)
aaronbrezel May 6, 2026
201e25e
fix: add RunConfig guards and missing tests to RecipeV2Panel
aaronbrezel May 6, 2026
20c4f38
feat: add RecipeV3Panel (didactic step-by-step variant)
aaronbrezel May 6, 2026
a88bed4
fix: alert on recipe config error in RecipeV3Panel step handlers
aaronbrezel May 6, 2026
5442004
feat: register RecipeV1Panel, RecipeV2Panel, RecipeV3Panel in sidebar
aaronbrezel May 6, 2026
a12a9ed
fix: stacked buttons, per-button help text, progress tracking, 5-row …
aaronbrezel May 7, 2026
ebfdd69
refactor: two-line button design for recipe action panels
aaronbrezel May 7, 2026
1e72512
fix: v3 step 2 writes prompt to all imported rows, not just row 1
aaronbrezel May 7, 2026
f19213c
feat: v3 step stepper — numbered badges with vertical connector
aaronbrezel May 7, 2026
66db3ce
style: widen sidebar to 360px, add more air to v3 stepper
aaronbrezel May 7, 2026
9e7dc2b
style: increase sidebar width to 420px
aaronbrezel May 7, 2026
1591d2f
style: compact layout for fixed 300px sidebar width
aaronbrezel May 7, 2026
d0427ca
style: remove numbered prefixes from Test and Cook buttons
aaronbrezel May 7, 2026
4500fac
content: update v1/v2/v3 recipe descriptions to real-world teaser text
aaronbrezel May 7, 2026
1e6370b
docs: add recipe architecture redesign design doc
aaronbrezel May 7, 2026
2c90a8e
feat: enable applyMarkdown on all document summarization recipes
aaronbrezel May 7, 2026
b01caf5
removing annoying number in v1
aaronbrezel May 7, 2026
ba38f0c
extra css rule to remove line
aaronbrezel May 7, 2026
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
240 changes: 240 additions & 0 deletions __tests__/panels/recipe-v1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* @jest-environment jsdom
*/
jest.mock("../../src/client/services", () => ({
prepRecipe: jest.fn(),
runBatchAI: jest.fn(),
}));

jest.mock("../../src/client/job-store", () => ({
jobStore: { dispatch: jest.fn() },
}));

jest.mock("../../src/client/panels/recipe", () => ({
buildRunTemplate: jest.fn().mockReturnValue({
promptCols: [{ col: "Drive Link", kind: "file" }],
systemPromptCol: "System Prompt",
outputCol: "AI_Summarization",
}),
}));

import { RecipeV1Panel } from "../../src/client/panels/recipe-v1";
import * as services from "../../src/client/services";
import type { RecipeDefinition, NavigationContext } from "../../src/client/types";

const mockPrepRecipe = services.prepRecipe as jest.Mock;
const mockRunBatchAI = services.runBatchAI as jest.Mock;

function makeNav(): jest.Mocked<NavigationContext> {
return {
navigate: jest.fn(),
back: jest.fn(),
canGoBack: jest.fn().mockReturnValue(true),
};
}

const baseDefinition: RecipeDefinition = {
id: "document-summarization-v1",
name: "Document Summarization V1",
icon: "📄",
variant: "v1",
description: "Test recipe",
inputs: [
{ id: "folder", label: "Drive Folder", required: true, placeholder: "Paste URL" },
{ id: "focus", label: "Area of Interest" },
],
prepTemplate: [
{
colTitle: "System Prompt",
fillStrategy: { kind: "template", template: "Summarize." },
role: "system-prompt",
},
{
colTitle: "Drive Link",
fillStrategy: { kind: "list-drive-folder", inputId: "folder" },
role: "file-prompt",
},
{ colTitle: "AI_Summarization", fillStrategy: { kind: "create-empty" }, role: "output" },
],
};

const mockPrepResult = { rowRange: { start: 2, end: 11 } };

function mount(definition = baseDefinition, savedState?: unknown) {
const container = document.createElement("div");
const nav = makeNav();
const panel = new RecipeV1Panel();
panel.mount(container, nav, definition, savedState as never);
return { container, nav, panel };
}

async function flush() {
for (let i = 0; i < 6; i++) await Promise.resolve();
}

beforeEach(() => {
mockPrepRecipe.mockClear();
mockRunBatchAI.mockClear();
});

describe("initial state", () => {
it("renders Prep enabled, Test/Cook/Configure disabled", () => {
const { container } = mount();
expect(container.querySelector<HTMLButtonElement>("#prep-btn")!.disabled).toBe(false);
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>("#cook-btn")!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>("#configure-btn")!.disabled).toBe(true);
});

it("renders one input per definition input", () => {
const { container } = mount();
expect(container.querySelectorAll("[data-input-id]")).toHaveLength(2);
});
});

describe("Prep flow", () => {
it("calls prepRecipe with cols excluding the output column", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(mockPrepRecipe).toHaveBeenCalledWith({
cols: [
expect.objectContaining({ colTitle: "System Prompt" }),
expect.objectContaining({ colTitle: "Drive Link" }),
],
inputValues: expect.objectContaining({ folder: "https://drive.google.com/abc" }),
});
const calledCols = mockPrepRecipe.mock.calls[0][0].cols;
expect(calledCols.every((c: { role?: string }) => c.role !== "output")).toBe(true);
});

it("enables Test, Cook, Configure after Prep succeeds", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(false);
expect(container.querySelector<HTMLButtonElement>("#cook-btn")!.disabled).toBe(false);
expect(container.querySelector<HTMLButtonElement>("#configure-btn")!.disabled).toBe(false);
});

it("shows alert and stays idle when required input is empty", async () => {
globalThis.alert = jest.fn();
const { container } = mount();
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(globalThis.alert).toHaveBeenCalledTimes(1);
expect(mockPrepRecipe).not.toHaveBeenCalled();
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(true);
});

it("returns to idle if Prep fails", async () => {
mockPrepRecipe.mockRejectedValue(new Error("server error"));
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(true);
});

it("resets to idle when an input field changes after prep", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(false);
container
.querySelector<HTMLInputElement>('[data-input-id="folder"]')!
.dispatchEvent(new Event("input"));
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(true);
});
});

describe("Test flow", () => {
async function prepAndGetContainer() {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
mockRunBatchAI.mockResolvedValue(undefined);
const { container, nav } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
return { container, nav };
}

it("calls runBatchAI with rowRange covering the first 5 data rows", async () => {
const { container } = await prepAndGetContainer();
container.querySelector<HTMLButtonElement>("#test-btn")!.click();
await flush();
expect(mockRunBatchAI).toHaveBeenCalledWith(
expect.objectContaining({ rowRange: { start: 2, end: 6 } }),
expect.any(String),
);
});

it("disables all buttons while testing", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
mockRunBatchAI.mockReturnValue(new Promise(() => {})); // never resolves
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
container.querySelector<HTMLButtonElement>("#test-btn")!.click();
await flush();
expect(container.querySelector<HTMLButtonElement>("#prep-btn")!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>("#cook-btn")!.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>("#configure-btn")!.disabled).toBe(true);
});

it("returns to prepped state after Test completes", async () => {
const { container } = await prepAndGetContainer();
container.querySelector<HTMLButtonElement>("#test-btn")!.click();
await flush();
expect(container.querySelector<HTMLButtonElement>("#test-btn")!.disabled).toBe(false);
expect(container.querySelector<HTMLButtonElement>("#cook-btn")!.disabled).toBe(false);
});
});

describe("Cook flow", () => {
it("calls runBatchAI with the full rowRange", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
mockRunBatchAI.mockResolvedValue(undefined);
const { container } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
container.querySelector<HTMLButtonElement>("#cook-btn")!.click();
await flush();
expect(mockRunBatchAI).toHaveBeenCalledWith(
expect.objectContaining({ rowRange: { start: 2, end: 11 } }),
expect.any(String),
);
});
});

describe("Configure AI flow", () => {
it("navigates to configure-ai-run with preppedRunConfig", async () => {
mockPrepRecipe.mockResolvedValue(mockPrepResult);
const { container, nav } = mount();
container.querySelector<HTMLInputElement>('[data-input-id="folder"]')!.value =
"https://drive.google.com/abc";
container.querySelector<HTMLButtonElement>("#prep-btn")!.click();
await flush();
container.querySelector<HTMLButtonElement>("#configure-btn")!.click();
expect(nav.navigate).toHaveBeenCalledWith(
"configure-ai-run",
expect.objectContaining({ outputCol: "AI_Summarization", rowRange: { start: 2, end: 11 } }),
);
});
});
Loading
Loading