knip: ui-settings no longer imports react-dom (the portal moved into OnboardingSurface), so the react-dom peer/dev dependencies and @types/react-dom go away with the usage. Review follow-ups: the Agent Note's Testing section now records the held-join reload regression scenario this PR adds (both languages, pairing re-recorded); the e2e hold gates EVERY settings.describe issued before release instead of only the first, so a future boot-time join consumer cannot silently collapse the widened window; the sampler's persistence across later navigations is documented and the vacuity wording softened to what the hold actually buys (timing independence).
273 lines
14 KiB
TypeScript
273 lines
14 KiB
TypeScript
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
|
|
// credential is absent, onboarding routes to the real Models editor, and its
|
|
// write lands in an isolated harness home without a reload or model call.
|
|
import { randomBytes } from 'node:crypto'
|
|
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 {
|
|
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
|
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import {
|
|
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
WELCOME_NOTICE_VERSION,
|
|
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
|
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
|
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
|
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
|
const MODE = webSnapshotMode()
|
|
|
|
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
const browserConsole: string[] = []
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
|
browser = await chromium.launch()
|
|
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
|
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
|
tripwire = watchConsole(page)
|
|
page.on('console', message => browserConsole.push(message.text()))
|
|
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('stores a key write-only and observes configured state without restarting', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
|
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
|
await welcome.waitFor({ timeout: 15_000 })
|
|
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
|
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
|
|
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
|
|
expect(await welcome.locator('button').count()).toBe(1)
|
|
|
|
const mask = page.locator('[class*="onboardingMask"]')
|
|
expect(await mask.count()).toBe(1)
|
|
const maskStyles = await mask.evaluate((mask) => {
|
|
const style = getComputedStyle(mask)
|
|
const rect = mask.getBoundingClientRect()
|
|
return {
|
|
position: style.position,
|
|
left: style.left,
|
|
right: style.right,
|
|
top: style.top,
|
|
bottom: style.bottom,
|
|
background: style.backgroundColor,
|
|
backdropFilter: style.backdropFilter,
|
|
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
}
|
|
})
|
|
expect(maskStyles).toEqual({
|
|
position: 'absolute',
|
|
left: '0px',
|
|
right: '0px',
|
|
top: '80px',
|
|
bottom: '0px',
|
|
background: 'rgba(0, 0, 0, 0.24)',
|
|
backdropFilter: 'blur(2px)',
|
|
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
|
})
|
|
|
|
// Closing the process/page before acknowledgement writes nothing, so the
|
|
// same durable profile presents the notice again after reload.
|
|
const firstReloadWarnings = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'load' })
|
|
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
|
|
await welcome.waitFor({ timeout: 15_000 })
|
|
|
|
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
|
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
|
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
|
await credentialStep.waitFor({ timeout: 15_000 })
|
|
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
|
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
|
|
|
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
|
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
|
const settings = page.getByRole('dialog', { name: '设置' })
|
|
await settings.waitFor({ timeout: 10_000 })
|
|
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
|
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
|
await keyInput.waitFor({ timeout: 10_000 })
|
|
|
|
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
|
|
await keyInput.fill(secret)
|
|
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
|
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
|
|
|
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
|
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
|
expect((await page.content()).includes(secret)).toBe(false)
|
|
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
|
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
|
|
|
// The same open Models surface reuses the refreshed join and exposes the
|
|
// configured write-only placeholder without a reload.
|
|
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
|
|
await deepSeekRow.waitFor({ timeout: 10_000 })
|
|
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
|
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
|
|
await configuredInput.waitFor({ timeout: 10_000 })
|
|
await expect.poll(
|
|
() => configuredInput.getAttribute('placeholder'),
|
|
{ timeout: 10_000 },
|
|
).toBe('已配置——输入新值可替换')
|
|
|
|
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
|
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
|
|
|
|
const secondReloadWarnings = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'load' })
|
|
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
|
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
|
|
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
|
|
|
// A different stored copy version represents an intentional version bump:
|
|
// the welcome step returns even though the credential is already ready.
|
|
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
|
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
|
|
}])
|
|
const thirdReloadWarnings = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'load' })
|
|
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
|
|
await welcome.waitFor({ timeout: 15_000 })
|
|
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
|
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
|
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
|
|
|
expect((await page.content()).includes(secret)).toBe(false)
|
|
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
|
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
|
expect(tripwire.warnings).toEqual([])
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
|
|
// Regression pin for the reload white flash: both steps are satisfied
|
|
// (welcome acknowledged, credential configured), yet each must LOAD its
|
|
// private join before it can decide not to show. The chrome lives inside
|
|
// the step (OnboardingSurface), so the deciding window paints and blocks
|
|
// nothing. Holding settings.describe widens that window from loopback
|
|
// RTT scale to a deterministic hundreds of milliseconds, removing all
|
|
// timing dependence from the sampler assertions below.
|
|
//
|
|
// The sampler init script persists across this shared page's later
|
|
// navigations (init scripts re-run per navigation); that stays harmless
|
|
// because no later scenario in this file legitimately shows the
|
|
// takeover, and only this test reads __takeoverSightings.
|
|
await page.addInitScript(() => {
|
|
const sightings: string[] = []
|
|
;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
|
|
setInterval(() => {
|
|
if (document.querySelector('[class*="onboardingStage"], [class*="onboardingMask"]') !== null) {
|
|
sightings.push('chrome')
|
|
}
|
|
if (document.getElementById('root')?.inert === true) sightings.push('inert')
|
|
}, 8)
|
|
})
|
|
// EVERY settings.describe issued before the release is held — not just
|
|
// the first — so the pin cannot silently collapse back to loopback
|
|
// timing if a second boot-time consumer of the join ever appears.
|
|
let released = false
|
|
const heldRoutes: Array<() => void> = []
|
|
const releaseDescribe = (): void => {
|
|
released = true
|
|
for (const resolve of heldRoutes.splice(0)) resolve()
|
|
}
|
|
await page.route('**/api/settings.describe', async (route) => {
|
|
if (!released) await new Promise<void>((resolve) => { heldRoutes.push(resolve) })
|
|
await route.continue()
|
|
})
|
|
const warningsBefore = tripwire.warnings.length
|
|
await page.reload({ waitUntil: 'commit' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
|
// The app is painted and interactive while the steps are still deciding.
|
|
await page.waitForTimeout(600)
|
|
releaseDescribe()
|
|
await page.waitForTimeout(400)
|
|
await page.unroute('**/api/settings.describe')
|
|
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
|
|
expect(await page.evaluate(() =>
|
|
(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
|
|
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
|
// Opened here rather than inherited: the credential test reloads the page
|
|
// to exercise the welcome step, so nothing carries an open dialog across.
|
|
await page.getByRole('button', { name: '设置', exact: true }).click()
|
|
const settings = page.getByRole('dialog', { name: '设置' })
|
|
await settings.waitFor({ timeout: 10_000 })
|
|
await settings.getByRole('button', { name: '模型' }).click()
|
|
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
|
|
await deepSeek.waitFor({ timeout: 10_000 })
|
|
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
|
await settings.getByText('自定义设置').click()
|
|
await settings.getByRole('button', { name: /删除模型/ }).first().click()
|
|
await settings.getByRole('button', { name: '添加模型' }).click()
|
|
const customModelId = settings.getByLabel('模型 ID 2')
|
|
await customModelId.fill('private-preview')
|
|
await settings.getByLabel('显示名称 2').fill('Private Preview')
|
|
// Capacities live behind the row's own disclosure, as in the pi-ai form.
|
|
await settings.getByRole('button', { name: '容量 2' }).click()
|
|
await settings.getByLabel('上下文窗口 2').fill('131072')
|
|
await settings.getByLabel('最大输出 token 数 2').fill('64K')
|
|
|
|
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
|
|
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
|
await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
|
|
|
|
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
|
expect(document).toContain('id: deepseek-v4-pro')
|
|
expect(document).toContain('id: private-preview')
|
|
expect(document).toContain('name: Private Preview')
|
|
expect(document).toContain('contextWindow: 131072')
|
|
expect(document).toContain('maxTokens: 64000')
|
|
expect(document).not.toContain('id: deepseek-v4-flash')
|
|
|
|
await page.keyboard.press('Escape')
|
|
// A connected Workspace is what puts a live composer — and its model
|
|
// trigger — on the page; the scaffold boots without one.
|
|
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
|
|
|
|
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
|
|
await modelTrigger.waitFor({ timeout: 10_000 })
|
|
await modelTrigger.click()
|
|
await page.getByRole('menuitem', { name: /模型/ }).click()
|
|
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
|
|
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
|
|
expect(tripwire.warnings).toEqual([])
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 60_000)
|
|
|
|
it('keeps the fixture inventory closed', async () => {
|
|
await assertFixtureInventory(
|
|
SNAPSHOT_DIR,
|
|
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
|
|
)
|
|
})
|
|
})
|