2026-07-30 09:29:40 +08:00
|
|
|
|
// Web e2e scenario: the Models settings page end to end through the real
|
2026-08-06 12:07:28 +08:00
|
|
|
|
// 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
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
2026-08-06 12:07:28 +08:00
|
|
|
|
// while the settings document records only that reference. Each saved row
|
|
|
|
|
|
// appears after route topology invalidation without presenting liveness as
|
2026-08-10 23:31:21 +08:00
|
|
|
|
// provider status. The customized-settings fold writes its curated fields —
|
|
|
|
|
|
// the endpoint, and a declared route's own name and protocol — as merge
|
|
|
|
|
|
// patches against the stored profile. Zero model calls: configuration is pure
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
2026-08-09 15:34:32 +08:00
|
|
|
|
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
2026-08-06 13:15:09 +08:00
|
|
|
|
// 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.
|
2026-07-30 09:29:40 +08:00
|
|
|
|
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'
|
2026-07-31 15:26:46 +08:00
|
|
|
|
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
2026-07-30 09:29:40 +08:00
|
|
|
|
|
2026-08-24 07:15:34 +08:00
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url))
|
2026-07-30 09:29:40 +08:00
|
|
|
|
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
|
|
|
|
|
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
2026-08-07 13:49:47 +08:00
|
|
|
|
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
2026-08-10 22:40:57 +08:00
|
|
|
|
const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
|
2026-08-18 13:07:11 +08:00
|
|
|
|
const MODEL_PICKER_EXPECTED = join(SNAPSHOT_DIR, 'model-picker.expected.md')
|
2026-08-06 13:15:09 +08:00
|
|
|
|
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
|
2026-07-31 11:14:07 +08:00
|
|
|
|
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
2026-07-30 09:29:40 +08:00
|
|
|
|
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()
|
2026-07-31 15:26:46 +08:00
|
|
|
|
// 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 })
|
2026-07-30 09:29:40 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-30 12:17:56 +08:00
|
|
|
|
it('opens the add card over the dormant directory vocabulary', async () => {
|
2026-07-30 09:29:40 +08:00
|
|
|
|
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
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// provider is configured yet, so the page is one add button.
|
2026-08-05 18:26:07 +08:00
|
|
|
|
const add = dialog.getByRole('button', { name: '添加提供方' })
|
2026-07-30 09:29:40 +08:00
|
|
|
|
await add.waitFor({ timeout: 10_000 })
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// 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()
|
2026-07-30 09:29:40 +08:00
|
|
|
|
expect(options).toContain('anthropic')
|
2026-07-30 12:17:56 +08:00
|
|
|
|
expect(options).toContain('minimax-cn')
|
|
|
|
|
|
await pick.selectOption('minimax-cn')
|
2026-08-06 16:44:04 +08:00
|
|
|
|
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 })
|
2026-07-30 09:29:40 +08:00
|
|
|
|
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
|
|
|
|
|
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
|
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
2026-08-06 22:42:56 +08:00
|
|
|
|
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 })
|
|
|
|
|
|
|
docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 15:09:19 +08:00
|
|
|
|
// 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.
|
2026-08-06 22:42:56 +08:00
|
|
|
|
await key.fill('sk-\u{1F600}minimax')
|
2026-08-06 23:15:08 +08:00
|
|
|
|
await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
|
2026-08-06 22:42:56 +08:00
|
|
|
|
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)
|
2026-08-06 23:15:08 +08:00
|
|
|
|
expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
|
2026-08-06 22:42:56 +08:00
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
2026-08-06 12:07:28 +08:00
|
|
|
|
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 })
|
2026-08-06 16:44:04 +08:00
|
|
|
|
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)
|
2026-08-06 12:07:28 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-06 13:15:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-06 12:07:28 +08:00
|
|
|
|
it('stores the key under the derived reference and keeps the route live', async () => {
|
2026-07-30 09:29:40 +08:00
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
|
|
|
|
|
|
const dialog = page.getByRole('dialog', { name: '设置' })
|
2026-08-06 12:07:28 +08:00
|
|
|
|
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
2026-08-06 16:44:04 +08:00
|
|
|
|
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
|
2026-07-30 09:29:40 +08:00
|
|
|
|
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// The profile lands in settings.yaml with only the derived reference, the
|
feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env
$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable
secret store it could not be hoisted into process.env — hoisting makes every
stored key read as a read-only launch override and blocks rotation from the
TUI and the web page. But its name and dotenv format promise an environment
file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the
same file was silently ignored: only the credential provider read the
document, and it addresses credential references alone.
Split the two jobs into two files.
.credentials.yaml is the provider-managed store: a strict YAML mapping of
CredentialRef to non-empty string, no version field, no wrapper level. Because
it holds credentials and nothing else, a non-mapping root, a non-identifier
key, a non-string value, an empty string, a duplicate key, and malformed YAML
are all rejections rather than skipped entries — loud at boot and at a write,
warn-and-keep-last-good on a live reload. The dotenv physical-line editor
gives way to a patch of the parsed document, so comments and untouched entries
keep their formatting and any string value round-trips, multi-line included.
Writer lock, read-modify-write, atomic 0600 write under a 0700 directory,
watcher, self-write suppression, and quiescent disposal are unchanged.
$DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new
loadLayeredEnv loads the invoking directory's .env then the Harness home's,
giving user < project < inherited; the home resolves from the inherited
environment first, so a project .env cannot redirect it.
Credential precedence is unchanged: the live environment still wins read-only
over the file, and shadowed writes still reject. Whether a provider-managed
store should instead win over the environment is a separate decision.
No migration: a key already in $DSH_HOME/.env keeps resolving through the new
environment layer, as a read-only env source that shadows the stored one.
2026-08-04 14:50:38 +08:00
|
|
|
|
// key value lands in the harness home's .credentials.yaml, the dormant route
|
2026-07-30 12:17:56 +08:00
|
|
|
|
// registers, and the topology frame invalidates the page into the row.
|
2026-08-06 16:44:04 +08:00
|
|
|
|
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 })
|
2026-07-30 09:29:40 +08:00
|
|
|
|
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
2026-07-30 12:17:56 +08:00
|
|
|
|
expect(document).toContain('minimax-cn:')
|
|
|
|
|
|
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
|
|
|
|
|
expect(document).not.toContain('sk-e2e-minimax')
|
2026-08-07 15:54:13 +08:00
|
|
|
|
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
|
2026-08-06 12:07:28 +08:00
|
|
|
|
await expect.poll(
|
|
|
|
|
|
async () => readFile(credentialFile, 'utf8').catch(() => ''),
|
|
|
|
|
|
{ timeout: 10_000 },
|
2026-08-07 15:54:13 +08:00
|
|
|
|
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
|
2026-07-30 12:17:56 +08:00
|
|
|
|
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
2026-07-30 09:29:40 +08:00
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
2026-07-30 12:17:56 +08:00
|
|
|
|
it('applies a customized-settings field as a merge patch', async () => {
|
|
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
|
2026-07-30 09:29:40 +08:00
|
|
|
|
const dialog = page.getByRole('dialog', { name: '设置' })
|
2026-08-06 12:07:28 +08:00
|
|
|
|
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
2026-07-30 12:17:56 +08:00
|
|
|
|
await dialog.getByText('自定义设置').click()
|
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.
The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.
**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
2026-08-07 17:36:08 +08:00
|
|
|
|
const url = dialog.getByLabel('API 地址')
|
|
|
|
|
|
await url.waitFor({ timeout: 10_000 })
|
|
|
|
|
|
await url.fill('https://gateway.minimax.example/v1')
|
2026-07-30 12:17:56 +08:00
|
|
|
|
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.
|
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.
The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.
**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
2026-08-07 17:36:08 +08:00
|
|
|
|
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
|
2026-08-06 16:44:04 +08:00
|
|
|
|
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
|
2026-07-30 12:17:56 +08:00
|
|
|
|
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.
The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.
**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
2026-08-07 17:36:08 +08:00
|
|
|
|
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
|
2026-07-30 12:17:56 +08:00
|
|
|
|
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
2026-07-30 09:29:40 +08:00
|
|
|
|
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
|
|
|
|
|
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
2026-07-31 11:14:07 +08:00
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
2026-08-18 13:07:11 +08:00
|
|
|
|
it('selects and clears the discovered model catalog in one action', 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),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await picker.getByRole('button', { name: '取消全选' }).click()
|
|
|
|
|
|
expect(await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))).toEqual(
|
|
|
|
|
|
Array.from({ length: count }, () => false),
|
|
|
|
|
|
)
|
|
|
|
|
|
await picker.getByRole('button', { name: '全选' }).waitFor()
|
|
|
|
|
|
const snapshot = await captureStableAria(
|
|
|
|
|
|
page,
|
|
|
|
|
|
'[role="dialog"][aria-label="选择要添加的模型"]',
|
|
|
|
|
|
scaffold.workspaceCwd,
|
|
|
|
|
|
)
|
|
|
|
|
|
await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE)
|
|
|
|
|
|
|
|
|
|
|
|
await picker.getByRole('button', { name: '全选' }).click()
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.
The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.
**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
2026-08-07 17:36:08 +08:00
|
|
|
|
it('declares a route the adapter does not ship', async () => {
|
2026-08-07 13:49:47 +08:00
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
|
|
|
|
|
|
const dialog = page.getByRole('dialog', { name: '设置' })
|
|
|
|
|
|
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
|
|
|
|
|
|
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
|
|
|
|
|
|
await declare.click()
|
|
|
|
|
|
await dialog.getByLabel('Provider ID').fill('acme-gateway')
|
|
|
|
|
|
await dialog.getByLabel('显示名称').fill('Acme Gateway')
|
|
|
|
|
|
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.
The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.
**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
2026-08-07 17:36:08 +08:00
|
|
|
|
// No reasoning effort on a provider card at all: effort is a per-model
|
|
|
|
|
|
// capability, the models under one provider disagree about it, and a
|
|
|
|
|
|
// switch in the composer already records provider+model+effort together.
|
fix(ui-models): three faults the running app surfaced
**A hand-declared route must not offer a reasoning effort.** The earlier
commit read the create card's missing control as drift and added one. It
is the other way round: such a model has no reasoning capability — pi-ai's
installed catalog is what supplies one, and it ships nothing under the
route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every
model on it and the whole provider drops out of the picker. Verified
against the adapter, not inferred. The create card no longer offers it and
the editor withholds it on the directory's `declared` bit, which is the
real bug: that control has always been wrong for these routes.
**A blocked composer locked the way out of the block.** Reusing the
no-workspace inert posture disabled the model seat along with everything
else, so the bar asked for a model while preventing the one control that
picks one. A block now rides its own `blocked` owner prop: the textarea,
send, commands, plan seat, and access chip all lock, and the model seat
alone stays live.
**A Provider ID could derive an illegal credential reference.** The card
accepted a digit-leading id, whose derived `123_API_KEY` then failed at
the credential seam with a raw regular expression the user cannot act on.
The id must now start with a letter, and a test pins the relation between
the two rules rather than the regex.
2026-08-07 16:45:50 +08:00
|
|
|
|
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
|
2026-08-07 13:49:47 +08:00
|
|
|
|
await dialog.getByRole('button', { name: '添加模型' }).click()
|
|
|
|
|
|
await dialog.getByLabel('模型 ID 1').fill('acme-large')
|
|
|
|
|
|
await dialog.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, 'settings.yaml'), 'utf8')
|
|
|
|
|
|
expect(document).toContain('acme-gateway:')
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-08-10 22:40:57 +08:00
|
|
|
|
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 settings.yaml 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')
|
|
|
|
|
|
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('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 })
|
2026-08-10 23:31:21 +08:00
|
|
|
|
// 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 })
|
2026-08-10 22:40:57 +08:00
|
|
|
|
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
|
|
|
|
|
expect(document).toContain('api: anthropic-messages')
|
|
|
|
|
|
expect(document).toContain('displayName: Acme 网关')
|
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
2026-08-06 12:07:28 +08:00
|
|
|
|
it('confirms an identified provider deletion before removing its profile and key', async () => {
|
2026-07-31 11:14:07 +08:00
|
|
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
|
|
|
|
|
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
2026-08-06 12:07:28 +08:00
|
|
|
|
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
|
|
|
|
|
|
const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
|
2026-07-31 11:14:07 +08:00
|
|
|
|
await deleteDialog.waitFor({ timeout: 10_000 })
|
|
|
|
|
|
const snapshot = await captureStableAria(
|
|
|
|
|
|
page,
|
2026-08-06 12:07:28 +08:00
|
|
|
|
'[role="dialog"][aria-label="删除 minimax-cn?"]',
|
2026-07-31 11:14:07 +08:00
|
|
|
|
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:')
|
2026-08-06 12:07:28 +08:00
|
|
|
|
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()
|
2026-07-31 11:14:07 +08:00
|
|
|
|
await expect.poll(
|
|
|
|
|
|
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
|
|
|
|
|
{ timeout: 10_000 },
|
|
|
|
|
|
).not.toContain('minimax-cn:')
|
feat(credentials): move the store to .credentials.yaml and layer $DSH_HOME/.env
$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable
secret store it could not be hoisted into process.env — hoisting makes every
stored key read as a read-only launch override and blocks rotation from the
TUI and the web page. But its name and dotenv format promise an environment
file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the
same file was silently ignored: only the credential provider read the
document, and it addresses credential references alone.
Split the two jobs into two files.
.credentials.yaml is the provider-managed store: a strict YAML mapping of
CredentialRef to non-empty string, no version field, no wrapper level. Because
it holds credentials and nothing else, a non-mapping root, a non-identifier
key, a non-string value, an empty string, a duplicate key, and malformed YAML
are all rejections rather than skipped entries — loud at boot and at a write,
warn-and-keep-last-good on a live reload. The dotenv physical-line editor
gives way to a patch of the parsed document, so comments and untouched entries
keep their formatting and any string value round-trips, multi-line included.
Writer lock, read-modify-write, atomic 0600 write under a 0700 directory,
watcher, self-write suppression, and quiescent disposal are unchanged.
$DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new
loadLayeredEnv loads the invoking directory's .env then the Harness home's,
giving user < project < inherited; the home resolves from the inherited
environment first, so a project .env cannot redirect it.
Credential precedence is unchanged: the live environment still wins read-only
over the file, and shadowed writes still reject. Whether a provider-managed
store should instead win over the environment is a separate decision.
No migration: a key already in $DSH_HOME/.env keeps resolving through the new
environment layer, as a read-only env source that shadows the stored one.
2026-08-04 14:50:38 +08:00
|
|
|
|
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
|
2026-08-06 12:07:28 +08:00
|
|
|
|
.not.toContain('MINIMAX_CN_API_KEY')
|
2026-07-31 11:14:07 +08:00
|
|
|
|
await expect.poll(
|
2026-08-06 12:07:28 +08:00
|
|
|
|
async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
|
2026-07-31 11:14:07 +08:00
|
|
|
|
{ timeout: 10_000 },
|
|
|
|
|
|
).toBe(0)
|
2026-07-30 09:29:40 +08:00
|
|
|
|
await page.keyboard.press('Escape')
|
|
|
|
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
|
|
|
|
}, 60_000)
|
|
|
|
|
|
|
|
|
|
|
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
2026-08-06 13:15:09 +08:00
|
|
|
|
await assertFixtureInventory(SNAPSHOT_DIR, [
|
2026-08-10 22:40:57 +08:00
|
|
|
|
'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
|
2026-08-18 13:07:11 +08:00
|
|
|
|
'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md',
|
|
|
|
|
|
'native-delete.expected.md',
|
2026-08-06 13:15:09 +08:00
|
|
|
|
])
|
2026-07-30 09:29:40 +08:00
|
|
|
|
})
|
|
|
|
|
|
})
|