User-perspective E2E testing

SkillWeb & browsing

Design, extend, and run ScienceDiscovery E2E user journeys through the browser, public HTTP API, CLI, or local product stack. Use when changing user-observable behavior, selecting mocked/real journeys, isolating a test stack, reporting user outcomes, or attributing an E2E failure. Browser journeys use the pinned Playwright environment in test/.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the User-perspective E2E testing skill

What this skill tells your AI

The instructions your AI receives, as published by openjiuwen-ai/sciencediscovery in .agents/skills/e2e-testing/SKILL.md and read by ahel’s review.

Project-local skill for ScienceDiscovery. E2E means simulating actual product use from a user's goal to an observable outcome, not selecting a particular test framework. Browser journeys use Playwright; public API, CLI, and local-stack journeys also qualify when they cross the real product entry point. Package tests, type checks, in-process calls to internal functions, or assertions that only prove a request/click occurred are not E2E.

Read the shared target/isolation rules for every journey. For browser work, follow the pinned Playwright, metadata, fixtures, and report rules below. For non-browser work, follow API / CLI / local-stack journeys. pnpm ci:e2e and the CI e2e layer still run the mocked browser subset; they neither discover nor certify every kind of user-perspective E2E.

The primary unit of coverage is a user journey: one recognizable user goal from entering the product through its observable outcome. Do not split the main suite into internal-module specs such as shell.spec.ts, python.spec.ts, or environment.spec.ts. Shell, Python, managed environments, subagents, permissions, and artifacts should appear naturally as steps inside journeys.

A journey is written as numbered user steps, and every run writes a step-by-step report a human can read without opening the spec. That contract is mandatory for new work. Browser automation uses Journey steps and automatic reports; API/CLI drivers record equivalent steps and outcomes without requiring page screenshots.

When to add coverage

When an implementation changes user-observable behavior, its implementer must add or improve the relevant journey with the feature, unless existing coverage already verifies the changed contract (identify and rerun it). Do not defer journey coverage to PR preparation or another role. This includes Run outcomes, tool execution, queryable versioned state, artifacts, permissions, failure feedback, cancellation, and recovery, even when no page changes. An independent tester, when assigned, designs scenarios from the user requirements rather than copying implementation conclusions.

Choose the interface the user actually uses. API coverage complements but does not replace browser checks for changed UI behavior. Cover success and the relevant failure/repeat/cancel/recovery paths in proportion to risk. Write E2E: not applicable only when there is no user-observable product path affected (for example documentation/comments-only changes), and explain why. Backend-only / no new UI is not an exemption. A missing environment or journey driver is BLOCKED or a coverage gap, not “not applicable.”

Target commit and worktree

Keep the repository's main worktree read-only. Obtain the target ref from the test or review request, fetch that ref only when it is not available locally, then resolve and record its immutable commit SHA. Do not automatically merge, rebase, or replace the requested target with another branch. Only when the requested target is origin/master, fetch it immediately before resolving and testing that fetched SHA.

Use the task's assigned worktree by default. Before a formal run, commit the candidate and record its immutable SHA; do not alter tracked files while that SHA is under test. Create a detached, run-specific worktree only when the task worktree is being rebased, fault injection must mutate it, or parallel runs cannot otherwise isolate their ports/data:

git worktree add --detach .worktrees/e2e-<change>-<run-id> <target-sha>

Keep the target commit unchanged. Assemble dependencies, services, data, ports, and evidence only in the assigned or explicitly justified E2E worktree, using ignored or run-specific paths. Report the requested target ref, resolved target SHA, worktree, and whether the run used any separate test-only derived commit.

If validation reveals that code, a spec, or this skill must change, stop treating the current run as validation of the target. Create and commit a new candidate under the repository's development rules, then rerun E2E against that new immutable commit.

API / CLI / local-stack journeys

  1. Start the isolated, committed product with ./scripts/start-stack.sh --mode local, or a documented equivalent product entry point when testing that entry itself. Use --no-build only for artifacts built from the target SHA. Follow Isolated stack per run for ports, data, service URLs and authentication. Verify the services required by the journey; check the Web entry only when the journey uses it.
  2. Drive the running product from a separate client through its supported HTTP API or documented CLI. A representative goal is: create a Project/Session, submit a request, handle any permission prompt, wait for the Run terminal state, retrieve the output/artifact, and confirm the Session can continue. Assert the documented result and feedback, not a hard-coded incidental tool order. Neither importing createNativeAgent/createAgentRun in-process nor starting only a test-constructed server substitutes for the product startup path. /api/health alone is a preflight, not this complete journey.
  3. Store reusable non-browser drivers in test/api/, named after the user goal, with their exact root-level invocation and required environment documented. The existing run_m1_smoke.sh and run_real_smoke.sh directly instantiate the adapter: they remain smoke/integration checks, not E2E by location or name. There is currently no generic non-browser E2E command; inspect the committed driver or add the missing journey. Do not invent a pnpm entry point or claim ci:e2e ran it. Integrate future drivers into the existing test catalog when requested, without silently changing CI layer behavior.
  4. Declare the same purpose, steps, environment, external capabilities, credentials, mocked/real choice and cost/side effects as E2E-META. Playwright tags, fixtures and check-e2e-meta.mjs apply to browser specs; a non-browser driver must document and enforce its own gates and reporting. Default to a journey-owned local model stub registered through the product API. Real models/services require explicit opt-in and documented costs. Browser network guards do not protect a CLI or backend: keep their configured endpoints local and audit all external calls as well.
  5. Wait for observable status with bounded polling/stream consumption, not fixed sleeps. Record numbered user steps, expected/actual results, SHA, startup and driver commands, health, redacted request/response summaries, CLI exit status/output, and necessary service logs in report.md for PASS, FAIL and BLOCKED. Assert product-promised persistent records where relevant; internal database reads alone do not replace the user's access path. A driver must fail on a failed outcome and report missing prerequisites as BLOCKED, never as a successful zero-case run. No fake screenshots required.
  6. Clean up only journey-owned records and processes in finally, after preserving evidence. Keep data and report files in the run's isolated directories; never publish credentials or commit generated results.

Browser: the pinned Playwright, and nothing else

Use only the @playwright/test declared by test/e2e.package.json, assembled into the gitignored .e2e/ environment:

node test/sync-e2e.mjs --write
cd .e2e
npm install
./node_modules/.bin/playwright install chromium

Never use npx playwright, a globally installed Playwright, a throwaway npm project, or system browser drivers. Every npm test/list command runs the fail-closed sync-e2e.mjs --check first. If a committed manifest, lockfile, or config differs from .e2e, synchronize and rerun npm install; never bypass the check. Each worktree gets its own .e2e/; only the npm download cache and the Playwright browser cache may be shared.

If Chromium exits at sandbox_host_linux.cc with Operation not permitted inside an execution sandbox, classify it as test infrastructure and request an approved run outside that sandbox. Do not work around it with extra unsafe browser flags or a different browser binary.

Isolated stack per run

Run formal E2E from the task's assigned immutable worktree or the explicitly justified detached worktree described above, never from the main worktree or an unrelated development worktree.

One run owns one worktree + SHA, one set of API/Runner/Gateway processes and ports, one data directory, and one artifact directory. Parallel runs must be fully isolated or serialized. Start the stack from the worktree root:

./scripts/start-stack.sh --mode local            # provisions and builds
./scripts/start-stack.sh --mode local --no-build # only if the build matches this SHA

Isolation variables (set for start-stack.sh and the API alike):

VariableDefaultMeaning
SCIENCE_AGENT_PORT4310API/Web port
SCIENCE_AGENT_RUNNER_PORT4311where the runner listens
SCIENCE_AGENT_RUNNER_URLhttp://127.0.0.1:<port>where the API dials — the API does not read SCIENCE_AGENT_RUNNER_PORT, so on a non-default port always set the full URL too
SCIENCE_AGENT_DATA_DIR.sciencediscovery-datadata root; also holds envs/ (service venvs)
SCIENCE_AGENT_AUTH_TOKENgenerated when omittedOptional server-side override; never assume a literal default
E2E_API_TOKENnoneRequired browser/API token; use the value printed/generated by this isolated stack
E2E_BASE_URLhttp://127.0.0.1:4310must point at the same API the fixtures use
UV_CACHE_DIRuv default (~/.cache/uv)Pin inside the run worktree (for example $SCIENCE_AGENT_DATA_DIR/uv-cache). The home cache sits outside the worktree and fails in environments that cannot write $HOME/.cache.

After starting, verify API/Runner and any other service required by the journey; verify the web entry for browser journeys. Export the stack's token as E2E_API_TOKEN; browser global setup fails before the first scenario when it is absent. Non-browser clients must use the same stack and authentication. When a required service, port, or credential is missing, record the run as BLOCKED with the missing item — never skip silently.

Isolation traps that produce confusing failures:

  • Never share .sciencediscovery-data/envs between worktrees (no symlinks). The service venvs are editable installs: their .pth files point back at the source tree of whichever worktree provisioned them, so a shared venv silently runs another worktree's code. Provision per worktree; uv sync is fast when UV_CACHE_DIR points at a worktree-local cache.
  • Pin UV_CACHE_DIR inside the worktree. uv's default cache is ~/.cache/uv. Export UV_CACHE_DIR to a run-owned path (for example $SCIENCE_AGENT_DATA_DIR/uv-cache) before start-stack.sh / uv sync. A permission error against the home cache is test infrastructure, not a product defect. npm and Playwright browser caches may still be shared as above; do not send uv's cache to $HOME.
  • Export every isolation variable explicitly. A worktree under .worktrees/ lives inside the main repository, and the gateway's dotenv loading searches parent directories, so the main repo's .env (its ports, data dir, model config) leaks into the gateway even when the worktree has no .env. Explicitly exported variables win over dotenv; alternatively copy a trimmed .env into the worktree as the run's baseline.

Browser: discover, filter, run

For repository CI scheduling, use the cross-runner catalog from the repository root before assembling a stack:

pnpm ci:tags
pnpm ci:list -- --tag layer:e2e --tag llm:stub --tag arch:amd64
pnpm ci:run -- --case e2e.mocked

Every catalog case declares arch:*, llm:*, npu:*, and sandbox:* plus layer/container/network tags. Repeated --tag clauses mean AND; comma-separated tags within one clause mean OR; --exclude removes matches. Keep e2e.mocked, e2e.real, and e2e.legacy aligned with the Playwright projects below whenever their requirements change. Never reclassify an unaudited dependency as safe: use an unreviewed tag or keep the case unsupported until evidence exists.

The catalog chooses a CI-capability group; Playwright still discovers and filters the individual specs in that group. Use the pinned .e2e commands below for file/title-level discovery.

Run from .e2e/ after synchronizing and installing the committed environment:

npm run check:meta                    # validate metadata, tags, quarantine
npm run test:list                     # list only @mocked (safe default)
npm test                              # run only @mocked (same as below)
npm run test:mocked                   # run only @mocked
npm run test:real:list                # discover only @real; makes no live call
npm run test:real                     # RUN live @real (explicit opt-in)
npm run test:mixed:list               # discover @mocked + @real
npm run test:mixed                    # RUN both groups (explicit opt-in)
npm run test:mocked -- foo.spec.ts    # one file, relative to test/
npm run test:mocked -- -g "title"     # semantic title filter

The mocked group is credential-free, not stack-free: start the isolated stack before the default suite. Only explicitly self-contained specs such as the network guard may run without it.

Grouping is enforced by the Playwright projects in test/playwright.config.ts:

  • mocked — only specs tagged @mocked. It is the sole default project and uses a journey-owned local stub model to drive deterministic, user-visible product contracts (for example permission feedback, tool process, artifacts, and environment state). It must be hermetic, credential-free, stable, and repeatable. Its custom test fixture installs the HTTP(S)/WebSocket guard before hooks or pages. The project also blocks service workers so they cannot bypass routing.
  • real — specs tagged @real. The project is defined only when E2E_REAL=1 is set, so no default command can trigger live LLM calls, network access, or paid usage. Without the variable, --project=real fails with "Project(s) 'real' not found": that run is BLOCKED, not passed. Keep this group small: it is a smoke check that a real user can express the goal naturally and reach the outcome, not a duplicate deterministic matrix. Assert stable user invariants, never exact model wording or a single incidental tool sequence.
  • legacy — untagged specs listed in LEGACY in test/check-e2e-meta.mjs. They are quarantined because their external behavior has not been audited. Use npm run test:legacy:list to inventory; run npm run test:legacy only with explicit approval and the same caution as real tests. During initial adoption, only pre-existing unaudited specs from the base branch may enter LEGACY; never place a newly written journey there. Migrate the list incrementally without making old specs undiscoverable.

test:real and test:mixed are execution opt-ins, not discovery commands. Before using either, inspect E2E-META, confirm credentials/endpoints/costs and record authorization. A skipped real test with a BLOCKED: reason remains BLOCKED in the run report; zero failures does not turn blocked/skipped cases into PASS.

E2E-META: every browser test documents itself

Each test() carries an E2E-META comment directly above it and a matching { tag: "@mocked" } or { tag: "@real" } option. node test/check-e2e-meta.mjs enforces the fields and tag consistency (legacy files are warned until migrated; new files must comply). Template:

/**
 * E2E-META
 * Purpose: <the user-visible behavior this verifies>
 * Steps:
 *   1. <main step>
 *   2. <main step>
 * Environment: <stack, base URL, services, ports, data/fixture preconditions>
 * Type: mocked | real
 * LLM: <none | local stub | provider/model/endpoint and nondeterminism>
 * WebSearch: <none | engine/endpoint and expected queries>
 * PaperSources: <none | PubMed/arXiv/etc. and expected queries/downloads>
 * MCP: <none | server/tools and expected calls>
 * OtherExternal: <none | any other network/process/service behavior>
 * Credentials: <none | exact env vars or seeded configuration>
 * CostSideEffects: <none | fees, rate limits, writes, messages, mutations>
 */

Mocked example:

/**
 * E2E-META
 * Purpose: A hung agent turn times out with a user-visible reason.
 * Steps:
 *   1. Register a silent local stub model over the API.
 *   2. Start a run and wait for the idle timeout.
 *   3. Assert the timeout reason in the session view.
 * Environment: Running stack at E2E_BASE_URL; isolated data; no models.
 * Type: mocked
 * LLM: local silent HTTP stub only; no live model.
 * WebSearch: none
 * PaperSources: none
 * MCP: none
 * OtherExternal: none — non-local browser requests are aborted.
 * Credentials: none
 * CostSideEffects: no cost; temporary records are deleted in finally.
 */
test("超时原因可追溯", { tag: "@mocked" }, async ({ page }) => {

Real example:

/**
 * E2E-META
 * Purpose: A live model run produces an anchored plan card.
 * Steps:
 *   1. Register the real model via env config; create project/session.
 *   2. Run a planning prompt; assert the card position.
 * Environment: Isolated stack at E2E_BASE_URL; E2E_SCREENSHOTS for output.
 * Type: real
 * LLM: real chat completions via E2E_LLM_BASE_URL; output varies.
 * WebSearch: none
 * PaperSources: none
 * MCP: none
 * OtherExternal: none
 * Credentials: E2E_LLM_BASE_URL / E2E_LLM_MODEL / E2E_LLM_TOKEN.
 * CostSideEffects: billable tokens and provider rate limits; creates isolated records.
 */
test("plan card anchors", { tag: "@real" }, async ({ page }, testInfo) => {
  requireRealEnv(testInfo, "E2E_LLM_BASE_URL", "E2E_LLM_MODEL", "E2E_LLM_TOKEN");
  await requireRealStack(testInfo);
  // Check any seeded model/connector state here and testInfo.skip(true, "BLOCKED: ...")
  // only when that identifiable precondition is absent.
  await page.goto("/");
  // Product assertions after satisfied gates still FAIL normally.
});

Journey steps and automatic reports (mandatory)

Every test/journey-*.spec.ts must be written as user steps through the journey fixture. This is a requirement, not a suggestion: node test/check-e2e-meta.mjs fails a journey spec that does not request the { journey } fixture, does not call journey.scenario(...), does not call journey.step(...), or takes its own page.screenshot().

The rules

  1. One journey.step() = one user step. Use the steps from the journey's design document. Merge adjacent micro-interactions (fill three fields, then save) into the step a user would name; never split by internal module and never make one step per click.
  2. The step description says what the user should see, in the reader's language, not what the code asserts. Someone who has never read the spec must be able to follow the report.
  3. Every step's evidence comes from the helper. It waits for the page to settle, screenshots, and files that step's console/network noise. Do not hand-roll page.screenshot("01-...") as primary evidence.
  4. journey.scenario({ goal, preconditions }) is required, at the top of the test. It supplies the report's goal and preconditions; without it the report falls back to the English E2E-META Purpose/Environment, which is contract text rather than an explanation for a reader.
  5. A report is written for every outcome — passed, failed, and blocked — because the fixture tears the reporter down. Steps that already ran stay in the report when a later step fails, and the failing step keeps its own screenshot plus an error summary.
  6. Do not weaken an assertion to make a report green. A journey that documents a known product gap stays FAIL and says so in its preconditions.

What a run produces

.e2e/journey-reports/<spec>/<test>/ (gitignored) receives:

FileContent
report.mdResult, commit SHA under test (and whether the tree was dirty), spec file, group/tags, start/end/duration, scenario goal, preconditions, gate reason when blocked, the step table, and per-step detail
report.htmlThe same content, self-contained: inline CSS only, screenshots by relative path, opens from the filesystem with no network
NN-<step>.pngOne screenshot per step, in step order; a step that failed or was blocked is suffixed accordingly

Per-step "key logs" are browser console errors/warnings, page errors, failed requests, and any response with status ≥ 400 — recorded as method, URL, and status. Bodies are never captured. Every recorded string passes through redaction: known credential variables, Bearer values, token=/api_key= query parameters, the repository root, and the home directory are replaced. Keep it that way; reports get published.

Both files are also attached to the Playwright HTML report, so a CI run carries them without a separate copy step.

Set E2E_JOURNEY_REPORTS to redirect the output root. Never commit reports, screenshots, or .e2e/.

Shortest complete example

import { expect } from "@playwright/test";

import { test } from "./helpers/e2e.ts";
import { createProjectAndSession, openProjectSession, scriptedModel,
  sendUserMessage, waitForRunTerminal, expandToolStep, cleanupJourney } from "./helpers/journeys.ts";

/**
 * E2E-META
 * ... all eleven fields ...
 */
test("J9 用户可以拿到一次可核对的执行结果", { tag: "@mocked" }, async ({ journey, page }) => {
  journey.scenario({
    goal: "一位研究员要确认产品真的在本机执行了他要求的计算,而不只是在聊天。",
    preconditions: ["隔离栈已启动", "模型由旅程自带的本地 stub 驱动,不访问外部服务"],
  });

  const marker = `J9-${Date.now()}`;
  const stub = await scriptedModel([
    { arguments: { command: `echo ${marker}` }, tool: "run_shell" },
    { text: "The requested check completed." },
  ]);
  const fixture = await createProjectAndSession(page, {
    model: { apiToken: stub.apiToken, baseUrl: stub.baseUrl, model: stub.model, name: `J9 ${Date.now()}` },
    projectName: `J9 ${Date.now()}`,
    sessionTitle: `J9 ${Date.now()}`,
  });

  try {
    await journey.step(
      "进入会话并提出请求",
      "任务发出后运行走到完成态,主按钮回到可再次运行的状态。",
      async () => {
        await openProjectSession(page, fixture);
        const run = await sendUserMessage(page, fixture.session.id, "Check the workspace and report what you find.");
        expect((await waitForRunTerminal(page, fixture.session.id, run.id)).status).toBe("completed");
      },
    );

    await journey.step(
      "展开工具步骤核对输出",
      "工具步骤展开后能看到这次执行的真实输出,说明命令确实跑过。",
      async () => {
        await expect(await expandToolStep(page, { contains: marker })).toContainText(marker);
      },
    );
  } finally {
    await cleanupJourney(page, fixture);
    await stub.stop();
  }
});

A @real journey follows the same shape; put requireRealEnv(...) and await requireRealStack(...) inside the first journey.step, so a run without credentials still produces a report whose first step reads ⛔ 前置未满足 with the gate reason.

Migration pace for existing specs

Specs quarantined in LEGACY may adopt this incrementally; they are not blocked on it. A new file has no grace period: any spec added as journey-*.spec.ts complies on its first commit, and a non-journey spec that grows into a full user flow should be renamed and converted rather than kept as an untracked exception.

Browser mocked rules

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
55
Forks
12
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
e2e-testing-openjiuwen-ai
Source
github.com/openjiuwen-ai/sciencediscovery