Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706

# Conflicts:
#	apps/web/tests/assembled-boot.ts
#	apps/web/tests/settings-chrome.e2e.ts
#	docs/module-graph.md
#	packages/client/runtime/tsconfig.json
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 00:21:45 +08:00
345 changed files with 14916 additions and 1478 deletions

View File

@@ -1,5 +1,5 @@
// Shared scaffolding for the assembled-jsdom snapshots: the real built
// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
// workspace `lib/client.js` artifacts booted through AppWebEntry's
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
// transport. Every file that mounts this graph needs the same boot entry list,
// the same bundle map, the same jsdom globals, and the same mount call, and
@@ -14,53 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{
id: '@deepseek-ai/dsh-client-ui-theme',
dir: 'ui-theme',
url: '/plugins/ui-theme.js',
rev: 'fx',
inject: [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-locale',
],
immediately: true,
},
{
id: '@deepseek-ai/dsh-client-locale',
dir: 'locale',
url: '/plugins/locale.js',
rev: 'fx',
inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime'],
immediately: true,
},
{
id: '@deepseek-ai/dsh-client-ui-layout',
dir: 'ui-layout',
url: '/plugins/ui-layout.js',
rev: 'fx',
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-theme'],
},
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-conversation',
dir: 'ui-conversation',
url: '/plugins/ui-conversation.js',
rev: 'fx',
inject: [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-locale',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-layout',
],
},
/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
@@ -69,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
]))
interface FixtureWindow extends Window {
@@ -101,8 +69,7 @@ let unmount: (() => void) | undefined
export function installAssembledBootEnv(): void {
beforeEach(() => {
localStorage.clear()
Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
localStorage.setItem('dsh.locale', 'en')
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
@@ -120,9 +87,6 @@ export function installAssembledBootEnv(): void {
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
const ownNavigator = navigator as unknown as Record<string, unknown>
delete ownNavigator.languages
delete ownNavigator.language
vi.unstubAllGlobals()
})
}
@@ -136,7 +100,7 @@ export function mountAssembledApp(): void {
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {

View File

@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)

View File

@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)

View File

@@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {

View File

@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
id: '/',
name: 'DeepSeek Harness',
short_name: 'DSH',
start_url: '/',
scope: '/',
display: 'fullscreen',
icons: [{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
}],
})
})

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// the assembled application. The built-boot smoke proves the graph boots but

View File

@@ -1,7 +1,8 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
// browser theme-color metadata)
// the Language row and busy-state Enter preference (both Host-backed), plus
// Permission as the persisted default for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
@@ -153,17 +154,38 @@ describe('web e2e: settings modal and General preferences', () => {
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> =>
await target.evaluate(() => ({
interface ThemeState {
attr: boolean
background: string
/** Pre-migration localStorage key; the Host-backed world never writes it. */
legacy: string | null
themeColor: string | null
themeColorCount: number
token: string
}
const readState = async (target: Page = page): Promise<ThemeState> => await target.evaluate(() => {
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
const computed = getComputedStyle(document.body)
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
background: computed.backgroundColor,
legacy: localStorage.getItem('dsh.theme'),
}))
themeColor: metas[0]?.content ?? null,
themeColorCount: metas.length,
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
}
})
const expectThemeColorSynchronized = (state: ThemeState): void => {
expect(state.themeColorCount).toBe(1)
expect(state.background).not.toBe('rgba(0, 0, 0, 0)')
expect(state.themeColor).toBe(state.background)
}
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
await page.emulateMedia({ colorScheme: 'light' })
const light = await readState()
expect(light.attr).toBe(false)
expectThemeColorSynchronized(light)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -178,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(dark.attr).toBe(true)
expect(dark.legacy).toBeNull()
expect(dark.token).not.toBe(light.token)
expectThemeColorSynchronized(dark)
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-theme:\n\s+preference: dark/)
await page.keyboard.press('Escape')
@@ -189,7 +212,9 @@ describe('web e2e: settings modal and General preferences', () => {
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
expect((await readState()).legacy).toBeNull()
const reloaded = await readState()
expect(reloaded.legacy).toBeNull()
expectThemeColorSynchronized(reloaded)
// A second live Host binds another ephemeral port but shares the same
// user-settings home. Its fresh origin has no theme localStorage and still
@@ -203,7 +228,9 @@ describe('web e2e: settings modal and General preferences', () => {
await secondPage.goto(second.baseUrl, { waitUntil: 'load' })
await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true)
expect((await readState(secondPage)).legacy).toBeNull()
const secondState = await readState(secondPage)
expect(secondState.legacy).toBeNull()
expectThemeColorSynchronized(secondState)
expect(secondTripwire.pageErrors).toEqual([])
expect(secondTripwire.warnings).toEqual([])
} finally {
@@ -217,12 +244,15 @@ describe('web e2e: settings modal and General preferences', () => {
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
expectThemeColorSynchronized(await readState())
// Restore for the specs that follow: light preference beats the emulated
// dark OS scheme, leaving the shared page in the light default.
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => {
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()