Skip to content

Commit 260ee19

Browse files
committed
test(stories): add a story of the whole chat
Compose the new chat family from the shared samples into one story, in both scroll policies: a conversation with agent steps, approvals and generated images, a prompt input with its toolbar and attachments, the conversation map and history window, and a panel with a thread tab. A browser spec drives the story to check that sending settles, approvals keep keyboard focus after they fold, and attachments move into the sent message.
1 parent e312fe9 commit 260ee19

2 files changed

Lines changed: 1438 additions & 0 deletions

File tree

‎e2e/full-chat.spec.ts‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
// The whole-chat composition lives in the story, so the spec drives the
4+
// static Storybook build the accessibility sweep also serves.
5+
const STORIES = "http://localhost:61000";
6+
7+
const open = async (page: Page, story: "settle" | "stick") => {
8+
await page.goto(
9+
`${STORIES}/iframe.html?id=chat-full-chat--${story}&viewMode=story&globals=theme:light`,
10+
);
11+
await page.locator("body.sb-show-main").waitFor({ state: "attached" });
12+
};
13+
14+
const focused = (page: Page) =>
15+
page.evaluate(() => {
16+
const active = document.activeElement;
17+
return {
18+
body: active === null || active === document.body,
19+
slot: active instanceof HTMLElement ? active.dataset["slot"] : undefined,
20+
text: active?.textContent ?? "",
21+
};
22+
});
23+
24+
for (const [story, decision, outcome] of [
25+
["settle", "Approve", "Approved once"],
26+
["stick", "Deny", "Denied"],
27+
] as const) {
28+
test(`${story}: deciding the waiting call by keyboard keeps focus on its run`, async ({
29+
page,
30+
}) => {
31+
await open(page, story);
32+
const approval = page.locator(
33+
"[data-slot='tool-approval'][data-status='pending']",
34+
);
35+
await expect(approval).toBeVisible();
36+
await approval.getByRole("button", { name: decision, exact: true }).focus();
37+
await page.keyboard.press("Enter");
38+
39+
// The call folds into the run before it, and its buttons go with it.
40+
await expect(approval).toHaveCount(0);
41+
const active = await focused(page);
42+
expect(active.body).toBe(false);
43+
expect(active.slot).toBe("activity-trigger");
44+
expect(active.text).toContain("Worked through 2 steps");
45+
46+
// The run opens from where focus landed and shows the outcome.
47+
await page.keyboard.press("Enter");
48+
const run = page.locator("[data-slot='activity-content']").last();
49+
await expect(run.getByText("mail__send").first()).toBeVisible();
50+
await expect(run.getByText(outcome)).toBeVisible();
51+
});
52+
}
53+
54+
test("a send takes the attached file into the message and clears the box", async ({
55+
page,
56+
}) => {
57+
await open(page, "settle");
58+
await page.locator("input[type='file']").setInputFiles({
59+
name: "notes.txt",
60+
mimeType: "text/plain",
61+
buffer: Buffer.from("Follow-ups for next week"),
62+
});
63+
const box = page.locator("[data-slot='attachment-drop-zone']");
64+
await expect(
65+
box.getByRole("button", { name: "Remove notes.txt" }),
66+
).toBeVisible();
67+
68+
const message = page.getByRole("textbox", { name: "Message" });
69+
await message.fill("Here are my notes");
70+
await message.press("Enter");
71+
72+
await expect(message).toHaveValue("");
73+
await expect(box.locator("[data-slot='attachments']")).toHaveCount(0);
74+
const log = page.getByRole("log", { name: "Conversation" });
75+
const sent = log.locator("[data-item-id='sent-1']");
76+
await expect(sent.getByText("Here are my notes")).toBeVisible();
77+
await expect(sent.getByText("notes.txt")).toBeVisible();
78+
79+
// The answer streams in, then the box can send again.
80+
const answer = log.locator("[data-item-id='answer-1']");
81+
await expect(answer.getByText(/^Done\. I read what you sent/)).toBeVisible();
82+
await expect(
83+
page.getByRole("main").getByRole("button", { name: "Send", exact: true }),
84+
).toBeVisible();
85+
86+
// Under settle, the sent message rests at the top of the view.
87+
await expect
88+
.poll(() =>
89+
log.evaluate((element) => {
90+
const item = element.querySelector("[data-item-id='sent-1']");
91+
if (item === null) throw new Error("no sent item");
92+
return Math.abs(
93+
item.getBoundingClientRect().top -
94+
element.getBoundingClientRect().top,
95+
);
96+
}),
97+
)
98+
.toBeLessThanOrEqual(1);
99+
});
100+
101+
test("a file sends on its own, with no text", async ({ page }) => {
102+
await open(page, "settle");
103+
const send = page
104+
.getByRole("main")
105+
.getByRole("button", { name: "Send", exact: true });
106+
await expect(send).toBeDisabled();
107+
await page.locator("input[type='file']").setInputFiles({
108+
name: "runs.csv",
109+
mimeType: "text/csv",
110+
buffer: Buffer.from("run,status\n1,ok\n"),
111+
});
112+
const message = page.getByRole("textbox", { name: "Message" });
113+
await expect(message).toHaveValue("");
114+
await expect(send).toBeEnabled();
115+
await send.click();
116+
117+
const box = page.locator("[data-slot='attachment-drop-zone']");
118+
await expect(box.locator("[data-slot='attachments']")).toHaveCount(0);
119+
const sent = page
120+
.getByRole("log", { name: "Conversation" })
121+
.locator("[data-item-id='sent-1']");
122+
await expect(sent.getByText("runs.csv")).toBeVisible();
123+
await expect(sent.locator("[data-slot='message-content']")).toHaveCount(0);
124+
});
125+
126+
test("Stop settles a call that was running as failed", async ({ page }) => {
127+
await open(page, "settle");
128+
const message = page.getByRole("textbox", { name: "Message" });
129+
await message.fill("Check the runs");
130+
await message.press("Enter");
131+
// The call runs for one frame of the replay, so Stop is pressed in the
132+
// page the moment its live line names it.
133+
await page.waitForFunction(
134+
() => {
135+
const trigger = document.querySelector(
136+
"[data-item-id='answer-1'] [data-slot='activity-trigger']",
137+
);
138+
if (trigger?.textContent?.includes("runs__list…") !== true) return false;
139+
document
140+
.querySelector<HTMLButtonElement>(
141+
"main [data-slot='prompt-input-submit']",
142+
)
143+
?.click();
144+
return true;
145+
},
146+
undefined,
147+
{ polling: "raf" },
148+
);
149+
150+
const answer = page.locator("[data-item-id='answer-1']");
151+
const trigger = answer.locator("[data-slot='activity-trigger']");
152+
await expect(trigger).toContainText("1 failed");
153+
await expect(answer.locator("[data-slot='message']")).toHaveAttribute(
154+
"data-status",
155+
"sent",
156+
);
157+
await trigger.click();
158+
await expect(answer.locator("[data-slot='tool']")).toHaveAttribute(
159+
"data-status",
160+
"error",
161+
);
162+
await expect(
163+
page.getByRole("main").getByRole("button", { name: "Send", exact: true }),
164+
).toBeVisible();
165+
});
166+
167+
test("a reply in the thread tab adds to the count under its parent", async ({
168+
page,
169+
}) => {
170+
await open(page, "settle");
171+
const log = page.getByRole("log", { name: "Conversation" });
172+
await expect(log.getByRole("button", { name: /2 replies/ })).toBeVisible();
173+
const reply = page.getByRole("textbox", { name: "Reply" });
174+
await reply.fill("Add owners to the follow-ups");
175+
await reply.press("Enter");
176+
// The reply and the answer that streams in after it.
177+
await expect(log.getByRole("button", { name: /4 replies/ })).toBeVisible();
178+
});

0 commit comments

Comments
 (0)