Files
opencode/packages/session-ui/component-tests/session-message-projection.spec.ts

407 lines
21 KiB
TypeScript

import { expect, story } from "../../stories/playwright/story"
story("keeps quote attachment icons fixed while long text truncates", async ({ mount, page }) => {
const gallery = await mount("current-session-timeline-rows--quote-attachments")
await page.evaluate(() => document.fonts.ready.then(() => undefined))
for (const title of ["Remove this", "Can we make this smaller?"]) {
const icon = gallery
.locator('[data-component="attachment-card"]')
.filter({ hasText: title })
.locator('[data-slot="icon-svg"]')
await expect(icon).toHaveCSS("width", "12px")
await expect(icon).toHaveCSS("height", "12px")
}
const label = gallery.locator('[data-slot="attachment-card-label"]').filter({ hasText: "It has a 32px minimum" })
expect(await label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
await expect(label).toHaveCSS("text-overflow", "ellipsis")
})
story.describe("message actions menu", () => {
story.use({ timezoneId: "UTC" })
story("shows the creation time and disables actions the message cannot take", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const message = timeline.locator('[data-component="user-message"]')
const more = timeline.getByRole("button", { name: "More actions", exact: true })
const fork = page.getByRole("menuitem", { name: "Fork from here", exact: true })
const copy = page.getByRole("menuitem", { name: "Copy message", exact: true })
// The meta line keeps agent and model; the time moved into the menu.
await expect(timeline.locator('[data-slot="user-message-meta"] time')).toHaveCount(0)
await timeline.getByLabel("Delivery state").selectOption("delivered")
await message.hover()
await expect(timeline.getByRole("button", { name: "Rewind to here", exact: true })).toBeVisible()
await more.click()
const menu = page.getByRole("menu")
await expect(menu.locator("time")).toHaveText("Jan 1, 2025, 12:00 AM")
await expect(fork).toBeEnabled()
await expect(copy).toBeEnabled()
// An open menu keeps the footer in view after the pointer leaves the message.
await page.mouse.move(0, 0)
await expect(timeline.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
// A pending steer cannot fork, and a message without text has nothing to copy.
await timeline.getByLabel("Delivery state").selectOption("steering")
await timeline.getByLabel("Prompt text", { exact: true }).uncheck()
await more.click()
await expect(fork).toHaveAttribute("aria-disabled", "true")
await expect(copy).toHaveAttribute("aria-disabled", "true")
await page.keyboard.press("Escape")
await timeline.getByLabel("Prompt text", { exact: true }).check()
await timeline.getByLabel("Delivery state").selectOption("delivered")
await page.evaluate(() => document.fonts.ready.then(() => undefined))
for (const width of [1000, 390]) {
await page.setViewportSize({ width, height: 844 })
const bubble = await timeline.locator('[data-slot="user-message-text"]').boundingBox()
const trigger = await more.boundingBox()
const footer = await timeline.locator('[data-slot="user-message-copy-wrapper"]').boundingBox()
const box = await message.boundingBox()
if (!bubble || !trigger || !footer || !box) throw new Error("Message geometry is unavailable")
expect(Math.abs(bubble.x + bubble.width - box.x - box.width)).toBeLessThan(1)
expect(footer.y).toBeGreaterThanOrEqual(bubble.y + bubble.height)
expect(footer.x + footer.width).toBeLessThanOrEqual(bubble.x + bubble.width + 1)
expect(Math.abs(trigger.x + trigger.width - footer.x - footer.width)).toBeLessThan(1)
expect(Math.abs(trigger.y + trigger.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1)
}
})
})
story("drops a pending footer at once on delivery with and without hover", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const message = timeline.locator('[data-component="user-message"]')
const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]')
const pending = timeline.locator('[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])')
await page.mouse.move(0, 0)
await expect(footer).toHaveCSS("height", "20px")
await expect(footer).toHaveCSS("margin-top", "8px")
// A steer names only the model it changes, so change it to measure the gap after the labels.
await timeline.getByLabel("Changes model", { exact: true }).check()
for (const hover of [true, false]) {
await timeline.getByLabel("Delivery state").selectOption("steering")
await expect(footer).toHaveCSS("opacity", "1")
await expect(pending).toHaveCSS("opacity", "1")
const before = await pending.boundingBox()
if (!before) throw new Error("Pending footer geometry is unavailable")
if (hover) await message.hover()
if (!hover) await page.mouse.move(0, 0)
const label = await timeline.locator('[data-slot="user-message-meta-item"]').last().boundingBox()
const actions = await timeline.locator('[data-slot="user-message-actions"]').boundingBox()
if (!label || !actions) throw new Error("Pending footer geometry is unavailable")
expect(actions.x - label.x - label.width).toBeLessThanOrEqual(9)
await timeline.getByLabel("Delivery state").selectOption("delivered")
// The first frame after delivery already shows the delivered footer: nothing shrinks or fades out.
const after = await message.evaluate((element) => {
const pending = element.querySelector(
'[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])',
)!
const footer = element.querySelector('[data-slot="user-message-copy-wrapper"]')!
return {
animations: [...pending.getAnimations(), ...footer.getAnimations()].length,
width: pending.getBoundingClientRect().width,
opacity: Number(getComputedStyle(pending).opacity),
footer: Number(getComputedStyle(footer).opacity),
}
})
expect(after).toEqual({ animations: 0, width: 0, opacity: 0, footer: hover ? 1 : 0 })
expect(before.width).toBeGreaterThan(0)
await expect(pending).toBeHidden()
await expect(footer).toHaveCSS("opacity", hover ? "1" : "0")
}
await page.emulateMedia({ reducedMotion: "reduce" })
await expect(page.locator("html")).toHaveAttribute("data-motion", "reduce")
await timeline.getByLabel("Delivery state").selectOption("steering")
await timeline.getByLabel("Delivery state").selectOption("delivered")
await expect(footer).toHaveCSS("transition-duration", "0s")
await expect(pending).toBeHidden()
await expect(footer).toHaveCSS("opacity", "0")
})
story("marks pending metadata without badging delivered, starting, or queued prompts", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const indicator = timeline.locator('[data-slot="user-message-meta"] [data-slot="user-message-pending"]')
const text = timeline.locator('[data-slot="user-message-text"]')
for (const status of ["steering", "stranded", "starting", "queued", "delivered"]) {
await timeline.getByLabel("Delivery state").selectOption(status)
const pending = status === "steering" || status === "stranded"
if (pending) await expect(indicator).toBeVisible()
if (!pending) await expect(indicator).toBeHidden()
const colors = await timeline.evaluate((element, pending) => {
const probe = document.createElement("span")
probe.style.color = `var(--v2-text-text-${pending ? "muted" : "base"})`
probe.style.backgroundColor = `var(--v2-background-bg-layer-${pending ? "01" : "02"})`
element.append(probe)
const result = { color: getComputedStyle(probe).color, background: getComputedStyle(probe).backgroundColor }
probe.remove()
return result
}, pending)
await expect(text).toHaveCSS("color", colors.color)
await expect(text).toHaveCSS("background-color", colors.background)
}
await timeline.locator('[data-component="user-message"]').hover()
await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveText([
"Build",
"Claude Sonnet 4",
"Balanced",
])
const separator = timeline.locator('[data-slot="user-message-meta-label"] [data-slot="user-message-meta-separator"]')
await expect(separator).toHaveText("\u00B7")
await expect(timeline.locator('[data-slot="user-message-meta-label"]')).toHaveCSS("gap", "4px")
// A steer with the previous prompt's agent and model joins the running turn unchanged, so it names neither.
await timeline.getByLabel("Delivery state").selectOption("steering")
await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending$/, { useInnerText: true })
await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveCount(0)
await timeline.getByLabel("Changes model", { exact: true }).check()
await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveText(["Claude Sonnet 4", "Balanced"])
await timeline.getByLabel("Voice message", { exact: true }).check()
const voice = timeline.getByRole("img", { name: "Voice", exact: true })
await expect(voice).toBeVisible()
await expect(indicator).toBeVisible()
// The faded bubble already marks a steer as waiting, so Pending is plain text with no icon.
await expect(indicator).toHaveText("Pending")
await expect(indicator.locator("svg")).toHaveCount(0)
await expect(voice.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-wave-audio")
await expect(voice).toHaveCSS("width", "20px")
await expect(voice).toHaveCSS("height", "20px")
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px")
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("height", "14px")
await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending/)
await timeline.getByLabel("Grouped prompts", { exact: true }).check()
await expect(timeline.locator('[data-run-folded] [data-slot="user-message-copy-wrapper"]')).toBeHidden()
await expect(
timeline.locator('[data-component="user-message"]:not([data-run-folded]) [data-slot="user-message-pending"]'),
).toBeVisible()
await expect(timeline.getByRole("img", { name: "Pending", exact: true })).toHaveCount(0)
await timeline.getByLabel("Grouped prompts", { exact: true }).uncheck()
await page.setViewportSize({ width: 390, height: 844 })
await expect(indicator).toBeVisible()
await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveCount(0)
await timeline.getByLabel("Prompt text", { exact: true }).uncheck()
await expect(timeline.getByText("notes.json", { exact: true })).toBeVisible()
await expect(indicator).toBeVisible()
await timeline.getByRole("button", { name: "Move to queue", exact: true }).click()
await expect(indicator).toBeHidden()
})
// Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "streaming" } })
const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]')
const trigger = tool.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
// SAFETY: the locator targets a rendered DOM element, which is always an HTMLElement in the story.
await tool.evaluate((element) => ((element as HTMLElement).dataset.regressionMarker = "before-stream"))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Stream sibling content" }).click()
await expect(timeline.getByText("Streaming added a later assistant text part.", { exact: true })).toBeVisible()
await expect(tool).toHaveAttribute("data-regression-marker", "before-stream")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator("xpath=ancestor::*[@data-timeline-row]")).toHaveAttribute(
"data-timeline-row",
"AssistantPart",
)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders interruption independently when the turn is not compacted", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible()
await expect(timeline.getByText("Before", { exact: true })).toBeVisible()
await expect(timeline.getByText("After", { exact: true })).toBeVisible()
const rows = await timeline
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders aliased and long custom model notices", async ({ mount, page }) => {
await page.setViewportSize({ width: 420, height: 700 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "models" } })
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
const short = timeline
.locator('[data-slot="session-timeline-notice"]')
.filter({ hasText: shortName })
.filter({ hasText: "xhigh" })
const long = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(short).toBeVisible()
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
const variantDefault = timeline
.locator('[data-slot="session-timeline-notice"]')
.filter({ hasText: "Switched variant to Default" })
const variantHigh = timeline
.locator('[data-slot="session-timeline-notice"]')
.filter({ hasText: "Switched variant to high" })
await expect(variantDefault.getByText("Switched variant to Default", { exact: true })).toBeVisible()
await expect(variantHigh.getByText("Switched variant to high", { exact: true })).toBeVisible()
await expect(variantDefault.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(variantHigh.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "attachments" } })
await expect(timeline.getByAltText("pixel.png")).toBeVisible()
await expect(timeline.getByText("tsconfig.json")).toBeVisible()
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.getByText("@explore", { exact: true })).toBeVisible()
const accent = await timeline.evaluate((element) => {
const probe = document.createElement("span")
probe.style.color = "var(--v2-text-text-accent)"
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
})
for (const mention of await timeline.locator("[data-highlight]").all()) {
await expect(mention).toHaveCSS("color", accent)
await expect(mention).toHaveCSS("font-weight", "440")
}
for (const prefix of await timeline.locator('[data-slot="user-message-mention-prefix"]').all()) {
await expect(prefix).toHaveCSS("color", accent)
}
const attachments = timeline.locator('[data-slot="user-message-attachments"]')
const body = timeline.locator('[data-slot="user-message-body"]')
const [attachmentsBox, bodyBox] = await Promise.all([attachments.boundingBox(), body.boundingBox()])
expect(attachmentsBox).not.toBeNull()
expect(bodyBox).not.toBeNull()
expect(bodyBox!.y - (attachmentsBox!.y + attachmentsBox!.height)).toBe(8)
})
story("shows note selectors and keeps annotation subtitles with or without comments", async ({ mount, page }) => {
const gallery = await mount("current-session-timeline-rows--note-attachments")
const cases = [
{ name: "Named", heading: "button#save", secondary: "Browser annotation" },
{ name: "Unnamed", heading: "button.icon-only", secondary: "Browser annotation" },
{ name: "Legacy", heading: "Legacy element", secondary: "Legacy element" },
{ name: "Whitespace", heading: "Fallback element", secondary: "Fallback element" },
{ name: "Title only", heading: "button#save", secondary: "Original label" },
{ name: "Commented", heading: "Fix this", secondary: "Browser annotation" },
{ name: "RTL subtitle", heading: "button#save", secondary: "تعليق المتصفح" },
{ name: "RTL legacy", heading: "العنصر الأصلي", secondary: "العنصر الأصلي" },
{
name: "Long selector",
heading: 'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]',
secondary: "Browser annotation",
},
]
await page.evaluate(() => document.fonts.ready.then(() => undefined))
for (const item of cases) {
const section = gallery.getByRole("region", { name: item.name, exact: true })
const card = section.getByRole("button")
const heading = card.locator('[data-slot="attachment-card-title"]')
const label = card.locator('[data-slot="attachment-card-label"]')
await expect(heading).toHaveText(item.heading)
await expect(label).toHaveText(item.secondary)
}
const long = gallery.getByRole("region", { name: "Long selector", exact: true }).getByRole("button")
await expect(long).toHaveCSS("width", "160px")
await expect(long).toHaveCSS("height", "52px")
await expect(long.locator('[data-slot="attachment-card-title"]')).toHaveCSS("white-space", "nowrap")
await expect(long.locator('[data-slot="attachment-card-title"]')).toHaveCSS("text-overflow", "ellipsis")
const rtl = gallery.getByRole("region", { name: "RTL subtitle", exact: true }).getByRole("button")
const label = rtl.locator('[data-slot="attachment-card-label"]')
await expect(label).toHaveCSS("white-space", "nowrap")
await expect(label).toHaveCSS("text-overflow", "ellipsis")
await expect(label).toHaveAttribute("dir", "ltr")
await expect(label).toHaveCSS("unicode-bidi", "isolate")
expect(
await long
.locator('[data-slot="attachment-card-title"]')
.evaluate((element) => element.scrollWidth > element.clientWidth),
).toBe(true)
const named = gallery.getByRole("region", { name: "Named", exact: true }).getByRole("button")
await page.clock.install()
await named.hover()
await page.clock.runFor(1000)
await expect(page.getByRole("tooltip")).toHaveText("button#save\nBrowser annotation")
await long.hover()
await page.clock.runFor(1000)
await expect(page.getByRole("tooltip", { name: /^main > section/ })).toHaveText(
'main > section.settings-panel > form > button[data-action="save-all-account-preferences"]\nBrowser annotation',
)
await page.clock.resume()
await named.click()
await expect(gallery.getByLabel("Selected attachment")).toHaveText("Named")
await rtl.focus()
await page.keyboard.press("Enter")
await expect(gallery.getByLabel("Selected attachment")).toHaveText("RTL subtitle")
await named.focus()
await page.keyboard.press("Space")
await expect(gallery.getByLabel("Selected attachment")).toHaveText("Named")
})
story("links URLs in user messages without swallowing punctuation or mentions", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "links" } })
const links = timeline.locator('[data-slot="user-message-text"] a[data-slot="user-message-link"]')
await expect(links).toHaveCount(2)
await expect(links.nth(0)).toHaveText("https://github.com/Hona/mesh")
await expect(links.nth(0)).toHaveAttribute("href", "https://github.com/Hona/mesh")
await expect(links.nth(0)).toHaveAttribute("target", "_blank")
await expect(links.nth(0)).toHaveCSS("cursor", "pointer")
await expect(links.nth(1)).toHaveText("https://opencode.ai/docs")
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.locator('[data-slot="user-message-text"]')).toHaveText(
"set it up on this pc https://github.com/Hona/mesh, then compare @src/a.ts with (https://opencode.ai/docs).",
)
})