feat(web): add interaction tests with play functions
Quentin Gliech
and
Claude Opus 4.6 (1M context)
created 1 month ago
Add play functions to stories for behavior testing:
- QueryInput: type "label:" → suggestions appear → Enter selects first
- WritePreview: click Preview → content switches → click Write → back
These run as real browser tests via the Storybook Vitest addon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change summary
webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap | 61
webui2/src/components/shared/__snapshots__/write-preview.test.tsx.snap | 29
webui2/src/components/shared/query-input.stories.tsx | 36
webui2/src/components/shared/write-preview.stories.tsx | 48
4 files changed, 174 insertions(+)
Detailed changes
@@ -47,6 +47,67 @@ exports[`QueryInput/AsyncCompletions matches snapshot 1`] = `
</div>
`;
+exports[`QueryInput/AutocompleteInteraction matches snapshot 1`] = `
+<div>
+ <div
+ class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
+ >
+ <div
+ class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-search"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m21 21-4.34-4.34"
+ />
+ <circle
+ cx="11"
+ cy="11"
+ r="8"
+ />
+ </svg>
+ </div>
+ <div
+ aria-hidden="true"
+ class="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
+ >
+ <span>
+ <span
+ class="text-yellow-600 dark:text-yellow-500"
+ >
+ label:
+ </span>
+ <span>
+ bug
+ </span>
+ </span>
+ <span>
+
+ </span>
+ </div>
+ <input
+ autocomplete="off"
+ class="caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans"
+ placeholder="Type label: to test autocomplete…"
+ spellcheck="false"
+ type="text"
+ value="label:bug "
+ />
+ </div>
+</div>
+`;
+
exports[`QueryInput/Default matches snapshot 1`] = `
<div>
<div
@@ -56,6 +56,35 @@ exports[`WritePreview/Empty matches snapshot 1`] = `
</div>
`;
+exports[`WritePreview/TabSwitching matches snapshot 1`] = `
+<div>
+ <div>
+ <div
+ class="flex gap-2 mb-2"
+ >
+ <button
+ class="rounded-sm px-2 py-0.5 text-sm transition-colors bg-muted font-medium"
+ type="button"
+ >
+ Write
+ </button>
+ <button
+ class="rounded-sm px-2 py-0.5 text-sm font-medium transition-colors disabled:opacity-40 text-muted-foreground hover:text-foreground"
+ type="button"
+ >
+ Preview
+ </button>
+ </div>
+ <textarea
+ class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm min-h-[120px]"
+ placeholder="Type something…"
+ >
+ Some **content** to preview.
+ </textarea>
+ </div>
+</div>
+`;
+
exports[`WritePreview/Uncontrolled matches snapshot 1`] = `
<div>
<div>
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Search } from "lucide-react";
+import { expect, userEvent, within } from "storybook/test";
import { useState } from "react";
import type { CompletionProvider } from "./query-input";
@@ -148,3 +149,38 @@ export const AsyncCompletions: Story = {
);
},
};
+
+export const AutocompleteInteraction: Story = {
+ args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+ render: () => {
+ const [value, setValue] = useState("");
+ return (
+ <QueryInput.Root
+ value={value}
+ onChange={setValue}
+ onSubmit={() => {}}
+ providers={providers}
+ >
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="Type label: to test autocomplete…" />
+ <QueryInput.Completions />
+ </QueryInput.Root>
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const input = canvas.getByRole("textbox");
+
+ // Type "label:" to trigger suggestions
+ await userEvent.type(input, "label:");
+ // Suggestions dropdown should appear
+ const bugOption = await canvas.findByText("bug");
+ await expect(bugOption).toBeVisible();
+
+ // First suggestion is already highlighted — press Enter to select
+ await userEvent.keyboard("{Enter}");
+
+ // Input should now contain the selected label
+ await expect(input).toHaveValue("label:bug ");
+ },
+};
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
import { useState } from "react";
import { Markdown } from "@/components/content/markdown";
@@ -80,3 +81,50 @@ export const Empty: Story = {
</WritePreview.Root>
),
};
+
+export const TabSwitching: Story = {
+ args: { children: null },
+ render: () => {
+ const [message, setMessage] = useState("Some **content** to preview.");
+ return (
+ <WritePreview.Root hasContent={!!message.trim()}>
+ <WritePreview.Tabs className="mb-2" />
+ <WritePreview.WriteSlot>
+ <Textarea
+ placeholder="Type something…"
+ className="min-h-[120px]"
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ />
+ </WritePreview.WriteSlot>
+ <WritePreview.PreviewSlot>
+ <div className="border-input min-h-[120px] rounded-md border px-3 py-2">
+ <Markdown content={message} />
+ </div>
+ </WritePreview.PreviewSlot>
+ </WritePreview.Root>
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Initially on Write tab — textarea visible
+ const textarea = canvas.getByRole("textbox");
+ await expect(textarea).toBeVisible();
+
+ // Click Preview tab
+ const previewBtn = canvas.getByRole("button", { name: "Preview" });
+ await userEvent.click(previewBtn);
+
+ // Textarea should be gone, preview content visible
+ await expect(canvas.queryByRole("textbox")).toBeNull();
+ await expect(canvas.getByText("content")).toBeVisible();
+
+ // Click Write tab to switch back
+ const writeBtn = canvas.getByRole("button", { name: "Write" });
+ await userEvent.click(writeBtn);
+
+ // Textarea visible again
+ await expect(canvas.getByRole("textbox")).toBeVisible();
+ },
+};