Merge branch 'master' into xtr/trajectory-timeline-polish
This commit is contained in:
@@ -111,6 +111,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
|
||||
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
|
||||
// trailing newline terminates its line, so the footer reads `+1` (not a
|
||||
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
|
||||
// it is absent from textContent — assert on the line body and the footer.
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Web e2e scenario: the Models settings page end to end through the real
|
||||
// wire — the add card offers the dormant pi-ai catalog, typing an API key
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference, and the saved
|
||||
// route registers live (the row's 已启用 badge is the topology invalidation
|
||||
// landing). The customized-settings fold writes the curated reasoning field
|
||||
// as a merge patch. Zero model calls: configuration is pure
|
||||
// while the settings document records only that reference; the saved row
|
||||
// appears after the route topology invalidation without presenting liveness
|
||||
// as provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference.
|
||||
// never shadow the derived reference. Removing that row is guarded by the
|
||||
// localized provider-confirmation dialog before the unset reaches the wire.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
const row = dialog.getByText('minimax-cn', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms provider deletion before removing its settings profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
await deleteDialog.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="dialog"][aria-label="删除模型提供方?"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
|
||||
|
||||
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
.getByRole('button', { name: '删除提供方', exact: true }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale).
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// plus Permission as the persisted default for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
describe('web e2e: settings modal and General preferences', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -50,9 +53,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is the active section by default; its skeleton rows plus the
|
||||
// functional Language and Appearance rows render.
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
@@ -73,6 +76,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stores Permission as the default for future sessions without changing an existing session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: 'Full access' })
|
||||
await selector.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('permission:')
|
||||
expect(document).toContain('defaultPreset: read-only')
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
expect(created.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'read-only' }],
|
||||
['sandbox/mode', { mode: 'read-only' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
await confirmation.getByRole('checkbox').click()
|
||||
await enable.click()
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
|
||||
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
|
||||
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn 已启用
|
||||
- text: minimax-cn
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- button "+ 添加提供方"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
- dialog "删除模型提供方?":
|
||||
- heading "删除模型提供方?" [level=2]
|
||||
- button "关闭":
|
||||
- img
|
||||
- paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
|
||||
- button "取消"
|
||||
- button "删除提供方"
|
||||
@@ -10,11 +10,11 @@
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: 权限 选择默认权限模式
|
||||
- button "Read only" [disabled]:
|
||||
- text: Read only
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Full access":
|
||||
- text: Full access
|
||||
- img
|
||||
- text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
|
||||
- text: 语言
|
||||
- button "中文":
|
||||
- text: 中文
|
||||
- img
|
||||
|
||||
Reference in New Issue
Block a user