Files

777 lines
32 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { expected, messages } from "../utils/markdown-sessions"
import { expectPath, holdRoute, seed, sessionHref, type SeedInput } from "../utils/app"
import type { MockServerConfig } from "../utils/mock-server"
import { fixture, installTimelineSettings, mockStressTimeline } from "../utils/session-fixture"
import { mockWorkspace } from "../utils/workspace"
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
type Reveal = { pending: number; clipped: string[]; bottomError: number; tables: number; codeBlocks: number }
for (const width of [1440, 390]) {
test(`reveals a cold timeline at the tail only after Markdown is measured at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(/markdown\.worker(?:-[^/?]+\.js|\.ts)(?:\?.*)?$/, async (route) => {
requested.resolve()
await release.promise
await route.continue()
})
await page.addInitScript((partID) => {
const observer = new MutationObserver(() => {
const answer = document.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
const content = answer?.closest<HTMLElement>("[data-timeline-virtual-content]")
const root = content?.closest<HTMLElement>(".scroll-view__viewport")
if (!answer || !content || !root || !content.checkVisibility({ checkVisibilityCSS: true })) return
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
window.__coldReveal = {
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
? [row.dataset.timelineKey!]
: [],
),
// The viewport runs on under the composer; its visible part ends at the composer region.
bottomError:
(spacer?.getBoundingClientRect().bottom ?? Infinity) -
root.getBoundingClientRect().bottom +
parseFloat(getComputedStyle(root).scrollPaddingBottom),
tables: answer.querySelectorAll("table").length,
codeBlocks: answer.querySelectorAll("pre").length,
}
observer.disconnect()
})
observer.observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] })
}, expected[fixture.sourceID].answerID)
await mockStressTimeline(page, { pageMessages: () => ({ items: messages[fixture.sourceID] }) })
await installTimelineSettings(page)
try {
await page.goto(sessionHref(fixture.sourceID), { waitUntil: "domcontentloaded" })
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
release.resolve()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const reveal = await page.evaluate(() => window.__coldReveal)
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
// The gap above the composer is part of the tail: the status lane, then the activity pill and its gap.
const spacer = page.locator('[data-timeline-row="bottom-spacer"]')
await expect(spacer).toBeVisible()
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(92)
} finally {
release.resolve()
}
})
}
test("paints cold and warm session tabs at the latest message", async ({ page }) => {
await openTabs(page, { pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }) })
await installTimelineSettings(page)
await page.goto(sessionHref(fixture.sourceID))
await expectAtTail(page, fixture.expected.sourceMessageIDs.at(-1)!)
// Cold: the first painted frame of the destination is already at its tail.
const cold = await sampleTabPaint(page, fixture.targetID)
await tab(page, fixture.targetID).click()
const coldPaint = await cold.firstPaint()
expect(coldPaint.last).toBe(true)
expect(Math.abs(coldPaint.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
await expectAtTail(page, fixture.expected.targetMessageIDs.at(-1)!)
// Warm: the cached view paints at its tail without replacing a node it painted first.
await tab(page, fixture.sourceID).click()
await expectAtTail(page, fixture.expected.sourceMessageIDs.at(-1)!)
const warm = await sampleTabPaint(page, fixture.targetID)
await tab(page, fixture.targetID).click()
const warmPaint = await warm.firstPaint()
expect(warmPaint.last).toBe(true)
expect(Math.abs(warmPaint.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
await expectAtTail(page, fixture.expected.targetMessageIDs.at(-1)!)
expect(await warm.stop()).toBe(0)
})
test("reloads a transcript only after inline file links have their final styling", async ({ page }) => {
const sessionID = "ses_cold_inline_paths"
await mockWorkspace(page, {
name: "ColdInlinePaths",
sessions: [{ id: sessionID, title: "Cold inline paths" }],
findFiles: ({ query }) => (query === "src/file.ts" ? ["src/file.ts"] : []),
pageMessages: () => ({
items: [
{ id: "user_cold_paths", type: "user", text: "Review these files", time: { created: 1 } },
{
id: "answer_cold_paths",
type: "assistant",
agent: "build",
model: { id: "test", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [
{
type: "text",
text: "| Area | Files |\n| --- | --- |\n| App | `src/file.ts`, `src/file.ts` |\n| Missing | `missing/file.ts` |",
},
],
},
],
}),
})
await page.goto(sessionHref(sessionID))
const answer = page.locator('[data-timeline-part-id="answer_cold_paths:text:0"]')
const content = page.locator("[data-timeline-virtual-content]")
await expect(answer.locator('code[data-inline-code-kind="path"]')).toHaveCount(2)
await expect(content).toHaveCSS("visibility", "visible")
const held = await holdRoute(page, /\/api\/fs\/find\?/)
try {
await page.reload({ waitUntil: "domcontentloaded" })
await held.arrived
await expect(answer.getByRole("table", { includeHidden: true })).toBeAttached()
await expect(content).toHaveCSS("visibility", "hidden")
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
await editor.fill("The composer still works")
await expect(editor).toHaveText("The composer still works")
const reveal = await content.evaluateHandle((root) => {
type PathReveal = { kinds?: (string | null)[] }
const state: PathReveal = {}
const observer = new MutationObserver(() => {
if (!root.checkVisibility({ checkVisibilityCSS: true })) return
state.kinds = [...root.querySelectorAll("code")].map((code) => code.getAttribute("data-inline-code-kind"))
observer.disconnect()
})
observer.observe(root, { attributes: true, attributeFilter: ["style"] })
return state
})
held.release()
await expect(content).toHaveCSS("visibility", "visible")
expect(await reveal.evaluate((state) => state.kinds)).toEqual(["path", "path", null])
await reveal.dispose()
await expect(answer.locator("code")).toHaveText(["src/file.ts", "src/file.ts", "missing/file.ts"])
await expect(answer.locator('code[data-inline-code-kind="path"]')).toHaveText(["src/file.ts", "src/file.ts"])
await expect(answer.getByText("missing/file.ts", { exact: true })).not.toHaveAttribute("data-inline-code-kind")
} finally {
held.release()
}
})
test("keeps verified inline paths across a workspace tab switch", async ({ page }) => {
const directory = "C:/OpenCode/InlinePaths"
const other = "C:/OpenCode/OtherInlinePaths"
const sourceID = "ses_inline_source"
const targetID = "ses_inline_target"
const queries: { query: string; directory: string }[] = []
const existing = new Set([directory])
await page.clock.setFixedTime(new Date("2026-10-21T00:00:00Z"))
const workspace = await mockWorkspace(page, {
name: "InlinePaths",
directory,
project: { sandboxes: [other] },
sessions: [
{ id: sourceID, title: "Known path" },
{ id: targetID, title: "Missing path", directory: other },
],
pageMessages: (id) => ({
items: [
{ id: `user_${id}`, type: "user", text: "Check the file", time: { created: 1 } },
{
id: `answer_${id}`,
type: "assistant",
agent: "build",
model: { id: "test", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [{ type: "text", text: "Inspect `src/file.ts`." }],
},
],
}),
findFiles: (input) => {
queries.push({ query: input.query, directory: input.directory })
return existing.has(input.directory) ? ["src/file.ts"] : []
},
})
await page.goto(sessionHref(sourceID))
const source = part(page, sourceID).locator("code")
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
const original = await source.elementHandle()
await tab(page, targetID).click()
const target = part(page, targetID).locator("code")
await expect(target).toHaveText("src/file.ts")
await expect.poll(() => queries.some((query) => query.directory === other)).toBe(true)
await expect(target).not.toHaveAttribute("data-inline-code-kind")
expect(await original!.evaluate((node) => node.isConnected)).toBe(false)
const count = queries.length
const held = await holdRoute(page, /\/api\/fs\/find\?/)
try {
await tab(page, sourceID).click()
await expect(source).toBeVisible()
// The source workspace's timeline is kept while the other workspace is selected, so its links need no new lookup.
expect(await source.evaluate((node, original) => node === original, original)).toBe(true)
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
expect(held.requests).toHaveLength(0)
expect(queries).toHaveLength(count)
} finally {
held.release()
}
// Markdown that renders again repeats its lookups: expired positives keep their styling while revalidation is
// pending, and negatives must discover new files. Link results last as long as the session screen, which going
// Home unmounts, so the text changes in place.
await page.clock.setFixedTime(new Date("2026-10-21T00:01:01Z"))
const refresh = await holdRoute(page, /\/api\/fs\/find\?/)
try {
await workspace.push([textEnded(sourceID, directory)])
await refresh.arrived
await expect(part(page, sourceID)).toContainText("again")
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
} finally {
refresh.release()
}
existing.add(other)
await tab(page, targetID).click()
await expect(target).toBeVisible()
await workspace.push([textEnded(targetID, other)])
await expect(part(page, targetID)).toContainText("again")
await expect(target).toHaveAttribute("data-inline-code-kind", "path")
})
test("scrolls within a long answer without mounting unrelated history", async ({ page }) => {
await openTimeline(page, messages[fixture.sourceID])
const answer = page.locator(`[data-timeline-part-id="${expected[fixture.sourceID].answerID}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(answer.getByRole("table")).toHaveCount(1)
const scroller = page.locator(".scroll-view__viewport", { has: answer })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const rows = page.locator("[data-timeline-key]")
const keys = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-timeline-key")),
)
const top = await answer.evaluate((element) => element.getBoundingClientRect().top)
await scroller.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => answer.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top + 240, 0)
expect(
await rows.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-key"))),
).toEqual(keys)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
})
test("fills a short cold transcript before revealing it", async ({ page }) => {
const history = messages[fixture.sourceID].slice(-6).map((message, index) => {
if (message.type === "user") return { ...message, text: `Prompt ${index}`, metadata: undefined }
if (message.type === "assistant")
return { ...message, content: [{ type: "text" as const, text: `**Answer ${index}**` }] }
return message
})
await openTimeline(page, history)
for (const message of history) {
if (message.type === "user") {
await expect(page.locator(`[data-timeline-row="UserMessage"][data-message-id="${message.id}"]`)).toBeInViewport()
}
if (message.type === "assistant") {
const answer = page.locator(`[data-timeline-part-id="${message.id}:text:0"]`)
await expect(answer).toBeInViewport()
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
}
}
})
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
await openTabs(page, { pageMessages: placeholderHistory })
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
)
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await tab(page, fixture.targetID).click()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
await tab(page, fixture.sourceID).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
})
test("focuses Find in the selected cached timeline", async ({ page }) => {
await openTabs(page, { pageMessages: placeholderHistory })
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await tab(page, fixture.targetID).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await tab(page, fixture.sourceID).click()
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
const search = page.locator('[data-component="timeline-search-bar"] input')
await expect(search).toBeFocused()
await page.keyboard.type("History")
await expect(search).toHaveValue("History")
await tab(page, fixture.targetID).click()
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.keyboard.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
})
test("keeps the old workspace's shell on its own Location while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const reads: string[] = []
const output = { text: "Initial shell output\n" }
await openTabs(
page,
{
sessions: fixture.sessions.map((session) =>
session.id === fixture.targetID ? { ...session, directory: destination } : session,
),
pageMessages: (id) => ({
items:
id === fixture.sourceID
? ([
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
{
id: "msg_workspace_shell",
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2 },
content: [
{
type: "tool",
id: "call_workspace_shell",
name: "shell",
time: { created: 2 },
state: {
status: "running",
input: { command: "run checks" },
metadata: { shellID: "sh_workspace_source" },
},
},
],
},
] satisfies SessionMessageInfo[])
: [],
}),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
// The shell runs in the source workspace only.
shellOutput: (input) =>
input.id === "sh_workspace_source" && input.directory === fixture.directory ? output.text : undefined,
},
{
settings: {
general: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
},
},
},
)
page.on("request", (request) => {
const url = new URL(request.url())
if (request.method() === "GET" && url.pathname === "/api/shell/sh_workspace_source/output")
reads.push(url.searchParams.get("location[directory]") ?? "")
})
await page.goto(sessionHref(fixture.sourceID))
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
try {
await tab(page, fixture.targetID).click()
await requested.promise
await expect(page.locator("[data-session-title] h1")).toHaveText(fixture.expected.targetTitle)
output.text += "Output after returning\n"
await tab(page, fixture.sourceID).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
release.resolve()
}
})
test("loads the transcript code font before opening rich history", async ({ page }) => {
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
await openTabs(page, {
pageMessages: () => ({
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
}),
})
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
// Chromium recreates every @font-face as "unloaded", keeping its downloaded file, whenever a lazily loaded
// stylesheet with @layer rules activates ahead of one already applied, so the preloaded face's status alone is
// not stable. Once fonts settle, the first layout of new code must take that file at once, which marks the
// current face loaded; a face that still has to fetch its file reports "loading" here instead.
expect(
await page.evaluate(async () => {
await document.fonts.ready
const code = document.body.appendChild(document.createElement("code"))
code.style.font = "440 13px var(--font-family-mono)"
code.textContent = "code"
code.getBoundingClientRect()
const loaded = document.fonts.check('440 13px "IBM Plex Mono"')
code.remove()
return loaded
}),
).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await openTabs(page, {
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
beforeMessagesResponse: async ({ sessionID }) => {
if (sessionID !== fixture.targetID) return
requested.resolve()
await release.promise
},
})
await page.goto(sessionHref(fixture.sourceID))
await expectAtTail(page, fixture.expected.sourceMessageIDs.at(-1)!)
try {
await tab(page, fixture.targetID).click()
await requested.promise
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
release.resolve()
await expectAtTail(page, fixture.expected.targetMessageIDs.at(-1)!)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
} finally {
release.resolve()
}
})
for (const grouped of [true, false]) {
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
page,
}) => {
const history: Record<string, SessionMessageInfo[]> = Object.fromEntries(
[fixture.sourceID, fixture.targetID].map((id) => [
id,
[
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [
{
type: "tool",
id: `tool_${id}`,
name: "shell",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { command: `echo ${id}` },
metadata: {},
content: [{ type: "text", text: `Output for ${id}` }],
},
},
{ type: "text", text: `Answer for ${id}` },
],
},
] satisfies SessionMessageInfo[],
]),
)
const mock = await openTabs(
page,
{ pageMessages: (id) => ({ items: history[id] ?? [] }) },
{
settings: {
general: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
},
},
},
},
)
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
if (grouped)
await page
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
await tab(page, fixture.targetID).click()
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await expect(shell).toHaveCount(0)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await mock.push([
{
id: "evt_cached_text",
created: 4,
type: "session.text.ended",
location: { directory: fixture.directory },
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
data: {
sessionID: fixture.sourceID,
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
ordinal: 0,
text: "Updated while inactive",
},
},
])
// The cached source timeline applies the update while detached; the destination stays selected.
await expect
.poll(() => original!.evaluate((element) => element.textContent?.includes("Updated while inactive") ?? false))
.toBe(true)
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
await expectPath(page, sessionHref(fixture.targetID))
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
await page.setViewportSize({ width: 900, height: 650 })
await tab(page, fixture.sourceID).click()
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
await expect
.poll(() =>
page
.locator("[data-timeline-key]")
.evaluateAll((rows) =>
rows.every(
(row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
),
),
)
.toBe(true)
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
}
function placeholderHistory(id: string) {
return { items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }] } satisfies {
items: SessionMessageInfo[]
}
}
function part(page: Page, sessionID: string) {
return page.locator(`[data-timeline-part-id="answer_${sessionID}:text:0"]`)
}
function textEnded(sessionID: string, directory: string): OpenCodeEvent {
return {
id: `evt_text_${sessionID}`,
created: 4,
type: "session.text.ended",
location: { directory },
durable: { aggregateID: sessionID, seq: 0, version: 1 },
data: { sessionID, assistantMessageID: `answer_${sessionID}`, ordinal: 0, text: "Inspect `src/file.ts` again." },
}
}
function tab(page: Page, sessionID: string) {
return page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`)
}
// The source and target sessions open as titlebar tabs of one expanded project.
async function openTabs(page: Page, input: Partial<MockServerConfig>, extra: SeedInput = {}) {
const mock = await mockStressTimeline(page, input)
await seed(page, {
tabs: [fixture.sourceID, fixture.targetID],
...extra,
})
return mock
}
async function openTimeline(page: Page, history: SessionMessageInfo[]) {
await mockStressTimeline(page, { pageMessages: () => ({ items: history }) })
await installTimelineSettings(page)
await page.goto(sessionHref(fixture.sourceID))
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
async function expectAtTail(page: Page, lastUserID: string) {
const content = page.locator("[data-timeline-virtual-content]")
await expect(content).toHaveCSS("visibility", "visible")
await expect(content.locator(`[data-timeline-row="UserMessage"][data-message-id="${lastUserID}"]`)).toBeInViewport()
await expect
.poll(() =>
page
.locator(".scroll-view__viewport", { has: content })
.evaluate((element) => Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop)),
)
.toBeLessThanOrEqual(1)
}
type TabPaint = { first?: { last: boolean; bottomError?: number }; settled: boolean; removed: number; stop(): void }
declare global {
interface Window {
__coldReveal?: Reveal
__tabPaint?: TabPaint
}
}
// Samples painted frames of the timeline showing `sessionID`: the first frame that shows any of its messages, and
// every node of the first settled frame (at the tail, Markdown rendered) that is later removed.
async function sampleTabPaint(page: Page, sessionID: string) {
const history = fixture.messages[sessionID]!
await page.evaluate(
({ ids, last }) => {
const destination = new Set(ids)
const painted = new WeakSet<Node>()
let running = true
const state: TabPaint = {
settled: false,
removed: 0,
stop: () => {
running = false
},
}
window.__tabPaint = state
new MutationObserver((records) => {
if (!state.settled || !running) return
records.forEach((record) =>
record.removedNodes.forEach((node) => {
if (painted.has(node)) state.removed += 1
if (!(node instanceof Element)) return
node.querySelectorAll("*").forEach((element) => {
if (painted.has(element)) state.removed += 1
})
}),
)
}).observe(document.documentElement, { childList: true, subtree: true })
const sample = () => {
if (!running || state.settled) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
[...element.querySelectorAll<HTMLElement>("[data-message-id]")].some((row) =>
destination.has(row.dataset.messageId!),
),
)
if (root) {
// The part of the viewport above the composer region, which the timeline scrolls on under.
const box = root.getBoundingClientRect()
const view = { top: box.top, bottom: box.bottom - parseFloat(getComputedStyle(root).scrollPaddingBottom) }
// A frame counts only when the row is painted: inside the viewport and not hidden by CSS.
const inView = (element: Element) => {
const rect = element.getBoundingClientRect()
return (
rect.bottom > view.top &&
rect.top < view.bottom &&
element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
const id = element.dataset.messageId
return id && inView(element) && destination.has(id) ? [id] : []
})
const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
const bottomError = spacer ? spacer.bottom - view.bottom : undefined
if (visible.length && !state.first) state.first = { last: visible.includes(last), bottomError }
if (
visible.includes(last) &&
Math.abs(bottomError ?? Infinity) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
state.settled = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
if (!inView(row)) return
painted.add(row)
row.querySelectorAll("*").forEach((element) => painted.add(element))
})
return
}
}
requestAnimationFrame(() => setTimeout(sample, 0))
}
requestAnimationFrame(() => setTimeout(sample, 0))
},
{
ids: history.map((message) => message.id),
last: history.findLast((message) => message.type === "user")!.id,
},
)
const read = () => page.evaluate(() => window.__tabPaint!.first)
return {
async firstPaint() {
await expect.poll(read).toBeDefined()
await expect.poll(() => page.evaluate(() => window.__tabPaint!.settled)).toBe(true)
return (await read())!
},
stop: () =>
page.evaluate(() => {
const state = window.__tabPaint!
state.stop()
return state.removed
}),
}
}