Files
AutoGPT/.github/scripts/validate_playwright_json.py
Nicholas TindleandClaude Opus 5.5 e09793064a ci(platform): add a journeys leg to e2e for hosted-config user journeys (#15397)
### Why / What / How

**Why:** CI's e2e job tests a different product from the one customers
use. It runs every flag at its code default (off), with credits
disabled, and the 30 smoke specs check that pages render, not that flows
work. The stability-patrol bug archaeology found ~123 of 300 recent
user-facing bugs would have been caught by a holistic browser journey,
about half of them only with hosted-like configuration. Fix PRs almost
never add one, because there's nowhere cheap to put it.

**What:** A second leg of the e2e job that runs `*-journey.spec.ts`
against the hosted configuration on its own runner, plus the conventions
and the first journey. Other patrol agents are adding schedules,
realtime-after-reconnect and session journeys on top of this.

**How:**
- `e2e_test` becomes a `smoke` / `journeys` matrix with `fail-fast:
false`. Each leg has its own runner, so wall clock is `max(leg)`, not
the sum.
- The smoke leg is unchanged. Only smoke writes the shared caches (GHCR
build cache, Next.js cache, Playwright browsers).
- The journeys leg skips the OpenAI key, so its seed runs ~30 s instead
of ~3 min, and it gets its own seed-cache key (`emb`/`noemb`).
- **Journeys are advisory for now:** `end-to-end tests (journeys)` is in
`ADVISORY_CHECK_RUNS`. We flip it to blocking once it has proven stable.
- **Hosted configuration.** One file,
`src/playwright/journeys/hosted-flags.json`, holds the flags production
serves on for everyone.
- Backend: `.github/scripts/hosted_journeys_env.py` turns it into
per-flag `FORCE_FLAG_*` lines, plus `ENABLE_CREDIT=true` (not
`FORCE_ALL_FLAGS`).
- Frontend: Playwright builds (`NEXT_PUBLIC_PW_TEST=true`) now read flag
values from local storage, which the journey fixtures set before the app
loads. I deliberately didn't use build-time `NEXT_PUBLIC_FORCE_FLAG_*`:
a differently-flagged bundle can't reuse the smoke leg's cached image.
That means a cold 450–600 s Next build on every run, which would make
the journeys leg finish after smoke on frontend-cached runs. Production
builds never read the override.
- `enable-platform-payment` is forced **off**, because journey users
have no subscription and would hit the 402 paywall.
- `EXECUTION_COST_COUNT_THRESHOLD=1` bills every node, so a run's cost
is visible and exactly assertable. This is a deliberate deviation from
hosted.
- **Journey fixtures** (`src/playwright/journeys/fixtures.ts`):
- `journeyUser` signs up a fresh user through the auth API and completes
onboarding, which grants the onboarding credits. It works against any
`PLAYWRIGHT_BASE_URL`, including a `!deploy` preview, and never uses
CI's seeded accounts.
- An automatic **guard** fails a journey on any unexpected `/api/*`
4xx/5xx, `pageerror` or `console.error`, printing the URL, status and a
body snippet. Journeys declare deliberate errors with `test.use({
expectedResponses })`. Known noise goes in a small reviewed allowlist
(`KNOWN_BENIGN`, with reasons).
- **First journey** (`run-correctness-journey.spec.ts`, archaeology J4).
Import a small agent through the Library UI, run it, and assert:
  - the run reaches COMPLETED and its output shows in the run view;
  - the Library run list shows the run;
- the credit ledger charged the run exactly its cost, and the balance
moved by exactly the new ledger entries;
- a run with a failing node ends FAILED (not stuck RUNNING/QUEUED) and
its error is shown.

### Changes 🏗️

- `.github/workflows/platform-fullstack-ci.yml`: the matrix, per-leg
seed cache, cache writes and artifacts (`playwright-report-<suite>`,
`playwright-test-results-<suite>`), `--min-tests ${{ matrix.min_tests
}}` (smoke 28, journeys 3), and Codecov `name: e2e-<suite>`.
- `.github/scripts/validate_playwright_json.py`: new opt-in
`--allow-known-failures`, used only by the journeys leg. A `test.fail()`
known failure is accepted only if it actually failed; smoke stays
strict.
- `.github/workflows/scripts/check_actions_status.py`: the journeys
check is advisory. Adds a test.
- `.github/scripts/hosted_journeys_env.py` and its test; contract tests
updated for the matrix.
- `autogpt_platform/frontend/playwright.journeys.config.ts`
- `src/playwright/journeys/`: `fixtures.ts`, `guard.ts`,
`journey-user.ts`, `credits.ts`, `hosted-flags.json`, `README.md`
(conventions; how to run locally, against a preview and in CI; open
hosted vs self-hosted gaps), and the first journey.
- `src/services/feature-flags/pw-flag-overrides.ts` and
`use-get-flag.ts`: the runtime flag override for Playwright builds, with
unit tests.
- `TaskListItem` / `SidebarItemCard`: a `data-testid` on Library run
items.

**Configuration changes:** none for self-hosters or hosted. The journeys
leg appends `ENABLE_CREDIT=true`, `EXECUTION_COST_COUNT_THRESHOLD=1` and
`FORCE_FLAG_*` to its own CI `backend/.env`.

**Not done here (follow-ups):**
- Scaling `executor`/`copilot_executor` to 2 needs their fixed host
ports (8002/8008) removed in a compose override. Then we'd need to check
that the executor RPC tolerates replicas.
- No scripted model server (W9); chat stays on `CHAT_TEST_MODE`.

**Coordination:** no overlap with #15225 (single-container cache),
#15231 (`login.page.ts`, which journeys don't use) or #15352 (copilot
unit tests).

### Agents and large language models used

Claude Code — Claude Opus 5.5

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [x] `.github/scripts` validator and contract tests, and
`check_actions_status` tests, pass locally
- [x] Feature-flag unit tests pass (97), including the new override
tests
- [x] Journeys leg passes in this PR's CI (run 38171220939): 3 tests,
validator reports "3 tests passed once without skips or retries"
  - [x] Smoke leg unchanged: 28+ tests, green in every run of this PR
- [x] Journeys pass locally on the patrol stack too (3 passed, one of
them the expected known failure)
- [x] Wall clock: the journeys leg finished before smoke in every run
(frontend rebuilt in all of them, since this PR touches the frontend):

    | Run | journeys leg | smoke leg | journeys Playwright step |
    |---|---|---|---|
    | 38166219280 | 11m31s (failed: spec bug) | 16m49s | 113 s |
    | 38168727001 | 12m00s (failed: findings below) | 19m37s | ~80 s |
    | 38171220939 | **10m02s ✅** | **14m40s ✅** | 71 s |

Without embeddings the journeys seed took 62–93 s; with embeddings,
smoke's takes ~3 min.

### Findings from the first journey (on `dev`, not caused by this PR)

1. **A run whose node fails reports COMPLETED, and the user never sees
the error.** A MatchTextPattern node raises `Invalid regex pattern` with
nothing wired to its error output. The run view then shows "Completed"
and "No output from this run.", and nothing else. Untyped node errors
are non-fatal by design (`backend/executor/manager.py`, the final-status
block). This is the open product question of #14275 / SECRT-2840, so the
journey pins it with `test.fail()`: it goes red the day the behavior
changes, and then the marker comes off. A separate journey checks that
such a run at least *ends* (passes).
2. **A brand-new user's first page 404s on `GET /api/store/profile`**
("User does not have a profile yet"). The profile is only created by
`POST /api/auth/user`, which the app fires on load in parallel with the
navbar's profile query. The guard caught it in 1 of 3 tests. Journey
users now finish account setup first. A first-login journey should
assert this and fail until it's fixed.
3. `importAgentFromFile` in `pages/library.page.ts` returns an empty
`seeRunsUrl`/`id`, because "See tasks" on the Library card is now a
button, not a link. The journey looks the agent up through the API
instead. The page object should get the same fix.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-11 23:53:27 +00:00

217 lines
7.5 KiB
Python

#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from typing import Iterator
def _require_mapping(value: object, name: str) -> dict[str, object]:
if not isinstance(value, dict):
raise ValueError(f"{name} must be an object")
return value
def _require_list(value: object, name: str) -> list[object]:
if not isinstance(value, list):
raise ValueError(f"{name} must be an array")
return value
def _require_count(value: object, name: str) -> int:
if type(value) is not int or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
return value
def _iter_tests(
suites: list[object], location: str = "suites"
) -> Iterator[dict[str, object]]:
for suite_index, raw_suite in enumerate(suites):
suite_name = f"{location}[{suite_index}]"
suite = _require_mapping(raw_suite, suite_name)
for spec_index, raw_spec in enumerate(
_require_list(suite.get("specs", []), f"{suite_name}.specs")
):
spec_name = f"{suite_name}.specs[{spec_index}]"
spec = _require_mapping(raw_spec, spec_name)
for test_index, raw_test in enumerate(
_require_list(spec.get("tests"), f"{spec_name}.tests")
):
yield _require_mapping(raw_test, f"{spec_name}.tests[{test_index}]")
yield from _iter_tests(
_require_list(suite.get("suites", []), f"{suite_name}.suites"),
f"{suite_name}.suites",
)
def validate_report(
report: object, min_tests: int = 1, allow_known_failures: bool = False
) -> int:
root = _require_mapping(report, "report")
errors = _require_list(root.get("errors"), "errors")
if errors:
raise ValueError(f"report contains {len(errors)} top-level errors")
config = _require_mapping(root.get("config"), "config")
projects = _require_list(config.get("projects"), "config.projects")
if not projects:
raise ValueError("config.projects must contain at least one project")
for index, raw_project in enumerate(projects):
project = _require_mapping(raw_project, f"config.projects[{index}]")
retries = _require_count(
project.get("retries"), f"config.projects[{index}].retries"
)
if retries != 0:
raise ValueError(
f"config.projects[{index}].retries must be 0, got {retries}"
)
stats = _require_mapping(root.get("stats"), "stats")
counts = {
key: _require_count(stats.get(key), f"stats.{key}")
for key in ("expected", "skipped", "unexpected", "flaky")
}
for key in ("skipped", "unexpected", "flaky"):
if counts[key] != 0:
raise ValueError(f"stats.{key} must be 0, got {counts[key]}")
if counts["expected"] == 0:
raise ValueError("report contains zero expected tests")
tests = list(_iter_tests(_require_list(root.get("suites"), "suites")))
if len(tests) != counts["expected"]:
raise ValueError(
f"discovered {len(tests)} tests but stats.expected is {counts['expected']}"
)
if len(tests) < min_tests:
raise ValueError(
f"report contains {len(tests)} tests, below required minimum {min_tests}"
)
for index, test in enumerate(tests):
test_name = f"test[{index}]"
if test.get("status") != "expected":
raise ValueError(
f"{test_name}.status must be expected, got {test.get('status')!r}"
)
# test.fail(): a known bug the test is pinned to. It still has to fail.
known_failure = allow_known_failures and test.get("expectedStatus") == "failed"
if test.get("expectedStatus") != "passed" and not known_failure:
raise ValueError(
f"{test_name}.expectedStatus must be passed, got "
f"{test.get('expectedStatus')!r}"
)
results = _require_list(test.get("results"), f"{test_name}.results")
if len(results) != 1:
raise ValueError(
f"{test_name}.results must contain exactly one attempt, got {len(results)}"
)
result = _require_mapping(results[0], f"{test_name}.results[0]")
retry = _require_count(result.get("retry"), f"{test_name}.results[0].retry")
if retry != 0:
raise ValueError(f"{test_name}.results[0].retry must be 0, got {retry}")
if known_failure:
if result.get("status") != "failed":
raise ValueError(
f"{test_name}.results[0].status must be failed for a known "
f"failure, got {result.get('status')!r}"
)
continue
if result.get("status") != "passed":
raise ValueError(
f"{test_name}.results[0].status must be passed, got "
f"{result.get('status')!r}"
)
result_errors = _require_list(
result.get("errors"), f"{test_name}.results[0].errors"
)
if result_errors:
raise ValueError(
f"{test_name}.results[0] contains {len(result_errors)} errors"
)
return len(tests)
def _write_invalid_report(path: Path, error: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"config": {"projects": []},
"suites": [],
"errors": [{"message": error}],
"stats": {
"startTime": "",
"duration": 0,
"expected": 0,
"skipped": 0,
"unexpected": 1,
"flaky": 0,
},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Fail unless a Playwright JSON report is complete and clean."
)
parser.add_argument(
"--synthesize-invalid",
action="store_true",
help="replace a missing, empty, or malformed report with a machine-readable error",
)
parser.add_argument(
"--min-tests",
type=int,
default=1,
help="minimum number of discovered passing tests required",
)
parser.add_argument(
"--allow-known-failures",
action="store_true",
help="accept tests marked test.fail() that failed as expected",
)
parser.add_argument("report", type=Path)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if args.min_tests < 1:
print("--min-tests must be at least 1")
return 2
try:
raw_report = args.report.read_text(encoding="utf-8")
if not raw_report.strip():
raise ValueError("report is empty")
report = json.loads(raw_report)
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
problem = f"{args.report}: {exc}"
if args.synthesize_invalid:
_write_invalid_report(args.report, problem)
print(problem)
return 1
try:
test_count = validate_report(
report,
min_tests=args.min_tests,
allow_known_failures=args.allow_known_failures,
)
except ValueError as exc:
print(f"{args.report}: {exc}")
return 1
print(f"{args.report}: {test_count} tests passed once without skips or retries")
return 0
if __name__ == "__main__":
raise SystemExit(main())