opencode/packages/app/component-tests/session-question-dock.spec.ts

1591 lines
73 KiB
TypeScript

import type { Locator } from "@playwright/test"
import type { MotionKnob } from "@opencode/ui/motion-tuning"
import { expect, sourceURL, story } from "../../stories/playwright/story"
for (const variant of ["classic", "experimental"] as const) {
story(`${variant} answer cards allow text selection without changing answers`, async ({ mount, page }) => {
await page.addInitScript(
(enabled) => localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: enabled } })),
variant === "experimental",
)
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const wide = question.getByRole("radio", { name: /Wide/ })
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
const drag = async (text: Locator) => {
await text.scrollIntoViewIfNeeded()
const box = await text.boundingBox()
expect(box).not.toBeNull()
await page.mouse.move(box!.x + 1, box!.y + box!.height / 2)
await page.mouse.down()
await page.mouse.move(box!.x + box!.width - 1, box!.y + box!.height / 2, { steps: 12 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(await text.innerText())
}
for (const slot of ["option-label", "option-description"]) {
await drag(wide.locator(`[data-slot="${slot}"] bdi`))
await expect(wide).not.toBeChecked()
}
await wide.locator('[data-slot="option-description"] bdi').click({ modifiers: ["Shift"] })
await expect(wide).not.toBeChecked()
await wide.click({ position: { x: 4, y: 4 } })
await expect(wide).toBeChecked()
await question.getByRole("radio", { name: /Focused/ }).click()
await drag(wide.locator('[data-slot="option-description"] bdi'))
await expect(wide).not.toBeChecked()
const mark = await wide.locator('[data-slot="question-option-check"]').boundingBox()
expect(mark).not.toBeNull()
await page.mouse.move(mark!.x + mark!.width / 2, mark!.y + mark!.height / 2)
await page.mouse.down()
await page.mouse.move(mark!.x + mark!.width / 2 + 1, mark!.y + mark!.height / 2)
await page.mouse.up()
await expect(wide).toBeChecked()
await question.getByRole("button", { name: "Next", exact: true }).click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
await expect(files).toBeFocused()
await files.click()
await expect(files).toBeChecked()
await drag(files.locator('[data-slot="option-label"] bdi'))
await expect(files).toBeChecked()
await files.click({ position: { x: 4, y: 4 } })
await expect(files).not.toBeChecked()
})
}
story(
"uses the original question cards by default and submits selected and custom answers",
async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
await expect(focused).toBeFocused()
const shortcuts = question.locator('[data-slot="question-option-shortcut"] [data-slot="keybind-v2-label"]')
await expect(shortcuts).toHaveText(["1", "2", "3"])
await expect(question.getByText("Select one answer", { exact: true })).toBeVisible()
await page.keyboard.press("ArrowRight")
await expect(wide).toBeFocused()
await page.keyboard.press(" ")
await expect(wide).toBeChecked()
await expect(shortcuts).toHaveText(["1", "2", "3"])
await expect(focused).not.toBeChecked()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
const next = question.getByRole("button", { name: "Next", exact: true })
const width = await next.evaluate((button) => button.getBoundingClientRect().width)
await next.click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const custom = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
await expect(files).toBeFocused()
const submit = question.getByRole("button", { name: "Submit", exact: true })
expect(await submit.evaluate((button) => button.getBoundingClientRect().width)).toBe(width)
await expect(shortcuts).toHaveText(["1", "2", "3", "4"])
await page.keyboard.down(" ")
await page.keyboard.down(" ")
await page.keyboard.up(" ")
await terminal.click()
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await expect(shortcuts).toHaveText(["1", "2", "3", "4"])
await terminal.click()
await expect(terminal).not.toBeChecked()
await terminal.click()
await input.fill("Diagnostics")
await expect(custom).toBeChecked()
await input.press("Enter")
await expect(custom).toBeFocused()
await expect(input).toHaveValue("Diagnostics")
await question.getByRole("button", { name: "Back", exact: true }).click()
await expect(wide).toBeChecked()
await next.click()
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await expect(custom).toBeChecked()
await submit.click()
await expect(question).toBeHidden()
},
)
story("keeps classic long answer lists inside the panel and scrolls them", async ({ mount, page }) => {
const component = await mount("session-question-switch--long-list")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const first = question.getByRole("checkbox", { name: /All typography/ })
await expect(first).toBeFocused()
await page.evaluate(() => document.fonts.ready.then(() => undefined))
await expect
.poll(() =>
question.evaluate((root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const box = root.getBoundingClientRect()
return { fits: box.top >= 0 && box.bottom <= innerHeight, scrolls: options.scrollHeight > options.clientHeight }
}),
)
.toEqual({ fits: true, scrolls: true })
await first.press("End")
await expect(question.getByRole("checkbox", { name: "Type your own answer…", exact: true })).toBeFocused()
await expect(question.locator('[data-slot="question-option"][data-custom="true"]')).toBeInViewport({ ratio: 1 })
await question.getByRole("button", { name: "Next", exact: true }).click()
await expect(question.getByRole("radio", { name: "One pull request", exact: true })).toBeFocused()
})
story.describe("on a 393x852 mobile viewport", () => {
story.use({ hasTouch: true, isMobile: true, viewport: { width: 393, height: 852 } })
for (const experimental of [false, true]) {
story(
`keeps navigation visible and scrolls long prompts and 12 options (${experimental ? "experimental" : "classic"})`,
async ({ mount, page }) => {
if (experimental) {
await page.addInitScript(() =>
localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } })),
)
}
const component = await mount("session-question-switch--long-prompt")
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
await page.evaluate(() => document.fonts.ready.then(() => undefined))
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const dismiss = question.getByRole("button", { name: "Dismiss", exact: true })
const next = question.getByRole("button", { name: "Next", exact: true })
await expect(dismiss).toBeInViewport({ ratio: 1 })
await expect(next).toBeInViewport({ ratio: 1 })
const twelfth = question.getByRole("radio", { name: /12\. Clean slate rebuild/ })
await twelfth.scrollIntoViewIfNeeded()
await expect(twelfth).toBeInViewport({ ratio: 1 })
await twelfth.tap()
await expect(twelfth).toBeChecked()
await expect(next).toBeInViewport({ ratio: 1 })
await next.tap()
const back = question.getByRole("button", { name: "Back", exact: true })
const submit = question.getByRole("button", { name: "Submit", exact: true })
await expect(back).toBeInViewport({ ratio: 1 })
await expect(submit).toBeInViewport({ ratio: 1 })
const detailsSlot = experimental ? '[data-slot="question-details-frame"]' : '[data-slot="question-details"]'
const layout = await question.evaluate((root, selector) => {
const details = root.querySelector<HTMLElement>(selector)!
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const footer = root.querySelector<HTMLElement>('[data-slot="question-footer"]')!
const detailsRect = details.getBoundingClientRect()
const optionsRect = options.getBoundingClientRect()
const footerRect = footer.getBoundingClientRect()
details.scrollTop = details.scrollHeight
const scrolledPrompt = details.scrollTop > 0
return {
promptScrollHeight: details.scrollHeight,
promptClientHeight: details.clientHeight,
scrolledPrompt,
optionsHeight: optionsRect.height,
optionsBelowPrompt: optionsRect.top >= detailsRect.bottom - 0.5,
footerBelowOptions: footerRect.top >= optionsRect.bottom - 9.5,
dockFitsViewport: root.getBoundingClientRect().bottom <= innerHeight + 0.5,
}
}, detailsSlot)
expect(layout.promptScrollHeight).toBeGreaterThan(852)
expect(layout.promptClientHeight).toBeGreaterThan(48)
expect(layout.promptScrollHeight).toBeGreaterThan(layout.promptClientHeight)
expect(layout.scrolledPrompt).toBe(true)
expect(layout.optionsHeight).toBeGreaterThan(80)
expect(layout.optionsBelowPrompt).toBe(true)
expect(layout.footerBelowOptions).toBe(true)
expect(layout.dockFitsViewport).toBe(true)
const lastOption = question.getByRole("radio", { name: /Export a diagnostic bundle/ })
await lastOption.scrollIntoViewIfNeeded()
await expect(lastOption).toBeInViewport({ ratio: 1 })
await lastOption.tap()
await expect(lastOption).toBeChecked()
await expect(submit).toBeInViewport({ ratio: 1 })
await submit.tap()
await expect(question).toBeHidden()
},
)
}
})
story(
"minimizes and restores classic questions immediately for reduced motion and keyboard actions",
async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await question.getByRole("button", { name: "Minimize question", exact: true }).press("Enter")
expect(
await question
.locator('[data-slot="question-options"]')
.evaluate((options) => options.getBoundingClientRect().height),
).toBe(0)
await question.getByRole("button", { name: "Restore question", exact: true }).press("Enter")
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await page.emulateMedia({ reducedMotion: "reduce" })
await expect(page.locator("html")).toHaveAttribute("data-motion", "reduce")
await question.getByRole("radio", { name: /Focused/ }).click()
const heights = await question.evaluate((root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const before = options.getBoundingClientRect().height
root.querySelector<HTMLButtonElement>('button[aria-label="Minimize question"]')!.click()
const minimized = options.getBoundingClientRect().height
root.querySelector<HTMLButtonElement>('button[aria-label="Restore question"]')!.click()
return { before, minimized, restored: options.getBoundingClientRect().height }
})
expect(heights.before).toBeGreaterThan(0)
expect(heights.minimized).toBe(0)
expect(heights.restored).toBe(heights.before)
},
)
story(
"opts into the new question UI in Experiments without losing answers and remembers the setting",
async ({ mount, page }) => {
const component = await mount("session-question-switch--experiments")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const toggle = component.getByRole("switch", { name: "New question UI", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await expect(toggle).not.toBeChecked()
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await input.fill("Split view")
await input.press("Enter")
await expect(question.getByRole("radio", { name: "Type your own answer…", exact: true })).toBeFocused()
await toggle.press(" ")
await expect(question).toHaveAttribute("data-variant", "experimental")
await expect(question.locator('[data-slot="question-option-key"]')).toHaveText(["1", "2", "3"])
await expect(input).toHaveValue("Split view")
await expect(question.getByRole("radio", { name: "Type your own answer…", exact: true })).toBeChecked()
await page.reload()
await expect(toggle).toBeChecked()
await expect(question).toHaveAttribute("data-variant", "experimental")
await question.getByRole("radio", { name: /Wide/ }).click()
await toggle.press(" ")
await expect(question.locator('[data-slot="question-option-box"]')).toHaveCount(3)
await expect(question.getByRole("radio", { name: /Wide/ })).toBeChecked()
await page.reload()
await expect(toggle).not.toBeChecked()
await expect(question.locator('[data-slot="question-option-box"]')).toHaveCount(3)
},
)
story.describe("with the new question UI experiment enabled", () => {
story.beforeEach(({ page }) =>
page.addInitScript(() => localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } }))),
)
story("resizes the dock to fit different answer counts", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-answer-counts")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const next = question.getByRole("button", { name: "Next", exact: true })
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await expect(next).toBeEnabled()
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
const initial = await height()
await next.click()
await expect.poll(height).toBeGreaterThan(initial)
await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible()
const expanded = await height()
const back = question.getByRole("button", { name: "Back", exact: true })
await expect(back).toBeEnabled()
await back.click()
await expect.poll(height).toBeLessThan(expanded)
await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible()
})
story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const next = question.getByRole("button", { name: "Next", exact: true })
await expect(focused).toBeFocused()
await focused.press("2")
await expect(wide).toBeChecked()
await expect(focused).not.toBeChecked()
await expect(wide).toBeFocused()
await next.press("1")
await expect(focused).toBeChecked()
await expect(wide).not.toBeChecked()
await focused.press("3")
await expect(customRadio).toBeChecked()
await expect(input).toBeFocused()
await expect(focused).not.toBeChecked()
await expect(wide).not.toBeChecked()
await input.press("Escape")
await expect(customRadio).toBeFocused()
await customRadio.press("2")
await expect(wide).toBeChecked()
await expect(customRadio).not.toBeChecked()
await next.click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
await expect(files).toBeFocused()
await files.press("1")
await expect(files).toBeChecked()
await files.press("2")
await expect(terminal).toBeChecked()
await expect(files).toBeChecked()
await terminal.press("2")
await expect(terminal).not.toBeChecked()
await expect(files).toBeChecked()
await terminal.press("4")
await expect(customCheckbox).toBeChecked()
await expect(input).toBeFocused()
await expect(files).toBeChecked()
await expect(terminal).not.toBeChecked()
await input.press("Escape")
await expect(customCheckbox).toBeFocused()
await customCheckbox.press("4")
await expect(customCheckbox).not.toBeChecked()
await expect(files).toBeChecked()
await expect(terminal).not.toBeChecked()
})
story("moves only the highlight with Up and Down and marks it with Space", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const highlight = question.locator('[data-slot="question-highlight"]')
const clip = () => highlight.evaluate((element) => element.style.clipPath)
await expect(focused).toBeFocused()
await expect(focused).not.toBeChecked()
const first = await clip()
await page.keyboard.press("ArrowDown")
await expect(wide).toBeFocused()
await expect(wide).not.toBeChecked()
await expect(focused).not.toBeChecked()
await expect(wide).toHaveAttribute("tabindex", "0")
await expect(focused).toHaveAttribute("tabindex", "-1")
await expect.poll(clip).not.toBe(first)
await page.keyboard.press("ArrowDown")
await expect(custom).toBeFocused()
await expect(custom).not.toBeChecked()
await expect(input).not.toBeFocused()
await page.keyboard.press("ArrowUp")
await expect(wide).toBeFocused()
await page.keyboard.press(" ")
await expect(wide).toBeChecked()
await expect(wide).toBeFocused()
await expect(question.getByRole("button", { name: "Next", exact: true })).toBeVisible()
await page.keyboard.press("Home")
await expect(focused).toBeFocused()
await expect(focused).not.toBeChecked()
await expect(wide).toBeChecked()
})
story("shows the highlight only for the arrow keys, never for hover or a click", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
const highlight = question.locator('[data-slot="question-highlight"]')
const clip = () => highlight.evaluate((element) => element.style.clipPath)
await expect(focused).toBeFocused()
await expect(highlight).toBeHidden()
await wide.dispatchEvent("click")
await expect(wide).toBeChecked()
await expect(highlight).toBeHidden()
await page.keyboard.press("1")
await expect(highlight).toBeHidden()
await wide.hover()
await expect(highlight).toBeHidden()
await expect(focused).toBeFocused()
await wide.click()
await expect(wide).toBeChecked()
await expect(highlight).toBeHidden()
await page.mouse.move(0, 0)
await expect(highlight).toBeHidden()
await focused.click()
await page.mouse.move(0, 0)
await expect(focused).toBeChecked()
await expect(highlight).toBeHidden()
await page.keyboard.press("ArrowDown")
await expect(wide).toBeFocused()
await expect(highlight).toBeVisible()
const keyboard = await clip()
await page.mouse.move(0, 0)
await expect(highlight).toBeVisible()
await focused.hover()
await expect(wide).toBeFocused()
await expect(highlight).toBeVisible()
expect(await clip()).toBe(keyboard)
await question.getByRole("textbox", { name: "Type your own answer…", exact: true }).hover()
await expect(wide).toBeFocused()
expect(await clip()).toBe(keyboard)
await page.mouse.move(0, 0)
await expect(highlight).toBeVisible()
})
story("Enter picks the highlighted answer and moves on", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
await expect(focused).toBeFocused()
await page.keyboard.press("ArrowDown")
await page.keyboard.press("Enter")
await expect(files).toBeFocused()
await expect(files).not.toBeChecked()
await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible()
await page.keyboard.press("ArrowLeft")
await expect(wide).toBeFocused()
await expect(wide).toBeChecked()
await expect(focused).not.toBeChecked()
await page.keyboard.press("ArrowRight")
await page.keyboard.press("ArrowDown")
await page.keyboard.press(" ")
await expect(terminal).toBeChecked()
await expect(files).not.toBeChecked()
await page.keyboard.press("Enter")
await expect(question).toBeHidden()
})
story("typing on the highlighted custom answer starts it", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await expect(focused).toBeFocused()
await focused.press("1")
await expect(focused).toBeChecked()
await page.keyboard.press("End")
await expect(custom).toBeFocused()
await expect(custom).not.toBeChecked()
await page.keyboard.press("S")
await expect(input).toBeFocused()
await expect(custom).toBeChecked()
await expect(focused).not.toBeChecked()
await page.keyboard.type("plit")
await expect(input).toHaveValue("Split")
await page.keyboard.press("Enter")
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused()
await page.keyboard.press("ArrowLeft")
await expect(custom).toBeChecked()
await expect(input).toHaveValue("Split")
})
story("switches questions with Left and Right outside the text field", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const submit = question.getByRole("button", { name: "Submit", exact: true })
const wide = question.getByRole("radio", { name: /Wide/ })
await expect(focused).toBeFocused()
await page.keyboard.press("ArrowDown")
await page.keyboard.press(" ")
await expect(wide).toBeChecked()
await page.keyboard.press("ArrowRight")
await expect(files).toBeFocused()
await expect(submit).toBeVisible()
await page.keyboard.press("ArrowRight")
await expect(submit).toBeVisible()
await expect(files).toBeFocused()
await page.keyboard.press("ArrowLeft")
await expect(wide).toBeFocused()
await expect(wide).toBeChecked()
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await input.focus()
await input.fill("ab")
await page.keyboard.press("ArrowLeft")
await expect(input).toBeFocused()
await expect(focused).toBeVisible()
})
// Presses each key after waiting its number of frames, and samples every frame until the switch is at rest: how visible
// the copies of the question text and of the answers are together, how many outgoing copies there are, how the live
// question text is moved, scaled and blurred, how far the count's last digit is rolled (in digit heights, down
// positive), where the progress row's bar is, and the footer: how visible Back is and whether a click at its middle
// reaches it, how visible the Next and Submit faces are, how visible the fainter of their two words is, and how wide
// the button they share shows. It also keeps the header's text and, for the count's last digit, each digit drawn and
// how visible it is.
const play = (question: Locator, keys: { key: string; after?: number }[]) =>
question.evaluate(async (root, keys) => {
const opacity = (el: Element) => {
let value = 1
for (let node: Element | null = el; node && node !== root; node = node.parentElement)
value *= Number(getComputedStyle(node).opacity)
return value
}
const shown = (slot: string) =>
[...root.querySelectorAll(`[data-slot="${slot}"]`)].reduce((sum, el) => sum + opacity(el), 0)
const live = () => root.querySelector('[data-slot="question-details"]:not([data-ghost])')
const mask = () =>
["question-details-frame", "question-options"].some((slot) =>
(root.querySelector<HTMLElement>(`[data-slot="${slot}"]`)?.style.maskImage ?? "").includes("to right"),
)
const glyphs = () =>
[
...root.querySelectorAll<HTMLElement>(
'[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]',
),
].flatMap((el) => {
const text = el.dataset.leaving ?? el.textContent ?? ""
return text === "" ? [] : [{ text, shown: opacity(el) }]
})
const roll = () => {
const glyph = root.querySelector(
'[data-component="rolling-number"] [data-slot="rolling-number-digit"]:last-child [data-slot="rolling-number-glyph"]:not([data-leaving])',
)
if (!(glyph instanceof HTMLElement)) return 0
return (
new DOMMatrix(getComputedStyle(glyph).transform === "none" ? undefined : getComputedStyle(glyph).transform)
.m42 / glyph.getBoundingClientRect().height
)
}
const bar = () => {
const current = root.querySelector('[data-slot="question-progress-current"]')
return current ? current.getBoundingClientRect().left : 0
}
const back = root.querySelector<HTMLElement>('[data-slot="question-footer-back"]')!
const faces = [...root.querySelectorAll<HTMLElement>('[data-slot="question-advance-face"]')]
const hit = () => {
const box = back.getBoundingClientRect()
return back.contains(document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2))
}
// The faces are cut back from their start edge with `inset(top right bottom left)`.
const width = () =>
Math.max(
...faces.map((face) => {
if (opacity(face) < 0.01 || getComputedStyle(face).visibility === "hidden") return 0
const inset = /inset\(([^)]*)\)/.exec(face.style.clipPath)?.[1]?.split(/\s+/).map(parseFloat) ?? []
return face.getBoundingClientRect().width - (inset[1] ?? 0) - (inset[3] ?? 0)
}),
)
// `click:N` clicks the Nth progress dash, `next` clicks the Next or Submit button in use, and `mod+Enter` presses the
// submit shortcut.
const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
const press = (key: string) => {
if (key === "next")
return root
.querySelector<HTMLElement>('[data-slot="question-advance-face"][data-active="true"] button')
?.click()
if (key === "mod+Enter")
return (document.activeElement ?? root).dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
metaKey: mac,
ctrlKey: !mac,
bubbles: true,
cancelable: true,
}),
)
const dash = /^click:(\d+)$/.exec(key)?.[1]
if (dash)
return root.querySelectorAll<HTMLElement>('[data-slot="question-progress-segment"]')[Number(dash)]?.click()
return (document.activeElement ?? root).dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
)
}
const frames: {
text: number
answers: number
ghosts: number
x: number
scale: number
blur: boolean
mask: boolean
digit: number
bar: number
back: number
hit: boolean
next: number
submit: number
width: number
words: number
highlights: number
footer: boolean
animations: number
title: string
glyphs: { text: string; shown: number }[]
}[] = []
const queue = [...keys]
let wait = 0
let still = 0
await new Promise<void>((resolve) => {
const tick = () => {
while (queue.length > 0 && wait <= 0) {
const next = queue.shift()!
press(next.key)
wait = queue[0]?.after ?? 0
}
wait--
const el = live()
const style = el ? getComputedStyle(el) : undefined
const matrix = new DOMMatrix(style?.transform === "none" ? undefined : style?.transform)
const frame = {
text: shown("question-details"),
answers: shown("question-options-list"),
ghosts: root.querySelectorAll("[data-ghost]").length,
x: matrix.m41,
scale: matrix.m11,
blur: (style?.filter ?? "none") !== "none",
mask: mask(),
digit: roll(),
bar: bar(),
back: getComputedStyle(back).visibility === "hidden" ? 0 : opacity(back),
hit: hit(),
next: getComputedStyle(faces[0]!).visibility === "hidden" ? 0 : opacity(faces[0]!),
submit: getComputedStyle(faces[1]!).visibility === "hidden" ? 0 : opacity(faces[1]!),
width: width(),
words: Math.min(
...faces.map((face) =>
getComputedStyle(face).visibility === "hidden"
? 0
: opacity(face.querySelector('[data-slot="question-advance-label"]')!),
),
),
highlights: root.querySelectorAll('[data-slot="question-highlight"]').length,
footer: !!back.getAttribute("style") || faces.some((face) => !!face.getAttribute("style")),
animations: document.getAnimations().length,
title: root.querySelector('[data-slot="question-header-title"]')?.textContent ?? "",
glyphs: glyphs(),
}
frames.push(frame)
const resting =
queue.length === 0 &&
frame.ghosts === 0 &&
style?.transform === "none" &&
!frame.blur &&
!frame.mask &&
frame.digit === 0 &&
!frame.footer &&
frame.bar === frames.at(-2)?.bar
still = resting ? still + 1 : 0
if (still >= 3 || frames.length >= 240) return resolve()
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
return frames
}, keys)
// The dock's motion is off unless its knob is on, so these turn it on before the dock loads.
story.describe("with the question dock's motion on", () => {
story.beforeEach(({ page }) =>
page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))),
)
story("keeps a question on screen while switching", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
const forward = await play(question, [{ key: "ArrowRight" }])
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeVisible()
const backward = await play(question, [{ key: "ArrowLeft" }])
await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible()
await expect(question.locator("[data-ghost]")).toHaveCount(0)
for (const frames of [forward, backward]) {
expect(frames.length).toBeLessThan(240)
expect(Math.min(...frames.map((frame) => frame.text))).toBeGreaterThanOrEqual(0.9)
expect(Math.min(...frames.map((frame) => frame.answers))).toBeGreaterThanOrEqual(0.9)
expect(frames.at(-1)).toMatchObject({ x: 0, scale: 1, blur: false })
}
expect(Math.min(...forward.map((frame) => frame.scale))).toBeLessThan(1)
expect(forward.some((frame) => frame.blur)).toBe(true)
})
story("switches questions without a second answer highlight", async ({ mount, page }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const highlight = question.locator('[data-slot="question-highlight"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(highlight).toBeVisible()
// The leaving copy of the answers carries no highlight, so only the new question's answer is ever highlighted.
const frames = await play(question, [{ key: "ArrowRight" }])
expect(frames.some((frame) => frame.ghosts > 0)).toBe(true)
expect(frames.every((frame) => frame.highlights === 1)).toBe(true)
})
story("keeps one outgoing copy and a visible question through rapid switches", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const options = question.locator('[data-slot="question-options"]')
await expect(question.getByRole("radio").first()).toBeFocused()
// From the middle question, so every run can go two either way and comes back.
await play(question, [{ key: "ArrowRight" }, { key: "ArrowRight", after: 30 }])
const runs = [
["ArrowRight", "ArrowLeft"],
["ArrowLeft", "ArrowRight"],
["ArrowRight", "ArrowRight", "ArrowLeft", "ArrowLeft"],
["ArrowLeft", "ArrowLeft", "ArrowRight", "ArrowRight"],
]
for (const run of runs)
for (const after of [0, 1, 2, 4]) {
const frames = await play(
question,
run.map((key) => ({ key, after })),
)
const label = `${run.join(" ")} every ${after} frames`
expect(frames.length, label).toBeLessThan(240)
expect(Math.min(...frames.map((frame) => frame.text)), label).toBeGreaterThanOrEqual(0.5)
expect(Math.min(...frames.map((frame) => frame.answers)), label).toBeGreaterThanOrEqual(0.5)
expect(Math.max(...frames.map((frame) => frame.ghosts)), label).toBeLessThanOrEqual(2)
await expect(question.locator("[data-ghost]")).toHaveCount(0)
expect(await options.evaluate((el) => el.scrollLeft), label).toBe(0)
}
})
story("rolls the question count up going on and down going back", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const title = question.locator('[data-slot="question-header-title"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await expect(title).toHaveText("1 of 5 questions")
// The new digit comes up from below going on, and down from above going back.
const forward = await play(question, [{ key: "ArrowRight" }])
await expect(title).toHaveText("2 of 5 questions")
const backward = await play(question, [{ key: "ArrowLeft" }])
await expect(title).toHaveText("1 of 5 questions")
expect(Math.max(...forward.map((frame) => frame.digit))).toBeGreaterThan(0.2)
expect(Math.min(...forward.map((frame) => frame.digit))).toBeGreaterThanOrEqual(0)
expect(Math.min(...backward.map((frame) => frame.digit))).toBeLessThan(-0.2)
expect(Math.max(...backward.map((frame) => frame.digit))).toBeLessThanOrEqual(0)
for (const frames of [forward, backward]) expect(frames.at(-1)?.digit).toBe(0)
await expect(question.locator('[data-slot="rolling-number-glyph"][data-leaving]')).toHaveAttribute(
"data-leaving",
"",
)
})
story("takes Next clicks and the submit shortcut while the footer is still moving", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const title = question.locator('[data-slot="question-header-title"]')
await expect(question.getByRole("radio").first()).toBeFocused()
// Two Next clicks and two submit shortcuts, two frames apart, each landing while Back is still fading in.
const frames = await play(question, [
{ key: "next" },
{ key: "next", after: 2 },
{ key: "mod+Enter", after: 2 },
{ key: "mod+Enter", after: 2 },
])
expect([2, 4, 6].every((index) => frames[index - 1]?.footer)).toBe(true)
await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeVisible()
await expect(title).toHaveText("5 of 5 questions")
})
story("rolls the count on from where it is when it changes again mid-roll", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const title = question.locator('[data-slot="question-header-title"]')
await expect(question.getByRole("radio").first()).toBeFocused()
// 1 → 2 → 3 and 1 → 2 → 1, the second change a few frames into the first roll.
for (const [second, end] of [
["ArrowRight", "3"],
["ArrowLeft", "1"],
] as const)
for (const after of [1, 2, 4]) {
const frames = await play(question, [{ key: "ArrowRight" }, { key: second, after }])
const label = `1 → 2 → ${end} after ${after} frames`
const shown = (frame: (typeof frames)[number], text: string) =>
frame.glyphs.filter((glyph) => glyph.text === text).reduce((sum, glyph) => sum + glyph.shown, 0)
// The digits drawn always add up to at least one whole digit, and the header's text never doubles a digit.
expect(
Math.min(...frames.map((frame) => frame.glyphs.reduce((sum, glyph) => sum + glyph.shown, 0))),
label,
).toBeGreaterThanOrEqual(0.95)
expect(
frames.every((frame) => /^\d of 5 questions$/.test(frame.title.replace(/\s+/g, " ").trim())),
label,
).toBe(true)
// A 2 that had barely appeared goes back out and never crosses the middle. The frame sampled right after the
// second key shows the digits where the change found them; going back always sends the 2 out.
const at = shown(frames[after]!, "2")
if (second === "ArrowLeft" || at < 0.45)
expect(Math.max(...frames.slice(after).map((frame) => shown(frame, "2"))), label).toBeLessThanOrEqual(
at + 0.02,
)
expect(frames.at(-1)?.glyphs, label).toEqual([{ text: end, shown: 1 }])
await expect(title).toHaveText(`${end} of 5 questions`)
await play(question, [{ key: "click:0" }])
}
})
// Where the bar sits against each dash, from the inline start of the progress row.
const dashes = (question: Locator) =>
question.evaluate((root) => ({
dashes: [...root.querySelectorAll('[data-slot="question-progress-bar"]')].map(
(el) => el.getBoundingClientRect().left,
),
bar: root.querySelector('[data-slot="question-progress-current"]')!.getBoundingClientRect().left,
}))
for (const dir of ["ltr", "rtl"] as const)
story(`slides the progress bar straight to a clicked dash (${dir})`, async ({ mount, page }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await page.evaluate((dir) => document.documentElement.setAttribute("dir", dir), dir)
const start = await dashes(question)
expect(start.bar).toBeCloseTo(start.dashes[0]!, 0)
// Jumping from the first question to the fourth slides the one bar across the dashes in between, one way only.
const frames = await play(question, [{ key: "click:3" }])
const end = await dashes(question)
const bars = frames.map((frame) => frame.bar)
const way = Math.sign(end.dashes[3]! - end.dashes[0]!)
expect(end.bar).toBeCloseTo(end.dashes[3]!, 0)
expect(bars.at(-1)).toBeCloseTo(end.dashes[3]!, 0)
expect(bars.some((x) => way * (x - end.dashes[0]!) > 1 && way * (x - end.dashes[3]!) < -1)).toBe(true)
expect(bars.slice(1).every((x, i) => way * (x - bars[i]!) >= -0.01)).toBe(true)
await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("4 of 5 questions")
// In RTL the dashes run from the right, so the bar moves left.
expect(way).toBe(dir === "rtl" ? -1 : 1)
})
story("swaps questions, the count and the progress bar at once under reduced motion", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
const frames = await play(question, [{ key: "ArrowRight" }])
expect(frames.every((frame) => frame.ghosts === 0 && frame.x === 0 && frame.scale === 1 && !frame.blur)).toBe(
true,
)
expect(Math.min(...frames.map((frame) => frame.text))).toBe(1)
expect(frames.every((frame) => frame.digit === 0)).toBe(true)
expect(new Set(frames.map((frame) => Math.round(frame.bar))).size).toBe(1)
expect(frames.every((frame) => frame.back === 1 && !frame.footer)).toBe(true)
await expect(question.locator('[data-slot="question-header-title"]')).toHaveText("2 of 5 questions")
const last = await play(question, [{ key: "click:4" }])
expect(last.every((frame) => frame.next === 0 && frame.submit === 1 && !frame.footer)).toBe(true)
})
// Rises, or falls, all the way from one end to the other through values in between, never turning back.
const glides = (values: number[], from: number, to: number) => {
const way = Math.sign(to - from)
expect(values.at(0)! * way).toBeLessThan(to * way)
expect(values.at(-1)).toBeCloseTo(to, 2)
expect(
values.some(
(value) => way * (value - from) > 0.02 * way * (to - from) && way * (to - value) > 0.02 * way * (to - from),
),
).toBe(true)
expect(values.slice(1).every((value, i) => way * (value - values[i]!) >= -0.01)).toBe(true)
}
story("fades Back in and out and turns Next into Submit with the switch", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const back = question.locator('[data-slot="question-footer-back"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await expect(question.getByRole("button", { name: "Back", exact: true })).toHaveCount(0)
// Going on from the first question, Back comes in and can be clicked at once, while Next stays where it is.
const nextBox = await question.getByRole("button", { name: "Next", exact: true }).boundingBox()
const arrive = await play(question, [{ key: "ArrowRight" }])
glides(
arrive.map((frame) => frame.back),
0,
1,
)
expect(arrive.every((frame) => frame.hit && frame.next === 1 && frame.submit === 0)).toBe(true)
expect(await question.getByRole("button", { name: "Next", exact: true }).boundingBox()).toMatchObject({
x: nextBox!.x,
width: nextBox!.width,
})
// On to the last question, Next fades into Submit while the button's edge sweeps to Submit's width.
await play(question, [{ key: "click:3" }])
const widths = await question.evaluate((root) =>
[...root.querySelectorAll<HTMLElement>('[data-slot="question-advance-face"]')].map(
(face) => face.getBoundingClientRect().width,
),
)
const submit = await play(question, [{ key: "ArrowRight" }])
glides(
submit.map((frame) => frame.next),
1,
0,
)
glides(
submit.map((frame) => frame.submit),
0,
1,
)
glides(
submit.map((frame) => frame.width),
widths[0]!,
widths[1]!,
)
expect(submit.some((frame) => frame.next > 0.1 && frame.submit > 0.1)).toBe(true)
// The buttons overlap, but the words "Next" and "Submit" are never both legible at once.
expect(Math.max(...submit.map((frame) => frame.words))).toBeLessThan(0.25)
expect(submit.every((frame) => frame.back === 1)).toBe(true)
// Back to the first question, Back goes, and cannot be clicked from the moment it starts to.
const leave = await play(question, [{ key: "click:0" }])
glides(
leave.map((frame) => frame.back),
1,
0,
)
expect(leave.some((frame) => frame.hit)).toBe(false)
expect(leave.at(-1)).toMatchObject({ next: 1, submit: 0, footer: false })
await expect(back).toHaveAttribute("inert", "")
})
story("stops a switch where it is when the knob turns off", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
const frames = await question.evaluate(
async (root, tuning) => {
const { motionTuning } = await import(tuning)
const knob = motionTuning.list().find((item: MotionKnob) => item.id === "question.motion")
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
const sample = () => ({
animations: document.getAnimations().length,
heights: [...root.querySelectorAll<HTMLElement>("*")].filter((el) => el.style.height).length,
ghosts: root.querySelectorAll("[data-ghost]").length,
})
document.activeElement!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
)
await frame()
await frame()
const moving = sample()
motionTuning.set(knob, false)
await frame()
await frame()
return { moving, after: sample() }
},
sourceURL(new URL("../../ui/src/motion-tuning.ts", import.meta.url)),
)
expect(frames.moving.heights + frames.moving.ghosts).toBeGreaterThan(0)
expect(frames.after).toEqual({ animations: 0, heights: 0, ghosts: 0 })
})
})
story(
"switches questions, folds and submits on the same frame with the dock's motion off",
async ({ mount, page }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const title = question.locator('[data-slot="question-header-title"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await expect(question.locator('[data-component="rolling-number"]')).toHaveCount(0)
await expect(question.locator('[data-slot="question-progress-track"]')).toHaveCount(0)
// From the frame of the key on, the next question is at rest: no outgoing copy, nothing moved, faded or blurred,
// and no animation or transition running anywhere on the page.
for (const [key, count] of [
["ArrowRight", "2 of 5 questions"],
["click:3", "4 of 5 questions"],
["ArrowRight", "5 of 5 questions"],
] as const) {
const frames = await play(question, [{ key }])
expect(frames.length, key).toBeLessThanOrEqual(4)
expect(
frames.every(
(frame) =>
frame.ghosts === 0 &&
frame.x === 0 &&
frame.scale === 1 &&
!frame.blur &&
!frame.mask &&
frame.text === 1 &&
frame.answers === 1 &&
frame.back === 1 &&
!frame.footer &&
frame.animations === 0,
),
key,
).toBe(true)
await expect(title).toHaveText(count)
}
// Minimizing and restoring fold the answers away and back at once.
const fold = (label: string) =>
question.evaluate(async (root, label) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const list = root.querySelector<HTMLElement>('[data-slot="question-options-list"]')!
const sample = () => ({
height: Math.round(options.getBoundingClientRect().height),
list: Math.round(list.getBoundingClientRect().height),
hidden: getComputedStyle(options).visibility === "hidden",
animations: document.getAnimations().length,
})
root.querySelector<HTMLElement>(`button[aria-label="${label}"]`)!.click()
const frames = [sample()]
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
frames.push(sample())
}
return frames
}, label)
// The last question's answers may be cut to the dock's height, so they come back to the height they rest at.
const resting = await question
.locator('[data-slot="question-options"]')
.evaluate((el) => Math.round(el.getBoundingClientRect().height))
const folded = await fold("Minimize question")
expect(folded.every((frame) => frame.height === 0 && frame.hidden && frame.animations === 0)).toBe(true)
const restored = await fold("Restore question")
expect(
restored.every(
(frame) => frame.height > 0 && frame.height === resting && !frame.hidden && frame.animations === 0,
),
).toBe(true)
// Submitting swaps the dock for the composer on one frame, with no closing flight.
const submitted = await page.evaluate(async () => {
const dock = () => document.querySelector('[data-component="dock-prompt"][data-kind="question"]')
const moving = () => !!document.querySelector("[data-moving]")
document.querySelector<HTMLElement>('[data-slot="question-advance-face"][data-active="true"] button')!.click()
for (let i = 0; i < 120 && dock() && !moving(); i++)
await new Promise((resolve) => requestAnimationFrame(resolve))
const frames = []
for (let i = 0; i < 3; i++) {
frames.push({ dock: !!dock(), moving: moving(), animations: document.getAnimations().length })
await new Promise((resolve) => requestAnimationFrame(resolve))
}
return frames
})
expect(submitted).toEqual(Array(3).fill({ dock: false, moving: false, animations: 0 }))
},
)
story("toggles checkboxes with Space on key down and moves focus with arrows", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await question.getByRole("button", { name: "Next", exact: true }).click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
await expect(files).toBeFocused()
await page.keyboard.down(" ")
await expect(files).toBeChecked()
await page.keyboard.down(" ")
await page.keyboard.up(" ")
await expect(files).toBeChecked()
await page.keyboard.press("ArrowDown")
await expect(terminal).toBeFocused()
await expect(terminal).not.toBeChecked()
await page.keyboard.press(" ")
await expect(terminal).toBeChecked()
await page.keyboard.press(" ")
await expect(terminal).not.toBeChecked()
await expect(files).toBeChecked()
})
story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const wide = question.getByRole("radio", { name: /Wide/ })
await expect(focused).toBeFocused()
await focused.press("1")
await focused.press("9")
await focused.press("Alt+2")
await expect(focused).toBeChecked()
await expect(wide).not.toBeChecked()
await question.getByRole("button", { name: "Next", exact: true }).click()
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused()
await page.keyboard.down("2")
await expect(terminal).toBeChecked()
await page.keyboard.down("2")
await expect(terminal).toBeChecked()
await page.keyboard.up("2")
})
story("types digits into the custom answer without selecting numbered options", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const focused = question.getByRole("radio", { name: /Focused/ })
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await expect(focused).toBeFocused()
await focused.press("3")
await expect(input).toBeFocused()
await input.press("1")
await input.press("2")
await input.press("3")
await expect(input).toHaveValue("123")
await expect(custom).toBeChecked()
await expect(focused).not.toBeChecked()
await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked()
})
story("leaves Enter and Escape to an IME composition in the custom answer", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true })
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
await input.focus()
await input.fill("にほん")
const compose = (key: string, keyCode: number) =>
input.evaluate(
(element, init) =>
element.dispatchEvent(new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true })),
{ key, keyCode, isComposing: keyCode !== 229 },
)
await compose("Enter", 13)
await compose("Escape", 27)
await compose("Enter", 229)
await compose("Escape", 229)
await expect(input).toBeFocused()
await expect(input).toHaveValue("にほん")
await expect(question).toBeVisible()
await input.press("Escape")
await expect(toggle).toBeFocused()
await expect(question).toBeVisible()
})
story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-request")
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
const shortcut = mac ? "⌘⏎" : "Ctrl+⏎"
const next = component.getByRole("button", { name: "Next", exact: true })
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
await next.hover()
await expect(page.getByRole("tooltip")).toContainText("Next")
await expect(page.getByRole("tooltip")).toContainText(shortcut)
await next.press(mac ? "Meta+Enter" : "Control+Enter")
const submit = component.getByRole("button", { name: "Submit", exact: true })
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
// Pressing the button closes its tooltip until the pointer comes back to it.
await page.mouse.move(0, 0)
await submit.hover()
await expect(page.getByRole("tooltip")).toContainText("Submit")
await expect(page.getByRole("tooltip")).toContainText(shortcut)
const back = component.getByRole("button", { name: "Back", exact: true })
await expect(back).toHaveText("Back")
await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft")
await back.hover()
// Moving straight from Submit opens Back's tooltip while Submit's is still fading out.
await expect(page.getByRole("tooltip", { name: /Back/ })).toContainText(mac ? "⌘[" : "Alt+←")
await back.press(mac ? "Meta+[" : "Alt+ArrowLeft")
await expect(next).toBeVisible()
})
story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const row = question.locator('[data-slot="question-option"][data-custom="true"]')
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true })
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
await expect(input).toBeEditable()
await expect(input).toHaveValue("")
const node = await input.evaluateHandle((element) => element)
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
const initial = await height()
const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line"
const predefined = question.getByRole("radio", { name: /Focused/ })
await predefined.click()
await input.focus()
await expect(input).toBeFocused()
await expect(predefined).toBeChecked()
await expect(toggle).not.toBeChecked()
await row.click({ position: { x: 4, y: 4 } })
await expect(toggle).toBeChecked()
await expect(input).toBeFocused()
await expect(input).toHaveValue("")
await expect(predefined).not.toBeChecked()
await input.fill(draft)
await expect(input).toHaveValue(draft)
await expect(toggle).toBeChecked()
await expect(predefined).not.toBeChecked()
await expect.poll(height).toBe(initial)
await input.press("Escape")
await expect(toggle).toBeFocused()
await expect(input).toHaveValue(draft)
await row.click({ position: { x: 30, y: 20 } })
await expect(input).toBeFocused()
await expect(input).toHaveValue(draft)
await expect
.poll(() => input.evaluate((element, original) => element === original && original.isConnected, node))
.toBe(true)
await node.dispose()
})
story("marks one-answer questions with round keys and many-answer questions with square keys", async ({ mount }) => {
const component = await mount("app-current-session-surface--question-request")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const keys = question.locator('[data-slot="question-option-key"]')
const focused = question.getByRole("radio", { name: /Focused/ })
await expect(focused).toBeFocused()
await expect(keys).toHaveText(["1", "2", "3"])
await expect(keys.first()).toBeVisible()
await expect(keys.first()).toHaveAttribute("data-type", "radio")
await expect(question.getByText("Pick any", { exact: true })).toHaveCount(0)
await focused.press("1")
await expect(focused.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true")
await question.getByRole("button", { name: "Next", exact: true }).click()
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
await expect(files).toBeFocused()
await expect(keys.first()).toHaveAttribute("data-type", "checkbox")
await expect(question.getByText("Pick any", { exact: true })).toBeVisible()
})
story("toggles a lone many-answer question and sends it with Enter", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--question-multi-select")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
await expect(files).toBeFocused()
await expect(question.getByText("Pick any", { exact: true })).toBeVisible()
await expect(question.locator('[data-slot="question-option-key"][data-type="radio"]')).toHaveCount(0)
await page.keyboard.press(" ")
await page.keyboard.press("2")
await expect(files).toBeChecked()
await expect(terminal).toBeChecked()
await expect(terminal.locator('[data-slot="question-option-key"]')).toHaveAttribute("data-picked", "true")
await page.keyboard.press("Enter")
await expect(question).toBeHidden()
})
const longList = async (mount: (id: string) => Promise<Locator>) => {
const component = await mount("session-question-switch--long-list")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("checkbox").first()).toBeFocused()
// The answers box has opened to its first height once it stops changing.
const box = () => question.evaluate((root) => Math.round(root.getBoundingClientRect().height))
await expect.poll(async () => (await box()) === (await box())).toBe(true)
return question
}
const fit = (question: Locator) =>
question.evaluate((root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const timeline = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')
const box = root.getBoundingClientRect()
return {
height: box.height,
// The whole dock, footer included, sits between the top of the timeline and the foot of the window.
within: box.top >= (timeline?.getBoundingClientRect().top ?? 0) - 0.5 && box.bottom <= innerHeight + 0.5,
scrolls: options.scrollHeight > options.clientHeight + 1,
top: options.scrollTop,
}
})
// Whether the whole of an answer's row shows, clear of the feathered edges.
const clear = (row: Locator) =>
row.evaluate((input) => {
const row = input.closest<HTMLElement>('[data-slot="question-option"]')!
const box = input.closest<HTMLElement>('[data-slot="question-options"]')!.getBoundingClientRect()
const at = row.getBoundingClientRect()
return at.top >= box.top + 20 - 0.5 && at.bottom <= box.bottom - 20 + 0.5
})
const feathered = (question: Locator) =>
question.locator('[data-slot="question-options"]').evaluate((options) => {
const mask = options.style.maskImage
// A gradient down the list may be written without its direction, which is the default.
return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") }
})
story("caps a long list of answers to the panel and scrolls them inside it", async ({ mount }) => {
const question = await longList(mount)
const rest = await fit(question)
expect(rest.within).toBe(true)
expect(rest.scrolls).toBe(true)
await expect(question.getByRole("checkbox", { name: /Footer/ })).not.toBeInViewport()
})
story("scrolls the highlighted answer into view with the keys", async ({ mount, page }) => {
const question = await longList(mount)
const footer = question.getByRole("checkbox", { name: /Footer/ })
await page.keyboard.press("End")
await page.keyboard.press("ArrowUp")
await expect(footer).toBeFocused()
expect(await clear(footer)).toBe(true)
await page.keyboard.press("Home")
await expect(question.getByRole("checkbox").first()).toBeFocused()
expect((await fit(question)).top).toBe(0)
})
story("feathers only the edges of the answers with more past them", async ({ mount }) => {
const question = await longList(mount)
// Scrolls to a share of the way down the list.
const edges = (share: number) =>
question.evaluate(async (root, share) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
options.scrollTop = Math.round((options.scrollHeight - options.clientHeight) * share)
await new Promise((done) => options.addEventListener("scroll", done, { once: true }))
const mask = options.style.maskImage
// A gradient down the list may be written without its direction, which is the default.
return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") }
}, share)
expect(await edges(0.5)).toEqual({ top: true, bottom: true })
expect(await edges(1)).toEqual({ top: true, bottom: false })
expect(await edges(0)).toEqual({ top: false, bottom: true })
})
story("leaves a list that fits without feathered edges", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await expect
.poll(() => question.locator('[data-slot="question-options"]').evaluate((el) => el.style.maskImage))
.toBe("")
})
story(
"caps a long list to the panel in the app's layout, where the dock overflows a zero-height row",
async ({ mount }) => {
const question = await longList(mount)
// As in the session screen, the composer region moves into a zero-height row at the foot of the panel and overflows
// it upward, over the timeline.
await question.evaluate((root) => {
const dock = root.closest('[data-component="session-composer-dock"]')!
const row = document.createElement("div")
row.style.cssText =
"position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 0"
row.style.flexShrink = "0"
dock.replaceWith(row)
row.append(dock)
dispatchEvent(new Event("resize"))
})
// Measured against the empty row, the cap would fall to its 240px minimum.
await expect.poll(async () => (await fit(question)).height).toBeGreaterThan(400)
const rest = await fit(question)
expect(rest.within).toBe(true)
expect(rest.scrolls).toBe(true)
},
)
story("brings the picked answer back into view after switching questions", async ({ mount, page }) => {
const question = await longList(mount)
const footer = question.getByRole("checkbox", { name: /Footer/ })
const title = question.locator('[data-slot="question-header-title"]')
await page.keyboard.press("End")
await page.keyboard.press("ArrowUp")
await page.keyboard.press(" ")
await expect(footer).toBeChecked()
await page.keyboard.press("ArrowRight")
await expect(title).toHaveText("2 of 2 questions")
await page.keyboard.press("ArrowLeft")
await expect(title).toHaveText("1 of 2 questions")
await expect(footer).toBeFocused()
expect(await clear(footer)).toBe(true)
})
story("sizes the answers box to each question with the dock's motion off", async ({ mount, page }) => {
const question = await longList(mount)
const options = question.locator('[data-slot="question-options"]')
const rest = await fit(question)
expect(rest.scrolls).toBe(true)
expect(await feathered(question)).toEqual({ top: false, bottom: true })
await page.keyboard.press("ArrowRight")
await expect(question.getByRole("radio").first()).toBeFocused()
await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: false })
expect(await options.evaluate((el) => el.style.height)).toBe("")
expect((await fit(question)).scrolls).toBe(false)
await page.keyboard.press("ArrowLeft")
await expect(question.getByRole("checkbox").first()).toBeFocused()
await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: true })
expect((await fit(question)).height).toBeCloseTo(rest.height, 0)
})
story.describe("a long list with the question dock's motion on", () => {
story.beforeEach(({ page }) =>
page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))),
)
story("restores a long list without growing past where it rests", async ({ mount }) => {
const question = await longList(mount)
const rest = await fit(question)
await question.getByRole("button", { name: "Minimize question" }).click()
await expect(question.getByRole("button", { name: "Restore question" })).toBeVisible()
await expect
.poll(() =>
question.locator('[data-slot="question-options"]').evaluate((el) => el.getBoundingClientRect().height),
)
.toBe(0)
const frames = await question.evaluate(async (root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
root.querySelector<HTMLElement>('[data-slot="question-header-actions"] > button:last-of-type')!.click()
return Array.from({ length: 45 }).reduce<Promise<{ height: number; asked: number; given: number }[]>>(
async (list) => {
const frames = await list
await new Promise((done) => requestAnimationFrame(done))
return [
...frames,
{
height: root.getBoundingClientRect().height,
asked: parseFloat(options.style.height) || 0,
given: options.getBoundingClientRect().height,
},
]
},
Promise.resolve([]),
)
})
const heights = frames.map((frame) => frame.height)
expect(heights.every((height, i) => i === 0 || height >= heights[i - 1]! - 0.5)).toBe(true)
expect(Math.max(...heights)).toBeLessThanOrEqual(rest.height + 0.5)
expect(heights.at(-1)).toBeCloseTo(rest.height, 0)
// The answers box is never asked to open past the room it is given, which is what made it snap back at the end.
expect(frames.every((frame) => frame.asked <= frame.given + 0.5)).toBe(true)
})
})
})