Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # apps/cli/config/base.cordis.yml # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
339
apps/web/tests/conversation-column-overflow.e2e.ts
Normal file
339
apps/web/tests/conversation-column-overflow.e2e.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Web e2e scenario: the conversation column scrolls on one axis only, as the
|
||||
// browser actually lays it out. The reported symptom was a horizontal
|
||||
// scrollbar under the whole center column once the window (or the sidebar
|
||||
// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the
|
||||
// column and becoming user-scrollable.
|
||||
//
|
||||
// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
|
||||
// hero box (ConversationRoot.module.css) so the blur scales with the input
|
||||
// card. What changed is the scroll container: `[data-conversation-scroll]`
|
||||
// scrolls vertically, and a box that scrolls in one axis computes the other
|
||||
// axis's initial `visible` to `auto`, so the bleed came back as a bar. The
|
||||
// fix states `overflow-x: hidden` there.
|
||||
//
|
||||
// Only a real engine reports that pair — the bleed and the resulting scroll
|
||||
// range — so the scenario sweeps viewport widths that bracket the glow's
|
||||
// width and asserts both at each stop. Asserting no horizontal scroll alone
|
||||
// would go vacuous the moment the glow stopped bleeding for an unrelated
|
||||
// reason, which is why each stop also records whether it bleeds; the wide stop
|
||||
// is the control where it does not.
|
||||
//
|
||||
// Zero model calls: the hero is the boot state, so nothing is seeded and no
|
||||
// replay row mounts. A stray stream would fail loud with NO_ADAPTER.
|
||||
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 {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-overflow', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the one-axis relation at every stop. It records
|
||||
* relations and booleans, never absolute coordinates: the column width follows
|
||||
* the viewport and the sidebar, and a golden carrying pixels would document the
|
||||
* platform instead of the change.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */
|
||||
const CONTROL_VIEWPORT = 600
|
||||
/**
|
||||
* Viewport widths bracketing the glow: the narrow stops retain the reported
|
||||
* bleed while the widest stop proves the relation can also be false.
|
||||
*/
|
||||
const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT]
|
||||
/** Element id of the mutation control's injected sheet, so the test can take it back out. */
|
||||
const CONTROL_STYLE_ID = 'dsh-column-overflow-control'
|
||||
/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */
|
||||
const WHEEL_DELTA = 300
|
||||
|
||||
/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */
|
||||
interface ColumnMetrics {
|
||||
/** Viewport width the stop was measured at. */
|
||||
width: number
|
||||
/** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */
|
||||
columnWidth: number
|
||||
/** Resolved `overflow-x` on the conversation scroll container. */
|
||||
overflowX: string
|
||||
/** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */
|
||||
glowBleeds: boolean
|
||||
/**
|
||||
* `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
|
||||
* `auto` both report the same value, because `hidden` clips the bleed rather
|
||||
* than reflowing it away. Recorded because it is the vacuity guard in
|
||||
* numbers — it must stay positive at the narrow stops, or the scenario has
|
||||
* stopped reproducing the situation the fix is for.
|
||||
*/
|
||||
bleedRange: number
|
||||
/** True when the column still scrolls vertically — the axis the fix must not take away. */
|
||||
scrollsVertically: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the conversation column at the page's current viewport.
|
||||
* @param page - the page under test.
|
||||
* @param width - the viewport width already applied, recorded with the reading.
|
||||
* @returns the stop's overflow relations.
|
||||
*/
|
||||
function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
|
||||
return page.evaluate((viewportWidth) => {
|
||||
const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scroller === null) throw new Error('conversation scroll container not in the DOM')
|
||||
const glow = scroller.querySelector<SVGElement>('[class*="heroGlow"]')
|
||||
if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero')
|
||||
const box = scroller.getBoundingClientRect()
|
||||
const glowBox = glow.getBoundingClientRect()
|
||||
return {
|
||||
width: viewportWidth,
|
||||
columnWidth: scroller.clientWidth,
|
||||
overflowX: getComputedStyle(scroller).overflowX,
|
||||
// `clientWidth` is the content edge, which is what the scrollable
|
||||
// overflow region is measured against; either side counts as a bleed,
|
||||
// though only the right one can produce a bar in this writing mode.
|
||||
glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5,
|
||||
bleedRange: scroller.scrollWidth - scroller.clientWidth,
|
||||
scrollsVertically: getComputedStyle(scroller).overflowY === 'auto',
|
||||
}
|
||||
}, width)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the column sideways the way a user would and report where it landed.
|
||||
*
|
||||
* This is the one signal that separates the two states, and it is why the
|
||||
* scenario needs a real engine: `overflow-x: hidden` leaves the box
|
||||
* programmatically scrollable and leaves `scrollWidth` untouched, so every
|
||||
* property reading agrees across the fix. Only refusing an actual input event
|
||||
* differs — measured at the 1200px stop, the shipped column stays at 0 while
|
||||
* the same page with `overflow-x: auto` forced on lands at its scroll boundary.
|
||||
* @param page - the page under test.
|
||||
* @returns `scrollLeft` after one horizontal wheel over the column.
|
||||
*/
|
||||
async function wheelHorizontally(page: Page): Promise<number> {
|
||||
const origin = await page.evaluate(() => {
|
||||
const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scroller === null) throw new Error('conversation scroll container not in the DOM')
|
||||
// Start from the origin so the reading is this gesture's own effect.
|
||||
scroller.scrollLeft = 0
|
||||
const box = scroller.getBoundingClientRect()
|
||||
// Near the top of the column, clear of the centered hero card: the wheel
|
||||
// must reach the column, not a nested scroller the composer owns.
|
||||
return { x: box.left + box.width / 2, y: box.top + 60 }
|
||||
})
|
||||
await page.mouse.move(origin.x, origin.y)
|
||||
await page.mouse.wheel(WHEEL_DELTA, 0)
|
||||
// A fixed settle, then two frames. Polling for a settled value cannot be
|
||||
// used here — the value under test is 0, which a poll starting at 0 accepts
|
||||
// before the gesture has had any chance to move it — so the wait is
|
||||
// generous enough to cover a smooth-scroll animation on any engine the lane
|
||||
// runs on. The timing is identical on both sides of the mutation control
|
||||
// below, which is what makes a 0 reading evidence rather than a race won.
|
||||
await page.waitForTimeout(400)
|
||||
return page.evaluate(() => new Promise<number>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve(document.querySelector<HTMLElement>('[data-conversation-scroll]')?.scrollLeft ?? -1)
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the positive horizontal scroll boundary without changing the
|
||||
* shipped overflow mode. This is distinct from `scrollWidth - clientWidth`
|
||||
* when a stable scrollbar gutter leaves part of the overflow on the negative
|
||||
* side of the scroll origin.
|
||||
* @param page - the page under test.
|
||||
* @returns the greatest positive `scrollLeft` reachable by the control gesture.
|
||||
*/
|
||||
async function horizontalScrollLimit(page: Page): Promise<number> {
|
||||
return page.evaluate((delta) => {
|
||||
const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scroller === null) throw new Error('conversation scroll container not in the DOM')
|
||||
const previousScrollBehavior = scroller.style.scrollBehavior
|
||||
scroller.style.scrollBehavior = 'auto'
|
||||
scroller.scrollLeft = delta
|
||||
const limit = scroller.scrollLeft
|
||||
scroller.scrollLeft = 0
|
||||
scroller.style.scrollBehavior = previousScrollBehavior
|
||||
return limit
|
||||
}, WHEEL_DELTA)
|
||||
}
|
||||
|
||||
/** A stop's readings plus where a horizontal wheel over it landed. */
|
||||
type ColumnStop = ColumnMetrics & {
|
||||
/** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */
|
||||
scrollLeftAfterWheel: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body: one line per stop, relations only.
|
||||
*
|
||||
* Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
|
||||
* which the fix pins to 0 by construction. The bleed is recorded as a boolean
|
||||
* rather than its width, so the golden survives any platform whose column
|
||||
* lands a pixel off — a fixture that has to be re-recorded per platform
|
||||
* documents the platform, not the change.
|
||||
* @param stops - the measured stops, in sweep order.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(stops: ColumnStop[]): string {
|
||||
return [
|
||||
'# Conversation column horizontal overflow',
|
||||
'',
|
||||
'| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} `
|
||||
+ `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: the conversation column scrolls on one axis', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* Resize to a viewport and read the column once its width stops moving.
|
||||
*
|
||||
* The glow rides the hero box, which rides the column, and the frame eases
|
||||
* its column tracks over `--ds-transition-duration-slow`: reading straight
|
||||
* after a resize can report the previous viewport's relation, or a width
|
||||
* caught mid-transition.
|
||||
* @param width - viewport width to settle at.
|
||||
* @returns the column's readings at that width.
|
||||
*/
|
||||
const settleAt = async (width: number): Promise<ColumnMetrics> => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
let previous = -1
|
||||
await expect.poll(async () => {
|
||||
const current = (await measureColumn(page, width)).columnWidth
|
||||
const settled = current === previous
|
||||
previous = current
|
||||
return settled
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
return measureColumn(page, width)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep the stops once per run and hand the SAME readings to every assertion
|
||||
* below, so the golden and the assertions describe one measurement instead of
|
||||
* two runs that could disagree. Memoized rather than re-run per test: the
|
||||
* gestures below move the viewport, and a second sweep would be a second
|
||||
* chance for a resize to settle differently.
|
||||
* @returns the stops in {@link WIDTHS} order.
|
||||
*/
|
||||
let swept: Promise<ColumnStop[]> | undefined
|
||||
const sweep = (): Promise<ColumnStop[]> => {
|
||||
swept ??= (async () => {
|
||||
const stops: ColumnStop[] = []
|
||||
for (const width of WIDTHS) {
|
||||
stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) })
|
||||
}
|
||||
return stops
|
||||
})()
|
||||
return swept
|
||||
}
|
||||
|
||||
it('never scrolls horizontally, at any width the glow bleeds past', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow'))
|
||||
const stops = await sweep()
|
||||
// The vacuity guard, in two halves: the glow has to reach past the column
|
||||
// at the narrow stops, and that reach has to still register as scrollable
|
||||
// overflow. Without both, the claim below holds for free.
|
||||
expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([
|
||||
1200, 1000, 800, CONTROL_VIEWPORT,
|
||||
])
|
||||
for (const stop of stops.filter(stop => stop.glowBleeds)) {
|
||||
expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0)
|
||||
}
|
||||
for (const stop of stops) {
|
||||
expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden')
|
||||
// The reported symptom, stated directly: a horizontal wheel over the
|
||||
// column moves nothing, at every stop.
|
||||
expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
|
||||
// The axis the column is a scroller for must survive the fix.
|
||||
expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('reports the pre-fix state when the axis is opened back up', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
|
||||
// The mutation control, run in the page rather than against a second
|
||||
// build: it restores exactly what the fix changed — the initial `visible`
|
||||
// that a one-axis scroller computes to `auto` — and shows the same gesture,
|
||||
// at the same timing, carrying the column to its positive scroll boundary.
|
||||
// Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
// Injected with an id rather than through `addStyleTag`, so the teardown
|
||||
// below can take the sheet out again by selector: it must not outlive this
|
||||
// test, or the golden ends up reading the control.
|
||||
await page.evaluate((id: string) => {
|
||||
const sheet = document.createElement('style')
|
||||
sheet.id = id
|
||||
sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }'
|
||||
document.head.append(sheet)
|
||||
}, CONTROL_STYLE_ID)
|
||||
try {
|
||||
// Resolve the mutated layout at the narrowest sweep stop. At wider stops,
|
||||
// a classic scrollbar can change the available box enough to remove the
|
||||
// overflow that the control is meant to expose.
|
||||
const before = await settleAt(CONTROL_VIEWPORT)
|
||||
expect(before.overflowX).toBe('auto')
|
||||
expect(before.bleedRange).toBeGreaterThan(0)
|
||||
const scrollLimit = await horizontalScrollLimit(page)
|
||||
// The control has a reachable horizontal range, and the gesture exceeds
|
||||
// it so the equality below proves that the wheel reached the far edge.
|
||||
expect(scrollLimit).toBeGreaterThan(0)
|
||||
expect(scrollLimit).toBeLessThan(WHEEL_DELTA)
|
||||
// Rounded: `scrollLeft` is fractional under a fractional layout while
|
||||
// the claim is that the column reached the positive boundary, not that
|
||||
// two engines agree on a sub-pixel.
|
||||
expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit))
|
||||
} finally {
|
||||
await page.evaluate((id: string) => {
|
||||
document.getElementById(id)?.remove()
|
||||
}, CONTROL_STYLE_ID)
|
||||
}
|
||||
// The override is gone and the shipped state is back: the later goldens
|
||||
// read the product, not the control.
|
||||
expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('matches the committed column-overflow golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden'))
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// No model calls, so no replay log: the golden is the whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -3,21 +3,27 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.'
|
||||
|
||||
describe('core Web profile', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: CORE_WEB_OVERLAY,
|
||||
toolsMode: 'native',
|
||||
})
|
||||
const systemPrompt = process.env.DSH_SYSTEM_PROMPT
|
||||
Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
try {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
} finally {
|
||||
if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt
|
||||
}
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
@@ -33,7 +39,16 @@ describe('core Web profile', () => {
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
|
||||
})
|
||||
|
||||
it('boots and executes both tools through the shipped Web composition', async () => {
|
||||
it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => {
|
||||
agentHandle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agentHandle.agent.whenIdle()
|
||||
|
||||
const requestHeader = agentHandle.agent.session.requestHeader()
|
||||
if (requestHeader === undefined) throw new Error('the core Web agent issued no model request')
|
||||
|
||||
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
|
||||
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
|
||||
const signal = new AbortController().signal
|
||||
@@ -60,7 +75,8 @@ describe('core Web profile', () => {
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
|
||||
prompt: requestHeader.system,
|
||||
tools: requestHeader.tools?.map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
@@ -69,16 +85,53 @@ describe('core Web profile', () => {
|
||||
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 CORE_WEB_EDITOR_OK
|
||||
2",
|
||||
"prompt": "You are a helpful software engineer assistant.",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent))
|
||||
|
||||
const entries = [...scaffold.ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined()
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
|
||||
it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => {
|
||||
const previous = process.env.DSH_SYSTEM_PROMPT
|
||||
process.env.DSH_SYSTEM_PROMPT = 'RL prompt override'
|
||||
let overrideScaffold: WebScaffold | undefined
|
||||
let overrideAgent: AgentHandle | undefined
|
||||
try {
|
||||
overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE })
|
||||
overrideAgent = await overrideScaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-override'),
|
||||
meta: { cwd: overrideScaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
overrideAgent.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: PROMPT }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await overrideAgent.agent.whenIdle()
|
||||
expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override')
|
||||
} finally {
|
||||
try {
|
||||
await overrideAgent?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
await overrideScaffold?.close()
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT')
|
||||
else process.env.DSH_SYSTEM_PROMPT = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
|
||||
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
|
||||
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
|
||||
// Post-reload golden: the same settled conversation rebuilt purely from
|
||||
// persistence + history — byte-equal rendering is exactly the recovery claim.
|
||||
@@ -83,6 +84,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
expect(Math.abs(
|
||||
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
|
||||
)).toBeLessThan(1)
|
||||
await input.fill('/cpt')
|
||||
await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([
|
||||
'compactCompact older conversation history',
|
||||
])
|
||||
const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE)
|
||||
await input.fill('')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
})
|
||||
@@ -258,7 +265,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
|
||||
'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ const CASES = [
|
||||
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
|
||||
function markdownFixture(): string {
|
||||
const session = Session.create(SessionId('markdown-cjk-strong-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }],
|
||||
@@ -75,7 +76,10 @@ function markdownFixture(): string {
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ async function stopServer(server: Server): Promise<void> {
|
||||
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
|
||||
function markdownImageFixture(remoteUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-image-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
|
||||
@@ -126,7 +127,14 @@ function markdownImageFixture(remoteUrl: string): string {
|
||||
}
|
||||
return [
|
||||
JSON.stringify(header),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
// Spaced event times, exactly as the sibling markdown fixtures pin them:
|
||||
// the stats line renders its LLM segment only while the step's measured
|
||||
// milliseconds exceed zero, so a fixture that leaves the times unset lets
|
||||
// the replay's own speed decide whether the golden matches.
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const DONE = 'INLINE_CODE_LINK_DONE'
|
||||
/** Build a settled assistant reply with linkable URL code and inert code controls. */
|
||||
function markdownFixture(linkUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-inline-code-links-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the local preview URL.' }],
|
||||
@@ -72,7 +73,10 @@ function markdownFixture(linkUrl: string): string {
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -161,6 +161,58 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the
|
||||
# in-browser directory picker. The shipped row is `-auto`, which resolves to
|
||||
# the native OS chooser on a loopback bind with a local display — an
|
||||
# interaction a Playwright page cannot drive, so the resolved backend would
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Shared scaffold for the keyless browser e2e lane (Agent Note:
|
||||
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
// Boots the REAL web composition — the shipped base plus web overlay through
|
||||
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
|
||||
// Boots the REAL web composition — the dsh-base and dsh-web-app bundle
|
||||
// patches over the empty profile root through the vendored Loader (the same
|
||||
// layer stack the profile boot composes), patched the
|
||||
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
|
||||
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: normally disables the llm-deepseek row and
|
||||
@@ -11,7 +12,7 @@
|
||||
// masking its credential, without making a model call.
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches after the shipped surface overlay, over the SAME tree (never a
|
||||
// patches after the shipped bundle layers, 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);
|
||||
@@ -22,9 +23,9 @@
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Page } from 'playwright'
|
||||
import { expect } from 'vitest'
|
||||
@@ -32,7 +33,13 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
addHarnessSourceSection,
|
||||
assertEntriesLoaded,
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
loadOverlayPatches,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
@@ -53,8 +60,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
|
||||
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
|
||||
import { REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
|
||||
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
|
||||
@@ -70,9 +76,11 @@ export function webSnapshotMode(): WebSnapshotMode {
|
||||
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/** The shipped composition under test: apps/cli's shared base and web overlay. */
|
||||
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml')
|
||||
const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml')
|
||||
/** The shipped composition under test: the dsh-base and dsh-web-app bundle patches over the empty profile root. */
|
||||
const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the profile module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
@@ -117,7 +125,7 @@ export interface WebScaffold {
|
||||
export interface LaunchOptions {
|
||||
/**
|
||||
* Optional product overlay applied after the shipped Web surface and before
|
||||
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
|
||||
* the scaffold's hermetic test patches, matching the launcher's `--patch`
|
||||
* ordering.
|
||||
*/
|
||||
extraOverlayPath?: string
|
||||
@@ -240,14 +248,22 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
}
|
||||
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
|
||||
// The include patch set — the same mechanism AppCLIEntry and the ACP
|
||||
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
|
||||
// stops matching a row fails the boot sweep loudly instead of drifting).
|
||||
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
|
||||
// The include patch set — the same layer stack the profile boot composes
|
||||
// (bundle patches in dsh.profile.bundles order), applied over the SAME empty root (a
|
||||
// patch id that stops matching a row fails the boot sweep loudly instead of
|
||||
// drifting).
|
||||
const basePatches = loadOverlayPatches('web e2e scaffold', BASE_PATCH_PATH)
|
||||
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_PATCH_PATH)
|
||||
const extraOverlayPatches = options.extraOverlayPath === undefined
|
||||
? []
|
||||
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
|
||||
const composedRows = composeEntries([basePatches, surfacePatches, extraOverlayPatches])
|
||||
const webRuntimeConfig = composedRows.find(row => row.id === 'web-runtime')?.config as {
|
||||
surfaceContext?: boolean
|
||||
} | undefined
|
||||
const surfaceContext = webRuntimeConfig?.surfaceContext !== false
|
||||
const patches: PatchOptions[] = [
|
||||
...basePatches,
|
||||
...surfacePatches,
|
||||
...extraOverlayPatches,
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
@@ -280,8 +296,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
|
||||
config: { host: '127.0.0.1', port: 0 },
|
||||
},
|
||||
// The bundle's web-runtime row resolves the same built dist under test
|
||||
// (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
|
||||
// Preserve the composed surface-context choice because a patch replaces
|
||||
// the row's complete config.
|
||||
{ id: 'web-runtime', config: { mode: 'production', printUrl: false, surfaceContext } },
|
||||
...options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
@@ -321,7 +342,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
let replayHandle: ReplayHandle | undefined
|
||||
try {
|
||||
process.chdir(workspaceCwd)
|
||||
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
|
||||
// The production resolution shape: an empty profile root inside the temp
|
||||
// harness home, with bare plugin names resolving through the flat module
|
||||
// fallback the launcher heals under <home>/profiles.
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
|
||||
const profileDir = join(harnessHome, 'profiles', 'scaffold')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
ctx.baseUrl = pathToFileURL(profileDir).href + '/'
|
||||
// This direct Loader harness supplies the same root-path capability as app-boot.
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
@@ -329,10 +358,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
|
||||
if (surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) })
|
||||
}
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
|
||||
config: { path: pathToFileURL(rootConfig).href, patches },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
|
||||
@@ -487,7 +487,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
|
||||
// Pin the in-browser picker: the shipped `-auto` row would resolve to
|
||||
// the native OS chooser on this bind, and no page can drive that.
|
||||
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Wide viewport (1680px, card at its cap)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## Narrow viewport (800px, card shrinking with the column)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
## Wide viewport, reservation removed in the page (control)
|
||||
|
||||
- Chat: scrollbar-gutter auto, overflow auto/auto
|
||||
- Chat: scrollbar-gutter auto, overflow hidden/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Conversation column horizontal overflow
|
||||
|
||||
| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1680px | hidden | false | 0px | true |
|
||||
| 1200px | hidden | true | 0px | true |
|
||||
| 1000px | hidden | true | 0px | true |
|
||||
| 800px | hidden | true | 0px | true |
|
||||
| 600px | hidden | true | 0px | true |
|
||||
7
apps/web/tests/snapshots/core-web-profile/session.jsonl
Normal file
7
apps/web/tests/snapshots/core-web-profile/session.jsonl
Normal file
@@ -0,0 +1,7 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
@@ -0,0 +1,3 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "compact Compact older conversation history" [selected]
|
||||
@@ -20,7 +20,7 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building
|
||||
- text: Let's start building Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building
|
||||
- text: Let's start building Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
|
||||
@@ -49,4 +49,4 @@
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -28,4 +28,4 @@
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -40,4 +40,4 @@
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -21,3 +21,6 @@
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
|
||||
@@ -69,3 +69,6 @@
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// assembled application can show is that the path a user actually takes
|
||||
// reaches it: the real selection service, the real client session opening over
|
||||
// the real /api transport, and a real browser deciding what is painted.
|
||||
// The initial Workspace pick also records the resident Hero/composer nodes and
|
||||
// proves that opening the first blank Session fills the strict outlets without
|
||||
// replacing those nodes.
|
||||
//
|
||||
// The round-trip against a loopback host is far too fast to observe, so this
|
||||
// scenario HOLDS the `session.history` response open at the browser's network
|
||||
@@ -55,9 +58,6 @@ describe('web e2e: startup auto-selection', () => {
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// A registered workspace is the precondition for auto-selection: the first
|
||||
// load has nothing to select, so the reload below is the path under test.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -65,6 +65,48 @@ describe('web e2e: startup auto-selection', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
|
||||
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
|
||||
await page.evaluate(() => {
|
||||
const refs = {
|
||||
root: document.querySelector('div[data-phase="hero"]'),
|
||||
workspaceChip: document.querySelector('[aria-label="Choose workspace"]'),
|
||||
scrollBody: document.querySelector('[data-conversation-scroll]'),
|
||||
composerSeat: document.querySelector('[data-composer-seat]'),
|
||||
textarea: document.querySelector('textarea'),
|
||||
}
|
||||
if (Object.values(refs).some(node => node === null)) throw new Error('incomplete initial Hero tree')
|
||||
;(window as unknown as { __heroTree: typeof refs }).__heroTree = refs
|
||||
})
|
||||
|
||||
// A registered Workspace is the precondition for the reload case below;
|
||||
// this first connection is also the no-Workspace → Workspace path.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'startup-auto-selection')
|
||||
|
||||
expect(await page.evaluate(() => {
|
||||
const before = (window as unknown as { __heroTree: Record<string, Element> }).__heroTree
|
||||
return {
|
||||
phase: document.querySelector('div[data-phase]')?.getAttribute('data-phase'),
|
||||
root: document.querySelector('div[data-phase="hero"]') === before.root,
|
||||
workspaceChip: document.querySelector('[aria-label="Choose workspace"]') === before.workspaceChip,
|
||||
scrollBody: document.querySelector('[data-conversation-scroll]') === before.scrollBody,
|
||||
composerSeat: document.querySelector('[data-composer-seat]') === before.composerSeat,
|
||||
textarea: document.querySelector('textarea') === before.textarea,
|
||||
textareaEnabled: !(document.querySelector('textarea') as HTMLTextAreaElement).disabled,
|
||||
}
|
||||
})).toEqual({
|
||||
phase: 'hero',
|
||||
root: true,
|
||||
workspaceChip: true,
|
||||
scrollBody: true,
|
||||
composerSeat: true,
|
||||
textarea: true,
|
||||
textareaEnabled: true,
|
||||
})
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it('keeps the hero and the composer on screen while the auto-selected blank session opens', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-startup-auto-selection'))
|
||||
// Runs before any page script on the reload below, so the first phase the
|
||||
|
||||
Reference in New Issue
Block a user