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.
This commit is contained in:
Yichen Jiang
2026-08-26 16:35:49 +08:00
parent 10d1c603be
commit 21e5ee9071
4 changed files with 65 additions and 7 deletions
@@ -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
@@ -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.
@@ -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
View File
@@ -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')