// The person's journey with app events in a real browser against a real server (temporary home, scripted engine, the // connected-apps service's code behind a fake Composio): set one up from chat and from + New, see its sentence, its // events and what it skipped, pause, resume, reconnect and delete. Runs where a Chromium and playwright-core are named: // OMB_UI_CHROMIUM= OMB_UI_PLAYWRIGHT_CORE= OMB_UI_DIST= // [OMB_UI_SHOTS=] vitest run server/app-events.ui.e2e.test.ts import { createRequire } from "node:module"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterAll, beforeAll, expect, it, vi } from "vitest"; import { WELCOME_VERSION } from "../electron/onboarding-record.mjs"; import { launchVerificationServer, runControlOmb } from "../scripts/control-omb.ts"; import { startBrokerFake, type BrokerFake } from "./testing/broker-fake.ts"; const { OMB_UI_CHROMIUM: chromiumPath, OMB_UI_PLAYWRIGHT_CORE: playwrightPath, OMB_UI_DIST: dist } = process.env; const shots = process.env.OMB_UI_SHOTS ?? join(process.cwd(), "docs", "screenshots", "automations-app-events"); const ready = Boolean(chromiumPath && playwrightPath && dist); let broker: BrokerFake; let fixture: Awaited>; let browser: any; let owner: { id: string; token: string; userId: string }; let gmail = ""; beforeAll(async () => { if (!ready) return; vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); broker = await startBrokerFake(); owner = await broker.install(); gmail = broker.account(owner.userId, "gmail", "sales"); broker.account(owner.userId, "slack"); fixture = await launchVerificationServer({ ...process.env, OMB_COMPOSIO_BROKER_URL: broker.url, OMB_COMPOSIO_BROKER_TOKEN: owner.token }, undefined, undefined, undefined, undefined, { scripted: true }, [], undefined, dist); const { chromium } = createRequire(join(playwrightPath!, "package.json"))("playwright-core"); // A mock keychain keeps the test browser from asking the person's macOS keychain for anything. browser = await chromium.launch({ executablePath: chromiumPath, headless: true, args: ["--use-mock-keychain"] }); mkdirSync(shots, { recursive: true }); }, 90_000); afterAll(async () => { await browser?.close(); await fixture?.close(); await broker?.close(); vi.restoreAllMocks(); }); it.skipIf(!ready)("sets one up from chat and from + New, shows its sentence and events, pauses, resumes, asks to reconnect and deletes", async () => { const cli = (...args: string[]) => runControlOmb([...args, "--url", fixture.info.url]) as Promise; const api = async (method: string, path: string, body?: unknown) => (await fetch(`${fixture.info.url}${path}`, { method, headers: { "content-type": "application/json", origin: fixture.info.url }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), })).json() as Promise; await api("PUT", "/api/config", { onboarding: { completedAt: new Date().toISOString(), version: WELCOME_VERSION } }); const { bot } = await cli("new-bot", "--name", "Scout"); await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "ask" }); const plan = (steps: unknown[], reply: string) => writeFileSync(join(fixture.info.dataDir, "room-plan.json"), JSON.stringify({ [bot.id]: { steps, reply } })); plan([ { tool: "list_trigger_events", arguments: { app: "gmail" } }, { tool: "create_trigger", arguments: { when: "app_event", name: "Acme replies", app: "gmail", event: "GMAIL_NEW_GMAIL_MESSAGE", filter: { from: "acme.com" }, instructions: "Draft a reply to the email in the event data. Do not send it." } }, ], "Done: when you get an email from Acme, I'll draft a reply for you to check."); const page = await browser.newPage({ viewport: { width: 1360, height: 860 }, deviceScaleFactor: 2 }); page.on("dialog", (dialog: any) => dialog.accept()); const shot = (name: string) => page.screenshot({ path: join(shots, name) }); await page.goto(fixture.info.url); await page.getByText("Scout", { exact: true }).first().waitFor(); const skipTour = page.getByRole("button", { name: "Skip tour" }); if (await skipTour.isVisible().catch(() => false)) await skipTour.click(); await page.getByText("Scout", { exact: true }).first().click(); // 1. From chat. const composer = page.locator("textarea").last(); await composer.fill("When I get an email from Acme, draft a reply."); await composer.press("Enter"); if (await skipTour.isVisible().catch(() => false)) await skipTour.click(); const card = page.locator("[data-trigger-card]").first(); await card.waitFor({ timeout: 30_000 }); await expect.poll(() => card.textContent(), { timeout: 15_000 }).toContain("When you get an email from acme.com, Scout drafts a reply to the email"); expect(await card.textContent()).not.toContain("event data"); expect(await card.textContent()).toContain("Checks every 15 minutes · waiting for the first email"); await shot("chat-card-set-up.png"); // 2. An email from Acme arrives at Composio; the card shows the run. const remoteId = (await api("GET", "/api/app-triggers")).triggers[0].remote.triggerId as string; plan([], "Drafted a reply to Ada about order #1042."); await broker.deliver(remoteId, { sender: "Ada ", subject: "Where is order #1042?", message_text: "Hi there" }); await expect.poll(() => card.textContent(), { timeout: 40_000 }).toContain("Drafted a reply to Ada"); await shot("chat-card-after-event.png"); // 3. Past today's limit the service keeps nothing more; the panel says how many were skipped. broker.setQueuedToday(owner.id, 500); for (let index = 0; index < 3; index += 1) await broker.deliver(remoteId, { sender: "bo@acme.com", subject: `Order #${index}` }); await api("POST", "/api/app-triggers/sync"); await page.locator('[data-tour="nav-automations"]').first().click(); const row = page.locator("[data-automation]").first(); await row.waitFor({ timeout: 15_000 }); await shot("automations-list.png"); await row.locator("button").first().click(); const panel = page.locator('aside[aria-label="Automation"]'); await panel.waitFor(); await expect.poll(() => panel.textContent(), { timeout: 15_000 }).toContain("3 skipped today: daily limit"); expect(await panel.textContent()).toContain("Started a run · From Ada · Where is order #1042?"); await shot("automation-panel.png"); // 4. Pause and resume. await panel.getByRole("button", { name: "Pause" }).click(); await expect.poll(async () => (await api("GET", "/api/app-triggers")).triggers[0].enabled, { timeout: 10_000 }).toBe(false); await expect.poll(() => panel.textContent(), { timeout: 10_000 }).toContain("Paused"); await shot("automation-paused.png"); await panel.getByRole("button", { name: "Resume" }).click(); await expect.poll(async () => (await api("GET", "/api/app-triggers")).triggers[0].enabled, { timeout: 10_000 }).toBe(true); // 5. + New → Set it up yourself → When something happens in an app. await page.getByRole("button", { name: "Close" }).first().click().catch(() => {}); await page.getByRole("button", { name: "New", exact: true }).click(); await page.getByRole("button", { name: "Set it up yourself" }).click(); await page.getByRole("radio", { name: "When something happens in an app" }).click(); await page.getByRole("button", { name: "Continue" }).click(); const editor = page.getByRole("dialog", { name: "When something happens in an app" }); await editor.locator('select[aria-label="App"]').waitFor({ timeout: 15_000 }); await editor.locator('input[placeholder="acme.com"]').fill("globex.com"); await editor.locator('input[placeholder="order"]').fill("invoice"); await editor.locator("textarea").fill("File the invoice in the shared folder and tell me the total."); await shot("new-app-event-editor.png"); await editor.getByRole("button", { name: "Set it up" }).click(); await expect.poll(async () => (await api("GET", "/api/app-triggers")).triggers.length, { timeout: 15_000 }).toBe(2); await expect.poll(() => page.locator("[data-automation]").count(), { timeout: 10_000 }).toBe(2); expect(await page.locator("main").textContent()).toContain("When you get an email from globex.com about “invoice”, Scout files the invoice in the shared folder"); await shot("automations-two.png"); // 6. The Gmail connection expires: Needs you, with Reconnect. await broker.expire(gmail); await expect.poll(() => page.locator('li[data-automation-status="needs-you"]').count(), { timeout: 40_000 }).toBe(2); expect(await page.locator("main").textContent()).toContain("Gmail needs to be reconnected."); await shot("automations-needs-you.png"); // 7. Delete from the panel. const first = page.locator("[data-automation]").first(); await first.locator("button").first().click(); await page.locator('aside[aria-label="Automation"]').getByRole("button", { name: "Delete" }).click(); await expect.poll(async () => (await api("GET", "/api/app-triggers")).triggers.length, { timeout: 10_000 }).toBe(1); await expect.poll(() => page.locator("[data-automation]").count(), { timeout: 10_000 }).toBe(1); await shot("automations-after-delete.png"); await page.close(); }, 240_000);