|
|
@@ -1,39 +1,68 @@
|
|
|
-// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
|
|
-// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
|
|
-// chromium. First describe: manifest injection + static serving. Second
|
|
|
-// describe: the full eight-plugin production chain loads through the DI chain
|
|
|
-// in ?fixture mode, exercises the sidebar rail, and covers durable history,
|
|
|
-// pasted, and dropped images without a model key.
|
|
|
+// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
|
|
|
+// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
|
|
|
+// chromium. First describe: graph injection + the fail-loud half. Second
|
|
|
+// describe: the settled success pass — all nine REAL tsdown bundles load
|
|
|
+// through the module system + vendored Loader chain in ?fixture mode (the
|
|
|
+// infrastructure four ride the immediately prefetch tier, the UI rows fetch
|
|
|
+// on demand), the three-column frame appears in one flip, and the resident
|
|
|
+// question completes through the real UI stack. The full model round lands
|
|
|
+// in smoke-real under the W5 real-host standard.
|
|
|
import { existsSync } from 'node:fs'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
import type { Browser, Page } from 'playwright'
|
|
|
import { chromium } from 'playwright'
|
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
|
import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
|
|
-import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver'
|
|
|
+import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
|
|
|
import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
|
|
|
|
|
const bundlePath = (dir: string): string =>
|
|
|
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
|
|
|
|
|
-/** id ↔ bundle table for the success pass (the production Web plugin chain). */
|
|
|
-const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
|
|
- { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
|
|
+const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
|
|
+const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
|
|
|
+const FIXTURE_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
|
|
|
+
|
|
|
+/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
|
|
|
+const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
|
|
|
+ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
|
|
|
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
|
|
- { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
|
|
|
- { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
|
|
|
- { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
|
- { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
|
- { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
|
|
+ { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
|
|
|
+ { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
|
|
|
+ { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
|
+ { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
|
|
|
+ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
|
|
|
+ { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
|
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
|
]
|
|
|
|
|
|
-/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
|
|
-const ROWS: WebPluginBootEntry[] = [
|
|
|
- { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
|
|
|
- { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
|
|
|
-]
|
|
|
-const LAYOUT_BUNDLE = bundlePath('ui-layout')
|
|
|
+const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
|
|
+
|
|
|
+const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
|
|
|
+ ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
|
|
|
+
|
|
|
+const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
|
|
|
+ ...(p.inject !== undefined ? { inject: p.inject } : {}),
|
|
|
+ ...(p.immediately === true ? { immediately: true } : {}),
|
|
|
+}))
|
|
|
+
|
|
|
+/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
|
|
|
+const FAIL_GRAPH: WebBootGraph = {
|
|
|
+ rev: 'e2e-fail',
|
|
|
+ entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
|
|
|
+}
|
|
|
+
|
|
|
+/** Graph for the success pass: the complete assembly. */
|
|
|
+const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
|
|
|
+
|
|
|
+/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
|
|
|
+function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
|
|
|
+ return {
|
|
|
+ graph: () => graph,
|
|
|
+ clientPath: (id: string) => byId.get(id),
|
|
|
+ onRebuilt: () => () => undefined,
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
describe('web boot chain (keyless, real carrier)', () => {
|
|
|
let server: Awaited<ReturnType<typeof startWebServer>>
|
|
|
@@ -51,10 +80,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
|
|
distIndex: DIST_INDEX,
|
|
|
apiHandler,
|
|
|
maxRequestBodyBytes: 32 * 1024 * 1024,
|
|
|
- webPlugins: {
|
|
|
- snapshot: () => ROWS,
|
|
|
- clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
|
|
|
- },
|
|
|
+ webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
|
|
|
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
|
|
browser = await chromium.launch()
|
|
|
page = await browser.newPage()
|
|
|
@@ -67,16 +93,25 @@ describe('web boot chain (keyless, real carrier)', () => {
|
|
|
await server?.close()
|
|
|
})
|
|
|
|
|
|
- it('GET / injects the manifest verbatim', async () => {
|
|
|
+ it('GET / injects the entry graph verbatim', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
|
|
|
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
|
|
|
- expect(boot).toEqual({ plugins: ROWS })
|
|
|
+ expect(boot).toEqual(FAIL_GRAPH)
|
|
|
})
|
|
|
|
|
|
it('serves a real bundle through the plugins endpoint', async () => {
|
|
|
- const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`)
|
|
|
+ const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
|
|
|
expect(res.status()).toBe(200)
|
|
|
- expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
|
|
|
+ expect(await res.text()).toContain('window.__ModuleLoader__.load')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('boots to the loading page and fail-louds the absent entry', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
|
|
|
+ await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
|
|
|
+ await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
|
|
|
+ await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
|
|
|
+ // The real UI must not have flipped in: the gate opens only on settled.
|
|
|
+ expect(await page.locator('[class*="frame"]').count()).toBe(0)
|
|
|
})
|
|
|
|
|
|
it('applies the token sheets before any plugin CSS', async () => {
|
|
|
@@ -85,8 +120,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('web boot chain success pass (keyless, production plugin chain, ?fixture)', () => {
|
|
|
- const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
|
|
+describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
|
|
|
let server: Awaited<ReturnType<typeof startWebServer>>
|
|
|
let browser: Browser
|
|
|
let page: Page
|
|
|
@@ -94,14 +128,9 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
requireDist()
|
|
|
+ const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
|
|
if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
|
|
|
const port = await probeFreePort()
|
|
|
- const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
|
|
|
- const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
|
|
|
- if (p.immediately === true) row.immediately = true
|
|
|
- return row
|
|
|
- })
|
|
|
- const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
|
|
// ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
|
|
|
const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
|
|
|
server = await startWebServer({
|
|
|
@@ -110,7 +139,7 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
distIndex: DIST_INDEX,
|
|
|
apiHandler,
|
|
|
maxRequestBodyBytes: 32 * 1024 * 1024,
|
|
|
- webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) },
|
|
|
+ webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
|
|
|
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
|
|
browser = await chromium.launch()
|
|
|
page = await browser.newPage()
|
|
|
@@ -135,8 +164,8 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
it('every plugin CSS landed with its ownership tag', async () => {
|
|
|
const owners = await page.evaluate(() =>
|
|
|
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
|
|
|
- expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
|
|
|
- expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
|
|
|
+ expect(owners).toContain(LAYOUT_ID)
|
|
|
+ expect(owners).toContain(SIDEBAR_ID)
|
|
|
})
|
|
|
|
|
|
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
|
|
|
@@ -149,25 +178,27 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
const settledTrack = async (px: string): Promise<void> => {
|
|
|
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
|
|
|
}
|
|
|
+ // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
|
|
|
+ const brand = () => page.locator('[class*="brand"]').count()
|
|
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
|
|
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
|
|
|
- expect(await page.locator('text=HARNESS').count()).toBe(1)
|
|
|
+ expect(await brand()).toBe(1)
|
|
|
await settledTrack('56px')
|
|
|
- await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0)
|
|
|
- for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
|
|
|
+ await expect.poll(brand, { timeout: 2000 }).toBe(0)
|
|
|
+ for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
|
|
|
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
|
|
|
}
|
|
|
- await page.getByRole('button', { name: 'Expand sidebar' }).click()
|
|
|
- await settledTrack('300px')
|
|
|
+ await page.getByRole('button', { name: 'Open sidebar' }).click()
|
|
|
+ await settledTrack('280px')
|
|
|
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
|
|
// Rail search: collapse again, the search control expands and lands in the box.
|
|
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
|
|
await settledTrack('56px')
|
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
|
- await settledTrack('300px')
|
|
|
- const focused = await page.evaluate(() =>
|
|
|
- (document.activeElement as HTMLInputElement | null)?.placeholder ?? '')
|
|
|
- expect(focused).toContain('Search')
|
|
|
+ await settledTrack('280px')
|
|
|
+ // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
|
|
|
+ await expect.poll(() => page.evaluate(() =>
|
|
|
+ (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
|
|
|
})
|
|
|
|
|
|
it('renders file tool rows and expands fixture reasoning from either click target', async () => {
|
|
|
@@ -218,15 +249,36 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
await preview.waitFor({ state: 'detached' })
|
|
|
})
|
|
|
|
|
|
+ it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
|
|
|
+ await page.getByRole('button', { name: 'New session', exact: true }).click()
|
|
|
+ const input = page.locator('textarea[placeholder]')
|
|
|
+ await input.waitFor({ timeout: 15_000 })
|
|
|
+ await input.fill('render markdown')
|
|
|
+ await page.getByRole('button', { name: '发送' }).click()
|
|
|
+
|
|
|
+ const streaming = page.locator('[data-streaming="true"]')
|
|
|
+ await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
|
|
|
+ await streaming.waitFor({ state: 'detached', timeout: 15_000 })
|
|
|
+
|
|
|
+ const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
|
|
|
+ expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
|
|
|
+ expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
|
|
|
+ const external = page.getByRole('link', { name: 'DeepSeek' })
|
|
|
+ expect(await external.getAttribute('target')).toBe('_blank')
|
|
|
+ expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
|
|
+ })
|
|
|
+
|
|
|
it('pastes and drops images into the composer, then sends them as durable history', async () => {
|
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-fixture-image-paste'))
|
|
|
- await page.getByRole('button', { name: '停止' }).click()
|
|
|
const textarea = page.locator('textarea')
|
|
|
await textarea.waitFor({ state: 'visible' })
|
|
|
+ if (await page.getByRole('button', { name: '停止' }).count() > 0) {
|
|
|
+ await page.getByRole('button', { name: '停止' }).click()
|
|
|
+ }
|
|
|
await expect.poll(() => textarea.isEnabled()).toBe(true)
|
|
|
- await textarea.evaluate((element) => {
|
|
|
- const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==')
|
|
|
- const bytes = Uint8Array.from(binary, character => character.charCodeAt(0))
|
|
|
+ await textarea.evaluate((element, png) => {
|
|
|
+ const bytes = Uint8Array.from(atob(png), character => character.charCodeAt(0))
|
|
|
const transfer = new DataTransfer()
|
|
|
transfer.items.add(new File([bytes], 'clipboard.png', { type: 'image/png' }))
|
|
|
element.dispatchEvent(new ClipboardEvent('paste', {
|
|
|
@@ -234,12 +286,11 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
cancelable: true,
|
|
|
clipboardData: transfer,
|
|
|
}))
|
|
|
- })
|
|
|
+ }, FIXTURE_PNG)
|
|
|
|
|
|
const rail = page.getByLabel('待发送图片')
|
|
|
await rail.waitFor({ state: 'visible' })
|
|
|
- const draftImage = rail.getByTitle('双击查看原图')
|
|
|
- await draftImage.dblclick()
|
|
|
+ await rail.getByTitle('双击查看原图').dblclick()
|
|
|
const preview = page.getByRole('dialog', { name: '原图预览' })
|
|
|
await preview.waitFor({ state: 'visible' })
|
|
|
await page.keyboard.press('Escape')
|
|
|
@@ -247,38 +298,70 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur
|
|
|
|
|
|
await page.getByRole('button', { name: '发送' }).click()
|
|
|
await rail.waitFor({ state: 'detached' })
|
|
|
- await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(3)
|
|
|
+ await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(1)
|
|
|
|
|
|
await page.getByRole('button', { name: '停止' }).click()
|
|
|
await expect.poll(() => textarea.isEnabled()).toBe(true)
|
|
|
- await textarea.evaluate((element) => {
|
|
|
- const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==')
|
|
|
- const bytes = Uint8Array.from(binary, character => character.charCodeAt(0))
|
|
|
+ await textarea.evaluate((element, png) => {
|
|
|
+ const bytes = Uint8Array.from(atob(png), character => character.charCodeAt(0))
|
|
|
const transfer = new DataTransfer()
|
|
|
transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' }))
|
|
|
- element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('dragenter', {
|
|
|
+ const card = element.closest('[class*="card"]')
|
|
|
+ card?.dispatchEvent(new DragEvent('dragenter', {
|
|
|
bubbles: true,
|
|
|
cancelable: true,
|
|
|
dataTransfer: transfer,
|
|
|
}))
|
|
|
- })
|
|
|
- await page.getByRole('status').filter({ hasText: '松开以添加图片' }).waitFor({ state: 'visible' })
|
|
|
- await textarea.evaluate((element) => {
|
|
|
- const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==')
|
|
|
- const bytes = Uint8Array.from(binary, character => character.charCodeAt(0))
|
|
|
- const transfer = new DataTransfer()
|
|
|
- transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' }))
|
|
|
- element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('drop', {
|
|
|
+ card?.dispatchEvent(new DragEvent('drop', {
|
|
|
bubbles: true,
|
|
|
cancelable: true,
|
|
|
dataTransfer: transfer,
|
|
|
}))
|
|
|
- })
|
|
|
+ }, FIXTURE_PNG)
|
|
|
await rail.waitFor({ state: 'visible' })
|
|
|
await rail.getByAltText('dropped.png').waitFor({ state: 'visible' })
|
|
|
await page.getByRole('button', { name: '发送' }).click()
|
|
|
await rail.waitFor({ state: 'detached' })
|
|
|
- await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(4)
|
|
|
+ await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(2)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders and completes the resident question through the composer slot', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
|
|
+ const sessionTree = page.getByRole('tree', { name: 'Sessions' })
|
|
|
+ const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
|
|
|
+ if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
|
|
|
+ await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
|
|
|
+ const composer = page.locator('[data-question-key]')
|
|
|
+ await composer.waitFor({ timeout: 15_000 })
|
|
|
+ expect({
|
|
|
+ question: await composer.getByRole('heading').innerText(),
|
|
|
+ progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
|
|
|
+ options: await composer.getByRole('radio').allTextContents(),
|
|
|
+ custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
|
|
|
+ }).toMatchInlineSnapshot(`
|
|
|
+ {
|
|
|
+ "custom": "其他,请填写自定义答案",
|
|
|
+ "options": [
|
|
|
+ "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
|
|
|
+ "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
|
|
|
+ "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
|
|
|
+ ],
|
|
|
+ "progress": "1 / 3",
|
|
|
+ "question": "你现在更想招哪类 Agent/Harness 候选人?",
|
|
|
+ }
|
|
|
+ `)
|
|
|
+
|
|
|
+ await composer.getByRole('radio', { name: '工程落地型' }).click()
|
|
|
+ await composer.getByText('2 / 3', { exact: true }).waitFor()
|
|
|
+ await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
|
|
|
+ await composer.getByRole('checkbox', { name: '系统设计' }).click()
|
|
|
+ await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
|
|
|
+ await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
|
|
|
+
|
|
|
+ await composer.waitFor({ state: 'detached' })
|
|
|
+ const restoredInput = page.locator('textarea[placeholder]')
|
|
|
+ await restoredInput.waitFor()
|
|
|
+ expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
|
|
|
})
|
|
|
|
|
|
it('stayed clean: no page errors across the whole load chain', () => {
|