63b80956f2
The settings shell painted the onboarding overlay (opaque stage, mask, #root inert) the moment a step was registered and not locally completed, while every step still had to load its private join before deciding whether to show — rendering null could not suppress the shell-owned chrome. Every reload on the hero therefore flashed a full-screen opaque layer (white in the light palette) for one settings/credential RPC round-trip after the session list turned ready. The chrome now belongs to the step: a new zero-cordis OnboardingSurface primitive (ui-primitives) renders the body-portaled overlay/mask/stage verbatim from the former SettingsRoot stylesheet and holds #root inert for exactly its own lifetime. WelcomeNotice and DeepSeekOnboardingDialog wrap only their visible branch in it, so their existing null branches paint and block nothing by construction. SettingsRoot keeps the coordinator unchanged but renders the elected step bare, and the settings.onboarding contract now names the surface wrap as the registrant's obligation. The onboarding e2e gains a held-join reload scenario pinning that a configured world never mounts the takeover chrome or inerts the app.
264 lines
14 KiB
TypeScript
264 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 the join's settings.describe response widens that
|
|
// window from loopback-invisible to hundreds of milliseconds — without
|
|
// the hold, the assertions below would pass vacuously.
|
|
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)
|
|
})
|
|
let releaseDescribe = (): void => {}
|
|
const held = new Promise<void>((resolve) => { releaseDescribe = resolve })
|
|
let gated = false
|
|
await page.route('**/api/settings.describe', async (route) => {
|
|
if (gated) { await route.continue(); return }
|
|
gated = true
|
|
await held
|
|
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'],
|
|
)
|
|
})
|
|
})
|