|
|
@@ -10,10 +10,11 @@
|
|
|
import { readFile } from 'node:fs/promises'
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
import { join } from 'node:path'
|
|
|
-import type { Browser, Page } from 'playwright'
|
|
|
+import type { Browser, Locator, Page } from 'playwright'
|
|
|
import { chromium } from 'playwright'
|
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
import {
|
|
|
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
|
|
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
|
|
@@ -30,6 +31,31 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
|
|
|
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
|
|
const MODE = webSnapshotMode()
|
|
|
|
|
|
+// The composer's own growth cap, in text lines (QuestionComposer.module.css
|
|
|
+// .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
|
|
|
+// carry different padding, and a cap measured in border-box pixels silently
|
|
|
+// means a different line count in each — which is exactly how the optionless
|
|
|
+// field came to stop two thirds of a line short.
|
|
|
+const CAP_LINES = 6
|
|
|
+
|
|
|
+/**
|
|
|
+ * Measure a saturated answer field: how many whole text lines it grew to, and
|
|
|
+ * whether it took over the scrolling once it stopped growing.
|
|
|
+ * @param field - the composer's custom-answer textarea.
|
|
|
+ * @returns whole text lines the content box holds, and whether the field scrolls.
|
|
|
+ */
|
|
|
+async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
|
|
|
+ await field.fill('x\n'.repeat(40))
|
|
|
+ return field.evaluate((el: HTMLTextAreaElement) => {
|
|
|
+ const style = getComputedStyle(el)
|
|
|
+ const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
|
|
+ return {
|
|
|
+ textLines: Math.round(text / parseFloat(style.lineHeight)),
|
|
|
+ scrolls: el.scrollHeight > el.clientHeight,
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
// The options carry long descriptions on purpose: the squeeze assertion below
|
|
|
// needs option copy that WRAPS, which is the only text layout that reproduces a
|
|
|
// collapsed row painting its copy outside its own box.
|
|
|
@@ -41,6 +67,7 @@ describe('web e2e: resident question composer round trip', () => {
|
|
|
let page: Page
|
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
const sessionEvents: SessionEvent[] = []
|
|
|
+ let answeredSession: SessionId | undefined
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
|
|
@@ -141,13 +168,13 @@ describe('web e2e: resident question composer round trip', () => {
|
|
|
// the squeeze above: record mode must reach the recording write.
|
|
|
const custom = composer.getByRole('textbox')
|
|
|
if (MODE !== 'record') {
|
|
|
- const lineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
|
|
|
+ const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
|
|
|
await custom.fill('a'.repeat(120))
|
|
|
const wrapped = await custom.evaluate(el => ({
|
|
|
height: el.getBoundingClientRect().height,
|
|
|
scrolls: el.scrollHeight > el.clientHeight,
|
|
|
}))
|
|
|
- expect(wrapped.height).toBeGreaterThan(lineHeight * 1.5)
|
|
|
+ expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
|
|
|
expect(wrapped.scrolls).toBe(false)
|
|
|
|
|
|
await custom.fill('')
|
|
|
@@ -156,10 +183,9 @@ describe('web e2e: resident question composer round trip', () => {
|
|
|
expect(await custom.inputValue()).toBe('\n\n')
|
|
|
expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
|
|
|
expect(await custom.evaluate(el => el.getBoundingClientRect().height))
|
|
|
- .toBeGreaterThan(lineHeight * 2.5)
|
|
|
+ .toBeGreaterThan(oneLineHeight * 2.5)
|
|
|
|
|
|
- await custom.fill('x\n'.repeat(40))
|
|
|
- expect(await custom.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
|
|
|
+ expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
|
|
|
await custom.fill('')
|
|
|
}
|
|
|
|
|
|
@@ -179,6 +205,7 @@ describe('web e2e: resident question composer round trip', () => {
|
|
|
await recordFixture(scaffold, sessionId, FIXTURE)
|
|
|
return
|
|
|
}
|
|
|
+ answeredSession = sessionId
|
|
|
// World state: the tool result carries the chosen answer, and DONE lands.
|
|
|
const results = sessionEvents.filter(e => e.type === 'tool/result')
|
|
|
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
|
|
|
@@ -202,6 +229,47 @@ describe('web e2e: resident question composer round trip', () => {
|
|
|
expect(tripwire.warnings).toEqual([])
|
|
|
}, 200_000)
|
|
|
|
|
|
+ // The fixture's question carries options, so the round trip above only ever
|
|
|
+ // exercises the inline shape. The optionless shape is the one that carries
|
|
|
+ // padding, which is where a cap measured in box pixels drifts off the line
|
|
|
+ // count — so it is asked straight through the user-questions seam (the same
|
|
|
+ // service the tool calls; no model round is involved in a layout metric).
|
|
|
+ it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
|
|
|
+ const sessionId = answeredSession
|
|
|
+ expect(sessionId).toBeDefined()
|
|
|
+ const agent = scaffold.ctx.agents.get(sessionId as SessionId)
|
|
|
+ expect(agent).toBeDefined()
|
|
|
+ const asked = scaffold.ctx.userQuestions.ask({
|
|
|
+ agent: agent as NonNullable<typeof agent>,
|
|
|
+ questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
|
|
|
+ })
|
|
|
+
|
|
|
+ const composer = page.locator('[data-question-key]')
|
|
|
+ await composer.waitFor({ timeout: 30_000 })
|
|
|
+ const field = composer.getByRole('textbox')
|
|
|
+ // The empty field reserves its two lines AND the textarea fills that frame:
|
|
|
+ // a reserved box the control does not fill leaves a strip that looks like
|
|
|
+ // the field but takes no click.
|
|
|
+ expect(await field.evaluate((el) => {
|
|
|
+ const frame = el.parentElement as HTMLElement
|
|
|
+ const style = getComputedStyle(frame)
|
|
|
+ const inner = frame.getBoundingClientRect().height
|
|
|
+ - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
|
|
|
+ return {
|
|
|
+ reserved: Math.round(frame.getBoundingClientRect().height),
|
|
|
+ fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
|
|
|
+ }
|
|
|
+ })).toEqual({ reserved: 64, fills: true })
|
|
|
+ // The same cap the inline shape stops at — the assertion a border-box cap fails.
|
|
|
+ expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
|
|
|
+
|
|
|
+ // Settle the wait so teardown is not racing a pending question.
|
|
|
+ await composer.getByRole('button', { name: 'Skip this question' }).click()
|
|
|
+ expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
|
|
|
+ await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
|
|
+ }, 60_000)
|
|
|
+
|
|
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
|
|
await assertFixtureInventory(SNAPSHOT_DIR, [
|
|
|
'session.jsonl',
|