Skip to content

test(lifecycle): drive the Golden Path through the browser (#1068) - #1153

Merged
frankbria merged 2 commits into
mainfrom
feat/1068-web-lifecycle
Aug 12, 2026
Merged

test(lifecycle): drive the Golden Path through the browser (#1068)#1153
frankbria merged 2 commits into
mainfrom
feat/1068-web-lifecycle

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1068. With this, every mode scripts/lifecycle advertises is real — the thing #948 spun the issue off to fix.

What

tests/e2e/lifecycle.spec.ts starts from an empty directory and drives the product through the UI: PRD upload → task generation → approval → execution. Every other spec in the harness asserts how a pre-seeded workspace renders, which cannot catch a broken write path. 3 tests, ~13s including server startup, free.

✓ a user builds a project from an empty workspace  (3.9s)
✓ the workspace on disk reflects what the browser did  (130ms)
✓ the seeded workspace was not touched  (143ms)
3 passed (12.8s)

Three things had to change to make it possible

1. MockProvider could not drive the THINK step at all. Its default is the literal string "Mock response", which no parser can read, and #1115 correctly turned an unparseable decomposition into a hard error:

TaskGenerationError: the response was truncated before the JSON array closed.

So CODEFRAME_LLM_PROVIDER=mock failed the Generate Tasks button, and the API lifecycle test had to pass use_llm=false to skip the LLM branch entirely. The mock now answers exactly one prompt with a valid shape — the task decomposition, matched on its full closing sentence rather than a keyword, because a substring classifier is the mistake #1113, #1116 and #1064 each made in turn. The canned answer carries a real dependency edge, since the prompt itself says an empty graph means the decomposition is wrong. Everything else is untouched, and a queued response or handler still wins.

2. WORKSPACE_ROOT now points at the parent of the seeded workspace, so the spec can create its own sibling and still sit inside a real allowlist (#896). Not unrestricted — the guard stays meaningful, and a third test asserts the seeded workspace was not touched, because this spec writes for real and every other spec asserts against that directory.

3. scripts/lifecycle grew a Playwright leg. web runs no pytest at all rather than running one against a path with no tests — that is how a mode reports success for nothing. all runs both engines.

mode runner needs a key
cli pytest -m lifecycle yes, costs money
api pytest -m "not lifecycle" no
web Playwright --grep @lifecycle no
all both yes

The assertions are outcomes, not page text

The run's result is polled from the API as a task status:

await expect.poll(async () => (await taskStatuses(request)).get(FIRST_TASK)).toBe('DONE');

My first version matched /done|completed|failed|blocked/i on the page — which matches the sidebar's Blockers link and would have passed before the run even started. That is the exact class of assertion this suite exists to replace.

The persistence test is exact rather than "something moved": the approved task is DONE and the unapproved one is BACKLOG. A looser check would pass if approval had leaked to the whole backlog — the #1146 bug, fixed two PRs ago.

The gates invert, as #1068 requires

tests/test_lifecycle_gates_948.py asserted api and web fail. They now assert every advertised mode resolves to a suite, that a typo is still rejected (different failures, and a wrapping script has to tell them apart), that the two free modes work with no ANTHROPIC_API_KEY present, and that all runs both engines. Two more: the spec exists and carries its tag, and a CI step actually selects it — the tag is how both the script and CI find it, so an untagged spec would silently run in neither.

Review finding

codex review flagged that the gate test reads tests/e2e/lifecycle.spec.ts while the file was untracked, so a clean checkout would FileNotFoundError. Right about the consequence — the file was staged in the same commit, but "the assertion depends on a file being committed" is worth having been checked.

Testing

Browser lifecycle 3 passed, 12.8s
Full chromium e2e suite 42 passed, 30.6s — no regression from the WORKSPACE_ROOT or mock changes
Full backend suite 6502 passed, 49 skipped, 489s
web-ui unit tests 1280 passed
ruff, actionlint clean
All four --mode values verified by --dry-run, including with the key unset

Known limitation

Workspace creation is the one step still done API-side in beforeAll: the UI has no "create a workspace from nothing" screen — it reads a path out of localStorage. Every pipeline step after that goes through the browser. Worth noting as a product gap rather than a test shortcut.

The web half of #1068. #948 deleted `tests/lifecycle/test_web_lifecycle.py`
because it was green theatre — a `@pytest.mark.skip` class raising
NotImplementedError, so `--mode web` collected only skips and exited 0 while
CLAUDE.md advertised it as the pre-PR gate. #1147 replaced the API half; this
replaces the web one, and every mode the script offers is now real.

`tests/e2e/lifecycle.spec.ts` starts from an EMPTY directory and drives the
product through the UI: PRD upload, task generation, approval, execution. Every
other spec in the harness asserts how a pre-seeded workspace renders, which
cannot catch a broken write path. 3 tests, ~13s including server startup, free.

Three things had to change for it to be possible:

1. MockProvider's default is "Mock response", which no parser can read. #1115
   correctly made an unparseable decomposition a hard error, so
   CODEFRAME_LLM_PROVIDER=mock could not drive the THINK step at all — the API
   lifecycle test had to pass use_llm=false to avoid the LLM branch entirely.
   The mock now answers exactly one prompt with a valid shape: the task
   decomposition, matched on its full closing sentence rather than a keyword
   (the classifier mistake #1113/#1116/#1064 each made). The canned answer has
   a real dependency edge, because the prompt itself says an empty graph means
   the decomposition is wrong. Everything else is untouched, and a queued
   response or handler still wins.

2. WORKSPACE_ROOT now points at the PARENT of the seeded workspace, so the spec
   can create its own sibling and still sit inside a real allowlist (#896). Not
   unrestricted — the guard stays meaningful, and a third test asserts the
   seeded workspace was not touched.

3. scripts/lifecycle grew a Playwright leg. `web` runs no pytest at all rather
   than running one against a path with no tests, and `all` runs both engines.

Assertions are outcome-based. The run's result is polled from the API as a task
status, not matched as page text: `/done|completed|failed|blocked/` would have
matched the sidebar's "Blockers" link and passed before the run finished. The
approved task reaches DONE and the unapproved one stays BACKLOG — exact, so it
would fail if approval leaked to the whole backlog (the #1146 bug).

The gates in tests/test_lifecycle_gates_948.py invert as that issue requires:
they asserted `api` and `web` FAIL, and now assert every advertised mode
resolves to a suite while a typo is still rejected. Plus: the spec exists and
carries its tag, and a CI step actually selects it — the tag is how both the
script and CI find it, so an untagged spec would silently run in neither.

CI runs it on every PR (`--grep "@smoke|@lifecycle"` in e2e-browser-smoke).
Full backend suite 6502 passed; all 42 chromium specs pass.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 9m 58s —— View job


GLM precision review — PR #1153

Scope: concrete defects only (logic errors, null derefs, security, data loss, races, breaking changes, resource leaks). Style/architecture/coverage are handled by a separate review.

  • Gather PR context (diff, comments, changed files)
  • Read each changed file + surrounding context/callers
  • Confirm concrete defects with failure scenarios
  • Post inline finding
  • Post summary table

Verdict: 1 minor finding (no critical/major defects).

severity file:line finding
minor scripts/lifecycle:93 web mode empties MARKER_ARGS, but PYTEST_ARGS (which expands "${MARKER_ARGS[@]}") is built unconditionally under set -u — aborts on bash < 4.4 (macOS default) before Playwright runs. CI (bash 5) and --dry-run are unaffected, so the gate test misses it.

What I verified and ruled out

  • codeframe/adapters/llm/mock.py — the shape-aware default is sound. The marker "Return ONLY a JSON array of objects with these fields." matches core/tasks.py:1291 exactly, and the stress-test prompt's different suffix ("…array of short goal strings.") does not collide. Task generation calls provider.complete() (core/tasks.py:1296), not async_stream(), so the bypass in async_stream's fallback is irrelevant here. The default_response == "Mock response" guard correctly leaves any caller that set its own default untouched.
  • tests/e2e/lifecycle.spec.ts — outcome assertions are correct: the /done|completed|…/ text-match pitfall is avoided by polling task status from the API; the persistence test is exact (DONE vs BACKLOG); the tasks? regex correctly matches both singular/plural. authToken() and taskStatuses() handle missing state gracefully (throw / empty Map) and fail the test rather than pass falsely.
  • tests/e2e/e2e-env.ts / playwright.config.ts — widening WORKSPACE_ROOT to dirname(WORKSPACE_DIR) keeps both the seeded workspace and the new lifecycle sibling inside a real allowlist; no path-escape or isolation break.
  • tests/test_lifecycle_gates_948.py_run_lifecycle uses --dry-run (with a fake key for paid modes), so the returncode == 0 assertions are safe; the all/web dry-run outputs do contain pytest/playwright (and not) exactly as asserted.
  • .github/workflows/test.yml--grep "@smoke|@lifecycle" is a valid Playwright regex; the lifecycle spec uses a separate workspace dir, so parallel smoke specs are not disturbed.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped change — it closes the actual gap (#948/#1068) rather than papering over it, and the PR description's own "review finding" callout plus the extensive --dry-run-based gate tests in test_lifecycle_gates_948.py show real rigor. I traced the pieces that matter most and they hold up:

  • mock.py marker match: _TASK_DECOMPOSITION_MARKER ("Return ONLY a JSON array of objects with these fields.") is unique to core/tasks.py's decomposition prompt — I checked prd_stress_test.py's similar-sounding prompt ("Return ONLY a JSON array of short goal strings.") and confirmed it doesn't collide. The canned _MOCK_DECOMPOSITION fields (title, description, depends_on_titles, complexity, estimated_hours, uncertainty, files_to_modify) match exactly what _generate_tasks_with_llm's validator expects, and the dependency edge ("Add a test..." → "Implement the core...") survives _clean_task_title unchanged and resolves via title_to_id. Good attention to detail.
  • scripts/lifecycle: traced the RUN_PYTEST/RUN_PLAYWRIGHT/PYTEST_PATH branching for all four modes against the new gate tests — the dry-run stdout assertions ("lifecycle.spec.ts", "playwright", "pytest" not in ... for web mode) line up correctly with the always-printed Path: summary line plus the conditional dry-run block. No mismatch found.
  • lifecycle.spec.ts: button/toast copy (Upload PRD/Upload New, Mark Ready, Generated N tasks from PRD, placeholder text) all matches current web-ui component text I grepped for. The outcome-polling approach (task status via API rather than page text) is a real improvement per the PR's own stated rationale.
  • CI wiring: the new --grep "@smoke|@lifecycle" step is correctly inside e2e-browser-smoke, which is already in test-summary's needs list, so the gate is real, not decorative.

One gap worth considering:

No direct unit test for the new MockProvider shape-aware default behavior. The logic in mock.py (complete() returning _MOCK_DECOMPOSITION only when default_response is still the literal "Mock response" and the marker is present in some message's content) is currently exercised only end-to-end, through the new Playwright spec spinning up the full backend + frontend. tests/adapters/test_llm.py (which already has a "Mock response" default-response assertion right next to where this would go) has nothing new. A couple of cheap, isolated cases would make regressions here fail fast in the backend suite instead of only in the browser job:

  • complete() with a message containing the marker → returns _MOCK_DECOMPOSITION (or something that parses to a 2-task list with the dependency edge).
  • complete() with a custom default_response set → the marker is ignored and the custom response wins (the "an explicitly queued response or handler still wins" contract the docstring promises, but for default_response specifically — it's the one path the docstring describes but doesn't have a queued/handler test proving it).

Nothing else stood out — no security, performance, or correctness concerns in the diff itself.

Comment thread scripts/lifecycle
@frankbria

Copy link
Copy Markdown
Owner Author

The spec runs in CI, not just locally

E2E Browser Smoke (Chromium), this PR's own run — the AC that a local pass cannot satisfy:

Run npx playwright test --project=chromium --grep "@smoke|@lifecycle"
  ✓  6 lifecycle.spec.ts › a user builds a project from an empty workspace (1.9s)
  ✓  7 lifecycle.spec.ts › the workspace on disk reflects what the browser did (209ms)
  ✓  8 lifecycle.spec.ts › the seeded workspace was not touched (206ms)
  21 passed (43.3s)

On a clean runner, with no ANTHROPIC_API_KEY. The whole job took 1m37s, so the browser Golden Path costs the PR gate about two seconds of test time.

Outcome evidence for each acceptance criterion

AC Evidence
test_web_lifecycle exists with real assertions, reusing the Playwright harness tests/e2e/lifecycle.spec.ts, built on the #684/#703 harness — 3 tests, no new runner
It runs in CI, free the transcript above
scripts/lifecycle accepts --mode web; the exit 3 guard is gone all four modes verified by --dry-run; web resolves to lifecycle.spec.ts under Playwright and runs no pytest leg
tests/test_lifecycle_gates_948.py flips 33 tests — every advertised mode must resolve, a typo must still be rejected, and a CI step must actually select the spec

The pipeline genuinely ran, rather than the page merely rendering — read straight off the workspace afterwards:

$ cf tasks list   # the workspace the browser built
DONE     | Implement the core function
BACKLOG  | Add a test for the core function

DONE from a PRD that did not exist at the start of the test, and BACKLOG for the task that was never approved.

Checks

Browser lifecycle (local) 3 passed, 12.8s
Full chromium e2e suite 42 passed — no regression from the WORKSPACE_ROOT or MockProvider changes
Full backend suite 6502 passed, 49 skipped
web-ui unit tests 1280 passed
CI all green
codex review one P1 (spec untracked at review time — it is in the commit), addressed

Bot review. `web` sets MARKER_ARGS=(), and the PYTEST_ARGS array was built
unconditionally — under `set -u`, bash < 4.4 (macOS still ships 3.2) errors on
"${empty[@]}". So the one mode that never uses those args was the one that
would have failed to start, on the shell a contributor is most likely to have.

Bash 5.2 here, which is why it passed locally and in CI. All four modes
re-verified by --dry-run; shellcheck clean.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 6m 0s —— View job


GLM precision review — PR #1153 (synchronize)

Scope: concrete defects only (logic errors, null derefs, security, data loss, races, breaking changes, resource leaks). Style/architecture/coverage are handled by a separate review.

  • Gather PR context (diff, comments, changed files)
  • Read each changed file + surrounding context/callers
  • Confirm concrete defects with failure scenarios
  • Post inline findings
  • Post summary table

Verdict: ✅ GLM review: no defects found.

The prior-review finding (empty "${MARKER_ARGS[@]}" under set -u aborting web mode on bash < 4.4) is the one real defect this PR carried, and the synchronize fixed it correctly. I verified the fix is complete and looked specifically for new issues the fix or the rest of the diff might introduce — none survived a concrete failure scenario.

What I verified

scripts/lifecycle — fix is complete, not partial. Every array expansion is now guarded by RUN_PYTEST == "1":

  • L133 ${MARKER_ARGS[*]} — inside if RUN_PYTEST==1 (the dry-run block)
  • L160 "${MARKER_ARGS[@]}" — inside the if RUN_PYTEST==1 PYTEST_ARGS builder
  • L187 "${PYTEST_ARGS[@]}" — inside if RUN_PYTEST==1

So web mode (RUN_PYTEST=0, MARKER_ARGS=()) reaches the Playwright leg (L190) without ever expanding the empty array. I traced all four modes through arg validation, the API-key gate (web is correctly exempt alongside api), dry-run output, and the STATUS-capture run block — the gate-test stdout assertions (lifecycle.spec.ts present, pytest absent for web, both present for all) line up with the always-printed summary + conditional dry-run lines.

codeframe/adapters/llm/mock.py — the shape-aware default is sound and non-regressing.

  • Marker "Return ONLY a JSON array of objects with these fields." matches core/tasks.py:1291 verbatim; prd_stress_test.py's similar prompt ends …array of short goal strings. and does not collide.
  • Task generation calls provider.complete() (core/tasks.py:1296), passing [{"role":"user","content":prompt}]] — so m.get("content","") is type-safe (always a dict) and the marker is present in the user message.
  • The content == "Mock response" guard means a caller that set its own default_response is untouched; queued responses / response_handler still win (checked earlier in complete()). async_stream's separate fallback is irrelevant — task gen doesn't use it.
  • No existing test regresses: the only literal-"Mock response" assertion (tests/adapters/test_llm.py:100) sends no message containing the marker (that sentence appears in zero test files), so the new branch is dormant for it.
  • _MOCK_DECOMPOSITION validates: complexity 1–5, estimated_hours ≥ 0.1, uncertainty ∈ {low,medium,high} all pass; _clean_task_title strips markdown only, so both plain titles survive unchanged and the depends_on_titles edge resolves to the first task's own title.

tests/e2e/* — no isolation or false-pass defect. Widening WORKSPACE_ROOT to dirname(WORKSPACE_DIR) keeps both the seeded .e2e-workspace and the new .e2e-lifecycle-workspace inside a real allowlist (no escape, no UNRESTRICTED escape hatch). CODEFRAME_LLM_PROVIDER=mock on the shared webServer doesn't disturb smoke specs — they assert against pre-seeded rows and don't trigger generation. The spec's outcome assertions are correct: it polls task status from the API (not page text), the persistence test is exact (DONE vs BACKLOG, the #1146 leak it guards against), and taskStatuses()/authToken() throw on missing state rather than passing falsely.

all mode mock export (checked, not a defect). all doesn't export CODEFRAME_LLM_PROVIDER=mock, but tests/lifecycle/test_api_lifecycle.py self-provides it via monkeypatch.setenv("CODEFRAME_LLM_PROVIDER","mock") (L71) and uses use_llm=False on generation — so the api leg is independent of the script's export. Pre-existing and correct.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review

Solid follow-up. I focused on verifying the two things flagged in prior reviews on this PR and re-reading the diff end to end.

The GLM review's bash bug is correctly fixed. scripts/lifecycle:152-174 now only builds PYTEST_ARGS (and therefore only expands "${MARKER_ARGS[@]}") inside if [[ "$RUN_PYTEST" == "1" ]]. For web mode, RUN_PYTEST=0, so the empty-array expansion that broke bash < 4.4 under set -u is never reached. I traced every remaining reference to MARKER_ARGS (case block, dry-run echo, PYTEST_ARGS build) and all three are correctly guarded or only populated for modes where the array is non-empty. Good fix, and the commit message correctly identifies the root cause.

My earlier suggestion (isolated unit tests for MockProvider's shape-aware default) was not addressedtests/adapters/test_llm.py's TestMockProvider class is unchanged in this PR. That's fine as a judgment call (the behavior is exercised end-to-end by the new Playwright spec, and this PR is scoped to the bot-review fix), just flagging that it's still a gap rather than repeating the full rationale from before.

Beyond re-verifying those two items, I checked a few things not previously called out:

  • playwright.config.ts now sets CODEFRAME_LLM_PROVIDER=mock unconditionally on the shared backend webServer, which affects every spec in the suite, not just lifecycle.spec.ts. I checked prd.spec.ts and the rest of tests/e2e/*.spec.ts for anything that depends on real-provider behavior (e.g. asserting on stress-test output or generated task content) — nothing does; prd.spec.ts only asserts button visibility. No regression here, but worth knowing this is now a suite-wide default, not a lifecycle-local one.
  • WORKSPACE_ROOT widening (tests/e2e/ instead of tests/e2e/.e2e-workspace/) is scoped sensibly — it's still a real allowlist boundary, just one directory up, and is test/CI-only (playwright.config.ts), not a change to the production WORKSPACE_ROOT semantics documented in CLAUDE.md.
  • .github/workflows/test.yml: e2e-browser-smoke is confirmed present in test-summary's needs list (test.yml:604) and its result is checked in the summary's failure condition, so the gate is real rather than decorative, as the PR description claims.
  • Minor/pre-existing, not introduced by this PR: scripts/lifecycle's -h/--help output is sed -n '2,16p' "$0", a hardcoded line range against the header comment block. Before this PR the header was 17 lines (2–18) and the fixed range already truncated the last two lines (--dry-run, -h, --help); after this PR the header shrank to 16 lines (2–17), so the range now only cuts the final -h, --help description line instead of two — an improvement, but still slightly stale. No test currently checks for it (the gate test only greps for the mode list, which is well within range), so it won't fail CI — purely a cosmetic nit if anyone touches this file again.

Nothing else stood out — the mock.py marker-matching logic, the lifecycle.spec.ts assertions (outcome-based via API polling, exact status checks), and test_lifecycle_gates_948.py's coverage of the mode matrix all hold up on inspection, consistent with what the earlier GLM and Claude reviews on this PR already found.

@frankbria
frankbria merged commit 6ec530f into main Aug 12, 2026
17 checks passed
@frankbria
frankbria deleted the feat/1068-web-lifecycle branch August 12, 2026 05:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2.23] Implement the web lifecycle test (Playwright; the API half shipped in #1147)

1 participant