mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 08:03:15 +00:00
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.
This commit is contained in:
parent
609e5551c4
commit
a8c7af4192
18 changed files with 1462 additions and 0 deletions
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -46,3 +46,36 @@ jobs:
|
|||
run: uv sync --locked --extra dev
|
||||
- name: Run unit tests
|
||||
run: make test
|
||||
|
||||
e2e:
|
||||
name: Playwright E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Install Python deps (langgraph dev runtime)
|
||||
run: uv sync --locked
|
||||
- name: Install Playwright + Chromium
|
||||
working-directory: tests/e2e
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps chromium
|
||||
# Playwright's webServer boots `langgraph dev`; globalSetup builds the real
|
||||
# ui/ SPA. The fake LLM/GitHub/Slack boundaries need no secrets.
|
||||
- name: Run E2E
|
||||
working-directory: tests/e2e
|
||||
run: npx playwright test
|
||||
- name: Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
tests/e2e/playwright-report
|
||||
tests/e2e/test-results
|
||||
retention-days: 7
|
||||
|
|
|
|||
8
tests/e2e/.gitignore
vendored
Normal file
8
tests/e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.playwright/
|
||||
blob-report/
|
||||
.e2e-tmp/
|
||||
.langgraph_api/
|
||||
__pycache__/
|
||||
110
tests/e2e/README.md
Normal file
110
tests/e2e/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Playwright E2E — the full Slack → implement → PR → reply flow
|
||||
|
||||
This drives the **whole happy path** through two mock UIs:
|
||||
|
||||
1. A user asks Open SWE to implement something in a **mock Slack** thread.
|
||||
2. The **real agent** runs (via `langgraph dev`): it implements the change in a
|
||||
**local temp-dir sandbox**, pushes a branch, and opens a PR on a **fake GitHub**.
|
||||
3. It posts the PR link back to the **same Slack thread** — visible in the mock UI.
|
||||
|
||||
## What is faked vs. real
|
||||
|
||||
Only the **LLM** and the **external SaaS HTTP boundaries** are faked. All agent
|
||||
code runs for real.
|
||||
|
||||
| Piece | Real or fake |
|
||||
|---|---|
|
||||
| Slack webhook → `process_slack_mention` → run dispatch | **real** (`agent.webapp`) |
|
||||
| `get_agent`, deepagents loop, tools, middleware, prompt | **real** |
|
||||
| `open_pull_request`, `slack_thread_reply` tools | **real** |
|
||||
| Sandbox | **real** `local` provider, rooted in a throwaway temp dir |
|
||||
| Git remote ("GitHub") | **real git**, a local bare repo the agent clones/pushes |
|
||||
| The LLM | **fake** — a scripted model (`fake_llm.py`) emitting a fixed tool sequence |
|
||||
| `api.github.com` REST (PR create) | **fake** (`/fake-gh/...`), state rendered at `/mock/github` |
|
||||
| `slack.com/api` (post message, etc.) | **fake** (`/fake-slack/...`), thread rendered at `/mock/slack` |
|
||||
| GitHub App token mint, `api.github.com/user` identity | stubbed (offline) |
|
||||
|
||||
The fake GitHub/Slack stores are the single source of truth the mock UIs render,
|
||||
so what Playwright asserts on is exactly what the real agent produced.
|
||||
|
||||
## Files
|
||||
|
||||
- `e2e_env.py` — env + constants set before any `agent.*` import (sandbox=local,
|
||||
fake API URLs, isolated `GIT_CONFIG_GLOBAL`, bot-token-only mode).
|
||||
- `fake_llm.py` — the scripted `BaseChatModel` (the only faked agent piece).
|
||||
- `patches.py` — monkeypatches the boundaries (LLM, GitHub/Slack URLs, token mint).
|
||||
- `agent_entrypoint.py` — langgraph `agent` graph: applies patches, re-exports the
|
||||
real `traced_agent`.
|
||||
- `harness.py` — langgraph `http.app`: the real `agent.webapp` plus the fake
|
||||
GitHub/Slack APIs, the mock UIs, and the control/compose endpoints.
|
||||
- `fakes.py` — in-memory PR/Slack stores + git seeding of the bare remote.
|
||||
- `langgraph.e2e.json` — dev-server config pointing at the two entrypoints above.
|
||||
- `static/{slack,github}.html` — the mock Slack/GitHub UIs (external SaaS we can't
|
||||
run locally). The dashboard is **not** mocked — it's the real `ui/` app.
|
||||
- `global-setup.ts` — builds the real `ui/` SPA (once) so the harness can serve it.
|
||||
- `tests/full_flow.spec.ts` — Slack → implement → PR → reply.
|
||||
- `tests/dashboard.spec.ts` — the Slack → web handoff (below).
|
||||
|
||||
## Slack → web handoff (dashboard.spec.ts) — the REAL ui/ app
|
||||
|
||||
After the Slack run, the bot posts an "Open in Web" link
|
||||
(`DASHBOARD_BASE_URL/agents/{thread_id}`). The test clicks that real link, which
|
||||
loads the **actual built `ui/` React app** — served same-origin from the harness
|
||||
so the session cookie and `/dashboard/api/*` calls work without CORS. The signed
|
||||
session cookie is real (minted via `/control/login`), so the per-user
|
||||
authorization is genuine:
|
||||
|
||||
- **Same user** (session email = the Slack triggerer = thread owner): the real
|
||||
`AgentThreadView` shows the transcript (incl. the PR link), the `AgentPromptBar`
|
||||
composer is present, and submitting a follow-up streams a new agent reply into
|
||||
the same thread.
|
||||
- **Different user** (any other org login): the same transcript renders, but the
|
||||
real UI shows **no composer** (`AgentThreadView` gates it on `thread.isOwner`).
|
||||
|
||||
Ownership is by `github_login` / `triggering_user_email` on the thread metadata;
|
||||
`GET /dashboard/api/threads/{id}` returns `isOwner`, which the real UI uses to
|
||||
gate the composer. The only extra fake here is the OAuth-token store (an external
|
||||
credential); the authorization logic itself is real.
|
||||
|
||||
The UI is built by `global-setup.ts` with `VITE_DASHBOARD_API_BASE_URL` pointed at
|
||||
the harness. It builds once; set `E2E_FORCE_UI_BUILD=1` to rebuild (e.g. after a
|
||||
UI change or port change). Requires `bun`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npx playwright test # boots langgraph dev automatically, then runs
|
||||
```
|
||||
|
||||
Watch it in human time:
|
||||
|
||||
```bash
|
||||
SLOW_MO=700 npx playwright test --headed
|
||||
```
|
||||
|
||||
## Artifacts (replay a run)
|
||||
|
||||
Every test records a **trace** (DOM-snapshot timeline + network + console + source)
|
||||
and a **video**; failures also get a screenshot. Locally they land in
|
||||
`test-results/<test>/` and are embedded in `playwright-report/`:
|
||||
|
||||
```bash
|
||||
npx playwright show-report # browse runs; each has a Trace tab
|
||||
npx playwright show-trace test-results/<test>/trace.zip # open one trace directly
|
||||
```
|
||||
|
||||
In CI the `Playwright E2E` job uploads both `playwright-report/` and
|
||||
`test-results/` as the **playwright-report** artifact on the run. Download it,
|
||||
then `npx playwright show-report <unzipped-dir>` (or drag a `trace.zip` onto
|
||||
<https://trace.playwright.dev>) to replay.
|
||||
|
||||
Poke at it by hand (from the repo root):
|
||||
|
||||
```bash
|
||||
uv run langgraph dev --config tests/e2e/langgraph.e2e.json --port 2024 \
|
||||
--no-browser --allow-blocking --no-reload
|
||||
# open http://127.0.0.1:2024/mock/slack and /mock/github
|
||||
```
|
||||
21
tests/e2e/agent_entrypoint.py
Normal file
21
tests/e2e/agent_entrypoint.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""LangGraph graph entrypoint for the E2E dev server.
|
||||
|
||||
Applies the boundary patches (fake LLM + fake GitHub/Slack), then re-exports the
|
||||
REAL traced agent factory. The langgraph dev config points the ``agent`` graph
|
||||
here instead of ``agent.server`` so the patches are in effect in the worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import patches # noqa: E402
|
||||
|
||||
patches.apply()
|
||||
|
||||
from agent.server import traced_agent # noqa: E402
|
||||
|
||||
__all__ = ["traced_agent"]
|
||||
83
tests/e2e/e2e_env.py
Normal file
83
tests/e2e/e2e_env.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Shared environment + constants for the full-flow E2E.
|
||||
|
||||
Imported FIRST by both the agent graph entrypoint and the HTTP harness, before
|
||||
any ``agent.*`` module — several webapp/auth/slack constants are read into module
|
||||
globals at import time, so the env must be set beforehand.
|
||||
|
||||
Everything here only configures *boundaries* (which sandbox, which fake API
|
||||
URLs, where git writes its global config). The agent code itself is unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TMP = Path(os.environ.setdefault("E2E_TMP", str(Path(__file__).parent / ".e2e-tmp")))
|
||||
|
||||
# Demo repo the fake GitHub serves and the agent operates on.
|
||||
OWNER = "fakeorg"
|
||||
REPO = "demo"
|
||||
BASE_BRANCH = "main"
|
||||
FEATURE_BRANCH = "add-greet"
|
||||
PR_TITLE = "Add greet() helper"
|
||||
FEATURE_FILE = "greet.py"
|
||||
|
||||
# Fixed Slack identifiers so the mock UI and assertions are deterministic.
|
||||
BOT_USER_ID = "U0BOT"
|
||||
BOT_USERNAME = "open-swe"
|
||||
DEMO_CHANNEL = "C_DEMO"
|
||||
HUMAN_USER = "U_HUMAN"
|
||||
|
||||
PORT = os.environ.setdefault("E2E_PORT", "2024")
|
||||
BASE_URL = os.environ.setdefault("E2E_BASE", f"http://127.0.0.1:{PORT}")
|
||||
|
||||
_GH_DIR = TMP / "github"
|
||||
_WORK_DIR = TMP / "work"
|
||||
BARE_REMOTE = _GH_DIR / f"{OWNER}__{REPO}.git"
|
||||
|
||||
_DEFAULTS = {
|
||||
# Sandbox: real local provider, rooted in a throwaway temp dir.
|
||||
"SANDBOX_TYPE": "local",
|
||||
"LOCAL_SANDBOX_ROOT_DIR": str(_WORK_DIR),
|
||||
# Keep git's --global writes (bot identity) out of the user's ~/.gitconfig.
|
||||
"GIT_CONFIG_GLOBAL": str(TMP / "gitconfig-global"),
|
||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
# Path the scripted agent clones from (a local bare repo = "fake GitHub").
|
||||
"E2E_REMOTE": str(BARE_REMOTE),
|
||||
# Webhook signing + bot identity.
|
||||
"GITHUB_WEBHOOK_SECRET": "test-github-secret",
|
||||
"SLACK_SIGNING_SECRET": "test-slack-secret",
|
||||
"SLACK_BOT_TOKEN": "xoxb-test-token",
|
||||
"SLACK_BOT_USER_ID": BOT_USER_ID,
|
||||
"SLACK_BOT_USERNAME": BOT_USERNAME,
|
||||
# Slack runs resolve the repo from this when the channel/thread carry none.
|
||||
"DEFAULT_REPO_OWNER": OWNER,
|
||||
"DEFAULT_REPO_NAME": REPO,
|
||||
# Bot-token-only mode: lets Slack runs proceed without a per-user OAuth token.
|
||||
"LANGSMITH_API_KEY_PROD": "test-bot-mode",
|
||||
# SDK client target (same dev server).
|
||||
"LANGGRAPH_URL": BASE_URL,
|
||||
# Dashboard: the "Open in Web" link target + session-cookie signing. Use
|
||||
# 127.0.0.1 (not localhost) so the local-dev LLM-key check stays skipped.
|
||||
"DASHBOARD_BASE_URL": BASE_URL,
|
||||
"DASHBOARD_API_BASE_URL": BASE_URL,
|
||||
"DASHBOARD_ALLOWED_ORIGINS": BASE_URL,
|
||||
"DASHBOARD_JWT_SECRET": "test-dashboard-jwt-secret",
|
||||
}
|
||||
|
||||
# The Slack run's triggering user (fake users.info returns this email) — the
|
||||
# thread owner. A session with this email may continue the thread on the web.
|
||||
SAME_USER = {"login": "dev-user", "email": "dev@example.com"}
|
||||
# Any other logged-in user: can view the thread read-only, cannot post.
|
||||
OTHER_USER = {"login": "someone-else", "email": "someone@example.com"}
|
||||
|
||||
for _k, _v in _DEFAULTS.items():
|
||||
os.environ.setdefault(_k, _v)
|
||||
|
||||
for _d in (TMP, _GH_DIR, _WORK_DIR):
|
||||
_d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
FAKE_GITHUB_API = f"{BASE_URL}/fake-gh"
|
||||
FAKE_SLACK_API = f"{BASE_URL}/fake-slack"
|
||||
151
tests/e2e/fake_llm.py
Normal file
151
tests/e2e/fake_llm.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""A scripted fake chat model — the ONLY faked piece of the agent.
|
||||
|
||||
It drives the real deepagents loop with a fixed sequence of tool calls that
|
||||
implement a tiny feature, push a branch to the fake-GitHub remote, open a PR via
|
||||
the real ``open_pull_request`` tool, and post the result back with the real
|
||||
``slack_thread_reply`` tool. The final Slack step reads the actual PR URL out of
|
||||
the preceding tool result, exactly as a real model would.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from e2e_env import (
|
||||
BASE_BRANCH,
|
||||
FEATURE_BRANCH,
|
||||
FEATURE_FILE,
|
||||
OWNER,
|
||||
PR_TITLE,
|
||||
REPO,
|
||||
)
|
||||
from langchain_core.callbacks import CallbackManagerForLLMRun
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
|
||||
# One shell command that does the whole git workflow. Each execute() runs in a
|
||||
# fresh shell rooted at the sandbox dir, so the clone+commit+push is bundled.
|
||||
_IMPLEMENT_SCRIPT = f"""
|
||||
set -e
|
||||
rm -rf repo
|
||||
git clone "$E2E_REMOTE" repo
|
||||
cd repo
|
||||
git config user.email "dev@example.com"
|
||||
git config user.name "Dev User"
|
||||
git checkout -b {FEATURE_BRANCH}
|
||||
cat > {FEATURE_FILE} <<'EOF'
|
||||
def greet(name):
|
||||
return f"Hello, {{name}}!"
|
||||
EOF
|
||||
git add -A
|
||||
git commit -m "{PR_TITLE}"
|
||||
git push origin {FEATURE_BRANCH}
|
||||
echo PUSHED_OK
|
||||
""".strip()
|
||||
|
||||
|
||||
def _pr_url_from_messages(messages: list[BaseMessage]) -> str | None:
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, ToolMessage):
|
||||
text = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
match = re.search(r"https?://[^\s\"']+/pull/\d+", text)
|
||||
if match:
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def _step_implement(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="Setting up the repo and implementing the change.",
|
||||
tool_calls=[{"name": "execute", "args": {"command": _IMPLEMENT_SCRIPT}, "id": "call-impl"}],
|
||||
)
|
||||
|
||||
|
||||
def _step_open_pr(_messages: list[BaseMessage]) -> AIMessage:
|
||||
return AIMessage(
|
||||
content="Opening a pull request.",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "open_pull_request",
|
||||
"args": {
|
||||
"owner": OWNER,
|
||||
"repo": REPO,
|
||||
"head": FEATURE_BRANCH,
|
||||
"base": BASE_BRANCH,
|
||||
"title": PR_TITLE,
|
||||
"body": "Adds a `greet()` helper as requested.",
|
||||
"draft": True,
|
||||
},
|
||||
"id": "call-pr",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _step_reply(messages: list[BaseMessage]) -> AIMessage:
|
||||
url = _pr_url_from_messages(messages) or "(PR url unavailable)"
|
||||
text = (
|
||||
f"✅ Done! I implemented the change and opened a PR: <{url}|{PR_TITLE}>\n\n"
|
||||
f"• Added `{FEATURE_FILE}` with a `greet()` helper.\n"
|
||||
"Let me know if you'd like any changes."
|
||||
)
|
||||
return AIMessage(
|
||||
content="Replying in the Slack thread with the PR link.",
|
||||
tool_calls=[{"name": "slack_thread_reply", "args": {"message": text}, "id": "call-reply"}],
|
||||
)
|
||||
|
||||
|
||||
FOLLOW_UP_REPLY = "Thanks! The PR is ready for review — anything else you'd like changed?"
|
||||
|
||||
|
||||
def _step_followup(_messages: list[BaseMessage]) -> AIMessage:
|
||||
# A web/Slack follow-up after the PR exists: a plain reply, no new PR. Its
|
||||
# content lands in the thread transcript the dashboard renders.
|
||||
return AIMessage(content=FOLLOW_UP_REPLY)
|
||||
|
||||
|
||||
def build_script() -> list[Any]:
|
||||
return [_step_implement, _step_open_pr, _step_reply]
|
||||
|
||||
|
||||
def build_followup_script() -> list[Any]:
|
||||
return [_step_followup]
|
||||
|
||||
|
||||
class FakeScriptedChatModel(BaseChatModel):
|
||||
"""Returns the next scripted AIMessage based on how far the loop has run."""
|
||||
|
||||
script: list[Any] = []
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "fake-scripted"
|
||||
|
||||
def bind_tools(self, tools: Any, **kwargs: Any) -> FakeScriptedChatModel: # noqa: ARG002
|
||||
return self
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: list[BaseMessage],
|
||||
stop: list[str] | None = None, # noqa: ARG002
|
||||
run_manager: CallbackManagerForLLMRun | None = None, # noqa: ARG002
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
# First human turn implements + opens the PR; later turns (a web/Slack
|
||||
# follow-up on the same thread) just reply.
|
||||
human_turns = sum(1 for m in messages if isinstance(m, HumanMessage))
|
||||
script = build_script() if human_turns <= 1 else build_followup_script()
|
||||
|
||||
# Step within the *current* turn: AIMessages since the last human turn.
|
||||
# (Counting the whole thread would short-circuit reused/multi-turn threads.)
|
||||
last_human = max(
|
||||
(i for i, m in enumerate(messages) if isinstance(m, HumanMessage)), default=-1
|
||||
)
|
||||
step = sum(1 for m in messages[last_human + 1 :] if isinstance(m, AIMessage))
|
||||
if step < len(script):
|
||||
message = script[step](messages)
|
||||
else:
|
||||
message = AIMessage(content="All set — the PR is open and linked in the thread.")
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
153
tests/e2e/fakes.py
Normal file
153
tests/e2e/fakes.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""In-memory state + git plumbing behind the fake GitHub and fake Slack.
|
||||
|
||||
These stores are the single source of truth that both the real agent code
|
||||
(via the faked HTTP endpoints) and the mock UIs read from — so what Playwright
|
||||
sees in the UI is exactly what the agent produced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from e2e_env import BARE_REMOTE, BASE_BRANCH, OWNER, REPO
|
||||
|
||||
# --- Slack -----------------------------------------------------------------
|
||||
# (channel, thread_ts) -> list of {user, text, ts, blocks, is_bot}
|
||||
SLACK_MESSAGES: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
_slack_seq = [1]
|
||||
|
||||
|
||||
def next_slack_ts() -> str:
|
||||
_slack_seq[0] += 1
|
||||
return f"1700000000.{_slack_seq[0]:06d}"
|
||||
|
||||
|
||||
_thread_seq = [0]
|
||||
|
||||
|
||||
def new_thread_ts() -> str:
|
||||
"""A globally-unique thread ts so every send maps to a fresh LangGraph thread
|
||||
(the in-mem store persists across restarts, so reused ids would carry state).
|
||||
Not reset by reset(), so back-to-back tests never collide."""
|
||||
_thread_seq[0] += 1
|
||||
return f"{int(time.time())}.{_thread_seq[0]:06d}"
|
||||
|
||||
|
||||
def add_slack_message(
|
||||
channel: str, thread_ts: str, *, user: str, text: str, blocks: Any = None, is_bot: bool = False
|
||||
) -> str:
|
||||
ts = next_slack_ts()
|
||||
SLACK_MESSAGES.setdefault((channel, thread_ts), []).append(
|
||||
{
|
||||
"user": user,
|
||||
"text": text,
|
||||
"ts": ts,
|
||||
"thread_ts": thread_ts,
|
||||
"blocks": blocks,
|
||||
"is_bot": is_bot,
|
||||
}
|
||||
)
|
||||
return ts
|
||||
|
||||
|
||||
def slack_thread(channel: str, thread_ts: str) -> list[dict[str, Any]]:
|
||||
return SLACK_MESSAGES.get((channel, thread_ts), [])
|
||||
|
||||
|
||||
# --- GitHub ----------------------------------------------------------------
|
||||
PULLS: list[dict[str, Any]] = []
|
||||
_pr_seq = [0]
|
||||
|
||||
|
||||
def _git(*args: str, cwd: Path | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def seed_bare_remote() -> None:
|
||||
"""Create a fresh bare repo (the fake GitHub remote) with one commit on main."""
|
||||
if BARE_REMOTE.exists():
|
||||
shutil.rmtree(BARE_REMOTE)
|
||||
seed_work = BARE_REMOTE.parent / f"seed-{OWNER}-{REPO}"
|
||||
if seed_work.exists():
|
||||
shutil.rmtree(seed_work)
|
||||
|
||||
seed_work.mkdir(parents=True)
|
||||
ident = ["-c", "user.email=seed@example.com", "-c", "user.name=Seed"]
|
||||
_git("init", "-b", BASE_BRANCH, str(seed_work))
|
||||
(seed_work / "README.md").write_text("# demo\n\nA tiny demo repo.\n")
|
||||
_git("add", "-A", cwd=seed_work)
|
||||
_git(*ident, "commit", "-m", "Initial commit", cwd=seed_work)
|
||||
_git("init", "--bare", "-b", BASE_BRANCH, str(BARE_REMOTE))
|
||||
_git("remote", "add", "origin", str(BARE_REMOTE), cwd=seed_work)
|
||||
_git("push", "origin", BASE_BRANCH, cwd=seed_work)
|
||||
shutil.rmtree(seed_work)
|
||||
|
||||
|
||||
def _diff_files(base: str, head: str) -> list[dict[str, Any]]:
|
||||
"""Compute changed files for a PR from the pushed branch in the bare remote."""
|
||||
try:
|
||||
out = _git("--git-dir", str(BARE_REMOTE), "diff", "--numstat", base, head)
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
files = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 3:
|
||||
adds, dels, name = parts
|
||||
files.append(
|
||||
{
|
||||
"filename": name,
|
||||
"additions": int(adds) if adds.isdigit() else 0,
|
||||
"deletions": int(dels) if dels.isdigit() else 0,
|
||||
}
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def create_pull(
|
||||
owner: str, repo: str, *, head: str, base: str, title: str, body: str, draft: bool
|
||||
) -> dict[str, Any]:
|
||||
_pr_seq[0] += 1
|
||||
number = _pr_seq[0]
|
||||
files = _diff_files(base, head)
|
||||
pr = {
|
||||
"number": number,
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"author": "open-swe[bot]",
|
||||
"files": files,
|
||||
"additions": sum(f["additions"] for f in files),
|
||||
"deletions": sum(f["deletions"] for f in files),
|
||||
}
|
||||
PULLS.append(pr)
|
||||
return pr
|
||||
|
||||
|
||||
def find_pull(number: int) -> dict[str, Any] | None:
|
||||
return next((p for p in PULLS if p["number"] == number), None)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
SLACK_MESSAGES.clear()
|
||||
PULLS.clear()
|
||||
_pr_seq[0] = 0
|
||||
_slack_seq[0] = 1
|
||||
seed_bare_remote()
|
||||
24
tests/e2e/global-setup.ts
Normal file
24
tests/e2e/global-setup.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Build the real ui/ SPA once so the harness can serve it same-origin. The API
|
||||
// base is baked in at build time, so it must match the harness port. Set
|
||||
// E2E_FORCE_UI_BUILD=1 to rebuild (e.g. after changing the port or the UI).
|
||||
export default function globalSetup() {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const ui = resolve(repoRoot, "ui");
|
||||
const shell = resolve(ui, ".output", "public", "_shell.html");
|
||||
const port = process.env.E2E_PORT ?? "2024";
|
||||
|
||||
if (existsSync(shell) && !process.env.E2E_FORCE_UI_BUILD) return;
|
||||
|
||||
if (!existsSync(resolve(ui, "node_modules"))) {
|
||||
execSync("bun install", { cwd: ui, stdio: "inherit" });
|
||||
}
|
||||
execSync("bun run build", {
|
||||
cwd: ui,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITE_DASHBOARD_API_BASE_URL: `http://127.0.0.1:${port}` },
|
||||
});
|
||||
}
|
||||
426
tests/e2e/harness.py
Normal file
426
tests/e2e/harness.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""HTTP app for the full-flow E2E (served as langgraph dev's http.app).
|
||||
|
||||
Mounts, on top of the REAL ``agent.webapp`` app:
|
||||
- fake GitHub REST API (/fake-gh/...) the real open_pull_request hits this
|
||||
- fake Slack API (/fake-slack/...) the real slack code hits this
|
||||
- mock UIs (/mock/slack, /mock/github) what the user/Playwright sees
|
||||
- control + compose (/control/*, /mock/slack/send) the test driver
|
||||
|
||||
Nothing here touches agent logic — it only stands in for the SaaS boundaries
|
||||
and renders their state back as a user-facing UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import e2e_env # noqa: E402
|
||||
import patches # noqa: E402
|
||||
|
||||
patches.apply()
|
||||
|
||||
import fakes # noqa: E402
|
||||
import httpx # noqa: E402
|
||||
from e2e_env import ( # noqa: E402
|
||||
BASE_URL,
|
||||
BOT_USER_ID,
|
||||
DEMO_CHANNEL,
|
||||
HUMAN_USER,
|
||||
REPO_ROOT,
|
||||
)
|
||||
from fastapi import HTTPException, Request # noqa: E402
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse # noqa: E402
|
||||
|
||||
from agent.dashboard.oauth import COOKIE_NAME, issue_session # noqa: E402
|
||||
from agent.webapp import app, generate_thread_id_from_slack_thread # noqa: E402
|
||||
|
||||
GITHUB_WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]
|
||||
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
CURRENT_THREAD: dict[str, str | None] = {"channel": DEMO_CHANNEL, "thread_ts": None}
|
||||
|
||||
fakes.seed_bare_remote()
|
||||
|
||||
|
||||
# --- control + Slack compose (the test driver) -----------------------------
|
||||
@app.post("/control/reset")
|
||||
async def control_reset() -> JSONResponse:
|
||||
fakes.reset()
|
||||
CURRENT_THREAD["thread_ts"] = None
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/control/state")
|
||||
async def control_state() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"channel": CURRENT_THREAD["channel"], "thread_ts": CURRENT_THREAD["thread_ts"]}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/mock/slack/send")
|
||||
async def slack_send(request: Request) -> JSONResponse:
|
||||
"""Simulate a user posting in Slack: store the message, then deliver the
|
||||
signed Events-API webhook to the real /webhooks/slack route."""
|
||||
form = await request.json()
|
||||
text = str(form.get("text", ""))
|
||||
mention_bot = bool(form.get("mention_bot", True))
|
||||
channel = DEMO_CHANNEL
|
||||
|
||||
ts = fakes.new_thread_ts()
|
||||
CURRENT_THREAD["thread_ts"] = ts
|
||||
fakes.add_slack_message(channel, ts, user=HUMAN_USER, text=text, is_bot=False)
|
||||
|
||||
payload = {
|
||||
"type": "event_callback",
|
||||
"event_id": f"Ev{ts}",
|
||||
"authorizations": [{"user_id": BOT_USER_ID}],
|
||||
"event": {
|
||||
"type": "app_mention" if mention_bot else "message",
|
||||
"channel": channel,
|
||||
"user": HUMAN_USER,
|
||||
"text": text,
|
||||
"ts": ts,
|
||||
"thread_ts": ts,
|
||||
},
|
||||
}
|
||||
raw = json.dumps(payload).encode()
|
||||
req_ts = str(int(time.time()))
|
||||
base = f"v0:{req_ts}:{raw.decode()}".encode()
|
||||
sig = "v0=" + hmac.new(SLACK_SIGNING_SECRET.encode(), base, hashlib.sha256).hexdigest()
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://harness") as client:
|
||||
resp = await client.post(
|
||||
"/webhooks/slack",
|
||||
content=raw,
|
||||
headers={
|
||||
"X-Slack-Signature": sig,
|
||||
"X-Slack-Request-Timestamp": req_ts,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"thread_ts": ts,
|
||||
"thread_id": generate_thread_id_from_slack_thread(channel, ts),
|
||||
"webhook_status": resp.status_code,
|
||||
"webhook": resp.json(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/control/login")
|
||||
async def control_login(request: Request) -> JSONResponse:
|
||||
"""Simulate a signed-in dashboard user by minting the real session cookie."""
|
||||
form = await request.json()
|
||||
login = str(form.get("login", "dev-user"))
|
||||
email = str(form.get("email", "dev@example.com"))
|
||||
token = issue_session(login=login, email=email, avatar_url=None)
|
||||
resp = JSONResponse({"ok": True, "login": login, "email": email})
|
||||
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", secure=False, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/control/logout")
|
||||
async def control_logout() -> JSONResponse:
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(COOKIE_NAME, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# --- serve the REAL built ui/ SPA, same-origin so the session cookie works ----
|
||||
# The "Open in Web" link (DASHBOARD_BASE_URL/agents/{id}) lands on the real app;
|
||||
# it calls /dashboard/api/* (same origin) and streams via the dashboard proxy.
|
||||
UI_PUBLIC = REPO_ROOT / "ui" / ".output" / "public"
|
||||
_ASSETS_ROOT = (UI_PUBLIC / "assets").resolve()
|
||||
|
||||
|
||||
def _ui_file(name: str) -> FileResponse:
|
||||
path = UI_PUBLIC / name
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, f"{name} not built — run `bun run build` in ui/")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@app.get("/assets/{asset_path:path}")
|
||||
async def ui_asset(asset_path: str) -> FileResponse:
|
||||
# Explicit route, not app.mount(StaticFiles): LangGraph's custom-app loader
|
||||
# serves APIRoutes but drops sub-app Mounts, so a mount 404s under it.
|
||||
target = (_ASSETS_ROOT / asset_path).resolve()
|
||||
if not str(target).startswith(str(_ASSETS_ROOT)) or not target.is_file():
|
||||
raise HTTPException(404, "asset not found")
|
||||
return FileResponse(target)
|
||||
|
||||
|
||||
@app.get("/_shell.html", response_class=HTMLResponse)
|
||||
async def ui_shell() -> FileResponse:
|
||||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/manifest.webmanifest")
|
||||
async def ui_manifest() -> FileResponse:
|
||||
return _ui_file("manifest.webmanifest")
|
||||
|
||||
|
||||
@app.get("/favicon.png")
|
||||
async def ui_favicon() -> FileResponse:
|
||||
return _ui_file("favicon.png")
|
||||
|
||||
|
||||
@app.get("/apple-touch-icon.png")
|
||||
async def ui_apple_icon() -> FileResponse:
|
||||
return _ui_file("apple-touch-icon.png")
|
||||
|
||||
|
||||
@app.get("/logo-mark.png")
|
||||
async def ui_logo_mark() -> FileResponse:
|
||||
return _ui_file("logo-mark.png")
|
||||
|
||||
|
||||
# Client routes used by the handoff tests: serve the SPA shell; the client
|
||||
# router boots at the current URL. Kept explicit (no catch-all) so LangGraph's
|
||||
# own root routes — which the dashboard proxy calls server-side — are untouched.
|
||||
@app.get("/agents/{thread_id}", response_class=HTMLResponse)
|
||||
async def ui_agents_thread(thread_id: str) -> FileResponse: # noqa: ARG001
|
||||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def ui_login() -> FileResponse:
|
||||
return _ui_file("_shell.html")
|
||||
|
||||
|
||||
@app.get("/mock/slack/messages")
|
||||
async def slack_messages() -> JSONResponse:
|
||||
thread = CURRENT_THREAD["thread_ts"]
|
||||
msgs = fakes.slack_thread(CURRENT_THREAD["channel"], thread) if thread else []
|
||||
return JSONResponse(
|
||||
[{"user": m["user"], "text": m["text"], "is_bot": m["is_bot"], "ts": m["ts"]} for m in msgs]
|
||||
)
|
||||
|
||||
|
||||
# --- mock UIs --------------------------------------------------------------
|
||||
@app.get("/mock/slack", response_class=HTMLResponse)
|
||||
async def mock_slack_page() -> str:
|
||||
return (STATIC_DIR / "slack.html").read_text()
|
||||
|
||||
|
||||
@app.get("/mock/github", response_class=HTMLResponse)
|
||||
async def mock_github_page() -> str:
|
||||
return (STATIC_DIR / "github.html").read_text()
|
||||
|
||||
|
||||
def _pr_html_url(pr: dict[str, Any]) -> str:
|
||||
return f"{BASE_URL}/mock/github/{pr['owner']}/{pr['repo']}/pull/{pr['number']}"
|
||||
|
||||
|
||||
@app.get("/mock/github/data")
|
||||
async def mock_github_data() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
[
|
||||
{
|
||||
"number": p["number"],
|
||||
"title": p["title"],
|
||||
"head": p["head"],
|
||||
"base": p["base"],
|
||||
"state": p["state"],
|
||||
"draft": p["draft"],
|
||||
"author": p["author"],
|
||||
"body": p["body"],
|
||||
"files": p["files"],
|
||||
"url": _pr_html_url(p),
|
||||
}
|
||||
for p in fakes.PULLS
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/mock/github/{owner}/{repo}/pull/{number}", response_class=HTMLResponse)
|
||||
async def mock_github_pr(owner: str, repo: str, number: int) -> HTMLResponse: # noqa: ARG001
|
||||
pr = fakes.find_pull(number)
|
||||
if pr is None:
|
||||
return HTMLResponse(f"<h1>PR #{number} not found</h1>", status_code=404)
|
||||
files = "".join(
|
||||
f'<li data-file="{f["filename"]}">{f["filename"]} '
|
||||
f"<span class='stat'>+{f['additions']} −{f['deletions']}</span></li>"
|
||||
for f in pr["files"]
|
||||
)
|
||||
draft = " (draft)" if pr["draft"] else ""
|
||||
return HTMLResponse(
|
||||
f"""<!doctype html><meta charset=utf-8>
|
||||
<title>PR #{pr["number"]} — {pr["owner"]}/{pr["repo"]}</title>
|
||||
<body style="font-family:system-ui;max-width:720px;margin:2rem auto">
|
||||
<p><a href="/mock/github">← all pull requests</a></p>
|
||||
<h1 id="pr-title">{pr["title"]}{draft}</h1>
|
||||
<p>#{pr["number"]} · <span id="pr-state">{pr["state"]}</span> ·
|
||||
<code id="pr-head">{pr["head"]}</code> → <code>{pr["base"]}</code> ·
|
||||
by <span id="pr-author">{pr["author"]}</span></p>
|
||||
<h3>Description</h3><pre id="pr-body">{pr["body"]}</pre>
|
||||
<h3>Files changed ({len(pr["files"])})</h3>
|
||||
<ul id="pr-files">{files}</ul>
|
||||
</body>"""
|
||||
)
|
||||
|
||||
|
||||
# --- fake GitHub REST API (open_pull_request hits this) --------------------
|
||||
def _gh_pr_json(pr: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"number": pr["number"],
|
||||
"html_url": _pr_html_url(pr),
|
||||
"state": pr["state"],
|
||||
"draft": pr["draft"],
|
||||
"merged": pr["merged"],
|
||||
"title": pr["title"],
|
||||
"body": pr["body"],
|
||||
"user": {"login": pr["author"]},
|
||||
"head": {"ref": pr["head"]},
|
||||
"base": {"ref": pr["base"]},
|
||||
"additions": pr["additions"],
|
||||
"deletions": pr["deletions"],
|
||||
"changed_files": len(pr["files"]),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/fake-gh/repos/{owner}/{repo}")
|
||||
async def gh_get_repo(owner: str, repo: str) -> JSONResponse:
|
||||
return JSONResponse({"full_name": f"{owner}/{repo}", "private": False})
|
||||
|
||||
|
||||
@app.get("/fake-gh/repos/{owner}/{repo}/pulls")
|
||||
async def gh_list_pulls(owner: str, repo: str) -> JSONResponse: # noqa: ARG001
|
||||
return JSONResponse([])
|
||||
|
||||
|
||||
@app.post("/fake-gh/repos/{owner}/{repo}/pulls")
|
||||
async def gh_create_pull(owner: str, repo: str, request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
pr = fakes.create_pull(
|
||||
owner,
|
||||
repo,
|
||||
head=body.get("head", ""),
|
||||
base=body.get("base", "main"),
|
||||
title=body.get("title", ""),
|
||||
body=body.get("body", ""),
|
||||
draft=bool(body.get("draft", True)),
|
||||
)
|
||||
return JSONResponse(_gh_pr_json(pr), status_code=201)
|
||||
|
||||
|
||||
@app.get("/fake-gh/repos/{owner}/{repo}/pulls/{number}")
|
||||
async def gh_get_pull(owner: str, repo: str, number: int) -> JSONResponse: # noqa: ARG001
|
||||
pr = fakes.find_pull(number)
|
||||
if pr is None:
|
||||
return JSONResponse({"message": "Not Found"}, status_code=404)
|
||||
return JSONResponse(_gh_pr_json(pr))
|
||||
|
||||
|
||||
# --- fake Slack API (real slack code hits this) ----------------------------
|
||||
def _ok(extra: dict[str, Any] | None = None) -> JSONResponse:
|
||||
return JSONResponse({"ok": True, **(extra or {})})
|
||||
|
||||
|
||||
@app.post("/fake-slack/chat.postMessage")
|
||||
async def slack_post_message(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
ts = fakes.add_slack_message(
|
||||
body.get("channel", ""),
|
||||
body.get("thread_ts", ""),
|
||||
user=BOT_USER_ID,
|
||||
text=body.get("text", ""),
|
||||
blocks=body.get("blocks"),
|
||||
is_bot=True,
|
||||
)
|
||||
return _ok({"ts": ts, "message": {"ts": ts}})
|
||||
|
||||
|
||||
@app.post("/fake-slack/chat.postEphemeral")
|
||||
async def slack_post_ephemeral(request: Request) -> JSONResponse:
|
||||
await request.body()
|
||||
return _ok({"message_ts": fakes.next_slack_ts()})
|
||||
|
||||
|
||||
@app.post("/fake-slack/assistant.threads.setStatus")
|
||||
async def slack_set_status(request: Request) -> JSONResponse:
|
||||
await request.body()
|
||||
return _ok()
|
||||
|
||||
|
||||
@app.post("/fake-slack/reactions.add")
|
||||
async def slack_reactions_add(request: Request) -> JSONResponse:
|
||||
await request.body()
|
||||
return _ok()
|
||||
|
||||
|
||||
@app.get("/fake-slack/users.info")
|
||||
async def slack_users_info(user: str = "") -> JSONResponse:
|
||||
return _ok(
|
||||
{
|
||||
"user": {
|
||||
"id": user,
|
||||
"name": "devuser",
|
||||
"real_name": "Dev User",
|
||||
"profile": {
|
||||
"email": "dev@example.com",
|
||||
"display_name": "Dev User",
|
||||
"real_name": "Dev User",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/fake-slack/conversations.info")
|
||||
async def slack_conversations_info(channel: str = "") -> JSONResponse:
|
||||
return _ok(
|
||||
{
|
||||
"channel": {
|
||||
"id": channel,
|
||||
"name": "demo",
|
||||
"topic": {"value": ""},
|
||||
"purpose": {"value": ""},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/fake-slack/conversations.replies")
|
||||
async def slack_conversations_replies(channel: str = "", ts: str = "") -> JSONResponse:
|
||||
msgs = fakes.slack_thread(channel, ts)
|
||||
return _ok(
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"type": "message",
|
||||
"user": m["user"],
|
||||
"text": m["text"],
|
||||
"ts": m["ts"],
|
||||
"thread_ts": m["thread_ts"],
|
||||
}
|
||||
for m in msgs
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/fake-slack/conversations.history")
|
||||
async def slack_conversations_history(channel: str = "") -> JSONResponse: # noqa: ARG001
|
||||
return _ok({"messages": []})
|
||||
|
||||
|
||||
@app.get("/fake-slack/chat.getPermalink")
|
||||
async def slack_get_permalink(channel: str = "", message_ts: str = "") -> JSONResponse: # noqa: ARG001
|
||||
return _ok({"permalink": f"{BASE_URL}/mock/slack"})
|
||||
|
||||
|
||||
# Quietly reference imports used only for env side effects.
|
||||
_ = (e2e_env, HUMAN_USER)
|
||||
11
tests/e2e/langgraph.e2e.json
Normal file
11
tests/e2e/langgraph.e2e.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://langgra.ph/schema.json",
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./tests/e2e/agent_entrypoint.py:traced_agent"
|
||||
},
|
||||
"http": {
|
||||
"app": "./tests/e2e/harness.py:app"
|
||||
}
|
||||
}
|
||||
76
tests/e2e/package-lock.json
generated
Normal file
76
tests/e2e/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "open-swe-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-swe-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
tests/e2e/package.json
Normal file
13
tests/e2e/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "open-swe-e2e",
|
||||
"private": true,
|
||||
"description": "Playwright E2E tests driving mock GitHub/Slack control panels against the real webhook routes.",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.0"
|
||||
}
|
||||
}
|
||||
72
tests/e2e/patches.py
Normal file
72
tests/e2e/patches.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Boundary monkeypatches: fake the LLM and the external SaaS endpoints.
|
||||
|
||||
Everything patched here is an *external boundary*, not agent logic:
|
||||
- the LLM (model factory) -> scripted fake
|
||||
- GitHub App token mint + GitHub REST base URL -> dummy token + fake GitHub
|
||||
- Slack API base URL -> fake Slack
|
||||
- the api.github.com/user identity lookup -> offline (falls back to config)
|
||||
|
||||
Applied at import of both the graph entrypoint and the HTTP harness (same dev
|
||||
process), so it runs before the first run regardless of import order. Idempotent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import e2e_env # noqa: F401 (sets env before any agent import)
|
||||
|
||||
_applied = False
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
global _applied
|
||||
if _applied:
|
||||
return
|
||||
|
||||
import importlib
|
||||
|
||||
from agent import server
|
||||
from agent.utils import auth, authorship
|
||||
from agent.utils import slack as slack_utils
|
||||
|
||||
# NB: ``from agent.tools import open_pull_request`` returns the re-exported
|
||||
# *function* (the tools package __init__ shadows the submodule), so patch the
|
||||
# actual module object by name instead.
|
||||
opr = importlib.import_module("agent.tools.open_pull_request")
|
||||
|
||||
from e2e_env import FAKE_GITHUB_API, FAKE_SLACK_API
|
||||
from fake_llm import FakeScriptedChatModel, build_script
|
||||
|
||||
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
|
||||
return FakeScriptedChatModel(script=build_script())
|
||||
|
||||
server.make_model = _fake_make_model
|
||||
|
||||
async def _dummy_install_token_with_expiry() -> tuple[str, str | None]:
|
||||
return "dummy-installation-token", None
|
||||
|
||||
async def _dummy_install_token() -> str:
|
||||
return "dummy-installation-token"
|
||||
|
||||
auth.get_github_app_installation_token_with_expiry = _dummy_install_token_with_expiry
|
||||
opr.get_github_app_installation_token = _dummy_install_token
|
||||
|
||||
# Point the real PR/Slack code at the in-process fakes.
|
||||
opr.GITHUB_API = FAKE_GITHUB_API
|
||||
slack_utils.SLACK_API_BASE_URL = FAKE_SLACK_API
|
||||
|
||||
# Keep the triggering-user identity lookup offline; the real fallback to
|
||||
# config-derived identity (Slack name/email) still runs.
|
||||
authorship._identity_from_github_token = lambda _token: None # noqa: SLF001
|
||||
|
||||
# OAuth-token store is an external credential boundary. Stub it so a web
|
||||
# follow-up (dashboard run.start) and PR-as-user resolution have a token;
|
||||
# the real ownership/authorization checks still run.
|
||||
from agent.dashboard import profiles, thread_api
|
||||
|
||||
async def _dummy_user_token(login: str, **_kwargs: object) -> str: # noqa: ARG001
|
||||
return "dummy-user-oauth-token"
|
||||
|
||||
profiles.get_valid_access_token = _dummy_user_token
|
||||
thread_api.get_valid_access_token = _dummy_user_token
|
||||
|
||||
_applied = True
|
||||
44
tests/e2e/playwright.config.ts
Normal file
44
tests/e2e/playwright.config.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const PORT = Number(process.env.E2E_PORT ?? 2024);
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
globalSetup: "./global-setup.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 60_000 },
|
||||
reporter: [["list"], ["html", { open: "never" }]],
|
||||
use: {
|
||||
baseURL,
|
||||
// Always capture the replayable artifacts: a trace (DOM snapshots, network,
|
||||
// console, source — open with `npx playwright show-trace`) and a screen
|
||||
// recording, plus a screenshot on failure. The CI job uploads them.
|
||||
trace: "on",
|
||||
video: "on",
|
||||
screenshot: "only-on-failure",
|
||||
// The built UI ships a PWA service worker; block it so tests never hit a
|
||||
// stale cache and always see live API responses.
|
||||
serviceWorkers: "block",
|
||||
// SLOW_MO=700 npx playwright test --headed → watch it run in human time.
|
||||
launchOptions: { slowMo: Number(process.env.SLOW_MO ?? 0) },
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
// Real langgraph dev: real agent graph + real webhook routes + the harness
|
||||
// http app (fake GitHub/Slack + mock UIs). Only the LLM is faked.
|
||||
command:
|
||||
"uv run langgraph dev --config tests/e2e/langgraph.e2e.json " +
|
||||
`--port ${PORT} --no-browser --allow-blocking --no-reload`,
|
||||
cwd: repoRoot,
|
||||
url: `${baseURL}/mock/github/data`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
45
tests/e2e/static/github.html
Normal file
45
tests/e2e/static/github.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mock GitHub — pull requests</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 680px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.1rem; }
|
||||
.pr { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.6rem 0.8rem; margin: 0.6rem 0; }
|
||||
.pr-title { font-weight: 700; }
|
||||
.meta { color: #666; font-size: 0.85rem; }
|
||||
code { background: #f4f4f5; padding: 0 0.25rem; border-radius: 4px; }
|
||||
a { color: #1264a3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>fakeorg/demo — Pull requests (mock)</h1>
|
||||
<div id="list"></div>
|
||||
|
||||
<script>
|
||||
async function load() {
|
||||
const res = await fetch("/mock/github/data");
|
||||
const prs = await res.json();
|
||||
const list = document.getElementById("list");
|
||||
if (!prs.length) {
|
||||
list.innerHTML = "<p style='color:#888'>No pull requests yet.</p>";
|
||||
return;
|
||||
}
|
||||
list.innerHTML = prs
|
||||
.map(
|
||||
(p) => `
|
||||
<div class="pr" data-pr="${p.number}">
|
||||
<div class="pr-title"><a href="${p.url}">#${p.number} ${p.title}</a>${p.draft ? " (draft)" : ""}</div>
|
||||
<div class="meta"><code>${p.head}</code> → <code>${p.base}</code> · ${p.state} · by ${p.author}</div>
|
||||
<div class="files">${p.files.map((f) => `<code>${f.filename}</code>`).join(" ")}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
load();
|
||||
setInterval(load, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
69
tests/e2e/static/slack.html
Normal file
69
tests/e2e/static/slack.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mock Slack — #demo</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 680px; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.1rem; }
|
||||
.msg { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.5rem 0.75rem; margin: 0.5rem 0; }
|
||||
.msg.bot { background: #f0f7ff; }
|
||||
.who { font-weight: 700; font-size: 0.8rem; color: #444; }
|
||||
.text { white-space: pre-wrap; }
|
||||
.compose { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
||||
.compose input { flex: 1; padding: 0.5rem; font: inherit; }
|
||||
button { padding: 0.5rem 0.9rem; font: inherit; cursor: pointer; }
|
||||
a { color: #1264a3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>#demo — Slack (mock)</h1>
|
||||
<div class="compose">
|
||||
<input id="text" value="<@U0BOT> please add a greet() helper and open a PR" />
|
||||
<button id="send">Send</button>
|
||||
<button id="reset">Reset</button>
|
||||
</div>
|
||||
<label style="font-size:0.8rem"><input type="checkbox" id="mention" checked /> mention the bot (app_mention)</label>
|
||||
<div id="thread"></div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function render(messages) {
|
||||
const html = messages
|
||||
.map((m) => {
|
||||
const who = m.is_bot ? "open-swe (bot)" : "you";
|
||||
const linked = m.text.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, '<a href="$1">$2</a>');
|
||||
return `<div class="msg ${m.is_bot ? "bot" : ""}" data-bot="${m.is_bot}"><div class="who">${who}</div><div class="text">${linked}</div></div>`;
|
||||
})
|
||||
.join("");
|
||||
$("thread").innerHTML = html || "<p style='color:#888'>No messages yet.</p>";
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch("/mock/slack/messages");
|
||||
render(await res.json());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
$("send").addEventListener("click", async () => {
|
||||
await fetch("/mock/slack/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: $("text").value, mention_bot: $("mention").checked }),
|
||||
});
|
||||
poll();
|
||||
});
|
||||
|
||||
$("reset").addEventListener("click", async () => {
|
||||
await fetch("/control/reset", { method: "POST" });
|
||||
render([]);
|
||||
});
|
||||
|
||||
poll();
|
||||
setInterval(poll, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
70
tests/e2e/tests/dashboard.spec.ts
Normal file
70
tests/e2e/tests/dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
53
tests/e2e/tests/full_flow.spec.ts
Normal file
53
tests/e2e/tests/full_flow.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Full happy path, driven entirely through the mock Slack + GitHub UIs:
|
||||
// user asks in Slack -> real agent implements in a local sandbox -> opens a PR
|
||||
// on the fake GitHub -> replies with the PR link in the SAME Slack thread.
|
||||
// Only the LLM is faked; all agent code runs for real via langgraph dev.
|
||||
test.describe("Open SWE full flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/mock/slack");
|
||||
await page.locator("#reset").click();
|
||||
await expect(page.locator("#thread")).toContainText("No messages yet");
|
||||
});
|
||||
|
||||
test("Slack request → implements → opens PR → links it back in the thread", async ({ page }) => {
|
||||
await page.locator("#text").fill("<@U0BOT> please add a greet() helper and open a PR");
|
||||
await page.locator("#send").click();
|
||||
|
||||
// The user's message lands in the thread.
|
||||
await expect(page.locator(".msg").filter({ hasText: "add a greet() helper" })).toBeVisible();
|
||||
|
||||
// The agent replies in the SAME thread with a link to the PR it opened.
|
||||
const reply = page.locator(".msg.bot").filter({ hasText: "Add greet() helper" });
|
||||
await expect(reply).toBeVisible();
|
||||
const prLink = reply.locator('a[href*="/pull/"]');
|
||||
await expect(prLink).toBeVisible();
|
||||
|
||||
// Follow the link → the PR page shows what GitHub would show.
|
||||
await prLink.click();
|
||||
await expect(page.locator("#pr-title")).toContainText("Add greet() helper");
|
||||
await expect(page.locator("#pr-state")).toHaveText("open");
|
||||
await expect(page.locator("#pr-head")).toHaveText("add-greet");
|
||||
await expect(page.locator('#pr-files li[data-file="greet.py"]')).toBeVisible();
|
||||
|
||||
// The PR list view shows it too.
|
||||
await page.goto("/mock/github");
|
||||
await expect(page.locator('.pr[data-pr="1"]')).toContainText("Add greet() helper");
|
||||
await expect(page.locator('.pr[data-pr="1"]')).toContainText("greet.py");
|
||||
});
|
||||
|
||||
test("a message that does not mention the bot produces no run and no PR", async ({ page }) => {
|
||||
await page.locator("#mention").uncheck();
|
||||
await page.locator("#text").fill("just chatting with the team, nothing for the bot");
|
||||
await page.locator("#send").click();
|
||||
|
||||
await expect(page.locator(".msg").filter({ hasText: "just chatting" })).toBeVisible();
|
||||
// No agent activity: give the (non-)run a moment, then assert nothing came back.
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.locator(".msg.bot")).toHaveCount(0);
|
||||
|
||||
const prs = await (await page.request.get("/mock/github/data")).json();
|
||||
expect(prs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue