open-swe/tests/e2e/tests/dashboard.spec.ts
Ramon Nogueira a8c7af4192
test(open-swe): add Playwright E2E for the Slack → PR → web handoff (#1583)
* test(open-swe): add Playwright E2E for the Slack → PR → web handoff

Local, secrets-free end-to-end suite that drives the full happy path through mock Slack/GitHub control panels and the real dashboard UI. Only the LLM and external SaaS HTTP boundaries (GitHub/Slack APIs, OAuth token mint) are faked — the real process_slack_mention, get_agent, deepagents loop, tools, middleware, and dashboard authorization all run under `langgraph dev` with a scripted fake chat model and a local temp-dir sandbox.

- full_flow: a Slack mention runs the agent, which implements a change in the sandbox, opens a PR against a fake GitHub remote, and replies with the PR link in the same thread.
- dashboard: clicking the bot's real "Open in Web" link loads the built ui/ app (served same-origin); the thread owner can continue the conversation, while a different user sees the same thread read-only (no composer).

Wired into Agent CI as a `Playwright E2E` job that runs on pull requests.

* fix(open-swe): serve E2E UI assets via explicit route; pin Playwright

The dashboard E2E served the built ui/ SPA's /assets via app.mount(StaticFiles), but LangGraph's custom-app loader serves APIRoutes and drops sub-app Mounts, so /assets 404'd under `langgraph dev` in CI — the React app never booted and the composer/transcript never rendered. Serve assets via an explicit route instead.

Also pin @playwright/test to the latest (1.61.0) for reproducible runs, and make the owner composer assertion tolerant of either hydration state.

* test(open-swe): record Playwright trace + video on every E2E run

Capture a replayable trace (DOM snapshots, network, console, source) and a screen recording for every test, not just retries, plus a screenshot on failure. The CI job already uploads playwright-report/ and test-results/, so each run now has a downloadable replay; documented how to open it.
2026-06-22 12:54:46 -07:00

70 lines
3.2 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test";
// Drives the REAL built ui/ app (served same-origin from the harness) for the
// Slack → web handoff. Only the LLM/GitHub/Slack/token boundaries are faked.
const SAME_USER = { login: "dev-user", email: "dev@example.com" };
const OTHER_USER = { login: "someone-else", email: "someone@example.com" };
async function loginAs(page: Page, user: { login: string; email: string }) {
const res = await page.request.post("/control/login", { data: user });
expect(res.ok()).toBeTruthy();
}
// Run the Slack flow so a thread + PR exist, then click the bot's real
// "Open in Web" link, landing on the actual dashboard app.
async function openThreadViaSlackLink(page: Page) {
await page.goto("/mock/slack");
await page.locator("#reset").click();
await expect(page.locator("#thread")).toContainText("No messages yet");
await page.locator("#text").fill("<@U0BOT> please add a greet() helper and open a PR");
await page.locator("#send").click();
await expect(page.locator(".msg.bot").filter({ hasText: "Add greet() helper" })).toBeVisible();
const webLink = page.locator('.msg.bot a[href*="/agents/"]').first();
await expect(webLink).toBeVisible();
await webLink.click();
await expect(page).toHaveURL(/\/agents\//);
}
// The SDK hydrates an idle thread's transcript from getState on load, which can
// briefly lag; a reload re-fetches it. Retry until the PR link renders.
async function expectTranscriptVisible(page: Page) {
await expect(async () => {
await page.reload();
await expect(
page.getByRole("link", { name: "Add greet() helper" }).first(),
).toBeVisible({ timeout: 8000 });
}).toPass({ timeout: 60000 });
}
test.describe("Slack → web handoff (real dashboard UI)", () => {
test("the SAME user continues the conversation in the web app", async ({ page }) => {
await loginAs(page, SAME_USER);
await openThreadViaSlackLink(page);
// The owner sees the composer (either the follow-up bar once the transcript
// hydrates, or the empty-state bar before it — both mean they can type).
const composer = page.getByPlaceholder(/Add a follow up|Send the first message/);
await expect(composer).toBeVisible();
// Continue from the web — a new agent reply streams into the same thread.
await composer.fill("Looks good — can you also add a docstring?");
await composer.press("Enter");
await expect(page.getByText(/anything else you'd like changed/)).toBeVisible();
// The transcript that started in Slack is here too (incl. the PR link).
await expect(page.getByRole("link", { name: "Add greet() helper" }).first()).toBeVisible();
});
test("a DIFFERENT user sees the thread read-only (no composer)", async ({ page }) => {
await loginAs(page, OTHER_USER);
await openThreadViaSlackLink(page);
// The same thread + transcript is visible…
await expectTranscriptVisible(page);
// …but a non-owner gets no composer.
await expect(page.getByPlaceholder("Add a follow up")).toHaveCount(0);
await expect(page.getByPlaceholder("Send the first message")).toHaveCount(0);
await expect(page.getByLabel("Send message")).toHaveCount(0);
});
});