mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into codex/pr-3111-review
# Conflicts: # scripts/ci-workflow.spec.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user