fix(e2e): switch to workflow category before opening controlled docs

UI-13a stayed on private after upload, so the doc menu had no
doc_proj* entries. Always use the category trigger first, then exact id.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 21:03:23 +00:00
co-authored by Cursor
parent 48a4dcb86d
commit ba4bb41e1c
10 changed files with 285 additions and 3 deletions
@@ -0,0 +1,48 @@
---
title: "UI-13a 在私密分类下打不开受控工作流文档"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
severity: high
---
# UI-13a 在私密分类下打不开受控工作流文档
## 现象
第15轮真实 UI(schema 30,`dev@48a4dcb`):UI-00 至 UI-12 全部 PASS,UI-11 快速完成修复已实证 PASS。**UI-13a 在真正权限断言前失败**。
`openWorkflowDocById(page, doc_proj6143_intake)` 报:`doc-selector menu missing data-doc-id=doc_proj6143_intake`。
现场快照:分类触发器为「我的私密 1」,文档下拉只有私密清单。intake 仍存在于对话引用中,只是当前分类菜单不可见。
## 根因分析
| 层 | 问题 | 后果 |
|---|---|---|
| UI-11 后状态 | 上传归档把 active category 留在 `private` | 后续步骤默认停在「我的私密」 |
| 验收导航 | `openWorkflowDocById` / `ByLabel` / `ByControlledTaskKey` 直接展开当前分类的文档菜单 | 私密菜单里没有 `doc_proj*` |
| 分类控件 | 真实页面必须先点 `doc-category-trigger` 再选 `data-category=workflow`(文案「流程受控」) | 脚本跳过该步即 fail-open 于错误清单 |
不是权限矩阵错,也不是 intake 丢失。
## 严重度
`high`
阻断 UI-13a 及之后所有「从任意分类回到受控文档」的步骤。
## 关联整改
- Remediation: [../remediation/2026-09-13-document-revision-workflow-category-navigation.md](../remediation/2026-09-13-document-revision-workflow-category-navigation.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-workflow-category-navigation.md](../ops/short-term/2026-09-13-document-revision-workflow-category-navigation.md)
- 分支: `fix/document-revision-workflow-category-navigation`
## 证据
- 失败证据(只读,不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-category-navigation-failure/`
## 开放问题
- 本轮不重跑真实 UI-13a;不得标 E2E 通过或任务 `done`
- 不得用直接 API / 改内存 `activeCategory` 规避页面分类控件
+8
View File
@@ -31,6 +31,14 @@
### 最新专项审计(2026-09-13)
**文件**: [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md)
**结论**: 第15轮 UI-00–12 PASS;UI-13a 在私密分类下直接展开文档菜单,找不到 `doc_proj*_intake`。须先用页面控件切到流程受控。
**整改**: [受控文档分类导航整改](../remediation/2026-09-13-document-revision-workflow-category-navigation.md)
### UI-11 快速完成观测(2026-09-13)
**文件**: [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md)
**结论**: 第14轮 UI-11 上传归档后助手已 `completed`;验收默认硬等 inflight 60s,把成功终态误判为失败。
+4 -2
View File
@@ -1,11 +1,11 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV:schema **30**;第14轮 UI-11 快速完成观测契约;不执行 env-service 重建 / 不声称 E2E 通过)
**production_ready**: no(DOCREV:schema **30**;第15轮 UI-13a 分类导航契约;不执行 env-service 重建 / 不声称 E2E 通过)
## 焦点
1. **DOCREV-RT**: [UI-11 快速完成观测契约](short-term/2026-09-13-document-revision-fast-completion-observation.md)(`fix/document-revision-fast-completion-observation`;第14轮成功终态被硬等 inflight 误杀;不声称 E2E 通过)
1. **DOCREV-RT**: [受控文档分类导航契约](short-term/2026-09-13-document-revision-workflow-category-navigation.md)(`fix/document-revision-workflow-category-navigation`;第15轮 UI-13a 停在私密分类找不到 intake;不声称 E2E 通过)
2. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03)
3. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
@@ -31,6 +31,7 @@
| Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress |
| Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress |
| Cursor-Auto | document-revision-fast-completion-observation | fix/document-revision-fast-completion-observation | - | document-revision-fast-completion-observation | in_progress |
| Cursor-Auto | document-revision-workflow-category-navigation | fix/document-revision-workflow-category-navigation | - | document-revision-workflow-category-navigation | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -79,3 +80,4 @@
| UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) |
| UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) |
| UI-11 快速完成观测误判审计 | [../audit/2026-09-13-document-revision-fast-completion-observation.md](../audit/2026-09-13-document-revision-fast-completion-observation.md) |
| UI-13a 私密分类导航失败审计 | [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md) |
@@ -0,0 +1,52 @@
---
title: "受控文档分类导航契约"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Cursor-Auto
branch: fix/document-revision-workflow-category-navigation
worktree: "-"
mutex: document-revision-workflow-category-navigation
---
# 受控文档分类导航契约
## 目标
堵住第15轮 UI-13a:UI-11 后停在「我的私密」,未切「流程受控」就展开文档菜单,找不到 `doc_proj*_intake`。只改验收导航,不降真实性。
## 范围
- `web/src/lib/workflowCategoryNavigation.ts` 与单测
- `web/e2e/document-revision-runtime.spec.ts` 三个 opener
- audit / remediation / CURRENT
## 非目标
- 不改产品分类状态机;不用 API/内存规避 UI
- 不碰证据;不合 dest;不启停容器;不 E2E;不 push
## 步骤
1. [x] 从 `dev@48a4dcb` 开分支并登记 mutex
2. [x] 导航谓词 + private→workflow / 缺失 fail-closed 测试
3. [x] 三个 opener 先真实切分类
4. [x] `npm test` + `npm run build` 并原子提交
## 验收标准
- [x] 从私密分类可恢复到精确受控文档
- [x] 目标缺失 fail-closed
- [ ] 现场 UI-13a(未跑)
## 相关链接
- Audit: [../../audit/2026-09-13-document-revision-workflow-category-navigation.md](../../audit/2026-09-13-document-revision-workflow-category-navigation.md)
- Remediation: [../../remediation/2026-09-13-document-revision-workflow-category-navigation.md](../../remediation/2026-09-13-document-revision-workflow-category-navigation.md)
## 证据 / 备注
- mutex:`document-revision-workflow-category-navigation`(新 mutex)
- 基线:`dev@48a4dcb`
- 验证:`npx tsx --test src/lib/workflowCategoryNavigation.test.ts` PASS;`cd web && npm test` PASS(309 + 29);`npm run build` PASS
- **未跑真实 UI-13a,不得标 done**;**不得 merge / 启停容器 / E2E / push**
@@ -0,0 +1,46 @@
---
title: "受控文档打开前必须先切工作流分类"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# 受控文档打开前必须先切工作流分类
## 背景
链到 [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md)。
只改验收导航契约,不改产品运行时,不用 API/内存状态绕过 UI。
## 目标
- 打开受控工作流文档前,必须通过真实页面控件切到 `data-category=workflow`(「流程受控」)
- 再按精确 `data-doc-id`(或既有 taskKey / label)选择
- 从「我的私密」等任意当前分类可恢复
- 目标不在正确分类菜单中 → fail-closed
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | 抽取导航谓词;三个 opener 先 `selectDeliverableCategory` | private→workflow→exact;缺失 fail-closed;`npm test` + `build` |
| Wave 1 | 占槽后重跑真实 UI-13a(本轮不做) | 权限断言前能打开 intake |
## 设计
1. `requiredCategoryForDocOpen`:`doc_proj*` / label / taskKey → `workflow`;`doc-free-*` → `private`(UI-16 仍走页面私密分类)。
2. `planExactDocOpen`:始终先 `ensure_category`;仅当当前分类已是目标分类时才在可见菜单里 `select_exact`;否则不得因私密菜单缺 `doc_proj*` 立即失败。
3. 三个 `openWorkflowDoc*` 都先点 `doc-category-trigger` + `data-category=...`,再打开文档菜单。禁止 `page.evaluate` / 路由 mock / 直调 `setActiveCategory`。
## 验收标准
- [x] private 菜单看不到 `doc_proj*` 时先切 workflow,不立刻 FAIL
- [x] workflow 菜单有精确 id 则 select
- [x] 正确分类下目标缺失 fail-closed
- [ ] 真实 UI-13a(本轮不做)
## 相关链接
- Audit / Short-term:同 slug `2026-09-13-document-revision-workflow-category-navigation`
- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui13a-category-navigation-failure/`
+1
View File
@@ -3,6 +3,7 @@
| 文档 | 说明 |
|------|------|
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
| [2026-09-13-document-revision-workflow-category-navigation.md](./2026-09-13-document-revision-workflow-category-navigation.md) | UI-13a 停在私密分类,打开受控文档前必须先切流程受控 |
| [2026-09-13-document-revision-fast-completion-observation.md](./2026-09-13-document-revision-fast-completion-observation.md) | UI-11 快完成被硬等 inflight 误杀,只改验收观测契约 |
| [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 |
| [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 |
+1 -1
View File
@@ -25,7 +25,7 @@
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-11 快完成观测](../audit/2026-09-13-document-revision-fast-completion-observation.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;UI-11 验收不得把已 completed 误判为缺 inflight | `ci` |
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[UI-13a 分类导航](../audit/2026-09-13-document-revision-workflow-category-navigation.md) | CI `TEST_DATABASE_URL` + 真实 PI E2E 实录;打开受控文档前须先切流程受控分类 | `ci` |
---
+11
View File
@@ -10,6 +10,10 @@ import {
ANY_TERMINAL_STATUS_RE,
evaluateSquadObservation,
} from '../src/lib/squadCompletionObservation';
import {
CATEGORY_TRIGGER_LABEL,
requiredCategoryForDocOpen,
} from '../src/lib/workflowCategoryNavigation';
/**
* Document revision runtime — real UI acceptance (UI-01..UI-16).
@@ -308,12 +312,14 @@ async function selectDeliverableCategory(
const item = page.locator(`.dropdown-menu button[data-category="${category}"]`).first();
if ((await item.count()) === 0) fail(`category menu missing data-category=${category}`);
await item.click();
await expect(trigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 });
}
async function openWorkflowDocByLabel(
page: Page,
label: RegExp,
): Promise<{ title: string; docId: string }> {
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'workflow-label' }));
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
@@ -334,6 +340,10 @@ async function openWorkflowDocByControlledTaskKey(
taskKey: string,
): Promise<{ title: string; docId: string }> {
if (!/^[a-z][a-z0-9_]*$/.test(taskKey)) fail(`invalid controlled taskKey: ${taskKey}`);
await selectDeliverableCategory(
page,
requiredCategoryForDocOpen({ kind: 'controlled-task-key', taskKey }),
);
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
@@ -354,6 +364,7 @@ async function openWorkflowDocByControlledTaskKey(
async function openWorkflowDocById(page: Page, docId: string): Promise<void> {
if (!docId) fail('openWorkflowDocById requires docId');
await selectDeliverableCategory(page, requiredCategoryForDocOpen({ kind: 'exact-id', docId }));
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
@@ -0,0 +1,57 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
planExactDocOpen,
requiredCategoryForDocOpen,
} from './workflowCategoryNavigation';
describe('workflowCategoryNavigation', () => {
it('private menu cannot see doc_proj* so it must switch to workflow before exact select', () => {
const fromPrivate = planExactDocOpen({
currentCategory: 'private',
visibleMenuDocIds: ['doc-free-1789-1'],
targetDocId: 'doc_proj6143_intake',
});
assert.deepEqual(fromPrivate, [{ action: 'ensure_category', category: 'workflow' }]);
const afterSwitch = planExactDocOpen({
currentCategory: 'workflow',
visibleMenuDocIds: ['doc_proj6143_intake', 'doc_proj6143_prelim_design'],
targetDocId: 'doc_proj6143_intake',
});
assert.deepEqual(afterSwitch, [
{ action: 'ensure_category', category: 'workflow' },
{ action: 'select_exact', docId: 'doc_proj6143_intake' },
]);
});
it('fail-closes when the workflow menu is showing but the exact doc is missing', () => {
const verdict = planExactDocOpen({
currentCategory: 'workflow',
visibleMenuDocIds: ['doc_proj6143_prelim_design'],
targetDocId: 'doc_proj6143_intake',
});
const last = verdict[verdict.length - 1];
assert.equal(last?.action, 'fail');
if (last?.action !== 'fail') {
throw new Error('expected fail-closed last step');
}
assert.match(last.reason, /doc-selector menu missing data-doc-id=doc_proj6143_intake/);
});
it('label and taskKey opens always require the workflow category', () => {
assert.equal(requiredCategoryForDocOpen({ kind: 'workflow-label' }), 'workflow');
assert.equal(
requiredCategoryForDocOpen({ kind: 'controlled-task-key', taskKey: 'intake' }),
'workflow',
);
assert.equal(
requiredCategoryForDocOpen({ kind: 'exact-id', docId: 'doc_proj12_intake' }),
'workflow',
);
assert.equal(
requiredCategoryForDocOpen({ kind: 'exact-id', docId: 'doc-free-1' }),
'private',
);
});
});
+57
View File
@@ -0,0 +1,57 @@
export type DeliverableCategory = 'workflow' | 'shared' | 'private';
export const CATEGORY_TRIGGER_LABEL: Record<DeliverableCategory, string> = {
workflow: '流程受控',
shared: '项目共享',
private: '我的私密',
};
export type DocOpenIntent =
| { kind: 'exact-id'; docId: string }
| { kind: 'controlled-task-key'; taskKey: string }
| { kind: 'workflow-label' };
export type DocSelectorStep =
| { action: 'ensure_category'; category: DeliverableCategory }
| { action: 'select_exact'; docId: string }
| { action: 'fail'; reason: string };
const CONTROLLED_DOC_ID = /^doc_proj\d+_[a-z][a-z0-9_]*$/;
export function requiredCategoryForDocOpen(intent: DocOpenIntent): DeliverableCategory {
if (intent.kind === 'workflow-label' || intent.kind === 'controlled-task-key') {
return 'workflow';
}
const docId = intent.docId.trim();
if (CONTROLLED_DOC_ID.test(docId)) {
return 'workflow';
}
if (docId.startsWith('doc-free-')) {
return 'private';
}
throw new Error(`cannot infer deliverable category for ${JSON.stringify(docId)}`);
}
/**
* Plan the real UI sequence: always ensure the target category via the category
* trigger, then select by exact data-doc-id from that category's menu.
* A private (or other) menu missing doc_proj* is not a failure until after switch.
*/
export function planExactDocOpen(input: {
currentCategory: DeliverableCategory;
visibleMenuDocIds: string[];
targetDocId: string;
}): DocSelectorStep[] {
const category = requiredCategoryForDocOpen({ kind: 'exact-id', docId: input.targetDocId });
const ensure: DocSelectorStep = { action: 'ensure_category', category };
if (input.currentCategory !== category) {
return [ensure];
}
if (!input.visibleMenuDocIds.includes(input.targetDocId)) {
return [
ensure,
{ action: 'fail', reason: `doc-selector menu missing data-doc-id=${input.targetDocId}` },
];
}
return [ensure, { action: 'select_exact', docId: input.targetDocId }];
}