Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Keep both Remote contributions master and this branch add: the mount loop now carries commandsRemote, goalsRemote, pluginInventoryRemote, and messageFeedbackRemote, with both new tsconfig references retained.
This commit is contained in:
@@ -21,7 +21,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
|
||||
// The sidebar renders from the boot graph: every inject layer activated.
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
await within(tree).findByText('4 sessions')
|
||||
// The compact layout dropped group session counts; the fixture workspace
|
||||
// group row renders immediately with its sessions beneath it.
|
||||
const fixtureGroup = (await within(tree).findAllByText('fixture'))
|
||||
.map(el => el.closest<HTMLElement>('[role="treeitem"]'))
|
||||
.find(el => el?.getAttribute('aria-expanded') !== null)
|
||||
if (fixtureGroup === undefined) throw new Error('fixture Workspace group missing')
|
||||
|
||||
// The resident fixture has both a question and an approval; composer routing
|
||||
// exposes the question first, and the assembled workspace plugin mirrors that
|
||||
|
||||
@@ -77,8 +77,13 @@ async function nextPaint(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// The compact layout dropped group session counts; the seeded baseline is
|
||||
// the Ungrouped bucket once cold summaries load.
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await results.first().waitFor({ timeout: 60_000 })
|
||||
|
||||
@@ -168,8 +168,10 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Session-list bootstrap can replace the controlled search state. Wait
|
||||
// for the seeded baseline before openSeed starts the lazy content query.
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// for the seeded baseline before openSeed starts the lazy content query
|
||||
// (the compact layout dropped group session counts; the Ungrouped bucket
|
||||
// row is the barrier).
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
return {
|
||||
events,
|
||||
page,
|
||||
@@ -258,7 +260,10 @@ async function conversationTurns(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
// Cold summaries initially show the temporary workspace basename, so the
|
||||
// persisted first-message marker is the stable user-facing identity. The
|
||||
// query itself triggers lazy content-index reconciliation; no transient
|
||||
|
||||
@@ -249,7 +249,10 @@ async function compareTabsWithoutReservation(page: Page): Promise<TabComparison>
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
const deadline = Date.now() + 60_000
|
||||
|
||||
@@ -197,8 +197,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
it.skipIf(MODE === 'record')('materialized a real Workspace and Session over the wire', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-materialize'))
|
||||
// Browser: the sidebar tree now carries the auto-created workspace group
|
||||
// with its one session, and the opened session is the selected row.
|
||||
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// with its one session, and the opened session is the selected row. The
|
||||
// compact layout dropped group session counts, so the group row itself is
|
||||
// the barrier.
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"][aria-expanded]').filter({ hasText: 'workspace' }).count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Host: the session's durable header cwd is the folder the workspace
|
||||
|
||||
@@ -53,7 +53,10 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom
|
||||
|
||||
async function ensureSeedOpen(page: Page): Promise<void> {
|
||||
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// Search is a collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
if (await chat.count() === 0) {
|
||||
await search.fill('WATERFALL')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
@@ -119,8 +122,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// the ready projection replaces (the compact layout dropped group session
|
||||
// counts; the Ungrouped bucket row is the barrier).
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -176,9 +180,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||||
// The API baselines can settle before React commits their projection. The
|
||||
// seeded count is the final user-visible barrier before editing search.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// seeded Ungrouped bucket row is the final user-visible barrier before
|
||||
// editing search (the compact layout dropped group session counts).
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// Search is a collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
// The cold row has not been opened, so only the persisted log can satisfy
|
||||
// this query. First search lazily reconciles the SQLite content index.
|
||||
await search.fill('zzzqx-no-such-session')
|
||||
|
||||
128
apps/web/tests/onboarding-usable-provider.e2e.ts
Normal file
128
apps/web/tests/onboarding-usable-provider.e2e.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Keyless browser e2e: a user who configures some OTHER provider is not asked
|
||||
// for the official DeepSeek key again, and the first-run setup card is a card
|
||||
// they can close. The shipped DeepSeek adapter stays mounted without a
|
||||
// credential throughout, so the only thing that ends onboarding here is the
|
||||
// pi-ai route the user configures through the real wire. Zero model calls:
|
||||
// configuration is pure settings/credentials/llm-domain traffic.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url))
|
||||
const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const CREDENTIAL_STEP = '添加一个 API Key 开始使用'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
browser = await chromium.launch()
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('closes the setup card without discarding the add card beside it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
|
||||
const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
// Nothing is reachable yet, so DeepSeek presents itself as its open card.
|
||||
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
|
||||
await setupKey.waitFor({ timeout: 10_000 })
|
||||
|
||||
const add = settings.getByRole('button', { name: '添加提供方' })
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
const pick = settings.getByLabel('提供方')
|
||||
await pick.waitFor({ timeout: 10_000 })
|
||||
await pick.selectOption('minimax-cn')
|
||||
await expect.poll(
|
||||
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
// Cancelling the setup card is the regression: it used to leave itself open
|
||||
// and close the add card, discarding that draft.
|
||||
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
|
||||
expect(await settings.getByLabel('提供方').count()).toBe(1)
|
||||
await expect.poll(
|
||||
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
// DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
|
||||
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
|
||||
const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
|
||||
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stops prompting for DeepSeek once the other provider can serve requests', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider'))
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
// Only minimax-cn is reachable; DeepSeek still holds no credential.
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
|
||||
expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
|
||||
expect(credentials).not.toContain('DEEPSEEK_API_KEY')
|
||||
|
||||
const warningsBefore = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
// The regression: the step read only the official route's credential, so a
|
||||
// fully configured user was taken over on every blank session.
|
||||
await expect.poll(
|
||||
async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
||||
|
||||
// The Models page agrees: DeepSeek stays a row rather than reopening its
|
||||
// setup card over a user who already has somewhere to send a request.
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
|
||||
expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
|
||||
|
||||
expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -56,9 +56,9 @@ describe('web e2e: plugin configuration section', () => {
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '插件' }).click()
|
||||
await dialog.getByRole('button', { name: '插件配置', exact: true }).click()
|
||||
await expect
|
||||
.poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 })
|
||||
.poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
|
||||
.toBe('true')
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -68,8 +68,11 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
|
||||
// Open the seeded session through content search: the sidebar groups
|
||||
// sessions by workspace and its row order is world-dependent, while the
|
||||
// search index covers the seeded log deterministically.
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// search index covers the seeded log deterministically. Search is a
|
||||
// collapsed header action; expand it so the input is actionable.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByPlaceholder('Search sessions', { exact: false })
|
||||
await search.fill('Run a PowerShell command')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
@@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// able to change a golden.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
|
||||
includeUserRoot: false,
|
||||
},
|
||||
},
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
@@ -445,7 +449,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
] },
|
||||
...options.agentPresets === undefined
|
||||
? []
|
||||
: [{ id: 'agent-presets', config: options.agentPresets }],
|
||||
// Never the derived harness-home root: a developer's own presets must not
|
||||
// be able to change a golden, whatever roots a scenario asks for.
|
||||
: [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md')
|
||||
const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]'
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: settings modal and General preferences', () => {
|
||||
@@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
|
||||
// Plugins is a read-only projection of the same assembled Loader tree.
|
||||
// Capture one stable shipped row rather than the whole inventory so adding
|
||||
// an unrelated plugin does not rewrite this surface's golden.
|
||||
await dialog.getByRole('button', { name: '插件', exact: true }).click()
|
||||
await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
|
||||
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
|
||||
await pluginRow.waitFor({ timeout: 10_000 })
|
||||
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
|
||||
.filter(entry => !entry.options.group)
|
||||
.length
|
||||
expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1)
|
||||
expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount)
|
||||
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
|
||||
.toBe(String(expectedPluginCount))
|
||||
expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
|
||||
expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
|
||||
const pluginsSnapshot = await captureStableAria(
|
||||
page,
|
||||
PLUGIN_ROW_SELECTOR,
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE)
|
||||
// Close path 1: Escape.
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
@@ -454,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
|
||||
|
||||
/**
|
||||
* Reveal the seeded rows: every seeded session is unattached, so they all sit
|
||||
* in the collapsed Ungrouped bucket. Converges on expanded rather than
|
||||
* clicking once — startup auto-selection can expand the bucket first, and a
|
||||
* second click would collapse it again. Hand-rolled polling because
|
||||
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
|
||||
* Show-more control because an open group intentionally renders only five
|
||||
* rows by default. Hand-rolled polling because
|
||||
* `expect.poll` is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
@@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise<void> {
|
||||
if (await bucket.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
}
|
||||
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
|
||||
if (await bucket.getAttribute('aria-expanded') === 'true'
|
||||
&& await rows.count() <= SEED_COUNT / 2
|
||||
&& await showMore.count() > 0) {
|
||||
await showMore.click()
|
||||
}
|
||||
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
- img
|
||||
- text: New Session
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search sessions..."
|
||||
- button "View options":
|
||||
- img
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search name, keywords..."
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- treeitem "workspace" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- text: workspace
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
- img
|
||||
- text: New Session
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search sessions..."
|
||||
- button "View options":
|
||||
- img
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search name, keywords..."
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- treeitem "workspace" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- text: workspace
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "Ungrouped 3 sessions" [expanded]:
|
||||
- treeitem "Ungrouped" [expanded]:
|
||||
- img
|
||||
- text: Ungrouped 3 sessions
|
||||
- treeitem "Use the read tool twice (2) now" [selected]
|
||||
- treeitem "Use the read tool twice (1) now"
|
||||
- text: Ungrouped
|
||||
- treeitem "Use the read tool twice 1min"
|
||||
- treeitem "Use the read tool twice (1) now"
|
||||
- treeitem "Use the read tool twice (2) now" [selected]
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "插件配置":
|
||||
- img
|
||||
- text: 插件配置
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
- img "API 密钥缺失"
|
||||
- button "编辑 DeepSeek (deepseek-official)": 编辑
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- listitem:
|
||||
- button "ui-settings, 已挂载, 已启用":
|
||||
- strong: ui-settings
|
||||
- img "已挂载"
|
||||
- text: 已启用
|
||||
- img
|
||||
@@ -1,6 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- treeitem "workspace" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "1 subagent running Delegate a background task. now"
|
||||
- text: workspace
|
||||
- treeitem "New Session" [selected]
|
||||
- treeitem "1 subagent running Delegate a background task. now"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- treeitem "workspace" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
- text: workspace
|
||||
- treeitem "Ask a research subagent to now"
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- treeitem "workspace" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- text: workspace
|
||||
- treeitem "Ask a research subagent to now"
|
||||
|
||||
@@ -68,6 +68,13 @@ describe('web e2e: startup auto-selection', () => {
|
||||
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
|
||||
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
|
||||
const headline = page.getByText('Into the Unknown', { exact: true })
|
||||
const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
|
||||
const fishHitbox = fish.locator('..')
|
||||
expect(await fish.evaluate(node => getComputedStyle(node).color))
|
||||
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
|
||||
await fishHitbox.hover()
|
||||
expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
|
||||
await page.evaluate(() => {
|
||||
const refs = {
|
||||
root: document.querySelector('div[data-phase="hero"]'),
|
||||
|
||||
@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
|
||||
const NESTED_LABEL = 'example editor'
|
||||
const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
|
||||
const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
|
||||
/** The grandchild's own first message; its arrival is what says its history finished loading. */
|
||||
const NESTED_PROMPT = 'Give one concrete event sourcing example.'
|
||||
const FOLLOWUP = 'Now give the same explanation to a human reader.'
|
||||
const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
|
||||
|
||||
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
seq: 1,
|
||||
time: authoredAt + 1,
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
|
||||
content: [{ type: 'text', text: NESTED_PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
)
|
||||
await nestedRow.click()
|
||||
await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
|
||||
// The offline banner renders from the descriptor alone, so it says nothing
|
||||
// about the transcript below it. The golden pins that transcript, and
|
||||
// `captureStableAria` calls two identical polls stable — including two of
|
||||
// "Loading history…". Wait for the message the golden asserts.
|
||||
await page.getByText(NESTED_PROMPT).waitFor()
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
const crumbs = await hierarchy.getByRole('button').allTextContents()
|
||||
expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])
|
||||
|
||||
@@ -59,7 +59,10 @@ interface RowAnchor {
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// Search collapsed into a header action; expand it before filling.
|
||||
const searchButton = page.getByRole('button', { name: 'Search sessions' })
|
||||
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
|
||||
const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1)
|
||||
@@ -182,7 +185,9 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// The compact layout dropped group session counts; the seeded baseline is
|
||||
// the Ungrouped bucket once cold summaries load.
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -74,12 +74,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await input.fill(prompt)
|
||||
await input.press('Enter')
|
||||
|
||||
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
const workflow = page.locator('[data-workflow-run][data-run-status="running"]')
|
||||
await workflow.waitFor({ timeout: 30_000 })
|
||||
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
await phase.waitFor({ timeout: 15_000 })
|
||||
await phase.click()
|
||||
const disclosures = workflow.locator('[data-disclosure-row]')
|
||||
await disclosures.nth(1).waitFor({ timeout: 15_000 })
|
||||
expect(await disclosures.nth(0).getAttribute('role')).toBeNull()
|
||||
expect(await disclosures.nth(0).getAttribute('aria-expanded')).toBeNull()
|
||||
expect(await disclosures.nth(1).getAttribute('role')).toBeNull()
|
||||
expect(await disclosures.nth(1).getAttribute('aria-expanded')).toBeNull()
|
||||
expect(await disclosures.nth(0).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
|
||||
expect(await disclosures.nth(1).evaluate(element => getComputedStyle(element).cursor)).not.toBe('pointer')
|
||||
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
|
||||
await member.waitFor({ timeout: 15_000 })
|
||||
await member.focus()
|
||||
@@ -139,15 +143,20 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
|
||||
await settled
|
||||
await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
|
||||
|
||||
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
|
||||
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
|
||||
await terminalWorkflow.waitFor()
|
||||
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
|
||||
expect(await terminalWorkflow.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await terminalWorkflow.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
await terminalWorkflow.click()
|
||||
const terminalPhase = page.getByRole('button', { name: /^Run/ })
|
||||
await terminalPhase.waitFor()
|
||||
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
|
||||
expect(await terminalPhase.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await terminalPhase.evaluate(element => getComputedStyle(element).cursor)).toBe('pointer')
|
||||
await terminalPhase.click()
|
||||
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
|
||||
@@ -165,6 +174,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
|
||||
await workflow.click()
|
||||
const phase = page.getByRole('button', { name: /^Run/ })
|
||||
await phase.waitFor()
|
||||
expect(await phase.getAttribute('aria-expanded')).toBe('false')
|
||||
await phase.click()
|
||||
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
|
||||
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
|
||||
|
||||
@@ -372,21 +372,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// Grouped default: workspace group rows render (the seeded session sits
|
||||
// under Ungrouped; the created workspaces are empty groups).
|
||||
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.getByRole('button', { name: 'Group by' }).click()
|
||||
// Grouping and ordering moved into the View options menu.
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'In one list' }).click()
|
||||
// Flat mode: the section label flips and the seeded session is a
|
||||
// top-level row with no group headers above it.
|
||||
await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
|
||||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view'))).toContain('flat')
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v4'))).toContain('flat')
|
||||
// Persisted across reload; then restore grouped for inter-spec hygiene.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Group by' }).click()
|
||||
await page.getByRole('button', { name: 'View options' }).click()
|
||||
await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
Reference in New Issue
Block a user