test(web): gate composer gestures on the editable attribute

A running turn disables the composer by flipping contenteditable to
false on the same element. fill() throws there immediately — a disabled
textarea used to hold it back through actionability — and isEnabled()
reports true for a div regardless, so the permission-policy scenario's
post-settle wait never waited and its next gesture raced the re-enable
render. The window is a few frames wide; #3083's Remote-routed subagent
control stretches settle enough to hit it on CI.

writeComposerDraft now waits for contenteditable="true" before acting,
and the permission-policy scenario drives all four sends through it with
the settle wait pinned to the attribute.
This commit is contained in:
Yichen Jiang
2026-08-26 13:44:32 +08:00
parent 2efaacd807
commit 94e3bfd5d1
2 changed files with 14 additions and 6 deletions
@@ -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]').first()
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | 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 expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
await expect.poll(() => input.getAttribute('contenteditable'), { timeout: 10_000 }).toBe('true')
}
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
+8
View File
@@ -117,6 +117,13 @@ 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. 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.
* @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 +134,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.click()
await page.keyboard.press('ControlOrMeta+A')
if (text === '') await page.keyboard.press('Backspace')