Files
deepseek-harness/apps/web/tests/models-settings.e2e.ts
T
Yichen Jiang dfaf3c288e Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a
# Conflicts:
#	apps/web/tests/models-settings.e2e.ts
#	docs/config-catalog.md
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/README.md
#	packages/llm/llm-deepseek/README.zh.md
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/src/config.ts
#	packages/llm/llm-pi-ai/src/index.ts
2026-08-07 15:54:13 +08:00

215 lines
12 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 add card offers the dormant pi-ai catalog, 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 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. The deletion dialog distinguishes a
// reference-free profile from a page-managed key before the credential and
// settings unsets reach the wire.
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 {
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/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.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 () => {
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 })
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('opens the add card over the dormant directory vocabulary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
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.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()
const pick = dialog.getByLabel('提供方')
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('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 })
// The paste that used to save cleanly and then fail the first turn with a
// ByteString TypeError now names the field that holds it.
await key.fill('sk-\u{1F600}minimax')
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, 'settings.yaml'), '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 settings.yaml 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, 'settings.yaml'), '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.getByText('自定义设置').click()
const effort = dialog.getByLabel('推理强度')
await effort.waitFor({ timeout: 10_000 })
await effort.selectOption('high')
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('推理强度').count(), { timeout: 10_000 }).toBe(0)
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('reasoning: high')
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 an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-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(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: '删除 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, 'settings.yaml'), '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', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
])
})
})