+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
741 lines
28 KiB
TypeScript
741 lines
28 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test"
|
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
import { fixture, pageMessages } from "./session-timeline.fixture"
|
|
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
|
|
|
|
const forbiddenText = ["Load details", "Show earlier steps"]
|
|
|
|
type SmokeState = {
|
|
ids: string[]
|
|
visibleIds: string[]
|
|
messageIds: string[]
|
|
visibleMessageIds: string[]
|
|
topVisibleId?: string
|
|
signature: string
|
|
scrollTop: number
|
|
scrollHeight: number
|
|
clientHeight: number
|
|
errorToasts: string[]
|
|
forbiddenText: string[]
|
|
}
|
|
|
|
type SmokeWindow = Window & {
|
|
__timelineSmokeState?: () => SmokeState
|
|
__timelineSmokeErrorToasts?: string[]
|
|
__timelineSmokeForbiddenText?: string[]
|
|
}
|
|
|
|
test.describe("smoke: session timeline", () => {
|
|
test.setTimeout(240_000)
|
|
|
|
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
|
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
|
await mockOpenCodeServer(page, {
|
|
sessions: fixture.sessions,
|
|
provider: fixture.provider,
|
|
directory: fixture.directory,
|
|
project: fixture.project,
|
|
pageMessages,
|
|
messageDelay: 3_000,
|
|
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
|
|
})
|
|
await configureSmokePage(page, fixture.directory)
|
|
|
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
|
await waitForTimelineStable(page)
|
|
const scroller = timelineScroller(page)
|
|
await pointAtTimeline(page)
|
|
const deadline = Date.now() + 120_000
|
|
while (!requests.some((request) => request.before && request.phase === "start")) {
|
|
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
|
await page.mouse.wheel(0, -240)
|
|
await page.waitForTimeout(20)
|
|
}
|
|
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
|
for (let index = 0; index < 12; index++) {
|
|
await page.mouse.wheel(0, -120)
|
|
await page.waitForTimeout(20)
|
|
}
|
|
const keys = await scroller.evaluate((element) => {
|
|
const view = element.getBoundingClientRect()
|
|
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
|
.filter((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
return rect.bottom > view.top && rect.top < view.bottom
|
|
})
|
|
.map((row) => row.dataset.timelinePartId)
|
|
.filter((id): id is string => !!id)
|
|
.slice(0, 3)
|
|
})
|
|
expect(keys.length).toBeGreaterThan(0)
|
|
const positions = () =>
|
|
scroller.evaluate((element, keys) => {
|
|
const top = element.getBoundingClientRect().top
|
|
return Object.fromEntries(
|
|
keys.map((key) => {
|
|
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
|
|
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
|
|
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
|
|
}),
|
|
)
|
|
}, keys)
|
|
const before = await positions()
|
|
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
|
|
|
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
|
|
await waitForTimelineStable(page)
|
|
await expect.poll(positions).toEqual(before)
|
|
})
|
|
|
|
test("preserves the timeline gap above the composer", async ({ page }) => {
|
|
await mockOpenCodeServer(page, {
|
|
sessions: fixture.sessions,
|
|
provider: fixture.provider,
|
|
directory: fixture.directory,
|
|
project: fixture.project,
|
|
pageMessages,
|
|
})
|
|
await configureSmokePage(page, fixture.directory)
|
|
|
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
|
await waitForTimelineStable(page)
|
|
const scroller = timelineScroller(page)
|
|
await scroller.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight
|
|
})
|
|
await waitForTimelineStable(page)
|
|
|
|
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
|
|
await expect(spacer).toBeVisible()
|
|
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
|
|
await expect
|
|
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
|
.toBeLessThanOrEqual(1)
|
|
})
|
|
|
|
test("paints cached session tabs at the latest message", async ({ page }) => {
|
|
await mockOpenCodeServer(page, {
|
|
sessions: fixture.sessions,
|
|
provider: fixture.provider,
|
|
directory: fixture.directory,
|
|
project: fixture.project,
|
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
|
})
|
|
await configureSmokePage(page, fixture.directory)
|
|
await page.addInitScript(
|
|
({ dirBase64, sourceID, targetID }) => {
|
|
localStorage.setItem(
|
|
"opencode.window.browser.dat:tabs",
|
|
JSON.stringify(
|
|
[sourceID, targetID].map((sessionId) => ({
|
|
type: "session",
|
|
server: "http://127.0.0.1:4096",
|
|
dirBase64,
|
|
sessionId,
|
|
})),
|
|
),
|
|
)
|
|
},
|
|
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
|
)
|
|
|
|
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
|
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
|
|
|
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
|
await page.evaluate(
|
|
({ destination, last }) => {
|
|
const ids = new Set(destination)
|
|
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
|
|
const firstPaintNodes = new WeakSet<Node>()
|
|
let firstPaint = false
|
|
let removedFirstPaintNodes = 0
|
|
let running = true
|
|
new MutationObserver((records) => {
|
|
if (!firstPaint || !running) return
|
|
records.forEach((record) =>
|
|
record.removedNodes.forEach((node) => {
|
|
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
|
|
if (!(node instanceof Element)) return
|
|
node.querySelectorAll("*").forEach((element) => {
|
|
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
|
|
})
|
|
}),
|
|
)
|
|
}).observe(document.documentElement, { childList: true, subtree: true })
|
|
const sample = () => {
|
|
if (!running) return
|
|
setTimeout(() => {
|
|
if (!running) return
|
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
element.querySelector("[data-timeline-row]"),
|
|
)
|
|
if (root) {
|
|
const view = root.getBoundingClientRect()
|
|
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
|
.filter((element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
return rect.bottom > view.top && rect.top < view.bottom
|
|
})
|
|
.map((element) => element.dataset.messageId!)
|
|
.filter((id) => ids.has(id))
|
|
const bottom = root
|
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
|
?.getBoundingClientRect()
|
|
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
|
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
|
firstPaint = true
|
|
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
if (rect.bottom <= view.top || rect.top >= view.bottom) return
|
|
firstPaintNodes.add(row)
|
|
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
|
|
})
|
|
}
|
|
}
|
|
requestAnimationFrame(sample)
|
|
}, 0)
|
|
}
|
|
;(
|
|
window as Window & {
|
|
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
|
}
|
|
).__sessionTabPaint = {
|
|
samples,
|
|
removed: () => removedFirstPaintNodes,
|
|
stop: () => {
|
|
running = false
|
|
},
|
|
}
|
|
requestAnimationFrame(sample)
|
|
},
|
|
{ destination, last },
|
|
)
|
|
|
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
|
await page.waitForFunction(() =>
|
|
(
|
|
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
|
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
|
)
|
|
await page.waitForTimeout(200)
|
|
const first = await page.evaluate(() => {
|
|
const probe = (
|
|
window as Window & {
|
|
__sessionTabPaint?: {
|
|
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
|
removed: () => number
|
|
stop: () => void
|
|
}
|
|
}
|
|
).__sessionTabPaint!
|
|
probe.stop()
|
|
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
|
|
})
|
|
expect(first.first?.last).toBe(true)
|
|
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
|
expect(first.removed).toBe(0)
|
|
})
|
|
|
|
test("paints a cold session tab at the latest message", async ({ page }) => {
|
|
await mockOpenCodeServer(page, {
|
|
sessions: fixture.sessions,
|
|
provider: fixture.provider,
|
|
directory: fixture.directory,
|
|
project: fixture.project,
|
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
|
})
|
|
await configureSmokePage(page, fixture.directory)
|
|
await page.addInitScript(
|
|
({ dirBase64, sourceID, targetID }) => {
|
|
localStorage.setItem(
|
|
"opencode.window.browser.dat:tabs",
|
|
JSON.stringify(
|
|
[sourceID, targetID].map((sessionId) => ({
|
|
type: "session",
|
|
server: "http://127.0.0.1:4096",
|
|
dirBase64,
|
|
sessionId,
|
|
})),
|
|
),
|
|
)
|
|
},
|
|
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
|
)
|
|
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
|
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
|
await page.evaluate(
|
|
({ destination, last }) => {
|
|
const ids = new Set(destination)
|
|
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
|
|
const sample = () => {
|
|
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
|
element.querySelector("[data-timeline-row]"),
|
|
)
|
|
if (root) {
|
|
const view = root.getBoundingClientRect()
|
|
const spacer = root
|
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
|
?.getBoundingClientRect()
|
|
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
|
|
const rect = element.getBoundingClientRect()
|
|
return rect.bottom > view.top && rect.top < view.bottom
|
|
})
|
|
samples.push({
|
|
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
|
|
last: messages.some((element) => element.dataset.messageId === last),
|
|
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
|
|
})
|
|
}
|
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
|
}
|
|
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
|
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
|
},
|
|
{ destination, last },
|
|
)
|
|
|
|
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
|
await page.waitForFunction(() =>
|
|
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
|
|
(sample) => sample.destination,
|
|
),
|
|
)
|
|
const result = await page.evaluate(() => {
|
|
const samples = (
|
|
window as Window & {
|
|
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
|
|
}
|
|
).__coldTabSamples!
|
|
return samples.find((sample) => sample.destination)!
|
|
})
|
|
expect(result.last).toBe(true)
|
|
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
|
})
|
|
|
|
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
|
const errors = trackPageErrors(page)
|
|
await mockOpenCodeServer(page, {
|
|
sessions: fixture.sessions,
|
|
provider: fixture.provider,
|
|
directory: fixture.directory,
|
|
project: fixture.project,
|
|
pageMessages,
|
|
})
|
|
await configureSmokePage(page, fixture.directory)
|
|
|
|
await selectHomeProject(page, fixture.project.name)
|
|
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
|
await expectSessionReady(page)
|
|
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
|
const expectedPartIDs = fixture.expected.targetPartIDs
|
|
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
|
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
|
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
|
|
|
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
|
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
|
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
|
await expect(shellSubtitle).toHaveCount(0)
|
|
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
|
await shellTrigger.click()
|
|
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
|
await expect(shellSubtitle).toHaveText("bun typecheck")
|
|
await shellTrigger.click()
|
|
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
|
await expect(shellSubtitle).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
async function configureSmokePage(page: Page, directory: string) {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem(
|
|
"settings.v3",
|
|
JSON.stringify({
|
|
general: {
|
|
editToolPartsExpanded: true,
|
|
shellToolPartsExpanded: true,
|
|
showReasoningSummaries: true,
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
await page.addInitScript((directory) => {
|
|
localStorage.setItem(
|
|
"opencode.global.dat:server",
|
|
JSON.stringify({
|
|
projects: {
|
|
local: [{ worktree: directory, expanded: true }],
|
|
},
|
|
lastProject: {
|
|
local: directory,
|
|
},
|
|
}),
|
|
)
|
|
}, directory)
|
|
|
|
await page.addInitScript(() => {
|
|
const smoke = window as SmokeWindow
|
|
smoke.__timelineSmokeErrorToasts = []
|
|
smoke.__timelineSmokeForbiddenText = []
|
|
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
|
|
const idsOf = (el: HTMLElement) =>
|
|
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
|
|
|
|
smoke.__timelineSmokeState = () => {
|
|
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
|
el.querySelector("[data-timeline-row], [data-session-title]"),
|
|
)
|
|
if (!scroller) {
|
|
return {
|
|
ids: [],
|
|
visibleIds: [],
|
|
messageIds: [],
|
|
visibleMessageIds: [],
|
|
topVisibleId: undefined,
|
|
signature: "",
|
|
scrollTop: 0,
|
|
scrollHeight: 0,
|
|
clientHeight: 0,
|
|
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
|
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
|
}
|
|
}
|
|
|
|
const ids: string[] = []
|
|
const visibleIds: string[] = []
|
|
const scrollerRect = scroller.getBoundingClientRect()
|
|
let topVisibleId: string | undefined
|
|
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
|
|
const next = idsOf(el)
|
|
ids.push(...next)
|
|
|
|
const rect = el.getBoundingClientRect()
|
|
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
|
|
if (!topVisibleId) topVisibleId = next[0]
|
|
visibleIds.push(...next)
|
|
}
|
|
}
|
|
|
|
const messageIds: string[] = []
|
|
const visibleMessageIds: string[] = []
|
|
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
|
|
const rect = el.getBoundingClientRect()
|
|
const id = el.dataset.messageId
|
|
if (id) {
|
|
messageIds.push(id)
|
|
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
|
|
}
|
|
return {
|
|
id,
|
|
top: Math.round(rect.top),
|
|
bottom: Math.round(rect.bottom),
|
|
}
|
|
})
|
|
const signature = JSON.stringify({
|
|
top: Math.round(scroller.scrollTop),
|
|
height: Math.round(scroller.scrollHeight),
|
|
rows,
|
|
ids,
|
|
})
|
|
|
|
return {
|
|
ids,
|
|
visibleIds,
|
|
messageIds,
|
|
visibleMessageIds,
|
|
topVisibleId,
|
|
signature,
|
|
scrollTop: Math.round(scroller.scrollTop),
|
|
scrollHeight: Math.round(scroller.scrollHeight),
|
|
clientHeight: Math.round(scroller.clientHeight),
|
|
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
|
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
|
}
|
|
}
|
|
let recordFrame: number | undefined
|
|
const record = () => {
|
|
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
|
|
const text = toast.textContent?.trim()
|
|
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
|
|
}
|
|
const text = document.body?.textContent ?? ""
|
|
for (const value of ["Load details", "Show earlier steps"]) {
|
|
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
|
|
smoke.__timelineSmokeForbiddenText!.push(value)
|
|
}
|
|
}
|
|
}
|
|
const start = () => {
|
|
const root = document.documentElement ?? document.body
|
|
if (!root) return
|
|
new MutationObserver(() => {
|
|
if (recordFrame) return
|
|
recordFrame = requestAnimationFrame(() => {
|
|
recordFrame = undefined
|
|
record()
|
|
})
|
|
}).observe(root, { childList: true, subtree: true })
|
|
record()
|
|
}
|
|
if (document.documentElement ?? document.body) start()
|
|
else document.addEventListener("DOMContentLoaded", start, { once: true })
|
|
})
|
|
}
|
|
|
|
async function expectCanScrollToStart(
|
|
page: Page,
|
|
expectedPartIDs: string[],
|
|
expectedMessageIDs: string[],
|
|
errors: string[],
|
|
) {
|
|
await pointAtTimeline(page)
|
|
const seenParts = new Set<string>()
|
|
const seenMessages = new Set<string>()
|
|
const samples: TraversalSample[] = []
|
|
let current = await timelineState(page)
|
|
let unchangedAtTop = 0
|
|
|
|
for (let attempt = 0; attempt < 600; attempt++) {
|
|
collectSeen(current, seenParts, seenMessages)
|
|
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
|
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
|
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
|
|
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
|
|
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
|
|
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
|
|
|
|
if (
|
|
current.scrollTop <= 1 &&
|
|
seenParts.size === expectedPartIDs.length &&
|
|
seenMessages.size === expectedMessageIDs.length
|
|
) {
|
|
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
|
return
|
|
}
|
|
|
|
const before = current
|
|
const changed = await scrollTimelineUp(page, current)
|
|
current = await timelineState(page)
|
|
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
|
|
else unchangedAtTop = 0
|
|
if (unchangedAtTop >= 2) break
|
|
}
|
|
|
|
collectSeen(current, seenParts, seenMessages)
|
|
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
|
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
|
}
|
|
|
|
async function timelineState(page: Page) {
|
|
return page.evaluate(
|
|
() =>
|
|
(window as SmokeWindow).__timelineSmokeState?.() ?? {
|
|
ids: [],
|
|
visibleIds: [],
|
|
messageIds: [],
|
|
visibleMessageIds: [],
|
|
topVisibleId: undefined,
|
|
signature: "",
|
|
scrollTop: 0,
|
|
scrollHeight: 0,
|
|
clientHeight: 0,
|
|
errorToasts: [],
|
|
forbiddenText: [],
|
|
},
|
|
)
|
|
}
|
|
|
|
function timelineScroller(page: Page) {
|
|
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
|
}
|
|
|
|
async function pointAtTimeline(page: Page) {
|
|
const box = await timelineScroller(page).boundingBox()
|
|
if (!box) throw new Error("Timeline scroller is not visible")
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
}
|
|
|
|
async function scrollTimelineUp(page: Page, before: SmokeState) {
|
|
return page.evaluate(
|
|
(prev) =>
|
|
new Promise<boolean>((resolve) => {
|
|
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
|
el.querySelector("[data-timeline-row], [data-session-title]"),
|
|
)
|
|
if (!scroller) {
|
|
resolve(false)
|
|
return
|
|
}
|
|
|
|
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
|
|
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
|
|
|
|
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
|
let frames = 0
|
|
let stableFrames = 0
|
|
let last = ""
|
|
let changed = false
|
|
const check = () => {
|
|
const current = read()
|
|
if (current !== prev) changed = true
|
|
if (current === last) stableFrames++
|
|
else {
|
|
stableFrames = 0
|
|
last = current
|
|
}
|
|
if (changed && stableFrames >= 2) {
|
|
resolve(true)
|
|
return
|
|
}
|
|
frames++
|
|
if (frames >= 30) {
|
|
resolve(changed)
|
|
return
|
|
}
|
|
requestAnimationFrame(check)
|
|
}
|
|
requestAnimationFrame(check)
|
|
}),
|
|
before.signature,
|
|
)
|
|
}
|
|
|
|
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
|
|
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
|
|
const actualSet = new Set(actual)
|
|
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
|
|
}
|
|
|
|
function unique(values: string[]) {
|
|
return values.filter((value, index) => values.indexOf(value) === index)
|
|
}
|
|
|
|
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
|
|
for (const id of state.ids) seenParts.add(id)
|
|
for (const id of state.visibleIds) seenParts.add(id)
|
|
for (const id of state.messageIds) seenMessages.add(id)
|
|
for (const id of state.visibleMessageIds) seenMessages.add(id)
|
|
}
|
|
|
|
type TraversalSample = ReturnType<typeof sampleTraversal>
|
|
|
|
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
|
|
return {
|
|
seenParts,
|
|
seenMessages,
|
|
mounted: state.ids.length,
|
|
visible: state.visibleIds.length,
|
|
mountedMessages: unique(state.messageIds).length,
|
|
visibleMessages: unique(state.visibleMessageIds).length,
|
|
top: state.scrollTop,
|
|
height: state.scrollHeight,
|
|
first: state.ids[0],
|
|
last: state.ids.at(-1),
|
|
topVisible: state.topVisibleId,
|
|
visibleFirst: state.visibleIds[0],
|
|
visibleLast: state.visibleIds.at(-1),
|
|
}
|
|
}
|
|
|
|
function sampleSummary(samples: TraversalSample[]) {
|
|
return samples
|
|
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
|
|
.map(
|
|
(sample, index) =>
|
|
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
|
|
)
|
|
.join("\n")
|
|
}
|
|
|
|
async function waitForTimelineStable(page: Page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
new Promise<boolean>((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
|
requestAnimationFrame(() => {
|
|
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
|
requestAnimationFrame(() =>
|
|
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
|
|
)
|
|
})
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function expectSessionTimelineReady(
|
|
page: Page,
|
|
expectedPartIDs: string[],
|
|
expectedMessageIDs: string[],
|
|
errors: string[],
|
|
) {
|
|
await waitForTimelineStable(page)
|
|
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
|
const currentState = await timelineState(page)
|
|
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
|
|
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
|
|
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
|
|
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
|
|
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
|
|
}
|
|
|
|
function expectCompleteScroll(
|
|
state: SmokeState,
|
|
expectedPartIDs: string[],
|
|
expectedMessageIDs: string[],
|
|
seenParts: Set<string>,
|
|
seenMessages: Set<string>,
|
|
samples: TraversalSample[],
|
|
) {
|
|
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
|
|
expect(
|
|
expectedPartIDs.filter((id) => !seenParts.has(id)),
|
|
`missing visible timeline parts\n${sampleSummary(samples)}`,
|
|
).toEqual([])
|
|
expect(
|
|
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
|
|
`missing visible messages\n${sampleSummary(samples)}`,
|
|
).toEqual([])
|
|
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
|
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
|
expect(expectedPartIDs.length).toBe(331)
|
|
}
|
|
|
|
async function selectHomeProject(page: Page, projectName: string) {
|
|
await page.goto("/")
|
|
const row = page
|
|
.locator('[data-component="home-project-row"]')
|
|
.filter({ hasText: new RegExp(projectName, "i") })
|
|
.first()
|
|
await expectAppVisible(row)
|
|
await row.click()
|
|
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
|
|
await expect(page).toHaveURL(/\/$/)
|
|
}
|
|
|
|
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
|
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
|
await expectSessionTitle(page, expectedTitle)
|
|
}
|
|
|
|
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
|
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
|
await expect(tab).toBeVisible()
|
|
await tab.click()
|
|
await expectSessionTitle(page, title)
|
|
}
|
|
|
|
async function expectSessionReady(page: Page) {
|
|
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
|
}
|