Files
deepseek-harness/apps/web/tests/models-settings.e2e.ts
T
Turtle 5c4c743ff3 Merge pull request #6300 from deepseek-harness/turtle/model-picker-defaults-c7775e2d3040
feat(ui-settings-models): open model picker on the configured models
2026-10-20 12:17:05 +08:00

581 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Web e2e scenario: the Models settings page end to end through the real
// wire — the one add card offers the dormant pi-ai catalog on its third-party
// mode and the create form on its custom-API mode, a blank key saves a
// reference-free profile for provider-native auth, and typing an API key later
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference. Each saved row
// appears after route topology invalidation without presenting liveness as
// provider status. The customized-settings fold writes its curated fields —
// the endpoint, a declared route's own name and protocol, and the reasoning
// levels of its models — as merge patches against the stored profile. Zero
// model calls: configuration is pure settings/credentials/llm-domain traffic,
// so there is no fixture and a stray stream would fail loud because the
// adapter registry is empty. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference. The deletion dialog distinguishes a
// reference-free profile from a page-managed key before the credential and
// settings unsets reach the wire.
import { assertModelInputLayout } from './model-input-layout.ts'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page, Request, Route } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const NATIVE_KEYLESS_EXPECTED = join(SNAPSHOT_DIR, 'native-keyless.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
const MODEL_PICKER_EXPECTED = join(SNAPSHOT_DIR, 'model-picker.expected.md')
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Models settings page configures a dormant provider', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
vi.stubEnv('OPENAI_API_KEY', 'models-settings-test-key')
scaffold = await launchWebScaffold({})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
await page.addInitScript(() => {
const events = { received: 0, handled: 0 }
Reflect.set(window, '__DSH_MODELS_ADAPTER_EVENTS__', events)
const NativeWebSocket = window.WebSocket
window.WebSocket = class extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols)
this.addEventListener('message', (event: MessageEvent<unknown>) => {
if (typeof event.data !== 'string') return
const frame: unknown = JSON.parse(event.data)
if (typeof frame !== 'object' || frame === null || !('type' in frame) || frame.type !== 'item' || !('value' in frame)) return
const value = frame.value
if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'emit' || !('event' in value) || value.event !== 'llm/adapters-updated') return
const received = ++events.received
// The Gateway starts notification handlers before this acknowledgement task.
const channel = new MessageChannel()
channel.port1.onmessage = () => {
events.handled = Math.max(events.handled, received)
channel.port1.close()
channel.port2.close()
}
channel.port2.postMessage(undefined)
})
}
}
})
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
vi.unstubAllEnvs()
await browser?.close()
await scaffold?.close()
})
it('rejects layout checks without an explicit viewport before resizing the page', async () => {
const context = await browser.newContext({ viewport: null })
try {
const unsized = await context.newPage()
await expect(assertModelInputLayout(unsized, unsized.locator('body')))
.rejects.toThrow('Model input layout checks require an explicit viewport')
expect(unsized.viewportSize()).toBeNull()
} finally {
await context.close()
}
})
it('opens the add card over the dormant directory vocabulary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '添加模型提供商' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
// The card opens on the third-party mode, whose panel carries the select.
const modes = dialog.getByRole('tablist', { name: '添加方式' })
await modes.waitFor({ timeout: 10_000 })
expect(await modes.getByRole('tab', { name: '第三方模型提供商' }).getAttribute('aria-selected')).toBe('true')
const pick = dialog.getByLabel('提供商', { exact: true })
await pick.waitFor({ timeout: 10_000 })
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
const options = await pick.locator('option').allTextContents()
expect(options).toContain('anthropic')
expect(options).toContain('minimax-cn')
await pick.selectOption('openai-codex')
await expect.poll(async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }).toBe(0)
expect(await dialog.getByRole('button', { name: '认证说明', exact: true }).count()).toBe(0)
expect(await dialog.textContent()).not.toMatch(/oauth/i)
const nativeSnapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(NATIVE_KEYLESS_EXPECTED, nativeSnapshot, MODE)
await pick.selectOption('minimax-cn')
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
it('refuses a key no HTTP header can carry before anything is written', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
const dialog = page.getByRole('dialog', { name: '设置' })
const key = dialog.getByLabel('API 密钥')
const save = dialog.getByRole('button', { name: '保存', exact: true })
// A key no HTTP header can carry would save cleanly and fail the first
// turn with a ByteString TypeError; the form names the offending field
// instead.
await key.fill('sk-\u{1F600}minimax')
expect(await key.getAttribute('type')).toBe('password')
await dialog.getByRole('button', { name: '显示密钥', exact: true }).click()
expect(await key.getAttribute('type')).toBe('text')
await dialog.getByRole('button', { name: '隐藏密钥', exact: true }).click()
expect(await key.getAttribute('type')).toBe('password')
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
// Clearing it restores submit: an empty field means "keep what is stored",
// never a refusal, or editing any other setting would demand the key.
await key.fill('')
await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
}, 60_000)
it('saves a blank key as a reference-free provider-native profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '保存', exact: true }).click()
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0)
expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0)
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(document).toContain('minimax-cn: {}')
expect(document).not.toContain('MINIMAX_CN_API_KEY')
}, 60_000)
it('describes reference-free deletion without claiming a credential exists', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除 minimax-cn?"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
}, 60_000)
it('stores the key under the derived reference and keeps the route live', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in cordis.patch.yml with only the derived reference, the
// key value lands in the harness home's .credentials.yaml, the dormant route
// registers, and the topology frame invalidates the page into the row.
await expect.poll(
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(0)
await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 })
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
await expect.poll(
async () => readFile(credentialFile, 'utf8').catch(() => ''),
{ timeout: 10_000 },
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
it('applies a customized-settings field as a merge patch', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.textContent()).not.toContain('API 密钥来源:')
expect(await dialog.textContent()).not.toMatch(/oauth/i)
await dialog.getByText('自定义设置').click()
const url = dialog.getByLabel('API 地址')
await url.waitFor({ timeout: 10_000 })
await url.fill('https://gateway.minimax.example/v1')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
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('filters the discovered model catalog and deselects only visible candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await settingsDialog.getByText('自定义设置').click()
await settingsDialog.getByRole('button', { name: '获取可用模型' }).click()
const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.waitFor({ timeout: 10_000 })
const boxes = picker.getByRole('checkbox')
const count = await boxes.count()
expect(count).toBeGreaterThan(0)
expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual(
Array.from({ length: count }, () => true),
)
const search = picker.getByRole('searchbox', { name: '搜索模型' })
await search.fill('highspeed')
await expect.poll(async () => boxes.count()).toBe(1)
await picker.getByRole('button', { name: '取消全选' }).click()
expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual(
[false],
)
await search.fill('')
await expect.poll(async () => boxes.count()).toBe(count)
const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))
expect(restored.filter(checked => !checked)).toHaveLength(1)
expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false)
await picker.getByRole('button', { name: '全选' }).waitFor()
await picker.getByRole('button', { name: '全选' }).click()
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="选择模型"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE)
expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual(
Array.from({ length: count }, () => true),
)
await picker.getByRole('button', { name: '取消', exact: true }).click()
await settingsDialog.getByRole('button', { name: '取消', exact: true }).click()
}, 60_000)
it('declares a route the adapter does not ship', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
const dialog = page.getByRole('dialog', { name: '设置' })
const add = dialog.getByRole('button', { name: '添加模型提供商' })
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
// The custom-API mode is the second segment of the one add card; its
// purpose line replaces the catalog one.
await dialog.getByRole('tab', { name: '自定义模型 API' }).click()
await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 })
// The third-party panel stays mounted, hidden, beside this one, so the
// form is addressed through its own panel.
const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' })
await custom.getByLabel('Provider ID').fill('acme-gateway')
await custom.getByLabel('显示名称').fill('Acme Gateway')
await custom.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
// No provider-scoped reasoning control: effort is a per-model capability
// and the models under one provider disagree about it, so each model row
// declares its own levels instead.
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
await custom.getByLabel('模型 ID 1').fill('acme-large')
await custom.getByRole('button', { name: '模型选项 1' }).click()
expect(await custom.getByLabel('上下文窗口 1').getAttribute('placeholder')).toBe('1M')
expect(await custom.getByLabel('最大输出 token 数 1').getAttribute('placeholder')).toBe('128K')
expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
// The switch seeds off, low, medium, and high rows; this route serves no medium.
const reasoning = custom.getByRole('group', { name: '推理等级 1' })
await reasoning.getByRole('switch', { name: '推理等级 1' }).click()
await reasoning.getByRole('button', { name: '删除档位 medium', exact: true }).click()
await custom.getByRole('button', { name: '创建提供商', exact: true }).click()
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(document).toContain('acme-gateway:')
expect(document).toContain('reasoningEfforts:')
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
context: { contextWindow: 1_000_000 },
inputModalities: ['text', 'image'],
reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] },
})
// The tag follows the adapter's installed catalog: this route is in no
// catalog, while minimax-cn is — even though both now have profiles.
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('reopens the name and protocol a declared route was created with', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click()
await dialog.getByText('自定义设置').click()
// The create card asked this route for a name and a protocol because
// nothing can default them; the editor reaches the same two fields rather
// than sending the user to cordis.patch.yml for what only this route names.
const protocol = dialog.getByLabel('API 协议')
await protocol.waitFor({ timeout: 10_000 })
expect(await protocol.inputValue()).toBe('openai-completions')
const name = dialog.getByLabel('显示名称', { exact: true })
expect(await name.inputValue()).toBe('Acme Gateway')
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
// The stored levels come back as rows beside the values they send.
const reasoning = dialog.getByRole('group', { name: '推理等级 1' })
expect(await reasoning.getByRole('combobox').evaluateAll(selects => selects.map(select => (select as HTMLSelectElement).value)))
.toEqual(['off', 'low', 'high'])
expect(await reasoning.getByLabel('high API 参数值').inputValue()).toBe('high')
await assertModelInputLayout(page, dialog)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
await protocol.selectOption('anthropic-messages')
await name.fill('Acme 网关')
await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
// A gateway with its own spelling for the top level.
await reasoning.getByLabel('high API 参数值').fill('deep')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
// The adapter re-resolved the route under the new protocol and re-registered
// it under the new name: an unserviceable profile would have been refused
// at the write instead, and a rename that did not re-register would leave
// the old label on the row.
await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 })
// The status line names the route as the refreshed directory reports it;
// the target captured when the card opened still carries the old name.
await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(document).toContain('api: anthropic-messages')
expect(document).toContain('displayName: Acme 网关')
expect(document).toContain('high: deep')
// The level keeps its id and name; only its wire spelling changed.
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
inputModalities: ['text'],
reasoning: { efforts: [{ id: 'off' }, { id: 'low' }, { id: 'high', name: 'High' }] },
})
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('saves image-only input and reopens the same selection', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-input-types'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
const inputs = dialog.getByRole('group', { name: '输入类型 1' })
expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(true)
expect(await inputs.getByRole('checkbox', { name: '文本' }).isDisabled()).toBe(true)
await inputs.getByRole('checkbox', { name: '图片' }).check()
await inputs.getByRole('checkbox', { name: '文本' }).uncheck()
const reads: { request: Request; release: () => void }[] = []
const firstStarted = Promise.withResolvers<undefined>()
let nextStarted: PromiseWithResolvers<undefined> | undefined
let released = false
const releaseAll = (): void => { released = true; for (const read of reads) read.release() }
const intercept = async (route: Route): Promise<void> => {
const body: unknown = route.request().postDataJSON()
const payload = typeof body === 'object' && body !== null && 'payload' in body ? body.payload : undefined
const args = typeof payload === 'object' && payload !== null && 'args' in payload ? payload.args : undefined
const refs = typeof args === 'object' && args !== null ? Object.values(args).find(Array.isArray) : undefined
// The page joins multiple references; the editor previews only its own reference.
if (Array.isArray(refs) && refs.length > 1 && !released) {
const gate = Promise.withResolvers<undefined>()
reads.push({ request: route.request(), release: () => { gate.resolve(undefined) } })
firstStarted.resolve(undefined)
nextStarted?.resolve(undefined)
await gate.promise
}
await route.continue()
}
await page.route('**/api/credentials/describe', intercept)
try {
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 10_000 })
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
inputModalities: ['image'],
})
await firstStarted.promise
const obsolete = [...reads]
const receivedBefore = await page.evaluate(() => {
const events: unknown = Reflect.get(window, '__DSH_MODELS_ADAPTER_EVENTS__')
if (typeof events !== 'object' || events === null || !('received' in events) || typeof events.received !== 'number') throw new Error('Missing adapter event observer')
return events.received
})
nextStarted = Promise.withResolvers<undefined>()
const successorHandled = page.waitForFunction((received) => {
const events: unknown = Reflect.get(window, '__DSH_MODELS_ADAPTER_EVENTS__')
return typeof events === 'object' && events !== null && 'handled' in events && typeof events.handled === 'number' && events.handled > received
}, receivedBefore)
scaffold.ctx.emit('llm/adapters-updated')
await successorHandled
await nextStarted.promise
const oldResponses = obsolete.map(read => page.waitForResponse(response => response.request() === read.request))
for (const read of obsolete) read.release()
await Promise.all(oldResponses.map(async (response) => { await (await response).finished() }))
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })))
const saved = dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true })
const edit = dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' })
expect(await saved.count()).toBe(0)
expect(await edit.isDisabled()).toBe(true)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refreshing.expected.md'),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
releaseAll()
// The saved notice confirms the current joined snapshot after the editor closes.
await saved.waitFor({ timeout: 10_000 })
await edit.click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await inputs.getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(false)
expect(await inputs.getByRole('checkbox', { name: '图片' }).isDisabled()).toBe(true)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'image-only.expected.md'),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
await dialog.getByRole('button', { name: '取消', exact: true }).click()
} finally {
releaseAll()
await page.unrouteAll({ behavior: 'wait' })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('inherits installed vision input and retains it when adopting a discovered model', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-catalog-inputs'))
await scaffold.ctx.settings.mutate('llm-pi-ai', [{
op: 'set', path: ['providers', 'openai'],
value: { models: [{ id: 'gpt-6-astra', name: 'GPT-6 Astra', contextWindow: 272000, maxTokens: 128000 }] },
}])
const dialog = page.getByRole('dialog', { name: '设置' })
const edit = dialog.getByRole('button', { name: '编辑 openai', exact: true })
try {
await edit.click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
const types = dialog.getByRole('group', { name: '输入类型 1' })
const image = types.getByRole('checkbox', { name: '图片', exact: true })
await expect.poll(() => image.isChecked()).toBe(true)
expect(await types.getByRole('checkbox', { name: '文本', exact: true }).isChecked()).toBe(true)
const before = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'catalog-inputs.expected.md'),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await types.waitFor({ state: 'detached' })
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toBe(before)
await edit.click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
await expect.poll(() => image.isEnabled()).toBe(true)
await image.uncheck()
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await types.waitFor({ state: 'detached' })
await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
// The saved notice confirms the browser directory refresh after the editor closes.
// Reopening before it copies the previous selection into a new draft, which
// checkbox polling cannot correct.
await dialog.getByText('已保存 openai。', { exact: true }).waitFor({ timeout: 10_000 })
await edit.click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
await expect.poll(() => image.isEnabled()).toBe(true)
expect(await image.isChecked()).toBe(false)
await dialog.getByRole('button', { name: '删除模型 1' }).click()
await dialog.getByRole('button', { name: '获取可用模型' }).click()
const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.getByRole('button', { name: '取消全选' }).click()
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
await picker.getByRole('button', { name: '更新列表' }).click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await image.isChecked()).toBe(true)
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await types.waitFor({ state: 'detached' })
await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text', 'image'] })
} finally {
await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
await edit.waitFor({ state: 'detached' })
await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toContain('openai:')
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除 minimax-cn?"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).toContain('minimax-cn:')
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
await page.getByRole('dialog', { name: '删除 minimax-cn?' })
.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
.not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).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', 'declared-edit.expected.md', 'declared.expected.md',
'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md',
'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md',
'refreshing.expected.md', 'image-only.expected.md',
])
})
})