C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404, 否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。 H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前 校验项目存在返 404(仿 chain/_require_project)。 H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise 互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise (M1 projects 表仍只有 premise,不编造 API)。 回归测试: - test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft 用例改 seed 真项目。 - test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。 - wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。 门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
13 KiB
13 KiB
Web Frontend QA Report
1. Executive Summary
Overall health: Solid at the contract/logic layer, blocked at the browser layer.
- Areas tested: 15 (12 functional+API areas, 2 full-LLM E2E areas, 1 cross-route browser smoke pass).
- Backend contract + component logic: Largely sound. Happy paths, validation (422), error envelopes (404/409), snake_case alignment, optimistic-update/rollback, and a11y structure mostly check out across all areas.
- Full LLM E2E that genuinely ran: write-sse (real Kimi draft stream, 1261 tokens), toolbox-generators (all 12 generators + 3 ingest flows), style (16-dim fingerprint + refine), chain (API + awaiting/resume seam verified against real jobs).
- What could NOT be verified (environmental): The running Next dev server had a corrupted
.nextbuild cache — every/projects/[id]/*SSR route returned HTTP 500 ("Cannot find module openapi-fetch@0.13.8.js"), and client JS chunks 404'd so React never hydrated. This blocked ALL in-browser/visual/keyboard/a11y verification. Root cause: stale build + two concurrentnext devprocesses; not a source defect. Fix: kill duplicate server,rm -rf apps/web/.next, restart.
Issue counts (de-duplicated): 23 total
- CRITICAL: 2 (1 functional, 1 build-cache infra)
- HIGH: 4 (1 functional, 3 infra — same root cause)
- MEDIUM: 6
- LOW: 11
The two functional CRITICAL/HIGH items are real and independent of the build issue. The build-cache 500 is a single environmental root cause counted once as CRITICAL (smoke pass) and re-observed as HIGH across multiple areas.
2. All Recorded Errors (de-duplicated, CRITICAL → LOW)
| Severity | Area / Component | Symptom | Repro |
|---|---|---|---|
| CRITICAL | write-sse — routers/projects.py::stream_draft |
POST /draft for a NON-EXISTENT project returns 200 + a full LLM-generated chapter instead of 404 — burns a paid LLM call; sibling endpoints correctly 404 | curl -X POST /projects/0000…0000/chapters/7/draft -H 'Accept: text/event-stream' → 200 + token/done |
| CRITICAL | Build cache — apps/web/.next (all 10 project routes + hydration) |
Corrupt .next: server bundles require missing vendor-chunks/openapi-fetch@0.13.8.js → all /projects/[id]/* routes 500; client JS chunks 404 → React never hydrates → entire app non-interactive |
curl localhost:3000/projects/{PID}/write → 500; type valid title in wizard → 下一步 stays disabled |
| HIGH | ProjectWizard.tsx (StepPremise + StepProtagonist) | Step 3 (立意) and Step 4 (主角/金手指) bind to the SAME form.premise field — step-4 input silently overwrites step-3; both cannot be saved |
/projects/new → type 立意 (step3) → type 主角 (step4) → submit → only step-4 text persists |
| HIGH | Build-cache 500 (re-observed in foreshadow / codex / skills / review / nav / chain) | Same missing-openapi-fetch vendor chunk 500s every /projects/[id]/* route, blocking browser QA |
curl localhost:3000/projects/{PID}/{route} → 500 |
| HIGH | GET /skills backend registry | Skills registry empty ({"skills":[]}); loads only from DB skills table with no seed rows and no POST/seed endpoint → SkillsPage permanently shows "暂无已注册技能" |
curl localhost:8000/skills → {"skills":[]} |
| HIGH | env — duplicate next dev processes |
Two concurrent next dev on same apps/web clobber shared .next → likely root cause of the corrupt build |
ps aux | grep 'next dev' → two PIDs |
| HIGH | rules — routers/rules.py + rule_repo.py |
POST rule to non-existent project returns 500 (unhandled FK violation) instead of 404 | curl -X POST /projects/{random-uuid}/rules -d '{"level":"project","content":"x"}' → 500 |
| HIGH | ReviewReport.tsx + style.md prompt + review_node | One-click 回炉 can target wrong/empty paragraph: style-auditor LLM segment idx and frontend split(/\n{2,}/) paragraph index use unrelated splitting schemes (no shared contract) |
Draft with single-newline segments → style segments idx>0 → 回炉 refines wrong/empty para |
| MEDIUM | OutlineEditor.tsx + server.ts fetchOutline | 卷号 volume selector is misleading: GET ignores volume filter, editor always renders ALL volumes; picker only affects POST generate | Change 卷号 to 2 → all volumes still listed |
| MEDIUM | useOutline.ts:44-49 | 422 validation errors read apiError.error.code, but 422 uses {detail:[...]} → degrades to generic "OUTLINE_FAILED" message |
POST /outline volume=0 → generic error, validation cause lost |
| MEDIUM | settings PUT /settings/providers + ProvidersSettings | PUT accepts arbitrary/unknown tier+provider (no enum/whitelist), upsert-only with no DELETE → bogus tier_routing row persists invisibly, pollutes gateway config | PUT {tier_routing:[{tier:'bogus',…}]} → 200, persists, unremovable via API |
| MEDIUM | backend char persist/read + CodexPage.tsx | Character relations silently dropped on persist/read (GET returns relations:[] despite ingest); codex list never renders relations anyway |
POST character with relations → GET → relations:[] |
| MEDIUM | backend POST /projects/{id}/characters | Character ingest non-idempotent: re-ingesting same name creates duplicate row → duplicate chips (UI dedup only session-vs-persisted) | POST same card twice → GET count 2 |
| MEDIUM | foreshadow PATCH to_status (backend) | Invalid to_status value returns 500 INTERNAL instead of 422 VALIDATION; to_status is a free string, not enum-validated |
PATCH /foreshadow/FS-01 -d '{"to_status":"BOGUS"}' → 500 |
| MEDIUM | ChainPage.tsx:61 + ChainAdjudication.tsx:102 | Chain run/resume failures call friendlyError(undefined) — discard error code/message; 503 LLM_UNAVAILABLE loses its "go to settings" action link |
Trigger run with no provider → generic "出错了" toast, no settings link |
| MEDIUM | generation.py list_rules (GET rules) | GET rules for non-existent project returns 200 {"rules":[]} instead of 404, masking bad project ids |
curl /projects/{random-uuid}/rules → 200 empty |
| LOW | rules / templates / foreshadow / projects / codex (CRUD gaps) | No DELETE/edit endpoint for rules; no DELETE /projects; whitespace-only content accepted server-side (min_length before trim) on rules/projects/foreshadow → unremovable junk rows | curl -X POST .../rules -d '{"content":" "}' → 201 |
| LOW | useDraftStream.ts (pre-stream error) | Pre-stream error envelope: extracts code+message but drops error.request_id → weakens end-to-end greppability |
Force 503 before stream → surfaced error has no request_id |
| LOW | FastAPI 422 envelope inconsistency (write-sse, outline, accept, injection) | 422 errors return raw FastAPI {detail:[...]} instead of project {error:{code,message,request_id}} envelope → no request_id, no code mapping |
curl /projects/{PID}/chapters/abc/injection → 422 {detail:…} |
| LOW | apps/api accept endpoint (useAccept.ts) | Accept 422 (empty final_text) uses raw FastAPI detail shape, not standard envelope (subset of above, accept-specific) | POST accept {final_text:'',decisions:[]} → 422 raw detail |
| LOW | ProvidersSettings.tsx testConnection | Test-connection collapses distinct 404 "needs key" vs 503 "probe failed" into one generic toast | Click test on unconfigured vs unreachable provider → identical toast |
| LOW | ProjectWizard.tsx submit + cards.ts + useGenerator | Create/generate failures show generic toast; backend validation detail / specific message discarded | Force a 422 → generic toast, backend reason lost |
| LOW | CommandPalette.tsx | Incomplete combobox a11y: input not role=combobox, no aria-activedescendant; <li role=option> has no id → AT won't announce highlighted command |
Open ⌘K, ArrowDown with screen reader → no announcement |
| LOW | Toast.tsx | Error toasts use polite live region (aria-live=polite/role=status) instead of assertive → failures don't interrupt SR |
show('…','error') → announced politely |
| LOW | RulesPage.tsx | Rule content textarea has no accessible label (placeholder only); list uses array index as React key (forced by GET not returning rule id) | Inspect 新增规则 form |
| LOW | TemplateFiller.tsx:23-40 | After transient GET /templates failure, shows misleading "暂无模板" empty state and never retries on reopen (useEffect guards on templates===null, but failure sets []) |
Open filler while API down → toast + empty; restore API, reopen → still empty |
| LOW | style refine_segment (routers/style.py) | Refine endpoint never validates chapter existence; any chapter_no returns 200 | POST .../chapters/99/refine -d '{"segment":"x"}' → 200 |
| LOW | TemplatesManager + backend | No max-length cap on title/body (5000-char title accepted, rendered inline unbounded); backend silently ignores unknown request fields | POST /templates long title → 201 |
| LOW | RegisterForm.tsx (toInt) | Non-integer numeric input (e.g. 'abc', '1.5') silently dropped with no validation feedback | Type 'abc' in 埋设章, submit → planted_at omitted silently |
| LOW | toolbox empty ingest | Empty ingest payload (world_entities:[]) returns 201 no-op instead of 4xx (client-guarded so UI-unreachable) |
POST .../glossary/ingest -d '{"world_entities":[]}' → 201 |
Several minor chain/style frontend timing edges (poll progress frozen 0→100, possible stale done-edge re-fire, no re-refine on identical segment text, ChainAdjudication 409 stuck panel, redundant poll.reset effect re-run) are noted in raw data as LOW/needs-browser and folded here to avoid noise.
3. Per-Area Status
| Area | Status | Coverage note |
|---|---|---|
| projects-list+create | issues-found (1 HIGH) | API fully exercised via curl; wizard click-through needs-browser |
| outline-editor | issues-found (2 MED) | API + logic verified; read-only generate-only (no PUT by design); ⚑badge path dead vs real data |
| rules | issues-found (1 HIGH, 3 MED) | Full API curl + component read; a11y/SR needs-browser |
| foreshadow-board | issues-found (1 HIGH infra, 1 MED) | API fully exercised; board UI blocked by build 500 |
| templates | pass (3 LOW only) | CRUD fully verified per contract; known blank-field HIGH confirmed fixed |
| settings-providers | issues-found (1 MED) | API + OAuth contracts verified; no plaintext leak; live OAuth completion not run |
| codex (设定库) | issues-found (2 MED) | API contract verified; in-browser blocked by build 500 |
| skills | issues-found (2 HIGH) | Logic correct but registry empty + route 500; populated-state untested |
| review-display | issues-found (1 HIGH) | API + all normalizers verified; visual panels blocked by build 500 |
| nav-shell+command | issues-found (1 MED, 1 LOW a11y) | All routes/commands map correctly; 28 unit tests pass; browser blocked |
| write-sse (LLM E2E) | issues-found (1 CRITICAL) | Real Kimi stream ran end-to-end; non-empty injection path untestable (no chars/entities) |
| toolbox-generators (LLM E2E) | pass (2 LOW only) | All 12 generators + 3 ingest flows ran real Kimi; 38 unit tests pass |
| style (LLM E2E) | issues-found (1 HIGH) | Full fingerprint+refine ran real Kimi; drift idx-alignment is contract analysis |
| chain (LLM E2E) | issues-found (1 MED) | API + awaiting/resume seam verified vs real jobs; 12 unit tests; browser blocked |
| browser smoke (all 14 routes) | issues-found (2 CRITICAL infra) | 4 top-level routes SSR 200; 10 project routes 500; zero hydration anywhere |
4. Notable Gaps / Could Not Be Verified
- All in-browser / visual / keyboard / screen-reader verification — blocked by the corrupt
.nextbuild (project routes 500, no hydration). Every a11y finding (combobox, toast severity, textarea label) is source-confirmed only; visual rendering of kanban, beat bars, annotated-text highlights, accept-gate disabled state, typewriter stream, and optimistic-update rendering is unverified. - Skills populated-state — registry is empty with no seed/write endpoint, so builtin/custom/community grouping + tier/genre/reads-writes badges were never rendered with real data (logic verified by code + unit tests only).
- Style one-click 回炉 idx-alignment (HIGH) — could not be deterministically triggered: existing PID has no fingerprint, so the style auditor degrades to score=100/segments=[]. Finding is from code/contract analysis; needs a learned fingerprint + a write+review run to confirm live.
- write-sse non-empty injection panel — the test PID has zero characters and zero world_entities, so
selected[]/author_pin reason badge path is structurally untestable on this data. - Live 503 / LLM_UNAVAILABLE paths — providers are configured, so 503 branches (outline, chain, settings test, draft pre-stream) could not be forced; verified by code reading only.
- Foreshadow ⚑ / 可回收 badge — all 30 chapters of the existing PID have empty
foreshadow_windows, so this render path is exercised only by unit tests. - 409 CONFLICT_UNRESOLVED ingest path (toolbox) — could not trigger a real continuity conflict on throwaway data; adjudication logic verified by reading only.
- Cleanup residue: No DELETE /projects endpoint exists (405), so ~8 throwaway QA projects created during mutation testing remain in the DB. One chain job (project 887af2d4) is still running. The existing rich PID (71b3725e…) data was never modified.