diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index 7eeb9dd03..ed4347573 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -1,56 +1,11 @@ // A fresh user signs up in a real browser, the client sets up its workspace // over stock routes, connects a placeholder provider, and creates a first // workbench that shows up in the sidebar. Asserts on visible UI only. -import { expect, test } from "bun:test"; -import type { Page } from "puppeteer-core"; +import { test } from "bun:test"; import { bootBrowserApp, browserGate } from "../lib/browser"; +import { runFirstRunFlow } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); -const STEP_TIMEOUT = 60_000; -const FIRST_WORKBENCH = "Summarize what shipped this week"; - -// Page scripts are strings: this tsconfig has no DOM lib. -async function waitForText(page: Page, label: string): Promise { - await page.waitForFunction(`document.body.innerText.includes(${JSON.stringify(label)})`, { - timeout: STEP_TIMEOUT, - }); -} - -async function clickText(page: Page, selector: string, label: string): Promise { - const script = `Array.from(document.querySelectorAll(${JSON.stringify(selector)})).find((e) => e.textContent.includes(${JSON.stringify(label)}))`; - await page.waitForFunction(script, { timeout: STEP_TIMEOUT }); - await page.evaluate(`(${script}).click()`); -} - -async function runFlow(page: Page, origin: string): Promise { - await page.goto(origin, { waitUntil: "networkidle0" }); - - await clickText(page, "button", "Create an account"); - await waitForText(page, "Create your account"); - await page.type("input[type=email]", `alice+${String(Date.now())}@example.com`); - await page.type("input[type=password]", "correct-horse-battery-staple"); - await page.click("button[type=submit]"); - - // Provider connect is the only onboarding step; a placeholder key is - // enough because connecting stores a credential without calling the provider. - await waitForText(page, "Connect a brain"); - await clickText(page, "label", "Anthropic"); - await page.waitForSelector("input[type=password]"); - await page.type("input[type=password]", "sk-ant-placeholder"); - await clickText(page, "button", "Connect"); - - // Connecting installs Myra, then the ready screen hands off to the - // new-workbench prompt (a workspace with no workbenches). - await clickText(page, "button", "Start your first workbench"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", FIRST_WORKBENCH); - await page.click("button[aria-label='Start this workbench']"); - - // Creating the workbench navigates to its own page, which shows the prompt. - await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); - await waitForText(page, FIRST_WORKBENCH); - expect(new URL(page.url()).pathname).toStartWith("/w/"); -} // The test database outlives a run, so each run signs up a distinct alice. describeBrowser("first run", () => { @@ -59,7 +14,7 @@ describeBrowser("first run", () => { test("sign up, connect a provider, create a first workbench", async () => { const { page, errors } = await app().newPage(); try { - await runFlow(page, app().origin); + await runFirstRunFlow(page, app().origin); } catch (cause) { const body = await page.evaluate("document.body.innerText"); process.stderr.write( diff --git a/e2e/browser/run-now.test.ts b/e2e/browser/run-now.test.ts new file mode 100644 index 000000000..fbd52dd28 --- /dev/null +++ b/e2e/browser/run-now.test.ts @@ -0,0 +1,65 @@ +// A manual trigger through the stock trigger route (what the Routines page's +// Run now calls) answers 202 and the run shows up in the stock runs listing. +// The harness has no scripted inference and no deployed non-agent workflow, +// so Myra's own deployment is the only thing triggerable and the brief + +// artifact half is not asserted. +import { expect, test } from "bun:test"; +import { bootBrowserApp, browserGate } from "../lib/browser"; +import { runFirstRunFlow } from "../lib/first-run"; + +const describeBrowser = browserGate(import.meta.path); + +// Runs in the page so the signed-in session cookie applies. +const TRIGGER_SCRIPT = `(async () => { + const bench = decodeURIComponent(location.pathname.split("/")[2]); + const json = async (r) => ({ status: r.status, body: await r.json().catch(() => null) }); + const benchTenant = await json(await fetch("/api/tenants/" + bench)); + const tenants = [bench, benchTenant.body?.parentId].filter(Boolean); + // Myra deploys asynchronously after setup, so poll for the deployment. + let lastListing = null; + for (let attempt = 0; attempt < 60; attempt++) { + for (const tenantId of tenants) { + const listed = await json(await fetch("/api/tenants/" + tenantId + "/workflows/deployments")); + const deployment = (Array.isArray(listed.body) ? listed.body : []).find((d) => d.status === "deployed"); + lastListing = { tenantId, status: listed.status, body: listed.body }; + if (!deployment) continue; + const base = "/api/tenants/" + tenantId + "/workflows/"; + const trigger = await json(await fetch(base + encodeURIComponent(deployment.id) + "/mail", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "Run now" }), + })); + const runs = await json(await fetch(base + "runs?limit=50")); + return { tenantId, deploymentId: deployment.id, trigger, runs }; + } + await new Promise((r) => setTimeout(r, 1000)); + } + return { tenants, lastListing }; +})()`; + +describeBrowser("run now", () => { + const app = bootBrowserApp(); + + test("a manual trigger is accepted and the run is listed", async () => { + const { page, errors } = await app().newPage(); + try { + await runFirstRunFlow(page, app().origin); + const result = (await page.evaluate(TRIGGER_SCRIPT)) as { + deploymentId?: string; + trigger?: { status: number; body: { runId?: string } | null }; + runs?: { body: unknown }; + }; + process.stderr.write(`${JSON.stringify(result)}\n`); + if (result.deploymentId === undefined) + process.stderr.write(`NO-DEPLOYMENT ${JSON.stringify(result)}\n`); + expect(result.deploymentId).toBeDefined(); + expect(result.trigger?.status).toBe(202); + expect(typeof result.trigger?.body?.runId).toBe("string"); + expect(JSON.stringify(result.runs?.body)).toContain(result.trigger?.body?.runId ?? ""); + } catch (cause) { + process.stderr.write(`run-now failed at ${page.url()}\nconsole: ${errors.join(" | ")}\n`); + throw cause; + } + await page.close(); + }, 240_000); +}); diff --git a/e2e/lib/first-run.ts b/e2e/lib/first-run.ts new file mode 100644 index 000000000..4c02d448b --- /dev/null +++ b/e2e/lib/first-run.ts @@ -0,0 +1,50 @@ +// Shared browser steps for e2e: sign up, connect a placeholder provider, and +// create a first workbench. +import { expect } from "bun:test"; +import type { Page } from "puppeteer-core"; + +export const STEP_TIMEOUT = 60_000; +export const FIRST_WORKBENCH = "Summarize what shipped this week"; + +// Page scripts are strings: this tsconfig has no DOM lib. +export async function waitForText(page: Page, label: string): Promise { + await page.waitForFunction(`document.body.innerText.includes(${JSON.stringify(label)})`, { + timeout: STEP_TIMEOUT, + }); +} + +export async function clickText(page: Page, selector: string, label: string): Promise { + const script = `Array.from(document.querySelectorAll(${JSON.stringify(selector)})).find((e) => e.textContent.includes(${JSON.stringify(label)}))`; + await page.waitForFunction(script, { timeout: STEP_TIMEOUT }); + await page.evaluate(`(${script}).click()`); +} + +export async function runFirstRunFlow(page: Page, origin: string): Promise { + await page.goto(origin, { waitUntil: "networkidle0" }); + + await clickText(page, "button", "Create an account"); + await waitForText(page, "Create your account"); + await page.type("input[type=email]", `alice+${String(Date.now())}@example.com`); + await page.type("input[type=password]", "correct-horse-battery-staple"); + await page.click("button[type=submit]"); + + // Provider connect is the only onboarding step; a placeholder key is + // enough because connecting stores a credential without calling the provider. + await waitForText(page, "Connect a brain"); + await clickText(page, "label", "Anthropic"); + await page.waitForSelector("input[type=password]"); + await page.type("input[type=password]", "sk-ant-placeholder"); + await clickText(page, "button", "Connect"); + + // Connecting installs Myra, then the ready screen hands off to the + // new-workbench prompt (a workspace with no workbenches). + await clickText(page, "button", "Start your first workbench"); + await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); + await page.type("textarea", FIRST_WORKBENCH); + await page.click("button[aria-label='Start this workbench']"); + + // Creating the workbench navigates to its own page, which shows the prompt. + await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); + await waitForText(page, FIRST_WORKBENCH); + expect(new URL(page.url()).pathname).toStartWith("/w/"); +}