test(e2e): stabilize phase-06 reference picker regressions

This commit is contained in:
肖应宇 2026-04-15 13:20:40 +08:00
parent cec16f2e93
commit 80e662dbdb
3 changed files with 441 additions and 47 deletions

View File

@ -0,0 +1,103 @@
---
phase: 06-
plan: 05
subsystem: testing
tags: [mentions, references, playwright, dropdown, regression]
requires:
- phase: 06-03
provides: Phase 06 回归基线与验证缺口
provides:
- 引用上限与去歧义展示合同对齐 requirement 10
- DF-INPUT-009 回归场景稳定化10 个成功 + 第 11 个阻止)
- toast/候选面板定位 helper 去 flaky 化
affects: [06-UAT, input-box, e2e, mention-picker]
tech-stack:
added: []
patterns: [stable-e2e-locators, deterministic-toast-assertion, retry-open-picker]
key-files:
created:
- .planning/phases/06-/06-05-SUMMARY.md
modified:
- frontend/tests/e2e/input-and-compose.spec.ts
- frontend/tests/e2e/support/chat-helpers.ts
key-decisions:
- "DF-INPUT-009 采用固定 fixture key + 明确 data-testid 断言,避免 strict text locator 多命中。"
- "toast 断言统一 first(),消除并发提示叠加导致的 strict mode 抖动。"
patterns-established:
- "openReferencePicker 增加重试与回退Backspace机制兼容 Dropdown 动画/重排时序。"
- "引用上限回归按 1..10 逐步计数断言,再验证第 11 次被阻止。"
requirements-completed: [ATREF-01, ATREF-02, ATREF-03, ATREF-04]
duration: 12min
completed: 2026-04-15
---
# Phase 06 Plan 05: Verification Gaps Closure Summary
**Phase 06 最后一个 gap-closure 计划已收口:引用上限 10 的回归路径稳定可重复DF-INPUT-009 不再因候选层/toast 定位抖动而失败。**
## Performance
- **Duration:** 12 min
- **Started:** 2026-04-15T05:06:00Z
- **Completed:** 2026-04-15T05:18:04Z
- **Tasks:** 2
- **Files modified:** 2
## Accomplishments
- 验证并确认 `input-box.tsx` 已满足上限 `10`、`DropdownMenu*` 链路与“文件名 + 类型 + 路径尾段”展示合同。
- 稳定化 DF-INPUT-009固定候选 key 选择路径、逐步数量断言、11th 阻止与 toast 断言。
- 强化 E2E helper候选面板开启重试、toast 定位去严格模式冲突,提升回归确定性。
## Task Commits
1. **Task 1: 对齐引用展示合同与上限 10** - 复核通过(本轮未新增代码改动)
2. **Task 2: 移除永久 skip 并稳定化 DF-INPUT-008/009 回归** - 本轮改动在工作区完成(待与当前分支既有改动统一提交)
## Files Created/Modified
- `.planning/phases/06-/06-05-SUMMARY.md` - 记录 plan 05 执行与验证结果。
- `frontend/tests/e2e/input-and-compose.spec.ts` - DF-INPUT-009 改为稳定 key 驱动的候选选择与计数断言。
- `frontend/tests/e2e/support/chat-helpers.ts` - `openReferencePicker` 增加重试;`toastByText` 统一 `.first()`
## Decisions Made
- 不回退 Phase 06 既有主链路实现,仅针对 verification gaps 做最小、可回归的测试稳定性修复。
- 保留 DF-INPUT-008 的条件 skip 语义(仅在必需 thread/env 缺失时跳过),移除永久 skip 风险。
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] DF-INPUT-009 候选点击在 Dropdown 动画期不稳定**
- **Found during:** Task 2 验证
- **Issue:** 候选项在可见但重排中导致 click actionability 抖动,产生超时。
- **Fix:** helper 增加开启重试;测试改用稳定 key + DOM click + 数量递增断言。
- **Files modified:** `frontend/tests/e2e/input-and-compose.spec.ts`, `frontend/tests/e2e/support/chat-helpers.ts`
- **Verification:** `pnpm -s test:e2e --grep "DF-INPUT-007|DF-INPUT-008|DF-INPUT-009"`007/009 pass008 conditional skip
- **Committed in:** N/A当前分支存在并行未提交改动待统一提交
---
**Total deviations:** 1 auto-fixed (Rule 1: bug)
**Impact on plan:** 修复仅针对回归稳定性,无范围膨胀,不影响已确认功能链路。
## Issues Encountered
- 并发 toast 与候选层动画导致 strict locator/actionability 偶发失败;已通过 helper 与断言收敛消除。
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- `06-05` summary 已补齐phase completeness 可推进到 phase-level verification。
- ATREF-04 对应的自动化护栏已可回归运行008 条件化、009 稳定通过)。
## Self-Check: PASSED
- FOUND: `.planning/phases/06-/06-05-SUMMARY.md`
- VERIFIED: `node --test src/core/threads/hooks.test.ts` 通过
- VERIFIED: `pnpm -s typecheck` 通过
- VERIFIED: `pnpm -s test:e2e --grep "DF-INPUT-007|DF-INPUT-008|DF-INPUT-009"` → 007/009 pass, 008 conditional skip

View File

@ -2,12 +2,30 @@ import { expect, test } from "@playwright/test";
import { import {
THREAD_FOR_WELCOME, THREAD_FOR_WELCOME,
THREAD_WITH_REFERENCE_FIXTURE,
THREAD_WITH_STALE_REFERENCE,
collectMentionCandidateKeys,
expandComposer,
mentionCandidateByKey,
newChatEntry, newChatEntry,
openChat, openChat,
openReferencePicker,
rewriteFirstReferenceAsArtifact,
reuseThreadChatEntry, reuseThreadChatEntry,
skipIfMissingThread, skipIfMissingThread,
stubReferenceFixtures,
toastByText,
} from "./support/chat-helpers"; } from "./support/chat-helpers";
const REFERENCE_ARTIFACT_PATH = "/generated/reference-contract.md";
const REFERENCE_UPLOAD_FIXTURES = Array.from({ length: 11 }, (_, index) => {
const fileNumber = String(index + 1).padStart(2, "0");
return {
filename: `fixture-${fileNumber}.md`,
virtual_path: `/mnt/user-data/uploads/fixture-${fileNumber}.md`,
};
});
test.describe("聊天工作台 / 输入区与发送", () => { test.describe("聊天工作台 / 输入区与发送", () => {
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => { test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
@ -125,81 +143,148 @@ test.describe("聊天工作台 / 输入区与发送", () => {
{ page }, { page },
testInfo, testInfo,
) => { ) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); testInfo,
THREAD_WITH_REFERENCE_FIXTURE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_REFERENCE_FIXTURE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!));
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text"); await expandComposer(page);
if ((await expander.count()) > 0) {
await expander.first().click();
}
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
await textarea.fill("请基于这个文件回答 @"); await textarea.fill("请基于这个文件回答 ");
const { panel } = await openReferencePicker(page);
const panel = page.getByTestId("mention-candidate-panel").first(); const items = page.getByTestId("mention-candidate-item");
await expect(panel).toBeVisible();
const items = panel.locator("button");
const itemCount = await items.count(); const itemCount = await items.count();
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。"); testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
await textarea.press("Enter"); await items.first().click();
await expect(textarea).toBeFocused(); await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue(/请基于这个文件回答/); await expect(textarea).toHaveValue(/请基于这个文件回答/);
await expect(page.getByTestId("reference-inline-preview")).toBeVisible(); await expect(page.getByTestId("reference-inline-preview")).toBeVisible();
await expect(page.getByLabel("移除引用").first()).toBeVisible(); await expect(page.getByTestId("reference-chip")).toHaveCount(1);
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(1);
await expect(panel).toBeHidden();
}); });
test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async ( test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async (
{ page }, { page },
testInfo, testInfo,
) => { ) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); testInfo,
THREAD_WITH_STALE_REFERENCE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_STALE_REFERENCE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_STALE_REFERENCE!));
// 该场景依赖特定后端/fixture 能制造 stale 引用;若环境无能力则显式跳过。 await expandComposer(page);
testInfo.skip(true, "当前 E2E 环境无法稳定注入 stale 引用,使用 hooks 单测覆盖软失败逻辑。"); const textarea = page.locator("textarea[name='message']");
await textarea.fill("stale 引用回归验证 ");
await openReferencePicker(page);
const uploadItem = page
.locator(
'[data-testid="mention-candidate-item"][data-candidate-key^="upload:"]',
)
.first();
await expect(uploadItem).toBeVisible();
await uploadItem.click();
await expect(page.getByTestId("reference-chip")).toHaveCount(1);
const stalePath = REFERENCE_ARTIFACT_PATH;
let staleArtifactRequested = false;
await rewriteFirstReferenceAsArtifact(page, stalePath);
await expect(page.getByTestId("reference-chip").first()).toContainText(
"生成文件",
);
await page.route(
new RegExp(
`/api/threads/${THREAD_WITH_STALE_REFERENCE}/artifacts${stalePath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(\\?.*)?$`,
),
async (route) => {
staleArtifactRequested = true;
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "e2e stale artifact" }),
});
},
{ times: 1 },
);
await page.locator("button[aria-label='Submit']").click();
await expect.poll(() => staleArtifactRequested).toBe(true);
await expect(
toastByText(page, "部分引用文件已失效,已自动移除并继续发送。"),
).toBeVisible();
await expect(
page.locator(".is-user").filter({ hasText: "stale 引用回归验证" }),
).toHaveCount(1);
}); });
test("DF-INPUT-009 引用上限为 6第 7 个被阻止并提示", async ( test("DF-INPUT-009 引用上限为 10第 11 个被阻止并提示", async (
{ page }, { page },
testInfo, testInfo,
) => { ) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); testInfo,
THREAD_WITH_REFERENCE_FIXTURE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_REFERENCE_FIXTURE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!));
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text"); await expandComposer(page);
if ((await expander.count()) > 0) {
await expander.first().click();
}
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
await textarea.fill("请参考这些文件 "); await textarea.fill("请参考这些文件 ");
await textarea.type("@"); await openReferencePicker(page);
const panel = page.getByTestId("mention-candidate-panel").first(); const candidateKeys = await collectMentionCandidateKeys(page);
await expect(panel).toBeVisible(); testInfo.skip(
const initialItems = panel.locator("button"); candidateKeys.length < 11,
const candidateCount = await initialItems.count(); "当前线程候选文件不足 11 个,无法验证 10 个上限。",
testInfo.skip(candidateCount < 7, "当前线程候选文件不足 7 个,无法验证上限。"); );
for (let i = 0; i < 6; i += 1) { for (const [index, key] of candidateKeys.slice(0, 10).entries()) {
await textarea.type("@"); await openReferencePicker(page);
const currentPanel = page.getByTestId("mention-candidate-panel").first(); const candidate = mentionCandidateByKey(page, key);
await expect(currentPanel).toBeVisible(); await expect(candidate).toBeVisible();
for (let step = 0; step < i; step += 1) { await candidate.evaluate((element) => {
await textarea.press("ArrowDown"); (element as HTMLElement).click();
} });
await textarea.press("Enter"); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(
index + 1,
);
} }
await expect(page.getByLabel("移除引用")).toHaveCount(6); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await textarea.type("@"); await openReferencePicker(page);
await expect(panel).toBeVisible(); const blockedCandidate = mentionCandidateByKey(page, candidateKeys[10]!);
for (let step = 0; step < 6; step += 1) { await expect(blockedCandidate).toBeVisible();
await textarea.press("ArrowDown"); await blockedCandidate.evaluate((element) => {
} (element as HTMLElement).click();
await textarea.press("Enter"); });
await expect(page.getByLabel("移除引用")).toHaveCount(6); await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await expect(page.getByText("单条消息最多引用 6 个文件")).toBeVisible(); await expect(
toastByText(page, "单条消息最多引用 10 个文件"),
).toBeVisible();
}); });
}); });

View File

@ -22,6 +22,10 @@ export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
export const THREAD_WITH_HTML_ARTIFACT = envThread( export const THREAD_WITH_HTML_ARTIFACT = envThread(
"FRONTEND_E2E_HTML_ARTIFACT_THREAD_ID", "FRONTEND_E2E_HTML_ARTIFACT_THREAD_ID",
); );
export const THREAD_WITH_REFERENCE_FIXTURE =
THREAD_WITH_ARTIFACTS ?? PRIMARY_THREAD_ID;
export const THREAD_WITH_STALE_REFERENCE =
THREAD_WITH_ARTIFACTS ?? PRIMARY_THREAD_ID;
export function skipIfMissingThread( export function skipIfMissingThread(
testInfo: TestInfo, testInfo: TestInfo,
@ -96,6 +100,208 @@ export async function openChat(
} }
} }
export async function expandComposer(page: Page) {
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text");
if ((await expander.count()) > 0) {
await expander.first().click();
}
}
export async function openReferencePicker(page: Page, value = "@") {
const textarea = page.locator("textarea[name='message']");
const panel = page.getByTestId("mention-candidate-panel").first();
await textarea.focus();
for (let attempt = 0; attempt < 3; attempt += 1) {
await textarea.type(value);
const visible = await panel.isVisible().catch(() => false);
if (visible) {
await page.waitForTimeout(100);
return { textarea, panel };
}
// Reset the transient token and retry when dropdown opening lags in fast loops.
await textarea.press("Backspace");
await page.waitForTimeout(100);
}
await expect(panel).toBeVisible();
return { textarea, panel };
}
export async function collectMentionCandidateKeys(page: Page) {
const items = page.getByTestId("mention-candidate-item");
const count = await items.count();
const keys: string[] = [];
for (let index = 0; index < count; index += 1) {
const key = await items.nth(index).getAttribute("data-candidate-key");
if (key) {
keys.push(key);
}
}
return keys;
}
export function mentionCandidateByKey(page: Page, key: string) {
return page.locator(
`[data-testid="mention-candidate-item"][data-candidate-key=${JSON.stringify(key)}]`,
);
}
export function toastByText(page: Page, text: string) {
return page.locator("[data-sonner-toast]").filter({ hasText: text }).first();
}
export async function rewriteFirstReferenceAsArtifact(
page: Page,
artifactPath: string,
) {
const textarea = page.locator("textarea[name='message']");
const rewritten = await textarea.evaluate((element, nextArtifactPath) => {
const fiberKey = Object.keys(element).find((key) =>
key.startsWith("__reactFiber$"),
);
if (!fiberKey) {
return false;
}
let fiber = ((element as unknown as Record<string, unknown>)[fiberKey]) as
| {
return?: unknown;
memoizedState?: unknown;
elementType?: { name?: string };
}
| undefined;
while (fiber) {
if (fiber.elementType?.name === "InputBox") {
break;
}
fiber = fiber.return as typeof fiber;
}
let hook = fiber?.memoizedState as
| {
memoizedState?: unknown;
next?: unknown;
queue?: { dispatch?: (value: unknown) => void };
}
| undefined;
while (hook) {
const state = hook.memoizedState;
const dispatch = hook.queue?.dispatch;
if (
Array.isArray(state) &&
state.some(
(item) =>
item &&
typeof item === "object" &&
"ref_kind" in item &&
(item as { ref_kind?: string }).ref_kind === "mention",
) &&
dispatch
) {
dispatch(
state.map((item, index) =>
index === 0 && item && typeof item === "object"
? {
...item,
path: nextArtifactPath,
ref_source: "artifact",
}
: item,
),
);
return true;
}
hook = hook.next as typeof hook;
}
return false;
}, artifactPath);
expect(rewritten).toBe(true);
}
export async function stubReferenceFixtures(
page: Page,
options: {
threadId: string;
artifactPaths?: string[];
uploadFiles?: Array<{
filename: string;
size?: number;
virtual_path: string;
path?: string;
artifact_url?: string;
}>;
},
) {
const { threadId, artifactPaths = [], uploadFiles = [] } = options;
await page.route(
new RegExp(`/api/langgraph/threads/${threadId}/state(?:/[^?]+)?(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json();
await route.fulfill({
response,
json: {
...json,
values: {
...json.values,
artifacts: artifactPaths,
},
},
});
},
);
await page.route(
new RegExp(`/api/langgraph/threads/${threadId}/history(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json();
const history = Array.isArray(json) ? json : [];
await route.fulfill({
response,
json: history.map((entry) => ({
...entry,
values: {
...entry.values,
artifacts: artifactPaths,
},
})),
});
},
);
await page.route(
new RegExp(`/api/threads/${threadId}/uploads/list(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json().catch(() => ({ files: [], count: 0 }));
const files = uploadFiles.map((file) => ({
filename: file.filename,
size: file.size ?? 128,
path: file.path ?? file.virtual_path,
virtual_path: file.virtual_path,
artifact_url:
file.artifact_url ??
`/api/threads/${threadId}/artifacts${file.virtual_path}`,
}));
await route.fulfill({
response,
json: {
...json,
files,
count: files.length,
},
});
},
);
}
export async function sendMessage(page: Page, text: string) { export async function sendMessage(page: Page, text: string) {
const textarea = page.locator("textarea[name='message']"); const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']"); const submit = page.locator("button[aria-label='Submit']");