test(onboarding): walk the onboarding questions end to end; mobile reads cards as text

- onboarding-intake.spec walks focus, goal (after typing into the composer, which still
  works), check-ins, quiet hours and the notifications card when the browser offers it,
  with the onboarding-intake-goal, -rhythm and -done screenshots.
- completeOnboarding and the wizard specs drop the preferences step; the scan step is
  optional (skipped when it asks, otherwise it advances by itself).
- The user-profile spec now checks the browser-filled profile and the focus fact; the
  conversation spec checks the shorter app suggestion and the following goal question.
- The hover spec measures a reply that fills its row (a bot row's rail sits at the row's
  end, so the old long onboarding line was what made it pass) and scopes Send to the
  composer, since an open goal card has its own.
- Mobile shows a choice card as its question and answer instead of an empty bubble.
- The focus options drop their unused summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:09:56 +00:00
parent 3db0f0cfbd
commit 97210805be
No known key found for this signature in database
11 changed files with 172 additions and 98 deletions

View File

@ -52,7 +52,6 @@ export interface FocusOption {
id: string;
letter: string;
label: string;
summary: string;
apps: string[];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
@ -63,7 +62,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [
id: "day",
letter: "A",
label: "Day-to-day work",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
@ -71,7 +69,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [
id: "inbox",
letter: "B",
label: "Inbox & email",
summary: "email and calendar",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
@ -79,7 +76,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [
id: "research",
letter: "C",
label: "Research & writing",
summary: "the web, notes, and docs",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
@ -87,7 +83,6 @@ export const FOCUS_OPTIONS: FocusOption[] = [
id: "crew",
letter: "D",
label: "Specialist crew",
summary: "a coordinated team of focused Oos",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},

View File

@ -1857,6 +1857,25 @@ describe("mobile thread event reduction", () => {
).toBe("Slack · Alex: Hello from the group");
});
it("reads a choice card as its question and answer", () => {
const card = {
kind: "choice" as const,
step: "goal" as const,
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [{ id: "skip", letter: "", label: "Skip" }],
};
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
expect(
blockText(
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
),
).toBe(`${card.question}\nRun a 10K`);
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
});
it("deduplicates durable messages and replaces matching transient subagent state", () => {
const initial = snapshot([
mobileMessage("message-1", [{ kind: "text", text: "old" }]),

View File

@ -874,6 +874,12 @@ export function blockText(message: MobileMessage) {
if (block.kind === "child_bot") {
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
}
// Choice cards are answered on the web; here they read as the question and its answer.
if (block.kind === "choice") {
const answer =
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
return answer ? `${block.question}\n${answer}` : block.question;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") {

View File

@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
}
/**
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
* is set, it then opens the main bot's own thread, where most specs start.
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
* set, it then opens the main bot's own thread, where most specs start.
*/
export async function completeOnboarding(
page: Page,
@ -101,13 +102,12 @@ export async function completeOnboarding(
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
/^(emoji|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
const design = page.getByRole("heading", { name: /Design your Oo/i });
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
@ -119,8 +119,6 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(preferences).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
@ -133,8 +131,15 @@ export async function completeOnboarding(
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
}).toPass({ timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page);

View File

@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
await composer.fill(text);
const send = page.getByRole("button", { name: "Send", exact: true });
// An open onboarding card in the transcript has its own Send.
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
await expect(send).toBeEnabled();
await send.click();
await expect(composer).toHaveValue("");
@ -72,13 +73,20 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const transcript = page.getByTestId("transcript");
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
// bot text bubble exists for hover layout checks.
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./);
await expect(page.getByText(/Got it\./)).toBeVisible({ timeout: 20_000 });
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(botText).toBeVisible({ timeout: 20_000 });
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible();
@ -121,7 +129,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const parentText = `hover-parent-${stamp}`;
const replyText = `hover-reply-${stamp}`;
const composer = page.getByRole("combobox", { name: /^Message/ });
await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText);

View File

@ -21,6 +21,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
// Onboarding questions offer a quiet way out instead of a dismiss button.
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "01-focus-choice");
await captureScreenshot(page, testInfo, "choice-card-onboarding");
@ -33,6 +36,9 @@ test("focus choice suggests apps and preserves a completed connection", async ({
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(
page.getByText("Got it. Slack, Gmail, and Google Calendar are a good place to start."),
).toBeVisible();
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
const connectionCards = page.getByRole("group", { name: / connection$/ });
@ -45,10 +51,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
);
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
await page
.getByTestId("transcript")
.getByText("Hit those three and I’ll start pulling the picture.")
.scrollIntoViewIfNeeded();
// The next onboarding question follows the suggestions.
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
await slackCard(page).scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "02-app-suggestions");
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });

View File

@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
});
/**
* E2E test for the new emoji-first onboarding flow:
* E2E test for the emoji-first onboarding flow:
* 1. Sign up → lands on onboarding
* 2. Design Oo: pick shape + color → live preview
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
* 4. Choose language
* 5. Skip integrations → bot created → land in chat
* 3. Choose language, skip the voice
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
*/
test("emoji-first onboarding: shape + color selection with live preview", async ({
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step
// Click Continue → goes to the language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 2: Tell us about yourself
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
// Fill in name
await page.getByPlaceholder(/Your name/i).fill("Alice");
// Fill in timezone
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
// Select a hobby
const hobbyButtons = page.locator("button[aria-pressed]");
if (await hobbyButtons.count()) {
await hobbyButtons.first().click(); // toggle first hobby
}
// Select work style
const workStyleButtons = page.locator("button[aria-pressed]:visible");
if ((await workStyleButtons.count()) >= 2) {
await workStyleButtons.nth(0).click();
}
// Select communication style
const commButtons = page.locator("button[aria-pressed]:visible");
if ((await commButtons.count()) >= 2) {
await commButtons.nth(0).click();
}
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
// Click Continue → goes to language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 3: Language selection
// Step 2: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.first()
.click();
// Preferences step — just continue
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();

View File

@ -0,0 +1,83 @@
/**
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
* after each answer, without ever blocking the composer.
*/
import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
function card(page: Page, step: string) {
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
}
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
page,
}, testInfo) => {
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
await completeOnboarding(page, undefined, { mainConversation: true });
await page.getByTestId("focus-choice-day").click();
const goal = card(page, "goal");
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
// Typing instead of answering goes to the main bot, and the card stays answerable.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Quick question first");
await composer.press("Enter");
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
timeout: 60_000,
});
const goalInput = goal.getByRole("textbox", {
name: "What’s one thing you want to get done in the next few weeks?",
});
await goalInput.fill("Run a 10K");
await goal.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
await goalInput.press("Enter");
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
const rhythm = card(page, "rhythm");
await expect(rhythm).toContainText("When should I check in with you?");
await rhythm.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
await rhythm.getByTestId("rhythm-choice-morning").click();
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
const quiet = card(page, "quiet_hours");
await expect(quiet).toContainText("When should I stay quiet?");
await quiet.getByTestId("quiet_hours-choice-22-07").click();
// Only a browser that can still turn push on is asked about notifications.
const closing = page.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
const notifications = card(page, "notifications");
await expect(notifications.or(closing).first()).toBeVisible();
if (await notifications.isVisible()) {
await notifications.getByTestId("notifications-choice-later").click();
}
await expect(closing).toBeVisible();
await expect(
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
).toHaveCount(0);
await closing.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-done");
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
page,
"notifications/settings/get",
null,
);
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
page,
"profile/get",
null,
);
expect(profile.facts).toContainEqual(
expect.objectContaining({
value: "Wants help with day-to-day work first",
source: "onboarding",
}),
);
});

View File

@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({

View File

@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i })
.first()
.click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
});

View File

@ -1,6 +1,7 @@
/**
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
* fact with its origin, confirms a pending one, and forgets one or all of them.
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
* with its origin, confirms a pending one, and forgets one or all of them.
*/
import { expect, test } from "@playwright/test";
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
test.use({ timezoneId: "Europe/Vienna" });
test.beforeEach(async ({ page }) => {
// Walk the full emoji-first flow, including the preferences step.
// Walk the full emoji-first flow.
await page.route("**/rpc/integrationSetup/get", (route) =>
route.fulfill({
json: {
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
});
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
await page.getByPlaceholder("Your name").fill("Pat Example");
await page.getByRole("button", { name: "Music", exact: true }).click();
await page.getByRole("button", { name: "Deep-dive focused" }).click();
await page.getByRole("button", { name: "Direct & concise" }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await waitForChatComposer(page);
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
await page.getByTestId("focus-choice-day").click();
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
// The answers no longer overwrite a bot's instructions.
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
expect(bots.length).toBeGreaterThan(0);
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
// The account name is used as is; onboarding no longer asks for it again.
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
// Sensitive facts from anywhere but the user wait for confirmation.
await rpc(page, "profile/update", {
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
"Direct and concise",
);
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
await expect(music).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-habits")).toContainText(
"Prefers focused deep dives",
const focus = profile
.getByTestId("user-fact")
.filter({ hasText: "Wants help with day-to-day work first" });
await expect(focus).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-goals")).toContainText(
"Wants help with day-to-day work first",
);
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(allergy).toHaveAttribute("data-status", "active");
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
await music.getByRole("button", { name: "Delete" }).click();
await expect(music).toHaveCount(0);
await focus.getByRole("button", { name: "Delete" }).click();
await expect(focus).toHaveCount(0);
await captureScreenshot(page, testInfo, "user-profile-settings");
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
page,
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
null,
);
expect(afterDelete.facts).toEqual(
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
expect.not.arrayContaining([
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
]),
);
expect(afterDelete.facts).toContainEqual(
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),