import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters"; import type { MessageBlock, SpaceNavigation } from "@rakazo/contracts"; import { getNotificationSettings, loadUserContext } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Response | Promise }; type AppHandles = Awaited>; type Choice = Extract; process.env.WAKEUP_DRIVER = "memory"; process.env.SANDBOX_PROVIDER = "fake"; process.env.AGENT_RUNTIME = "scripted"; const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const describeWithDatabase = hasDb ? describe : describe.skip; describeWithDatabase("onboarding questions in the main conversation", () => { let handles: AppHandles; let app: App; const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-onboarding-")); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", wakeupDriver: "memory", signupsEnabled: "true", composio: new ComposioEmulator(), }); app = handles.app; }); afterAll(async () => { await handles?.stop(); rmSync(dataDir, { recursive: true, force: true }); }); /** A fresh user with their main bot, as the web wizard leaves them. */ async function newUser(name: string) { const cookie = await signup(app, `${name.toLowerCase()}-${stamp}@rakazo.test`, name); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); await rpc(app, cookie, "profile/update", { timeZone: "Europe/Vienna", locale: "en-US" }); const main = await rpc<{ id: string }>(app, cookie, "bots/ensureMain"); const { main: conversation } = await rpc<{ main: { groupId: string } }>( app, cookie, "spaces/list", ); const { id: threadId } = await handles.prisma.thread.findUniqueOrThrow({ where: { groupId: conversation.groupId }, select: { id: true }, }); return { cookie, userId: me.userId, spaceId: me.spaceId, botId: main.id, groupId: conversation.groupId, threadId, }; } /** The main conversation's preview as the sidebar lists it, in its space and across spaces. */ async function listedPreview(user: { cookie: string; groupId: string }) { const { current, spaces } = await rpc(app, user.cookie, "spaces/list"); const previews = [current.groups, ...spaces.map((space) => space.groups)] .flat() .filter((group) => group.id === user.groupId) .map(({ preview, previewBlock }) => ({ preview, previewBlock })); expect(previews).toHaveLength(2); expect(previews[1]).toEqual(previews[0]); return previews[0]; } async function messages(threadId: string) { const rows = await handles.prisma.message.findMany({ where: { threadId }, orderBy: { seq: "asc" }, select: { blocks: true }, }); return rows.flatMap((row) => row.blocks as MessageBlock[]); } async function cards(threadId: string): Promise { return (await messages(threadId)).filter((block): block is Choice => block.kind === "choice"); } async function pending(threadId: string) { return (await cards(threadId)).filter((card) => !card.answerId); } const onboardingRow = (userId: string) => handles.prisma.userOnboarding.findUniqueOrThrow({ where: { userId } }); it("asks one question after another and records every answer", async () => { const ada = await newUser("Ada"); const choose = (body: Record) => rpc(app, ada.cookie, "onboarding/choose", { botId: ada.botId, ...body }); await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]); expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null }); // The card has no text of its own; the web words the preview from the card. expect(await listedPreview(ada)).toEqual({ preview: "", previewBlock: (await pending(ada.threadId))[0], }); expect( (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) .onboardingOpen, ).toBe(true); await choose({ step: "focus", optionId: "day" }); expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); const facts = await handles.prisma.userFact.findMany({ where: { userId: ada.userId } }); expect(facts.map(({ category, value, source }) => ({ category, value, source }))).toEqual([ { category: "goals", value: "Wants help with day-to-day work first", source: "onboarding" }, ]); const afterFocus = await messages(ada.threadId); expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0); // The cards speak for themselves: onboarding writes no text of its own. expect(afterFocus.filter((block) => block.kind === "text")).toEqual([]); // The app suggestions come before the next question. expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" }); // A stale answer to a card that is no longer pending changes nothing. await choose({ step: "focus", optionId: "inbox" }); expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); await expect(choose({ step: "goal", optionId: "morning" })).rejects.toThrow(/400/); await expect(choose({ step: "goal", optionId: "_text", text: " " })).rejects.toThrow(/400/); // A double submit records the goal once. await Promise.all([ choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), ]); const goals = await handles.prisma.userGoal.findMany({ where: { userId: ada.userId } }); expect(goals.map(({ title, source }) => ({ title, source }))).toEqual([ { title: "Run a 10K", source: "onboarding" }, ]); expect((await cards(ada.threadId)).find((card) => card.step === "goal")).toMatchObject({ answerId: "_text", answerText: "Run a 10K", }); await choose({ step: "rhythm", optionId: "morning" }); expect(await getCoachRhythm(handles.prisma, ada.userId)).toEqual({ morning: "08:00", evening: null, weekly: null, }); expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["quiet_hours"]); expect( (await pending(ada.threadId))[0]?.options.map((option) => option.label.replace(/\s/g, " ")), ).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); // A browser that can still turn push on gets the notifications question. await choose({ step: "quiet_hours", optionId: "22-07", canEnablePush: true }); expect((await getNotificationSettings(handles.prisma, ada.userId)).quietHours).toEqual({ start: "22:00", end: "07:00", }); expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["notifications"]); await choose({ step: "notifications", optionId: "later" }); expect(await onboardingRow(ada.userId)).toMatchObject({ step: null, completedAt: expect.any(Date), }); expect(await pending(ada.threadId)).toEqual([]); // The closing note carries the first check-in for the web and an English line for others. const closing = (await messages(ada.threadId)).at(-1); expect(closing).toMatchObject({ kind: "intake_note", note: "done" }); if (closing?.kind !== "intake_note") throw new Error("no closing note"); expect(closing.text.replace(/\s/g, " ")).toMatch( /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, ); expect(await listedPreview(ada)).toEqual({ preview: closing.text, previewBlock: closing }); const firstCheckIn = new Date(closing.nextCheckInAt ?? ""); expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now()); expect( new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Vienna", hour: "2-digit", minute: "2-digit", }).format(firstCheckIn), ).toBe("08:00"); expect( (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) .onboardingOpen, ).toBe(false); // Onboarding runs once. const count = (await messages(ada.threadId)).length; await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); expect((await messages(ada.threadId)).length).toBe(count); }); it("moves on when a question is skipped or dismissed, and saves nothing for it", async () => { const bo = await newUser("Bo"); const choose = (body: Record) => rpc(app, bo.cookie, "onboarding/choose", { botId: bo.botId, ...body }); await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: bo.botId }); await rpc(app, bo.cookie, "onboarding/dismissFocus", { botId: bo.botId }); expect((await cards(bo.threadId))[0]).toMatchObject({ step: "focus", answerId: "skip" }); await choose({ step: "goal", optionId: "skip" }); await choose({ step: "rhythm", optionId: "none" }); await choose({ step: "quiet_hours", optionId: "skip" }); expect(await onboardingRow(bo.userId)).toMatchObject({ step: null, focus: null, completedAt: expect.any(Date), }); expect(await handles.prisma.userFact.count({ where: { userId: bo.userId } })).toBe(0); expect(await handles.prisma.userGoal.count({ where: { userId: bo.userId } })).toBe(0); expect(await getCoachRhythm(handles.prisma, bo.userId)).toEqual({ morning: null, evening: null, weekly: null, }); expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull(); expect((await messages(bo.threadId)).at(-1)).toEqual({ kind: "intake_note", note: "done", nextCheckInAt: null, text: "All set. I’m here whenever you need me.", }); // Another bot still gets only its focus card, outside onboarding. const helper = await rpc<{ id: string; threadId: string }>(app, bo.cookie, "bots/create", { name: "Helper", title: "", description: "", instructions: "", notifyOnFinish: true, }); const { thread } = await handles.prisma.bot.findUniqueOrThrow({ where: { id: helper.id }, select: { thread: { select: { id: true } } }, }); await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: helper.id }); const [focus] = await pending(thread!.id); expect(focus?.step).toBeUndefined(); expect(focus?.options.map((option) => option.id)).toEqual(["day", "inbox", "research", "crew"]); await rpc(app, bo.cookie, "onboarding/choose", { botId: helper.id, optionId: "research" }); expect(await pending(thread!.id)).toEqual([]); expect(await onboardingRow(bo.userId)).toMatchObject({ focus: null }); }); it("keeps each user's onboarding to themselves", async () => { const owner = await newUser("Owner"); const other = await newUser("Other"); await rpc(app, owner.cookie, "onboarding/promptFocus", { botId: owner.botId }); for (const [procedure, body] of [ ["onboarding/promptFocus", { botId: owner.botId }], ["onboarding/choose", { botId: owner.botId, step: "focus", optionId: "day" }], ["onboarding/dismissFocus", { botId: owner.botId }], ["onboarding/start", { botId: owner.botId }], ] as const) { const response = await raw(app, other.cookie, procedure, body); expect(response.status, procedure).toBeGreaterThanOrEqual(400); } expect(await onboardingRow(owner.userId)).toMatchObject({ step: "focus", focus: null }); expect((await pending(owner.threadId)).map((card) => card.step)).toEqual(["focus"]); expect( await handles.prisma.userOnboarding.findUnique({ where: { userId: other.userId } }), ).toBe(null); expect(await handles.prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); }); }); async function signup(app: App, email: string, name: string) { const response = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, body: JSON.stringify({ email, password: "password12", name }), }); if (response.status >= 400) { throw new Error(`signup failed ${response.status}: ${await response.text()}`); } return sessionCookieHeader(response); } async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { return app.request(`/rpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173", }, body: JSON.stringify({ json: body }), }); } async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { const response = await raw(app, cookie, procedure, body); const text = await response.text(); const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; if (response.status >= 400 || payload.error) { throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); } return payload.json as T; }