174 lines
8.7 KiB
TypeScript
174 lines
8.7 KiB
TypeScript
// Web e2e scenario: switching models in the composer is how this deployment's
|
|
// default is chosen. The gesture writes the shared `agent-default-model` settings section, a
|
|
// session created afterwards starts from it, and a session that already logged
|
|
// a route keeps deriving from its own log — the tier order the gateway
|
|
// resolves on every read.
|
|
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
|
// is no fixture and a stray stream would fail loud because the adapter registry is empty. Both
|
|
// routes are declared host-side (not through the UI, which has its own
|
|
// scenario) through the pi-ai adapter the shipped tree already mounts: a
|
|
// fixture-less scaffold registers no adapter at all, so the routes the
|
|
// picker offers — and the one the composer must start on — have to come from
|
|
// somewhere, and settings profiles are the product's own way to add them.
|
|
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 { SessionId } from '@deepseek-ai/dsh-session'
|
|
import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
|
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
|
|
|
/** Points the shipped shared Agent default at this scenario's own route. */
|
|
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
|
|
|
/** The route this scenario starts on, patched over the shipped default. */
|
|
const START_ROUTE = 'origin-gateway'
|
|
const START_MODEL = 'origin-large'
|
|
/** The route the switch lands on, which then becomes the saved default. */
|
|
const ROUTE = 'acme-gateway'
|
|
const MODEL = 'acme-large'
|
|
|
|
describe('web e2e: the composer model switch is the default for later sessions', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
/** Create one session and its agent through the same wire face the browser uses. */
|
|
const createSession = async (sessionId: string): Promise<string> => {
|
|
const response = await scaffold.ctx.sessionController.create({
|
|
sessionId: SessionId(sessionId),
|
|
cwd: scaffold.workspaceCwd,
|
|
})
|
|
return response.sessionId
|
|
}
|
|
|
|
/** The route the Client derives from the Session projection and Host default. */
|
|
const currentOf = (sessionId: string): Promise<unknown> => {
|
|
const session = scaffold.ctx.sessions.get(SessionId(sessionId))
|
|
if (session === undefined) throw new Error(`session "${sessionId}" is not live`)
|
|
return Promise.resolve(
|
|
scaffold.ctx.sessionProjections.snapshot(session).values.modelSelection?.next
|
|
?? scaffold.ctx.agentDefaultModel.currentSelection(),
|
|
)
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
|
// Two routes so the picker has somewhere to start and somewhere to go.
|
|
// Declared through the settings seam rather than the Models page: this
|
|
// scenario is about the composer, and the declaring flow is covered by
|
|
// models-settings.e2e.
|
|
await scaffold.ctx.settings.update('llm-pi-ai', {
|
|
providers: {
|
|
[START_ROUTE]: {
|
|
displayName: 'Origin Gateway',
|
|
api: 'openai-completions',
|
|
baseURL: 'https://gateway.origin.example/v1',
|
|
models: [{ id: START_MODEL, name: 'Origin Large' }],
|
|
},
|
|
[ROUTE]: {
|
|
displayName: 'Acme Gateway',
|
|
api: 'openai-completions',
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
models: [{ id: MODEL, name: 'Acme Large' }],
|
|
},
|
|
},
|
|
})
|
|
browser = await chromium.launch()
|
|
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
// The composer's seats only exist once a workspace is connected: without
|
|
// one the input is the locked placeholder and no session scope is open.
|
|
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
await scaffold?.close()
|
|
})
|
|
|
|
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
|
// A session that has already run a turn, spelled as the fact a turn
|
|
// leaves behind: its own logged route.
|
|
const loggedId = await createSession('default-model-logged')
|
|
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
|
|
header: { config: { provider: START_ROUTE, model: START_MODEL } },
|
|
reason: 'initial',
|
|
})
|
|
|
|
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
|
await trigger.waitFor({ timeout: 15_000 })
|
|
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
|
|
await trigger.click()
|
|
await page.getByRole('menuitem', { name: /模型/ }).click()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
const blocked = scaffold.ctx.hmr.runExclusive(async () => {
|
|
entered.resolve(undefined)
|
|
await release.promise
|
|
})
|
|
try {
|
|
await entered.promise
|
|
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
|
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
|
|
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
|
|
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
|
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
|
|
} finally {
|
|
release.resolve(undefined)
|
|
await blocked
|
|
}
|
|
|
|
// The switch is what sets the default: the shared Agent-route settings section
|
|
// now names it, beside the provider profiles the Models page writes.
|
|
await expect.poll(
|
|
async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'),
|
|
{ timeout: 10_000 },
|
|
).toContain('id: agent-default-model')
|
|
const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
|
|
expect(document).toContain(`provider: ${ROUTE}`)
|
|
expect(document).toContain(`model: ${MODEL}`)
|
|
|
|
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection())
|
|
.toEqual({ provider: ROUTE, model: MODEL })
|
|
// A session created after the switch starts from it...
|
|
expect(await currentOf(await createSession('default-model-after')))
|
|
.toEqual({ provider: ROUTE, model: MODEL })
|
|
// ...while the one holding a logged route keeps deriving from its log.
|
|
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('retains the saved route and editable composer when its provider is removed', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
|
|
const box = page.locator('[data-composer-input]').first()
|
|
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
|
|
|
|
// Removing the selected provider preserves the other route and the saved
|
|
// selection. A merge update would retain the removed profile.
|
|
await scaffold.ctx.settings.replace('llm-pi-ai', { providers: {
|
|
[START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions',
|
|
baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] },
|
|
} })
|
|
|
|
const seat = page.getByRole('button', { name: new RegExp(`^选择模型.*${ROUTE}/${MODEL}`) })
|
|
await seat.waitFor()
|
|
expect(await box.isEnabled()).toBe(true)
|
|
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: ROUTE, model: MODEL })
|
|
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode())
|
|
await seat.click()
|
|
await page.getByRole('menuitem', { name: /模型/ }).click()
|
|
await page.getByRole('menuitemradio').first().click()
|
|
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
})
|