Files
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

283 lines
14 KiB
TypeScript

import { PlaywrightWorkspaceSetupResult, expect, test } from '@playwright-utils/workspace-test-base'
import { Locator, Page } from '@playwright/test'
import snappy from 'snappyjs'
import { recordingMetaJson } from 'scenes/session-recordings/__mocks__/recording_meta'
import {
lateFullSnapshotAsJSONLines,
snapshotsAsJSONLines,
} from 'scenes/session-recordings/__mocks__/recording_snapshots'
import { SessionRecordingType } from '~/types'
// The base mock recording is ~12s across two browser windows, and serving each window as its own blob_v2 source exercises multi-source loading, promotion, and cross-source seeks.
const SESSION_ID = recordingMetaJson.id
const [windowOneJSONL, windowTwoJSONL] = snapshotsAsJSONLines().trim().split('\n')
const BASE_TS = 1682952380877 // first event of windowOneJSONL, matches recordingMetaJson.start_time
interface MockRecording {
meta: SessionRecordingType
blobs: string[] // JSONL body per blob key ('0', '1', ...)
delayMsByKey?: Record<string, number>
}
function isoAt(offsetMs: number): string {
return new Date(BASE_TS + offsetMs).toISOString()
}
function metaVariant(id: string, durationMs: number): SessionRecordingType {
return {
...recordingMetaJson,
id,
recording_duration: Math.floor(durationMs / 1000),
start_time: isoAt(0),
end_time: isoAt(durationMs),
}
}
function shiftJSONLine(line: string, offsetMs: number): string {
const parsed = JSON.parse(line)
return JSON.stringify({
window_id: parsed.window_id,
data: parsed.data.map((e: { timestamp: number }) => ({ ...e, timestamp: e.timestamp + offsetMs })),
})
}
function stripFullSnapshots(line: string): string {
const parsed = JSON.parse(line)
return JSON.stringify({
window_id: parsed.window_id,
data: parsed.data.filter((e: { type: number }) => e.type !== 2),
})
}
function lastEventTimestamp(line: string): number {
const parsed = JSON.parse(line)
return parsed.data[parsed.data.length - 1].timestamp
}
const RECORDINGS: Record<string, MockRecording> = {
// Two windows served as two blob sources.
[SESSION_ID]: {
meta: recordingMetaJson,
blobs: [windowOneJSONL, windowTwoJSONL],
},
// Same two sources, but the second blob arrives late — forces buffering then recovery.
[`${SESSION_ID}-slow`]: {
meta: metaVariant(`${SESSION_ID}-slow`, lastEventTimestamp(windowTwoJSONL) - BASE_TS),
blobs: [windowOneJSONL, windowTwoJSONL],
delayMsByKey: { '1': 4000 },
},
// ~6s of activity, a 2-minute idle gap, then the same activity again.
[`${SESSION_ID}-gap`]: {
meta: metaVariant(`${SESSION_ID}-gap`, lastEventTimestamp(windowOneJSONL) - BASE_TS + 126000),
blobs: [`${windowOneJSONL}\n${shiftJSONLine(windowOneJSONL, 126000)}`],
},
// The only full snapshot arrives 5s into the recording — seeks before it must clamp forward.
[`${SESSION_ID}-late`]: {
meta: metaVariant(`${SESSION_ID}-late`, 6000),
blobs: [lateFullSnapshotAsJSONLines(BASE_TS, 5000).trim()],
},
// No full snapshot at all — the recording can never render.
[`${SESSION_ID}-nofs`]: {
meta: metaVariant(`${SESSION_ID}-nofs`, lastEventTimestamp(windowOneJSONL) - BASE_TS),
blobs: [stripFullSnapshots(windowOneJSONL)],
},
}
// Frame each blob's JSONL as a raw-Snappy block behind a 4-byte big-endian length, matching what recording-api serves for blob_v2.
function snappyBlocks(texts: string[]): Buffer {
const parts: Buffer[] = []
for (const text of texts) {
const compressed = Buffer.from(snappy.compress(Buffer.from(text, 'utf-8')))
const length = Buffer.alloc(4)
length.writeUInt32BE(compressed.length, 0)
parts.push(length, compressed)
}
return Buffer.concat(parts)
}
async function mockRecordingApi(page: Page): Promise<void> {
for (const [sessionId, recording] of Object.entries(RECORDINGS)) {
await page.route(
new RegExp(`/v1/environments/\\d+/session_recordings/${sessionId}/snapshots/?(\\?.*)?$`),
async (route) => {
const url = new URL(route.request().url())
// Content requests carry ?source=blob_v2 (+ a blob key range); listing requests don't.
if (url.searchParams.get('source') === 'blob_v2') {
const start = Number(
url.searchParams.get('start_blob_key') ?? url.searchParams.get('blob_key') ?? 0
)
const end = Number(url.searchParams.get('end_blob_key') ?? start)
const keys = recording.blobs
.map((_, i) => `${i}`)
.filter((k) => Number(k) >= start && Number(k) <= end)
const delay = Math.max(...keys.map((k) => recording.delayMsByKey?.[k] ?? 0), 0)
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
// blob_v2 content is length-prefixed Snappy on the wire, which the player fetches with decompress=false and decompresses client-side.
return route.fulfill({
status: 200,
contentType: 'application/octet-stream',
body: snappyBlocks(keys.map((k) => recording.blobs[Number(k)])),
})
}
return route.fulfill({
status: 200,
json: {
sources: recording.blobs.map((blob, i) => {
const parsedFirst = JSON.parse(blob.split('\n')[0])
return {
source: 'blob_v2',
blob_key: `${i}`,
start_timestamp: new Date(parsedFirst.data[0].timestamp).toISOString(),
end_timestamp: new Date(lastEventTimestamp(blob.split('\n').at(-1)!)).toISOString(),
}
}),
},
})
}
)
await page.route(new RegExp(`/v1/environments/\\d+/session_recordings/${sessionId}/?(\\?.*)?$`), (route) =>
route.fulfill({ status: 200, json: recording.meta })
)
}
}
function playerFrame(page: Page): Locator {
return page.locator('.PlayerFrame__content .replayer-wrapper iframe')
}
// One button whose data-attr reflects player state and stays assertable while the auto-hiding controls chrome is hidden (hover via revealControls before clicking it).
function playPauseButton(page: Page): Locator {
return page.locator('[data-attr=recording-play], [data-attr=recording-pause], [data-attr=recording-rewind]').first()
}
async function revealControls(page: Page): Promise<void> {
await page.locator('.SessionRecordingPlayerWrapper').hover()
}
// The buffering state class lands on both the player container and the "Buffering…" overlay text, so scope to the first match to avoid a strict-mode violation.
function bufferingIndicator(page: Page): Locator {
return page.locator('.SessionRecordingPlayer--buffering').first()
}
async function scrubTo(page: Page, fraction: number): Promise<void> {
await revealControls(page)
const slider = page.locator('.PlayerSeekbar__slider')
const box = (await slider.boundingBox())!
await page.mouse.click(box.x + box.width * fraction, box.y + box.height / 2)
}
test.describe.configure({ mode: 'serial' })
test.describe('Session replay player', () => {
let workspace: PlaywrightWorkspaceSetupResult | null = null
test.beforeAll(async ({ playwrightSetup }) => {
workspace = await playwrightSetup.createWorkspace({ skip_onboarding: true })
})
test.beforeEach(async ({ page, playwrightSetup }) => {
await playwrightSetup.loginAndNavigateToTeam(page, workspace!)
await mockRecordingApi(page)
})
test('plays a multi-source recording, pauses, seeks, scrubs, and reaches the end', async ({ page }) => {
await test.step('boots and autoplays from the start', async () => {
await page.goto(`/replay/${SESSION_ID}?t=0`)
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:0[1-9]|00:1[01]/, { timeout: 15000 })
})
await test.step('pauses mid-playback', async () => {
await revealControls(page)
await page.locator('[data-attr=recording-pause]').click()
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-play')
})
await test.step('seeking backward while paused stays paused', async () => {
await revealControls(page)
await page.getByTestId('seek-skip-backward').click()
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:00.*00:11/)
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-play')
await expect(bufferingIndicator(page)).not.toBeVisible()
})
await test.step('resumes and plays across both windows to the end', async () => {
await revealControls(page)
await page.locator('[data-attr=recording-play]').click()
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
// Reaching the end proves playback advanced through both blob sources.
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-rewind', { timeout: 30000 })
})
await test.step('rewind at the end restarts playback from the start', async () => {
await page.getByTestId('replay-overlay-rewind').click()
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:0[0-5].*00:11/)
})
await test.step('scrubbing into the second window while playing keeps playing', async () => {
await scrubTo(page, 0.8)
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:0[89]|00:1[01]/)
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
// ...and end detection still works after a scrub.
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-rewind', { timeout: 15000 })
})
})
test('deep link with ?t and ?pause renders a paused frame mid-recording', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}?pause=true&t=9`)
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:09.*00:11/)
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-play')
await expect(bufferingIndicator(page)).not.toBeVisible()
})
test('deep link past the end clamps to the last frame instead of buffering forever', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}?pause=true&t=999`)
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:11.*00:11/)
await expect(page.getByTestId('replay-overlay-resume')).toBeAttached()
await expect(bufferingIndicator(page)).not.toBeVisible()
})
test('fast-forwards through long inactivity and resumes normal playback', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}-gap?t=0`)
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
// ~6s of activity precede the 2-minute gap; skipping plays the gap at >=50x.
await expect(page.getByText('Skipping inactivity')).toBeVisible({ timeout: 20000 })
await expect(page.getByTestId('recording-timestamp')).toHaveText(/02:0[6-9]|02:1[0-9]/, { timeout: 20000 })
await expect(playPauseButton(page)).toHaveAttribute('data-attr', 'recording-pause')
})
test('seeking before a late full snapshot clamps forward to the first renderable frame', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}-late?pause=true&t=0`)
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:05.*00:06/)
await expect(bufferingIndicator(page)).not.toBeVisible()
})
test('a recording with no full snapshot shows a terminal error instead of buffering', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}-nofs?pause=true&t=0`)
await expect(page.getByRole('heading', { name: "This recording can't be played" })).toBeVisible({
timeout: 30000,
})
await expect(bufferingIndicator(page)).not.toBeVisible()
})
test('buffers while a source is still loading and recovers when it arrives', async ({ page }) => {
await page.goto(`/replay/${SESSION_ID}-slow?pause=true&t=9`)
// The target position lives in the delayed second source, so the player must buffer first...
await expect(bufferingIndicator(page)).toBeVisible({ timeout: 15000 })
// ...and render the requested frame once the source lands, without manual intervention.
await expect(playerFrame(page)).toBeVisible({ timeout: 30000 })
await expect(page.getByTestId('recording-timestamp')).toHaveText(/00:09.*00:11/)
await expect(bufferingIndicator(page)).not.toBeVisible()
})
})