Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions apps/web/e2e/analytics-cors-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { readSessionAnalytics } from "@atape/application"
import { Effect } from "effect"
import { BrowserSessionAnalyticsGatewayLayer } from "../src/runtime/sessionAnalyticsGateway"

// Browser-only test entry: run the same application Interface and production
// Browser Adapter as the reader, with this Vite server's configured API origin.
export const readAnalyticsAcrossOrigins = () => Effect.runPromise(
readSessionAnalytics("session-reader").pipe(Effect.provide(BrowserSessionAnalyticsGatewayLayer)))
59 changes: 59 additions & 0 deletions apps/web/e2e/analytics-cors.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test"
import { createServer as createHTTPServer } from "node:http"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { createServer as createViteServer } from "vite"
import { tokenActivityFixture } from "../test/sessionTokenActivityFixture"

test("production analytics negotiation reads across origins without an OPTIONS preflight", async ({ page }) => {
const requests: Array<{ readonly method: string; readonly accept: string; readonly origin: string }> = []
let browserOrigin = ""
const api = createHTTPServer((request, response) => {
requests.push({ method: request.method ?? "", accept: request.headers.accept ?? "", origin: request.headers.origin ?? "" })
// This is deliberately as restrictive as the previous Server: no preflight
// allow-list for Accept. A long unsafe header must fail, not get waived.
if (request.method !== "GET" || request.url !== "/api/v1/sessions/session-reader/analytics" || request.headers.origin !== browserOrigin) {
response.writeHead(403); response.end(); return
}
response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store",
"Access-Control-Allow-Origin": browserOrigin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin" })
response.end(JSON.stringify(tokenActivityFixture))
})
const cacheDirectory = await mkdtemp(join(tmpdir(), "atape-analytics-cors-"))
let vite: Awaited<ReturnType<typeof createViteServer>> | undefined
try {
await new Promise<void>((resolve, reject) => { api.once("error", reject); api.listen(0, "127.0.0.1", resolve) })
const apiAddress = api.address()
if (apiAddress === null || typeof apiAddress === "string") throw new Error("Missing API test listener.")
const apiOrigin = `http://127.0.0.1:${apiAddress.port}`
vite = await createViteServer({
configFile: false, root: fileURLToPath(new URL("..", import.meta.url)), cacheDir: cacheDirectory,
define: { "import.meta.env.VITE_ATAPE_API_ORIGIN": JSON.stringify(apiOrigin) },
server: { host: "127.0.0.1", port: 0, strictPort: false, open: false },
plugins: [{ name: "analytics-cors-test-page", configureServer(server) {
server.middlewares.use("/__analytics-cors-probe", (_request, response) => {
response.setHeader("Content-Type", "text/html")
response.end('<!doctype html><title>Analytics CORS probe</title><script type="module">window.analyticsProbe = import("/e2e/analytics-cors-probe.ts").then(module => module.readAnalyticsAcrossOrigins());</script>')
})
} }]
})
await vite.listen()
const address = vite.httpServer?.address()
if (address === null || address === undefined || typeof address === "string") throw new Error("Missing browser test listener.")
browserOrigin = `http://127.0.0.1:${address.port}`
expect(browserOrigin).not.toBe(apiOrigin)
await page.goto(`${browserOrigin}/__analytics-cors-probe`)
await page.waitForFunction(() => "analyticsProbe" in window)
const report = await page.evaluate(async () => await (window as typeof window & { analyticsProbe: Promise<unknown> }).analyticsProbe)
expect(report).toMatchObject({ analyticsVersion: 5, sessionId: "session-reader", tokenActivity: { samples: 4 } })
expect(requests).toEqual([{ method: "GET", origin: browserOrigin,
accept: "application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json" }])
expect(Buffer.byteLength(requests[0]!.accept)).toBeLessThanOrEqual(128)
} finally {
await vite?.close()
await new Promise<void>(resolve => { api.close(() => resolve()); api.closeAllConnections() })
await rm(cacheDirectory, { recursive: true, force: true })
}
})
8 changes: 4 additions & 4 deletions apps/web/e2e/session-analytics.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ const activityAPI = async (page: Page) => {
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()); reads.push(url)
expect(route.request().method()).toBe("GET")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers()).not.toHaveProperty("atape-accept-analytics-version")
await hold?.(url)
if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "The selected snapshot changed." } }); return }
Expand Down Expand Up @@ -458,7 +458,7 @@ test("old v1 and v2 Servers expose unavailable activity while retaining their ex
const reads: URL[] = []
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()); reads.push(url)
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
expect(url.searchParams.has("range")).toBe(false)
expect(url.searchParams.get("metric")).not.toBe("records")
await route.fulfill({ json: version === 1 ? analyticsFixture : capabilitiesFixture })
Expand Down Expand Up @@ -540,7 +540,7 @@ const stableToolAPI = async (page: Page) => {
let mode: "ready" | "changed" | "replacement" | "denied" = "ready"
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()); reads.push(url)
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return }
if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return }
if (mode === "replacement") {
Expand Down Expand Up @@ -647,7 +647,7 @@ test("a v3 Server keeps activity and display-label tool filters when it ignores
const reads: URL[] = []
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()); reads.push(url)
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
await route.fulfill({ json: activityFixture })
})
await page.goto(path)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/session-files.pw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const fileAPI = async (page: Page) => {
let mode: "ready" | "changed" | "replacement" | "denied" = "ready"
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()), query = url.searchParams; reads.push(url)
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json, application/vnd.atape.session-analytics.v3+json, application/json")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return }
if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return }
if (mode === "replacement") {
Expand Down
142 changes: 142 additions & 0 deletions apps/web/e2e/session-token-activity.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { expect, test, type Page } from "@playwright/test"
import { stableToolsFixture } from "../test/sessionAnalyticsFixture"
import { tokenActivityReport, tokenActivityResponse, tokenBucketLabel, tokenHead, tokenModelKey, tokenSnapshot, tokenUnknownModelKey } from "../test/sessionTokenActivityFixture"

const path = "/teams/team-id/projects/project-1/sessions/session-reader?thread=root"
test.beforeEach(async ({ context, request }) => {
await request.post("http://127.0.0.1:8080/__fixture/reset")
await context.addCookies([{ name: "fixture_session", value: "1", url: "http://127.0.0.1:4187" }])
})

const tokenAPI = async (page: Page) => {
const reads: URL[] = [], violations: string[] = []
let mode: "ready" | "changed" | "denied" = "ready"
await page.route("**/api/v1/sessions/session-reader/analytics*", async route => {
const url = new URL(route.request().url()); reads.push(url)
expect(route.request().method()).toBe("GET")
expect(route.request().headers().accept).toBe("application/vnd.atape.session-analytics.v4+json;v=5, application/vnd.atape.session-analytics.v3+json, application/json")
if (mode === "changed") { await route.fulfill({ status: 409, json: { code: "refresh_required", detail: "Changed" } }); return }
if (mode === "denied") { await route.fulfill({ status: 404, json: { code: "not_found", detail: "Unavailable" } }); return }
const response = tokenActivityResponse(url.searchParams)
if (response.violation) violations.push(response.violation)
await route.fulfill({ status: response.status, json: response.body })
})
return { reads, violations, mode: (value: typeof mode) => { mode = value } }
}

const openTokens = async (page: Page) => {
await page.goto(path)
await page.getByRole("button", { name: "Session analysis", exact: true }).click()
const panel = page.getByRole("region", { name: "Session analysis", exact: true })
await expect(panel.getByRole("heading", { name: "Token consumption", exact: true })).toBeVisible()
return { panel, tokens: panel.getByRole("region", { name: "Token consumption", exact: true }) }
}

const conditionalReader = async (page: Page) => {
const reads: URL[] = []
await page.route("**/api/v1/sessions/session-reader?*", async route => {
const url = new URL(route.request().url()); reads.push(url)
const response = await route.fetch({ url: `${url.origin}${url.pathname}?thread=root` }), value = await response.json()
value.thread = { ...value.thread, id: url.searchParams.get("thread") ?? "root" }
value.head = tokenHead; value.snapshot = tokenSnapshot
if (url.searchParams.get("at") === "token-context-21") value.events = [{ id: "token-context-21", kind: "tool_result", author: "Agent",
text: "Contemporaneous context, not a per-response Token attribution", occurredAt: "2026-10-13T08:01:30Z",
tool: { sessionUpdate: "tool_call_update", toolCallId: "context-call", title: "Read file", status: "completed" } }]
await route.fulfill({ json: value })
})
return reads
}

test("Token hotspots retain model, Thread, snapshot and page through conditional Reader focus and browser Back", async ({ page }) => {
const api = await tokenAPI(page), readerReads = await conditionalReader(page), { panel, tokens } = await openTokens(page)
await expect(tokens.getByText("3 reported · 1 missing", { exact: true })).toHaveCount(5)
await expect(tokens.getByLabel("Recorded input cache shares", { exact: true })).toContainText("Cache read share20%Cache write share10%Uncached input share70%")
await tokens.getByLabel("Token model", { exact: true }).selectOption(tokenModelKey)
await tokens.getByLabel("Token thread", { exact: true }).selectOption("child")
await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child")
await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue(tokenModelKey)
const hot = tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel("child", tokenModelKey), exact: true })
await hot.click()
const range = tokenActivityReport("child", tokenModelKey).buckets[1]!.range
await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("token_records")
await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(20)
expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toMatchObject({ metric: "token_records", model: tokenModelKey,
thread: "child", snapshot: tokenSnapshot, tokenRange: range })
await panel.getByRole("button", { name: "Next evidence page", exact: true }).click()
await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(4)
expect(api.reads.at(-1)!.searchParams.get("tokenRange")).toBe(range)
expect(api.reads.at(-1)!.searchParams.get("cursor")).toBe(`next-${range}`)
const link = panel.getByRole("link", { name: "Token interval context", exact: true })
await expect(link).toHaveAttribute("href", /snapshot=token-snapshot-one/)
await link.click()
await expect(page.locator("#event-token-context-21")).toBeFocused()
await expect(page.locator(".narrative-activity:has(#event-token-context-21)")).toHaveAttribute("open", "")
expect(Object.fromEntries(readerReads.at(-1)!.searchParams)).toMatchObject({ snapshot: tokenSnapshot, head: tokenHead,
thread: "child", at: "token-context-21" })
expect(readerReads.at(-1)!.searchParams.has("model")).toBe(false)
await page.goBack()
await expect(page).toHaveURL(/analysis=open/)
await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue(tokenModelKey)
await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child")
await expect(hot).toHaveAttribute("aria-pressed", "true")
await expect(panel.getByRole("button", { name: "First evidence page", exact: true })).toBeEnabled()
await expect(panel.locator(".analytics-evidence-list").getByRole("link")).toHaveCount(4)
expect(api.violations).toEqual([])
})

test("changing Token scopes clears interval and cursor, preserves a literal unknown model, and full refresh clears all selections", async ({ page }) => {
const api = await tokenAPI(page), { panel, tokens } = await openTokens(page)
await expect(tokens.getByLabel("Token model", { exact: true }).locator(`option[value="${tokenModelKey}"]`)).toHaveText("unknown")
await expect(tokens.getByLabel("Token model", { exact: true }).locator(`option[value="${tokenUnknownModelKey}"]`)).toHaveText("Model unknown")
await tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel(), exact: true }).click()
await panel.getByRole("button", { name: "Next evidence page", exact: true }).click()
await tokens.getByLabel("Token model", { exact: true }).selectOption(tokenUnknownModelKey)
await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("records")
expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toEqual({ metric: "records", model: tokenUnknownModelKey, snapshot: tokenSnapshot })
await tokens.getByLabel("Token thread", { exact: true }).selectOption("child")
await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("child")
expect(api.reads.at(-1)!.searchParams.get("model")).toBe(tokenUnknownModelKey)
await panel.getByLabel("Evidence type", { exact: true }).selectOption("thoughts")
await expect(panel.getByLabel("Evidence type", { exact: true })).toHaveValue("thoughts")
expect(api.reads.at(-1)!.searchParams.get("model")).toBe(tokenUnknownModelKey)
await tokens.getByLabel("Token model", { exact: true }).selectOption("")
await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue("")
expect(api.reads.at(-1)!.searchParams.has("model")).toBe(false)
await tokens.locator(".analytics-token-hotspots").getByRole("button", { name: tokenBucketLabel("child"), exact: true }).click()
api.mode("changed")
await panel.getByRole("button", { name: "Next evidence page", exact: true }).click()
await expect(panel.getByRole("heading", { name: "Conversation has changed", exact: true })).toBeVisible()
await expect(panel.locator(".analytics-token-activity")).toHaveCount(0)
api.mode("ready")
await panel.getByRole("button", { name: "Reload analysis", exact: true }).first().click()
await expect(tokens.getByLabel("Token thread", { exact: true })).toHaveValue("")
await expect(tokens.getByLabel("Token model", { exact: true })).toHaveValue("")
expect(Object.fromEntries(api.reads.at(-1)!.searchParams)).toEqual({ metric: "tools" })
expect(api.violations).toEqual([])
})

test("zero-context intervals stay visible without a nearest-record jump and Token presentation fits a narrow Reader", async ({ page }) => {
const api = await tokenAPI(page), { tokens } = await openTokens(page)
const empty = tokens.locator(".analytics-token-bars").getByRole("button", { name: tokenBucketLabel(null, null, 2), exact: true })
await expect(empty).toBeVisible()
await expect(empty).toBeDisabled()
await expect(empty).toHaveAttribute("title", "No contemporaneous records are available in this interval.")
const untimed = tokens.getByText("Known subtotals without a usage time (1 samples)", { exact: true })
await untimed.click()
await expect(tokens.getByText("Included in subtotals; no interval can be selected without a time.", { exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
expect(await page.locator(".session-main-reader").evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
expect(api.reads).toHaveLength(1)
expect(api.violations).toEqual([])
})

test("a v4 Server retains its reports and explicitly marks Token distribution unavailable", async ({ page }) => {
await page.route("**/api/v1/sessions/session-reader/analytics*", route => route.fulfill({ json: stableToolsFixture }))
const { panel, tokens } = await openTokens(page)
await expect(tokens.getByText("This Server does not provide Token distribution or cache shares yet.", { exact: true })).toBeVisible()
await expect(tokens.getByLabel("Recorded input cache shares", { exact: true })).toHaveCount(0)
await expect(tokens.locator(".analytics-token-bars")).toHaveCount(0)
await expect(panel.getByRole("heading", { name: "Recorded tools", exact: true })).toBeVisible()
await expect(panel.getByRole("heading", { name: "Recorded file activity", exact: true })).toBeVisible()
})
Loading
Loading