Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions packages/app/e2e/session/session-renderer-diagnostics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import {
promptSelector,
scrollViewportSelector,
sessionMessageItemSelector,
sessionTurnListSelector,
} from "../selectors"
import { createSdk } from "../utils"

type Sdk = ReturnType<typeof createSdk>

type CapturedDiagnosticEvent = {
name: string
route_session_id?: string
visible_session_id?: string
timeline_session_id?: string
trace_id?: string
data?: Record<string, unknown>
}

type TimelineMetrics = {
top: number
height: number
client: number
distanceFromBottom: number
}

async function installRendererDiagnosticsCapture(page: Page) {
await page.addInitScript(() => {
const win = window as typeof window & {
__pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[]
api?: {
emitRendererDiagnostic?: (event: CapturedDiagnosticEvent) => Promise<void>
}
}
win.__pawwork_renderer_diagnostics = []
const originalEmit = win.api?.emitRendererDiagnostic?.bind(win.api)
win.api = {
...(win.api ?? {}),
emitRendererDiagnostic: async (event) => {
win.__pawwork_renderer_diagnostics?.push(JSON.parse(JSON.stringify(event)))
await originalEmit?.(event)
},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}

async function readRendererDiagnostics(page: Page) {
return page.evaluate(() => {
const win = window as typeof window & {
__pawwork_renderer_diagnostics?: CapturedDiagnosticEvent[]
}
return win.__pawwork_renderer_diagnostics ?? []
}) as Promise<CapturedDiagnosticEvent[]>
}

async function seedSessionTurns(input: { sdk: Sdk; sessionID: string; count: number }) {
for (let i = 0; i < input.count; i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: `diagnostics seed ${i}\n${Array.from({ length: 16 }, (_, line) => `line ${line} ${"content ".repeat(8)}`).join("\n")}`,
},
],
})
}
}

function timelineMetrics(page: Page) {
return page.evaluate(
({ scrollViewportSelector, turnListSelector }) => {
const list = document.querySelector(turnListSelector)
const viewport = list?.closest(scrollViewportSelector)
if (!(viewport instanceof HTMLElement)) return null
return {
top: viewport.scrollTop,
height: viewport.scrollHeight,
client: viewport.clientHeight,
distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
}
},
{ scrollViewportSelector, turnListSelector: sessionTurnListSelector },
) as Promise<TimelineMetrics | null>
}

async function expectTimelineMetrics(page: Page) {
const metrics = await timelineMetrics(page)
expect(metrics, "session timeline viewport should exist").not.toBeNull()
return metrics!
}

async function scrollTimelineToBottom(page: Page) {
const found = await page.evaluate(
({ scrollViewportSelector, turnListSelector }) => {
const list = document.querySelector(turnListSelector)
const viewport = list?.closest(scrollViewportSelector)
if (!(viewport instanceof HTMLElement)) return false
viewport.scrollTop = viewport.scrollHeight
viewport.dispatchEvent(new Event("scroll", { bubbles: true }))
return true
},
{ scrollViewportSelector, turnListSelector: sessionTurnListSelector },
)
expect(found, "session timeline viewport should exist").toBe(true)
}

async function sendVisiblePrompt(input: { page: Page; text: string }) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.insertText(input.text)
await expect.poll(async () => (await prompt.textContent())?.replace(/\u200B/g, "").trim()).toBe(input.text)
await input.page.keyboard.press("Enter")
}

function numberData(event: CapturedDiagnosticEvent, key: string) {
const value = event.data?.[key]
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}

test("captures renderer diagnostics while guarding send scroll position", async ({ page, project }) => {
test.setTimeout(120_000)

await installRendererDiagnosticsCapture(page)
await project.open()
const sdk = project.sdk

await withSession(sdk, `e2e renderer diagnostics ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await seedSessionTurns({ sdk, sessionID: session.id, count: 18 })

await project.gotoSession(session.id)
await expect(page.locator(sessionMessageItemSelector)).toHaveCount(10, { timeout: 30_000 })
await scrollTimelineToBottom(page)
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(40)

const scrollAnchorBefore = await page
.locator(sessionMessageItemSelector)
.last()
.evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null))
const metricsBefore = await expectTimelineMetrics(page)
const beforeCount = await page.locator(sessionMessageItemSelector).count()

await sendVisiblePrompt({ page, text: `diagnostics guard ${Date.now()}` })
await expect(page.locator(sessionMessageItemSelector)).toHaveCount(beforeCount + 1, { timeout: 30_000 })
await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(80)

const metricsAfter = await expectTimelineMetrics(page)
const scrollAnchorAfter = await page
.locator(sessionMessageItemSelector)
.nth(beforeCount - 1)
.evaluate((item) => (item instanceof HTMLElement ? item.dataset.messageId : null))
expect(scrollAnchorBefore).not.toBeNull()
expect(scrollAnchorAfter).toBe(scrollAnchorBefore)
expect(Math.abs(metricsAfter.top - metricsBefore.top)).toBeLessThan(200)

const events = await readRendererDiagnostics(page)
expect(events.some((event) => event.name === "session.action.submit")).toBe(true)
expect(events.some((event) => event.name === "session.timeline.mount")).toBe(true)
expect(events.some((event) => event.name === "session.timeline.visible")).toBe(true)
expect(events.filter((event) => event.name === "session.timeline.mount")).toHaveLength(1)
expect(events.filter((event) => event.name === "session.timeline.unmount")).toHaveLength(0)
expect(events.filter((event) => event.name.startsWith("incident."))).toEqual([])

const visibleCounts = events
.filter((event) => event.name === "session.timeline.visible")
.map((event) => numberData(event, "rendered_count") ?? 0)
expect(Math.min(...visibleCounts)).toBeGreaterThan(0)
expect(
events.some((event) => event.name === "session.scroll.sample" && event.data?.user_scrolled === false),
).toBe(true)
})
})
3 changes: 3 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context"
import type { RendererDiagnosticInput, RendererDiagnosticsExportResult } from "@/context/platform"
import { useCheckServerHealth } from "./utils/server-health"

const HomeRoute = lazy(() => import("@/pages/home"))
Expand Down Expand Up @@ -87,6 +88,8 @@ declare global {
}
api?: {
setDesktopContext?: (context: DesktopContext) => Promise<void>
emitRendererDiagnostic?: (event: RendererDiagnosticInput) => Promise<void>
exportDiagnosticsLog?: () => Promise<RendererDiagnosticsExportResult>
getAboutInfo?: () => Promise<AboutInfo>
onAboutOpen?: (handler: () => void) => () => void
setLspEnabled?: (value: boolean) => Promise<void>
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { emitRendererDiagnostic } from "@/context/renderer-diagnostics"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { promptProbe } from "@/testing/prompt"
Expand Down Expand Up @@ -510,6 +511,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {

const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const submittedPromptLength = input.promptLength(currentPrompt)
const submittedImageCount = images.length
const submittedCommentCount = input.commentCount()

const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
Expand All @@ -521,6 +525,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {

removeCommentItems(commentItems)
clearInput()
void emitRendererDiagnostic({
name: "session.action.submit",
trace_id: messageID,
route_session_id: session.id,
visible_session_id: session.id,
timeline_session_id: session.id,
data: {
action: "submit",
provider: model.providerID,
model: model.modelID,
endpoint_kind: "prompt",
prompt_length: submittedPromptLength,
image_count: submittedImageCount,
comment_count: submittedCommentCount,
},
}).catch(() => {})
Comment thread
Astro-Han marked this conversation as resolved.

const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export type ReportProblemResult =
| { status: "unavailable"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "none" } }
| { status: "failed"; summaryCopied: false; feedbackOpened: false; fullReport: { status: "failed" } }

export type RendererDiagnosticInput = {
name: string
level?: "info" | "warn"
monotonic_ms?: number
trace_id?: string
route_session_id?: string
visible_session_id?: string
timeline_session_id?: string
message_id?: string
part_id?: string
data?: Record<string, unknown>
}

export type RendererDiagnosticsExportResult = { ok: true; path: string } | { ok: false; error: string }

export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
Expand Down Expand Up @@ -115,6 +130,12 @@ export type Platform = {
/** Prepare a problem report and open the configured feedback form (desktop only) */
reportProblem?(input?: ReportProblemInput): Promise<ReportProblemResult>

/** Emit a local renderer diagnostics event. Desktop only; no-op on web. */
emitRendererDiagnostic?(event: RendererDiagnosticInput): Promise<void>

/** Export the current local renderer diagnostics log. Desktop only. */
exportDiagnosticsLog?(): Promise<RendererDiagnosticsExportResult>

/** Install updates (desktop only) */
update?(): Promise<void>

Expand Down
Loading
Loading