Merge remote-tracking branch 'origin/master' into codex/pr-3111-review

# Conflicts:
#	scripts/ci-workflow.spec.ts
This commit is contained in:
creatixchu
2026-08-27 10:57:27 +08:00
428 changed files with 6338 additions and 3221 deletions
@@ -21,7 +21,7 @@ async function freshComposer(): Promise<HTMLElement> {
fireEvent.click(start)
return await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build"]',
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -42,7 +42,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
@@ -14,7 +14,7 @@
- img
- img
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -45,7 +45,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -24,7 +24,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -36,7 +36,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -40,7 +40,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -1,9 +1,10 @@
- listbox "Trigger suggestions":
- text: Files & folders
- option "Folder · folderx/ folderx Browse folder" [selected]:
- text: Folder · folderx/ folderx
- button "Browse folder":
- option "File · reference.txt reference.txt"
- text: Session conversations
- option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}"
- option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}"
- option "folderx/ folderx Browse folder" [selected]:
- text: folderx/ folderx
- button "Browse folder":
- img
- option "reference.txt reference.txt"
- text: Sessions
- option "Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}"
- option "Research notes reference-source-session · {{cwd}} · {{timestamp}}"
@@ -13,7 +13,7 @@
- button "Session recall Research notes":
- img
- text: Session recall Research notes
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -34,7 +34,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -375,7 +375,7 @@
- text: 7/25 {{clock}} Ran for {{duration}}
- button "Back to bottom":
- img
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -31,7 +31,7 @@
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -44,7 +44,7 @@
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
+1 -1
View File
@@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
// Startup reuses the fixture workspace's blank session, keeping this
// command independent of alpha's running replay and pending question.
const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]')
const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
await input.waitFor({ timeout: 10_000 })
await input.fill('/goal guard rapid clear clicks')
await input.press('Enter')
+3 -3
View File
@@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
// this assembled lane pins the intake chain over the built graph.
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build"]',
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -165,7 +165,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
fireEvent.click(start)
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build"]',
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -220,7 +220,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build"]',
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -141,7 +141,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
// Regression pin for the reload flash: both steps are satisfied, yet each
// must load private facts before deciding not to show. Dialog chrome lives
// inside each visible branch, so the deciding window paints and blocks
// nothing. Holding settings.describe widens that window from loopback
// nothing. Holding settings/describe widens that window from loopback
// RTT scale to a deterministic hundreds of milliseconds, removing all
// timing dependence from the sampler assertions below.
//
@@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
if (document.getElementById('root')?.inert === true) sightings.push('inert')
}, 8)
})
// EVERY settings.describe issued before the release is held — not just
// EVERY settings/describe issued before the release is held — not just
// the first — so the pin cannot silently collapse back to loopback
// timing if a second boot-time consumer of the join ever appears.
let released = false
@@ -171,7 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
released = true
for (const resolve of heldRoutes.splice(0)) resolve()
}
await page.route('**/api/settings.describe', async (route) => {
await page.route('**/api/settings/describe', async (route) => {
if (!released) await new Promise<void>((resolve) => { heldRoutes.push(resolve) })
await route.continue()
})
@@ -182,7 +182,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await page.waitForTimeout(600)
releaseDescribe()
await page.waitForTimeout(400)
await page.unroute('**/api/settings.describe')
await page.unroute('**/api/settings/describe')
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
expect(await page.evaluate(() =>
(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
@@ -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))
@@ -94,26 +94,26 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
}
const input = page.locator('[data-composer-input]').first()
const input = page.locator('[data-composer-input][contenteditable="true"]').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 page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
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
+37 -28
View File
@@ -310,25 +310,13 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
const configureLater = page.getByRole('button', { name: 'Configure later' })
await configureLater.waitFor({ timeout: 30_000 })
await configureLater.click()
await page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]')
await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
.waitFor({ timeout: 30_000 })
const exercised = await page.evaluate(async () => {
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
interface PreviewApi {
host: { createDirectory(payload: { path: string; name: string }): Promise<Result<{ path: string }>> }
skills: { list(payload: { sessionId: string }): Promise<Result<{ skills: unknown[] }>> }
settings: {
describe(payload: object): Promise<Result<{ namespaces: Array<{ ns: string; revision: number }> }>>
update(payload: { ns: string; patch: object; expectedRevision: number }): Promise<Result<unknown>>
}
credentials: {
set(payload: { ref: string; value: string }): Promise<Result<unknown>>
unset(payload: { ref: string }): Promise<Result<unknown>>
describe(payload: { refs: string[] }): Promise<Result<{
credentials: Record<string, { configured: boolean }>
}>>
}
}
interface PreviewTransport {
fetch(input: string, init: RequestInit): Promise<Response>
@@ -349,33 +337,54 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
const sessionId = sessions.result.value.items[0]?.sessionId
if (sessionId === undefined) throw new Error('workspace adoption created no Session')
// Remote namespaces answer over the same unary carrier; the args object
// keys every wire parameter by its name.
const remote = async <T>(endpoint: string, args: object): Promise<T> => {
const answered = await transport.fetch(`/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`,
method: endpoint, payload: { args },
}),
})
const body = await answered.json() as Result<T>
if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`)
return body.result.value
}
const api = transport.createApiClient()
const skills = await api.skills.list({ sessionId })
if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`)
const createDirectory = async (path: string, name: string): Promise<void> => {
const created = await api.host.createDirectory({ path, name })
if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`)
await remote<string>('directoryPicker/createDirectory', { path, name })
await new Promise((resolve) => { setTimeout(resolve, 250) })
const refreshed = await api.skills.list({ sessionId })
if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`)
}
await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created')
const settings = await api.settings.describe({})
if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`)
const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell')
if (shell === undefined) throw new Error('settings.describe omitted the shell namespace')
const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision })
if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`)
const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`)
const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] })
if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`)
const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' })
if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`)
// Settings and credentials both answer over the Remote carrier, so this
// half of the sweep posts the generated endpoints directly like the
// session read above.
const settings = await remote<{ namespaces: { ns: string; revision: number }[] }>(
'settings/describe', {},
)
const shell = settings.namespaces.find(namespace => namespace.ns === 'shell')
if (shell === undefined) throw new Error('settings/describe omitted the shell namespace')
await remote('settings/update', {
ns: 'shell',
patch: { timeoutMs: 61_000 },
expectedRevision: shell.revision,
})
await remote('credentials/set', { ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
const credentials = await remote<Record<string, { configured: boolean }>>(
'credentials/describe',
{ refs: ['PREVIEW_TEST_SECRET'] },
)
await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' })
await new Promise((resolve) => { setTimeout(resolve, 250) })
return {
skillCount: skills.result.value.skills.length,
credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured,
credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured,
}
})
expect(exercised.skillCount).toBeGreaterThan(0)
+15
View File
@@ -195,6 +195,21 @@ describe('web e2e: resident question composer round trip', () => {
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
if (MODE !== 'record') {
// A strict Session-slot switch remounts the composer. Open a fresh blank
// Session, then return to the still-waiting request and require its
// Session-scoped store to restore both option and free-text drafts.
const originalRow = page.locator('[role="treeitem"]')
.filter({ hasText: 'Use the ask_user_question tool' }).first()
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
await page.getByText('New Session', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => composer.count(), { timeout: 10_000 }).toBe(0)
await originalRow.click()
await composer.waitFor({ timeout: 15_000 })
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
// This golden now owns the composed state after a real A -> B -> A
// Session cycle, not merely the state before the remount.
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
}
+13 -13
View File
@@ -150,14 +150,14 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Files & folders')
expect(snapshot).toContain('Session conversations')
expect(snapshot).toContain('Sessions')
expect(snapshot).not.toContain('text: reference Files & folders')
expect(snapshot).toContain('File \u00b7 reference.txt')
expect(snapshot).toContain('Session \u00b7 Research notes')
expect(snapshot).toContain('reference.txt')
expect(snapshot).toContain('Research notes')
expect(snapshot).not.toContain('text: Subagents')
await input.fill('@reference')
await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click()
await menu.getByRole('option', { name: /reference\.txt/ }).click()
// The pick lands an atomic chip: a real DOM capsule carrying the domain
// icon and the label (the canonical reference text lives on the node and
// expands on submit; the surface text is the label plus the separator).
@@ -167,7 +167,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await expect.poll(() => input.textContent()).toBe('reference.txt ')
await input.fill('@Research')
await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click()
await menu.getByRole('option', { name: /Research notes/ }).click()
const sessionReference = page.locator('[data-composer-chip]').last()
await expect.poll(() => sessionReference.textContent()).toBe('Research notes')
await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
@@ -183,7 +183,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await input.fill('@reference')
await menu.getByRole('option', { name: /File · reference\.txt/ }).click()
await menu.getByRole('option', { name: /reference\.txt/ }).click()
await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
// The #2813 gesture: collapse the caret to the document start, directly
@@ -192,7 +192,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.press('ArrowLeft')
await page.keyboard.type('@Research')
await menu.getByRole('option', { name: /Session · Research notes/ }).click()
await menu.getByRole('option', { name: /Research notes/ }).click()
// Both chips survive the boundary insert: the session chip lands ahead of
// the intact file chip.
@@ -212,7 +212,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await input.fill('@reference')
await menu.getByRole('option', { name: /File · reference\.txt/ }).click()
await menu.getByRole('option', { name: /reference\.txt/ }).click()
await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
// First ArrowLeft crosses the trailing space; the second steps across the
@@ -250,7 +250,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
// as an atomic chip — folder glyph, no trigger character, one unit.
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 menu.getByRole('option', { name: /^folderx\// }).waitFor({ timeout: 60_000 })
await page.keyboard.press('Enter')
const chip = input.locator('[data-composer-chip]').last()
await expect.poll(() => chip.textContent()).toBe('folderx/')
@@ -260,18 +260,18 @@ 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 writeComposerDraft(page, input, '@folderx')
await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor()
await menu.getByRole('option', { name: /^folderx\// }).waitFor()
await page.keyboard.press('Tab')
await expect.poll(() => input.textContent()).toBe('@folderx/')
await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor()
await menu.getByRole('option', { name: /child\.txt/ }).waitFor()
// The row chevron drills the same way by pointer.
await writeComposerDraft(page, input, '@folderx')
const row = menu.getByRole('option', { name: /Folder · folderx\// })
const row = menu.getByRole('option', { name: /^folderx\// })
await row.waitFor()
await row.getByRole('button', { name: 'Browse folder' }).click()
await expect.poll(() => input.textContent()).toBe('@folderx/')
await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor()
await menu.getByRole('option', { name: /child\.txt/ }).waitFor()
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
+17
View File
@@ -351,7 +351,19 @@ describe('web e2e: settings modal and General preferences', () => {
const readFontSize = async (target: Page = page): Promise<string> => await target.evaluate(
() => document.body.style.getPropertyValue('--dsh-content-font-size'),
)
// The secondary tier resolved by the real engine: a probe element's
// font-size forces min/max/calc evaluation, which the CSS-text specs
// cannot exercise. Setting 1 at ≤14, setting 2 above.
const readSecondaryFontSize = async (): Promise<string> => await page.evaluate(() => {
const probe = document.createElement('div')
probe.style.fontSize = 'var(--dsh-content-font-size-secondary, 13px)'
document.body.appendChild(probe)
const size = getComputedStyle(probe).fontSize
probe.remove()
return size
})
expect(await readFontSize()).toBe('14px')
expect(await readSecondaryFontSize()).toBe('13px')
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
@@ -360,9 +372,13 @@ describe('web e2e: settings modal and General preferences', () => {
const increase = dialog.getByRole('button', { name: '增大字号' })
await increase.click()
await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 })
// 15 is the piecewise boundary: the secondary tier holds at 13px (2)
// where the ≤14 branch would have given 14px (1).
await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px')
await increase.click()
await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 })
await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px')
await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px')
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/)
await page.keyboard.press('Escape')
@@ -375,6 +391,7 @@ describe('web e2e: settings modal and General preferences', () => {
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px')
expect(await readSecondaryFontSize()).toBe('14px')
// Restore the default for the specs that follow (and the dialog golden).
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -24,7 +24,7 @@
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving...
- textbox "Message the agent"
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
+1 -1
View File
@@ -130,7 +130,7 @@ describe('web e2e: startup auto-selection', () => {
expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
releaseOpening()
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+2 -2
View File
@@ -1,4 +1,4 @@
// Cold boot may issue at most two settings.describe calls regardless of client
// Cold boot may issue at most two settings/describe calls regardless of client
// plugin count. No model call or replay fixture is involved.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
@@ -38,7 +38,7 @@ describe('startup RPC budget', () => {
// absorbs the first-connection reset wave the budget must include.
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: 30_000 })
await page.waitForTimeout(3000)
const describeCount = calls.filter(method => method === 'settings.describe').length
const describeCount = calls.filter(method => method === 'settings/describe').length
expect(describeCount, `startup /api calls:\n${calls.join('\n')}`).toBe(DESCRIBE_BUDGET)
})
})
+3 -3
View File
@@ -393,7 +393,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(await page.locator('[data-composer-seat]').evaluate(element =>
getComputedStyle(element).visibility)).toBe('hidden')
releaseCatalog()
const input = page.getByRole('textbox', { name: 'Message the agent' })
const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
await input.waitFor({ timeout: 15_000 })
await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
@@ -417,7 +417,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
resolveEnded()
})
})
const input = page.getByRole('textbox', { name: 'Message the agent' })
const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
await input.fill(FOLLOWUP)
await input.press('Enter')
await expect.poll(
@@ -500,7 +500,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
.click()
await page.getByRole('button', { name: '3 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor()
const forkResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/session/fork')
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
+1 -1
View File
@@ -259,7 +259,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', { name: 'Message the agent' })
const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(false)
+1 -3
View File
@@ -21,9 +21,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
fireEvent.click(start)
const composer = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build"]',
)
const surface = document.querySelector<HTMLElement>('[data-composer-input]')
if (surface === null) throw new Error('composer surface missing')
return surface
}, { timeout: 10_000 })
+12 -2
View File
@@ -83,7 +83,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
}
@@ -106,7 +106,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或会话"]')
.waitFor({ timeout: 15_000 })
}
@@ -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
* `<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
@@ -127,6 +136,7 @@ export async function writeComposerDraft(
input: ReturnType<Page['locator']>,
text: string,
): Promise<void> {
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')