فهرست منبع

test(web): share the per-key composer draft helper

CI hit the same dropped-fill race in lifecycle-chrome's slash-menu
sequence that the folder scenario hit: fill()'s single-task select-all +
edit lands on a Lexical selection that has not absorbed the DOM
selection after a trigger-menu interaction, so the previous draft
survives and poisons the next test. Promote the per-key gesture to
support.ts and use it at both proven-fragile sites.
Yichen Jiang 1 ماه پیش
والد
کامیت
07319c011d
3فایلهای تغییر یافته به همراه32 افزوده شده و 25 حذف شده
  1. 5 5
      apps/web/tests/lifecycle-chrome.e2e.ts
  2. 4 20
      apps/web/tests/reference-composer.e2e.ts
  3. 23 0
      apps/web/tests/support.ts

+ 5 - 5
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -21,7 +21,7 @@ import {
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, 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/lifecycle-chrome', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -79,7 +79,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await page.locator('[data-composer-input]').first().press('Escape')
     await expect.poll(() => menu.count()).toBe(0)
     const input = page.locator('[data-composer-input]').first()
-    await input.fill('/')
+    await writeComposerDraft(page, input, '/')
     await menu.waitFor({ timeout: 10_000 })
     const typedBox = await menu.boundingBox()
     expect(launchedBox).not.toBeNull()
@@ -88,13 +88,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect(Math.abs(
       launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
     )).toBeLessThan(1)
-    await input.fill('/cpt')
+    await writeComposerDraft(page, input, '/cpt')
     await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
       'compactCompact older conversation history',
     ])
     const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
-    await input.fill('')
+    await writeComposerDraft(page, input, '')
     await expect.poll(() => menu.count()).toBe(0)
   })
 
@@ -175,7 +175,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
     }
     const settled = scaffold.whenTurnSettled()
-    await input.fill(PROMPT)
+    await writeComposerDraft(page, input, PROMPT)
     const observeTurn = async () => {
       const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
       if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })

+ 4 - 20
apps/web/tests/reference-composer.e2e.ts

@@ -25,7 +25,7 @@ import {
   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('./expected/reference-composer', import.meta.url))
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
@@ -110,22 +110,6 @@ function targetSessionFixture(): string {
   ].join('\n')
 }
 
-/**
- * Replace the draft through per-key gestures. `fill()` issues select-all and
- * insertText inside one task; directly after a chip deletion Lexical's internal
- * selection has not yet absorbed the DOM selection, and the batched insertText
- * lands on a null selection and is dropped. Real keystrokes leave room for
- * `selectionchange` between keys, which is also what a user's typing does.
- * @param page - the page under test.
- * @param input - the composer surface locator.
- * @param text - the replacement draft.
- */
-async function writeDraft(page: Page, input: ReturnType<Page['locator']>, text: string): Promise<void> {
-  await input.click()
-  await page.keyboard.press('ControlOrMeta+A')
-  await page.keyboard.type(text)
-}
-
 describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -264,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // Settle: Enter on the highlighted folder row resolves the folder itself
     // as an atomic chip — folder glyph, no trigger character, one unit.
-    await writeDraft(page, input, '@folderx')
+    await writeComposerDraft(page, input, '@folderx')
     // First folder query on this page: allow the Host index a cold start.
     await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor({ timeout: 60_000 })
     await page.keyboard.press('Enter')
@@ -275,14 +259,14 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
 
     // Tab drills: the literal descent text stays editable and the open menu
     // lists the folder's children.
-    await writeDraft(page, input, '@folderx')
+    await writeComposerDraft(page, input, '@folderx')
     await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor()
     await page.keyboard.press('Tab')
     await expect.poll(() => input.textContent()).toBe('@folderx/')
     await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor()
 
     // The row chevron drills the same way by pointer.
-    await writeDraft(page, input, '@folderx')
+    await writeComposerDraft(page, input, '@folderx')
     const row = menu.getByRole('option', { name: /Folder · folderx\// })
     await row.waitFor()
     await row.getByRole('button', { name: 'Browse folder' }).click()

+ 23 - 0
apps/web/tests/support.ts

@@ -110,6 +110,29 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
     .waitFor({ timeout: 15_000 })
 }
 
+/**
+ * Replace the composer draft through per-key gestures. `fill()` issues
+ * select-all and insertText inside one task; directly after a trigger-menu or
+ * chip interaction Lexical's internal selection has not yet absorbed the DOM
+ * 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.
+ * @param page - the page under test.
+ * @param input - the `[data-composer-input]` surface locator.
+ * @param text - the replacement draft; `''` clears the draft. Must not
+ * contain a newline: typed Enter submits the composer.
+ */
+export async function writeComposerDraft(
+  page: Page,
+  input: ReturnType<Page['locator']>,
+  text: string,
+): Promise<void> {
+  await input.click()
+  await page.keyboard.press('ControlOrMeta+A')
+  if (text === '') await page.keyboard.press('Backspace')
+  else await page.keyboard.type(text)
+}
+
 /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
 export async function saveFailureShot(page: Page, name: string): Promise<void> {
   const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))