` 同时无视 `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 完成。
diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts
index ab5060e065..79f75a53bf 100644
--- a/apps/web/tests/permission-policy-context.e2e.ts
+++ b/apps/web/tests/permission-policy-context.e2e.ts
@@ -15,7 +15,7 @@ import {
assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.jsonl', import.meta.url))
@@ -97,23 +97,23 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
let sessionId: Awaited
> | undefined
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
- await input.fill(`/permission ${preset}`)
+ await writeComposerDraft(page, input, `/permission ${preset}`)
await input.press('Enter')
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
- await input.fill(PROMPTS[index] as string)
+ await writeComposerDraft(page, input, PROMPTS[index] as string)
await input.press('Enter')
sessionId = await settled
await input.waitFor({ timeout: 10_000 })
}
- await input.fill('/permission read-only')
+ await writeComposerDraft(page, input, '/permission read-only')
await input.press('Enter')
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
- await input.fill(PROMPTS[3])
+ await writeComposerDraft(page, input, PROMPTS[3])
await input.press('Enter')
sessionId = await settled
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index b43fcbaa76..66bfd4111d 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -117,6 +117,15 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
* selection, and the batched edit lands on a null selection and is silently
* 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. 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
+ * `` 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
@@ -127,6 +136,7 @@ export async function writeComposerDraft(
input: ReturnType
,
text: string,
): Promise {
+ 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')