Merge remote-tracking branch 'origin/master' into worktree/unify-workspace-add
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
/**
|
||||
* connectFreshWorkspace twin over the product default Chinese locale (the
|
||||
@@ -53,7 +53,7 @@ describe('web e2e: Full access confirmation', () => {
|
||||
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
|
||||
// Keep the product default Chinese locale: the golden pins the actual
|
||||
// registered dictionary rather than a test-local translation callback.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
@@ -37,7 +37,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
@@ -34,7 +34,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
// 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' })
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
// masking its credential, without making a model call.
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches after the shipped surface overlay: temp persistenceRoot; local skill
|
||||
// roots confined to the temp workspace; workspace-context disabled (recorded
|
||||
// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled
|
||||
// (its fire-and-forget title call would race the loop for the session's replay
|
||||
// cursor); webserver pinned to port 0 with the built dist; ordinary keyless
|
||||
// modes disable llm-deepseek and fill the open llm seam post-boot with
|
||||
// installLlmReplay on the settled root ctx
|
||||
// patches after the shipped surface overlay, over the SAME tree (never a
|
||||
// second yml): temp persistenceRoot; host-level skill roots confined to the
|
||||
// temp workspace while project skill discovery remains real; workspace-context
|
||||
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
|
||||
// session-title-llm disabled (its fire-and-forget title call would race the
|
||||
// loop for the session's replay cursor); webserver pinned to port 0 with the
|
||||
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
|
||||
// llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Web e2e scenario: seeded history. A recorded session seeded cold through
|
||||
// the REAL persistence API renders purely from the log — the surface nothing
|
||||
// else covers: sidebar cold listing, the implicit resume/attach inside the
|
||||
// history RPC, history-page tool views, and the client fold of historical
|
||||
// history RPC, history-page tool views, and the client's log-ordered transcript
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). The cold session also carries the one
|
||||
// keyless command-row surface: an Access-chip pick runs `/permission` on the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded
|
||||
// fixture under the
|
||||
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
|
||||
// live through the composer (real read tool against seeded workspace files)
|
||||
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
|
||||
@@ -34,6 +35,90 @@ const SEED_ID = 'seeded-history-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/**
|
||||
* Append a complete, valid compaction transaction over the recorded turn's own
|
||||
* surface. The recording stays model-authentic and reusable; replay adds this
|
||||
* deterministic condition before seeding it cold, so the scenario pins the bug
|
||||
* this change fixes — a landed compaction must not erase history the reader
|
||||
* already saw — through the real host and the real browser.
|
||||
* @param raw - the committed seed fixture text.
|
||||
* @returns the fixture with a compacted turn appended.
|
||||
*/
|
||||
function withCompaction(raw: string): string {
|
||||
const lines = raw.trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
surfaceOp?: unknown
|
||||
data?: { turn?: unknown }
|
||||
})
|
||||
const surfaceSeqs = events
|
||||
.filter(event => event.surfaceOp === 'append'
|
||||
&& (event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'steering/message'))
|
||||
.map(event => event.seq)
|
||||
const first = surfaceSeqs[0]
|
||||
const last = surfaceSeqs.at(-1)
|
||||
const tail = events.at(-1)
|
||||
if (first === undefined || last === undefined || tail === undefined) {
|
||||
throw new Error('seeded-history compaction requires a non-empty closed surface')
|
||||
}
|
||||
// The transaction opens the turn after the recording's last closed one; read
|
||||
// it from the fixture so a re-recording with a different turn count stays
|
||||
// valid instead of appending a duplicate turn number.
|
||||
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
|
||||
if (typeof lastTurn !== 'number') {
|
||||
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
|
||||
}
|
||||
const turn = lastTurn + 1
|
||||
let seq = tail.seq + 1
|
||||
let time = tail.time + 1
|
||||
/**
|
||||
* Append one event at the next seq/time.
|
||||
* @param event - the event body, without seq/time.
|
||||
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
|
||||
return taken
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
|
||||
const startSeq = at({ type: 'compact/start', data: { turn } })
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
}],
|
||||
shadowedRange: { start: first, end: last },
|
||||
shadowedSeqs: surfaceSeqs,
|
||||
shadowedTokenCount: 10_000,
|
||||
provider: 'snapshot',
|
||||
model: 'snapshot-compactor',
|
||||
},
|
||||
})
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn } })
|
||||
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -53,7 +138,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
await seedSession(scaffold, withCompaction(raw), SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -119,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
// Tool cards render from logged tool/call + tool/result alone (views are
|
||||
// host-recomputed per page; the generic card is the documented default).
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// The bug this fixes: the compaction shadowed the whole recorded surface on
|
||||
// the model side, and the prompt and full tool output are still on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
@@ -230,6 +319,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
||||
const marker = page.getByRole('button', { name: /Context compacted/ })
|
||||
await marker.waitFor({ timeout: 10_000 })
|
||||
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
|
||||
timeout: 5_000,
|
||||
}).toBe(1)
|
||||
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// Restore the shared page state for any later case.
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
|
||||
// The Access chip submits `/permission <preset>` — a host command with no
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
@@ -33,7 +33,9 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
// Chinese browser: the shared page asserts the localized settings surface
|
||||
// the client derives from it (the English default has its own spec below).
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -215,6 +217,30 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens an English browser in English without any stored preference', async () => {
|
||||
// A second page under a different browser language: nothing is persisted
|
||||
// for it, so the settings surface must follow the browser rather than the
|
||||
// product fallback the shared zh page shows.
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
try {
|
||||
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
// This page has no closing inventory spec to sweep its console, so the
|
||||
// scenario clears both tripwire channels itself.
|
||||
expect(enTripwire.pageErrors).toEqual([])
|
||||
expect(enTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await enPage.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- region "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。
|
||||
- paragraph: 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
- paragraph:
|
||||
- text: 我们尤其希望听见那些失败、困惑与不顺手的时刻——
|
||||
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,
|
||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
- button "继续"
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: 7/25 {{clock}}
|
||||
- button "Context compacted View compaction summary":
|
||||
- img
|
||||
- text: Context compacted View compaction summary
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -10,11 +10,18 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met
|
||||
|
||||
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Browser language a page must advertise to boot into the product's Chinese
|
||||
* surface: with no stored preference the client derives its initial locale
|
||||
* from the browser, and Playwright's default browser asks for English.
|
||||
*/
|
||||
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
||||
|
||||
/**
|
||||
* Open the standard browser-test page with English selected before client
|
||||
* boot. This keeps role locators and goldens deterministic across localized
|
||||
* component migrations; the settings locale scenario deliberately bypasses
|
||||
* this helper to cover the product's default Chinese state.
|
||||
* component migrations; the scenarios asserting the Chinese surface bypass
|
||||
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
|
||||
* @param browser - Playwright browser owning the page.
|
||||
* @param height - Viewport height; width is fixed to the lane baseline.
|
||||
* @returns the initialized page.
|
||||
|
||||
Reference in New Issue
Block a user