Explorar el Código

test(web): derive the editable gate from the gesture target

Review follow-ups: wait on the caller's own locator instead of assuming
it is the page's first composer, describe the actual read-only window
(submit adjudication and locked states — a running turn stays editable
for queueing), drop the pre-Lexical narration from the JSDoc, and record
the gesture-semantics trap as an Agent Note.
Yichen Jiang hace 2 semanas
padre
commit
21e5ee9071

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md
+2026-08-26-composer-gesture-editable-gate.md: 9b01a79d8d8a546641eaf87040d7a19fc5b4d5bc
+2026-08-26-composer-gesture-editable-gate.zh.md: b5dc8d36c7b195a152d32e92fac7ae36eea880d9

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e gestures gate on the contenteditable attribute
+
+Status: implemented
+
+English | [中文](2026-08-26-composer-gesture-editable-gate.zh.md)
+
+## Problem
+
+Two Playwright gesture semantics silently changed when the composer became a Lexical `contenteditable` `<div>`, and both bit only under CI load. While the input machine is adjudicating or submitting a send — and in every locked state — the composer renders read-only by flipping `contenteditable` to `"false"` on the same element. On that element `fill()` throws immediately (`Element is not an <input>, <textarea> or [contenteditable] element`) instead of waiting through actionability, and `expect.poll(() => input.isEnabled())` is a no-op guard: Playwright's enablement check ignores both `aria-disabled` and `contenteditable` on a `<div>`, so it reports `true` throughout the read-only window. The exposed race is only a few frames wide — the permission-policy scenario stayed green for weeks until a Remote-routed subagent refactor stretched submit settling enough for CI to land inside it.
+
+## Decision
+
+Composer e2e gestures go through `writeComposerDraft` in `apps/web/tests/support.ts`, which waits for the editable attribute on the gesture's own target (`input.and(page.locator('[contenteditable="true"]'))`) before acting and replaces the draft with per-key strokes. Scenario code that must wait for the composer to reopen after a submit gates on the `contenteditable` attribute, never on `isEnabled()`.
+
+## Alternatives considered
+
+- **Waiting inside each scenario** instead of inside the helper: rejected — every new scenario re-discovers the trap the hard way, and the fix that motivated this note was already the second such site.
+- **Keeping `fill()` and polling `aria-disabled`** before each call: rejected — it leaves the dropped-edit race `fill()` has directly after trigger-menu and chip interactions (Lexical's internal selection lags the DOM selection inside one task), which the per-key helper also covers.
+- **Making the product surface tolerate `fill()`** (accepting synthetic edits while read-only): rejected — the read-only window is deliberate UI truth during submit adjudication; loosening it for tests would change user-visible behavior.
+
+## Consequences
+
+- A bare `input.fill(...)` against `[data-composer-input]` is a latent CI race even when it passes locally; the helper is the supported gesture.
+- `isEnabled()` on the composer asserts nothing. Existing polls of it guard nothing and read as coverage they do not provide.
+- A running turn by itself keeps the composer editable — that is what queueing types into — so the gate waits only through submit adjudication and locked states, not for turn completion.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-08-26-composer-gesture-editable-gate.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Composer e2e 手势以 contenteditable 属性为门
+
+Status: implemented
+
+[English](2026-08-26-composer-gesture-editable-gate.md) | 中文
+
+## 问题
+
+composer 变为 Lexical `contenteditable` `<div>` 后,两个 Playwright 手势语义悄然改变,且都只在 CI 高负载下咬人。输入机在裁决或发送一次提交期间——以及所有 locked 状态下——composer 通过把同一元素的 `contenteditable` 翻成 `"false"` 呈现只读。在该元素上 `fill()` 立即抛错(`Element is not an <input>, <textarea> or [contenteditable] element`)而不再经 actionability 等待;`expect.poll(() => input.isEnabled())` 则是无效护栏:Playwright 的 enablement 检查对 `<div>` 同时无视 `aria-disabled` 与 `contenteditable`,整个只读窗口内一律报 `true`。暴露的竞态只有几帧宽——permission-policy 场景绿了数周,直到 subagent 控制 Remote 化把提交 settle 拉长,CI 才落进窗口。
+
+## 决策
+
+composer 的 e2e 手势统一走 `apps/web/tests/support.ts` 的 `writeComposerDraft`:动作前在手势自身的目标上等待可编辑属性(`input.and(page.locator('[contenteditable="true"]'))`),再以逐键击键替换草稿。场景代码若需等待提交后 composer 重新开放,一律以 `contenteditable` 属性为门,永不使用 `isEnabled()`。
+
+## 曾考虑的替代方案
+
+- **在各场景内各自等待**而不是收进 helper:否决——每个新场景都会以最痛的方式重新发现这个陷阱,而促成本 note 的修复本身已是第二个踩点。
+- **保留 `fill()`、每次调用前 poll `aria-disabled`**:否决——这仍留着 `fill()` 在触发菜单与 chip 交互之后的丢编辑竞态(单 task 内 Lexical 内部 selection 落后于 DOM selection),逐键 helper 同时覆盖了它。
+- **让产品表面容忍 `fill()`**(只读期间接受合成编辑):否决——只读窗口是提交裁决期间刻意的 UI 事实;为测试放松它会改变用户可见行为。
+
+## 后果
+
+- 对 `[data-composer-input]` 裸写 `input.fill(...)` 即使本地全绿也是潜伏的 CI 竞态;helper 是受支持的手势。
+- 对 composer 调用 `isEnabled()` 断言不了任何东西。既有的此类 poll 不护任何路径,却读起来像提供了覆盖。
+- turn 运行本身保持 composer 可编辑——排队输入正是打进这里——因此该门只等待提交裁决与 locked 状态,不等待 turn 完成。

+ 9 - 7
apps/web/tests/support.ts

@@ -118,12 +118,14 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
  * dropped, leaving the previous draft in place. Real keystrokes leave room for
  * `selectionchange` between keys, which is also what a user's typing does.
  *
- * Waits for the surface to be editable first. A running turn disables the
- * composer by setting `contenteditable="false"` on the same element, where
- * `fill()` throws immediately instead of waiting (a disabled `<textarea>`
- * held it back via actionability) and `isEnabled()` reports `true` for a
- * `<div>` regardless — so a gesture directly after a turn settles must gate
- * on the attribute, not on enablement.
+ * Waits for the surface to be editable first. While the input machine is
+ * adjudicating or submitting a send — and in every locked state (removed
+ * session, no workspace, an owner block) — the composer renders read-only
+ * with `contenteditable="false"` on the same element. `fill()` throws
+ * immediately on that element, and `isEnabled()` reports `true` for a
+ * `<div>` regardless of the attribute — so a gesture directly after a
+ * submit must gate on the attribute, not on enablement. A running turn by
+ * itself keeps the composer editable (that is what queueing types into).
  * @param page - the page under test.
  * @param input - the `[data-composer-input]` surface locator.
  * @param text - the replacement draft; `''` clears the draft. Must not
@@ -134,7 +136,7 @@ export async function writeComposerDraft(
   input: ReturnType<Page['locator']>,
   text: string,
 ): Promise<void> {
-  await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 15_000 })
+  await input.and(page.locator('[contenteditable="true"]')).waitFor({ timeout: 15_000 })
   await input.click()
   await page.keyboard.press('ControlOrMeta+A')
   if (text === '') await page.keyboard.press('Backspace')