From 2ba6f313810fa88914184e07664d2c7b0fcc66f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 21 Jul 2026 11:33:23 +0200 Subject: [PATCH 1/2] chore(lint): prevent generic isRecord guards --- biome.jsonc | 3 +- lint-rules/no-generic-is-record.grit | 22 ++++ src/commands/alert/mutation-utils.ts | 4 +- src/lib/formatters/replay.ts | 135 +++++++++++++++----- src/lib/init/wizard-runner.ts | 46 +++++-- test/lib/formatters/replay.test.ts | 95 ++++++++++++++ test/script/no-generic-is-record.test.ts | 154 +++++++++++++++++++++++ 7 files changed, 415 insertions(+), 44 deletions(-) create mode 100644 lint-rules/no-generic-is-record.grit create mode 100644 test/lib/formatters/replay.test.ts create mode 100644 test/script/no-generic-is-record.test.ts diff --git a/biome.jsonc b/biome.jsonc index d4077f2e0f..81d34635f1 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -8,7 +8,8 @@ "./lint-rules/no-manual-transactions.grit", "./lint-rules/no-inline-touch-cache.grit", "./lint-rules/no-stderr-write-in-commands.grit", - "./lint-rules/no-args-join-in-release.grit" + "./lint-rules/no-args-join-in-release.grit", + "./lint-rules/no-generic-is-record.grit" ], "files": { // custom-ca.ts excluded: Biome's type analysis hits the 200k type limit diff --git a/lint-rules/no-generic-is-record.grit b/lint-rules/no-generic-is-record.grit new file mode 100644 index 0000000000..b5303c467c --- /dev/null +++ b/lint-rules/no-generic-is-record.grit @@ -0,0 +1,22 @@ +file($name, $body) where { + $name <: r".*src/.*", + $body <: contains or { + `function isRecord($args): $return_type { $function_body }` as $match, + `function isRecord($args) { $function_body }` as $match, + `async function isRecord($args): $return_type { $function_body }` as $match, + `async function isRecord($args) { $function_body }` as $match, + `const isRecord = ($args): $return_type => $implementation` as $match, + `const isRecord = ($args) => $implementation` as $match, + `const isRecord = $arg => $implementation` as $match, + `const isRecord: $type = ($args) => $implementation` as $match, + `const isRecord = function ($args) { $function_body }` as $match, + `const isRecord: $type = function ($args) { $function_body }` as $match, + `let isRecord = ($args): $return_type => $implementation` as $match, + `let isRecord = ($args) => $implementation` as $match, + `let isRecord = $arg => $implementation` as $match, + `let isRecord: $type = ($args) => $implementation` as $match, + `let isRecord = function ($args) { $function_body }` as $match, + `let isRecord: $type = function ($args) { $function_body }` as $match + }, + register_diagnostic(span=$match, message="Use a local, shape-specific guard (for example, hasFileMap()) instead of the generic isRecord() name.") +} diff --git a/src/commands/alert/mutation-utils.ts b/src/commands/alert/mutation-utils.ts index c2126ffcc8..077c689cb4 100644 --- a/src/commands/alert/mutation-utils.ts +++ b/src/commands/alert/mutation-utils.ts @@ -63,12 +63,12 @@ function parseJsonValue(raw: string, field: string): unknown { } } -function isRecord(value: unknown): value is Record { +function isJsonObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function toObject(value: unknown, field: string): Record { - if (!isRecord(value)) { + if (!isJsonObject(value)) { throw new ValidationError(`${field} entries must be JSON objects.`, field); } return value; diff --git a/src/lib/formatters/replay.ts b/src/lib/formatters/replay.ts index 6785b126e3..d1053015e8 100644 --- a/src/lib/formatters/replay.ts +++ b/src/lib/formatters/replay.ts @@ -35,8 +35,71 @@ export type ReplayViewData = { type MarkdownRow = [string, string]; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; +function hasReplayEventData( + value: unknown +): value is { data: unknown; timestamp?: unknown } { + return typeof value === "object" && value !== null && "data" in value; +} + +function hasReplayTag( + value: unknown +): value is { payload?: unknown; tag: string } { + return ( + typeof value === "object" && + value !== null && + "tag" in value && + typeof value.tag === "string" && + value.tag.length > 0 + ); +} + +function hasReplayHref(value: unknown): value is { href: string } { + return ( + typeof value === "object" && + value !== null && + "href" in value && + typeof value.href === "string" && + value.href.length > 0 + ); +} + +function hasPerformanceSpanFields( + value: unknown +): value is { data?: unknown; description?: unknown; op?: unknown } { + return ( + typeof value === "object" && + value !== null && + ("data" in value || "description" in value || "op" in value) + ); +} + +function hasNumericDuration(value: unknown): value is { duration: number } { + return ( + typeof value === "object" && + value !== null && + "duration" in value && + typeof value.duration === "number" + ); +} + +function hasClickFields( + value: unknown +): value is { label?: unknown; selector?: unknown } { + return ( + typeof value === "object" && + value !== null && + ("label" in value || "selector" in value) + ); +} + +function hasBreadcrumbFields( + value: unknown +): value is { category?: unknown; message?: unknown } { + return ( + typeof value === "object" && + value !== null && + ("category" in value || "message" in value) + ); } function getEventTimestampMillis(value: unknown): number | null { @@ -59,14 +122,17 @@ function compactDetails(values: Array): string[] { } function summarizePerformanceSpan( - payload: Record | null + payload: unknown ): Omit | null { - const op = firstString(payload?.op); - const description = firstString(payload?.description); - const durationMs = - isRecord(payload?.data) && typeof payload.data.duration === "number" - ? payload.data.duration - : null; + if (!hasPerformanceSpanFields(payload)) { + return null; + } + + const op = firstString(payload.op); + const description = firstString(payload.description); + const durationMs = hasNumericDuration(payload.data) + ? payload.data.duration + : null; if (!(description || op)) { return null; @@ -83,11 +149,15 @@ function summarizePerformanceSpan( function summarizeClickLikeEvent( label: string, - payload: Record | null, + payload: unknown, includeLabel = false ): Omit { - const selector = firstString(payload?.selector); - const clickLabel = firstString(payload?.label); + if (!hasClickFields(payload)) { + return { label, details: [] }; + } + + const selector = firstString(payload.selector); + const clickLabel = firstString(payload.label); return { label, @@ -99,10 +169,14 @@ function summarizeClickLikeEvent( } function summarizeBreadcrumb( - payload: Record | null + payload: unknown ): Omit | null { - const category = firstString(payload?.category); - const message = firstString(payload?.message); + if (!hasBreadcrumbFields(payload)) { + return null; + } + + const category = firstString(payload.category); + const message = firstString(payload.message); if (!(category || message)) { return null; } @@ -115,51 +189,46 @@ function summarizeBreadcrumb( const TAGGED_REPLAY_EVENT_SUMMARIZERS: Record< string, - ( - payload: Record | null - ) => Omit | null + (payload: unknown) => Omit | null > = { breadcrumb: summarizeBreadcrumb, - click: (payload: Record | null) => - summarizeClickLikeEvent("click", payload, true), - deadClick: (payload: Record | null) => + click: (payload: unknown) => summarizeClickLikeEvent("click", payload, true), + deadClick: (payload: unknown) => summarizeClickLikeEvent("dead.click", payload), performanceSpan: summarizePerformanceSpan, - rageClick: (payload: Record | null) => + rageClick: (payload: unknown) => summarizeClickLikeEvent("rage.click", payload), }; function summarizeTaggedReplayEvent( tag: string, - payload: Record | null + payload: unknown ): Omit | null { const summarize = TAGGED_REPLAY_EVENT_SUMMARIZERS[tag]; return summarize ? summarize(payload) : null; } function summarizeReplayEvent(event: unknown): ReplayActivityEvent | null { - if (!isRecord(event)) { + if (!hasReplayEventData(event)) { return null; } const timestampMs = getEventTimestampMillis(event.timestamp); - const data = isRecord(event.data) ? event.data : null; - const tag = typeof data?.tag === "string" ? data.tag : ""; - const payload = isRecord(data?.payload) ? data.payload : null; - - if (tag) { - const replayEvent = summarizeTaggedReplayEvent(tag, payload); + if (hasReplayTag(event.data)) { + const replayEvent = summarizeTaggedReplayEvent( + event.data.tag, + event.data.payload + ); if (replayEvent) { return { timestampMs, ...replayEvent }; } } - const href = firstString(data?.href); - if (href) { + if (hasReplayHref(event.data)) { return { timestampMs, label: "page.view", - details: [`href=${href}`], + details: [`href=${event.data.href}`], }; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 90a3892cca..10aa98c733 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -113,14 +113,47 @@ function nextPhase( return names[Math.min(phase - 1, names.length - 1)] ?? "done"; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function hasFileMap( + value: unknown +): value is { files: Record } { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "files" in value && + typeof value.files === "object" && + value.files !== null && + !Array.isArray(value.files) + ); +} + +function hasHttpStatus(value: unknown): value is { status: number } { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "status" in value && + typeof value.status === "number" + ); +} + +function hasActiveStepsPath(value: Record): value is Record< + string, + unknown +> & { + activeStepsPath: Record; +} { + return ( + typeof value.activeStepsPath === "object" && + value.activeStepsPath !== null && + !Array.isArray(value.activeStepsPath) + ); } function filePathMarkersForHistory( data: unknown ): Record | undefined { - if (!(isRecord(data) && isRecord(data.files))) { + if (!hasFileMap(data)) { return; } @@ -431,7 +464,7 @@ function assertWorkflowResult(raw: unknown): WorkflowRunResult { ) { throw new Error(`Unexpected workflow status: ${String(obj.status)}`); } - if (isRecord(obj.activeStepsPath)) { + if (hasActiveStepsPath(obj)) { const activeStepIds = Object.keys(obj.activeStepsPath); if (activeStepIds.length > 0) { obj.suspended = activeStepIds.map((id) => [id]); @@ -628,10 +661,7 @@ function isStepAlreadyAdvancedError(err: unknown): boolean { } function httpStatus(err: unknown): number | undefined { - if (!isRecord(err)) { - return; - } - return typeof err.status === "number" ? err.status : undefined; + return hasHttpStatus(err) ? err.status : undefined; } function runStateRecoveryBackoffMs(): number[] { diff --git a/test/lib/formatters/replay.test.ts b/test/lib/formatters/replay.test.ts new file mode 100644 index 0000000000..7176fcb1db --- /dev/null +++ b/test/lib/formatters/replay.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for shape-specific Session Replay activity extraction. + */ + +import { describe, expect, test } from "vitest"; +import { extractReplayActivityEvents } from "../../../src/lib/formatters/replay.js"; + +describe("extractReplayActivityEvents", () => { + test("extracts page, click, performance, and breadcrumb shapes", () => { + const events = extractReplayActivityEvents( + [ + [ + { timestamp: 1, data: { href: "/checkout" } }, + { + timestamp: 2, + data: { + tag: "click", + payload: { selector: "#pay", label: "Pay now" }, + }, + }, + { + timestamp: 3, + data: { + tag: "performanceSpan", + payload: { + op: "resource.fetch", + description: "GET /api/cart", + data: { duration: 42 }, + }, + }, + }, + { + timestamp: 4, + data: { + tag: "breadcrumb", + payload: { category: "ui.click", message: "Submitted cart" }, + }, + }, + ], + ], + 10 + ); + + expect(events).toEqual([ + { + timestampMs: 1, + label: "page.view", + details: ["href=/checkout"], + }, + { + timestampMs: 2, + label: "click", + details: ["selector=#pay", "label=Pay now"], + }, + { + timestampMs: 3, + label: "resource.fetch", + details: ["description=GET /api/cart", "duration_ms=42"], + }, + { + timestampMs: 4, + label: "ui.click", + details: ["message=Submitted cart"], + }, + ]); + }); + + test("ignores malformed shapes and keeps empty click payload behavior", () => { + const events = extractReplayActivityEvents( + [ + [ + null, + [], + { timestamp: 1 }, + { timestamp: 2, data: null }, + { timestamp: 3, data: { href: "" } }, + { timestamp: 4, data: { tag: "click", payload: {} } }, + { + timestamp: 5, + data: { + tag: "performanceSpan", + payload: { op: "db", data: { duration: "slow" } }, + }, + }, + ], + ], + 10 + ); + + expect(events).toEqual([ + { timestampMs: 4, label: "click", details: [] }, + { timestampMs: 5, label: "db", details: [] }, + ]); + }); +}); diff --git a/test/script/no-generic-is-record.test.ts b/test/script/no-generic-is-record.test.ts new file mode 100644 index 0000000000..b9aaa82c62 --- /dev/null +++ b/test/script/no-generic-is-record.test.ts @@ -0,0 +1,154 @@ +/** + * Contract test for the no-generic-is-record Biome plugin. + */ + +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import "../../script/node-polyfills.js"; + +const REPO_ROOT = join(import.meta.dirname, "../.."); +const BIOME_BIN = join(REPO_ROOT, "node_modules/.bin/biome"); +const RULE_PATH = join(REPO_ROOT, "lint-rules/no-generic-is-record.grit"); +const bun = globalThis.Bun; + +describe("no-generic-is-record lint rule", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "no-generic-is-record-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("matches generic copies but allows domain-specific guards", async () => { + const commandDir = join(tempDir, "src/commands/example"); + const libDir = join(tempDir, "src/lib"); + const configPath = join(tempDir, "biome.json"); + await Promise.all([ + mkdir(commandDir, { recursive: true }), + mkdir(libDir, { recursive: true }), + ]); + + await Promise.all([ + bun.write(configPath, JSON.stringify({ plugins: [RULE_PATH] })), + bun.write( + join(commandDir, "function-copy.ts"), + `export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} +` + ), + bun.write( + join(commandDir, "inferred-function-copy.ts"), + `function isRecord(value: unknown) { + return typeof value === "object" && value !== null; +} +` + ), + bun.write( + join(commandDir, "async-function-copy.ts"), + `async function isRecord(value: unknown) { + return typeof value === "object" && value !== null; +} +` + ), + bun.write( + join(libDir, "const-copy.ts"), + `export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; +` + ), + bun.write( + join(libDir, "typed-const-copy.ts"), + `type Guard = (value: unknown) => value is Record; +const isRecord: Guard = (value) => + typeof value === "object" && value !== null; +` + ), + bun.write( + join(libDir, "function-expression-copy.ts"), + `const isRecord = function (value: unknown) { + return typeof value === "object" && value !== null; +}; +` + ), + bun.write( + join(libDir, "let-copy.ts"), + `let isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; +isRecord = () => false; +` + ), + bun.write( + join(libDir, "unparenthesized-copy.ts"), + `const isRecord = value => value !== null; +` + ), + bun.write( + join(libDir, "record-flag.ts"), + `const schema = { type: "record" }; +export const isRecord = schema.type === "record"; +` + ), + bun.write( + join(libDir, "workflow.ts"), + `export function hasFileMap( + value: unknown +): value is { files: Record } { + return typeof value === "object" && value !== null && "files" in value; +} +` + ), + ]); + + const result = bun.spawnSync( + [ + BIOME_BIN, + "lint", + `--config-path=${configPath}`, + "--reporter=json", + "--max-diagnostics=none", + tempDir, + ], + { cwd: REPO_ROOT, stdout: "pipe", stderr: "pipe" } + ); + + const stdout = new TextDecoder().decode(result.stdout); + const stderr = new TextDecoder().decode(result.stderr); + expect(result.exitCode, `${stderr}\n${stdout}`).toBe(1); + const report = JSON.parse(stdout) as { + summary: { errors: number }; + diagnostics: Array<{ location: { path: { file: string } } }>; + }; + expect(report.summary.errors).toBe(8); + expect( + report.diagnostics.map((diagnostic) => diagnostic.location.path.file) + ).toEqual( + expect.arrayContaining([ + expect.stringMatching(/function-copy\.ts$/), + expect.stringMatching(/inferred-function-copy\.ts$/), + expect.stringMatching(/async-function-copy\.ts$/), + expect.stringMatching(/const-copy\.ts$/), + expect.stringMatching(/typed-const-copy\.ts$/), + expect.stringMatching(/function-expression-copy\.ts$/), + expect.stringMatching(/let-copy\.ts$/), + expect.stringMatching(/unparenthesized-copy\.ts$/), + ]) + ); + expect( + report.diagnostics.some((diagnostic) => + diagnostic.location.path.file.endsWith("src/lib/workflow.ts") + ) + ).toBe(false); + expect( + report.diagnostics.some((diagnostic) => + diagnostic.location.path.file.endsWith("src/lib/record-flag.ts") + ) + ).toBe(false); + }); +}); From 8e598d3c40e8eb7f7edb2cee756bd76e7c5f29cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 24 Jul 2026 22:42:33 +0200 Subject: [PATCH 2/2] refactor(replay): use generated recording types Consume the @sentry/api operation and response validator at the HTTP boundary instead of maintaining a broader local schema. Remove the redundant lint-rule contract test because Biome is the enforcement path. --- src/lib/api/replays.ts | 78 ++++++++---- src/lib/formatters/replay.ts | 19 ++- src/types/index.ts | 2 - src/types/replay.ts | 8 -- test/lib/api/replays.test.ts | 13 +- test/lib/formatters/replay.test.ts | 26 ++-- test/script/no-generic-is-record.test.ts | 154 ----------------------- 7 files changed, 87 insertions(+), 213 deletions(-) delete mode 100644 test/script/no-generic-is-record.test.ts diff --git a/src/lib/api/replays.ts b/src/lib/api/replays.ts index a1a722b297..d470641662 100644 --- a/src/lib/api/replays.ts +++ b/src/lib/api/replays.ts @@ -4,6 +4,11 @@ * Functions for listing and retrieving Session Replays. */ +import { + type ListProjectReplayRecordingSegmentsResponse, + listProjectReplayRecordingSegments, +} from "@sentry/api"; +import { zListProjectReplayRecordingSegmentsResponse } from "@sentry/api/zod"; import type { z } from "zod"; import { REPLAY_LIST_FIELDS, @@ -14,19 +19,20 @@ import { type ReplayListItem, type ReplayListResponse, ReplayListResponseSchema, - type ReplayRecordingSegments, - ReplayRecordingSegmentsSchema, } from "../../types/index.js"; +import { ApiError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, autoPaginate, + getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, parseLinkHeader, + unwrapPaginatedResult, } from "./infrastructure.js"; /** Replay sort field names supported by the backend replay index endpoint. */ @@ -106,13 +112,31 @@ type FetchReplayPageOptions = { }; type FetchReplayRecordingSegmentsPageOptions = { - regionUrl: string; orgSlug: string; projectSlugOrId: string; replayId: string; cursor?: string; }; +/** + * Validate the generated replay recording response before formatter code trusts + * its object boundary. The SDK invokes response validators outside its normal + * error-result path, so convert Zod failures to the CLI's API error type here. + */ +async function validateReplayRecordingSegmentsResponse( + data: unknown +): Promise { + const result = + await zListProjectReplayRecordingSegmentsResponse.safeParseAsync(data); + if (!result.success) { + throw new ApiError( + "Unexpected replay recording segments response", + 0, + result.error.message + ); + } +} + /** Options for {@link getReplayRecordingSegments}. */ export type GetReplayRecordingSegmentsOptions = { /** @@ -236,8 +260,7 @@ export async function getReplay( * Fetch replay recording segments for a single replay. * * Uses the project-scoped replay endpoint because recording segments are - * partitioned by project. `download=true` matches the frontend contract and - * returns the parsed segment payload directly. + * partitioned by project. * * Uses a manual pagination loop rather than {@link autoPaginate} because * `autoPaginate` trims results to `limit`, but `expectedSegments` is a soft @@ -248,15 +271,13 @@ export async function getReplayRecordingSegments( projectSlugOrId: string, replayId: string, options: GetReplayRecordingSegmentsOptions = {} -): Promise { - const regionUrl = await resolveOrgRegion(orgSlug); +): Promise { const expectedSegments = options.expectedSegments ?? Number.POSITIVE_INFINITY; - const segments: ReplayRecordingSegments = []; + const segments: ListProjectReplayRecordingSegmentsResponse = []; let cursor: string | undefined; for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { const { data, nextCursor } = await fetchReplayRecordingSegmentsPage({ - regionUrl, orgSlug, projectSlugOrId, replayId, @@ -275,25 +296,32 @@ export async function getReplayRecordingSegments( return segments; } +/** + * Fetch one SDK-backed recording page while preserving its cursor metadata. + */ async function fetchReplayRecordingSegmentsPage( options: FetchReplayRecordingSegmentsPageOptions -): Promise> { - const { cursor, orgSlug, projectSlugOrId, regionUrl, replayId } = options; - const { data, headers } = await apiRequestToRegion( - regionUrl, - `/projects/${orgSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/`, - { - params: { - cursor, - download: true, - per_page: API_MAX_PER_PAGE, - }, - schema: ReplayRecordingSegmentsSchema, - } - ); +): Promise> { + const { cursor, orgSlug, projectSlugOrId, replayId } = options; + const config = await getOrgSdkConfig(orgSlug); + const result = await listProjectReplayRecordingSegments({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlugOrId, + replay_id: replayId, + }, + query: { + cursor, + per_page: API_MAX_PER_PAGE, + }, + responseValidator: validateReplayRecordingSegmentsResponse, + }); - const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); - return { data, nextCursor }; + return unwrapPaginatedResult( + result, + "Failed to fetch replay recording segments" + ); } /** diff --git a/src/lib/formatters/replay.ts b/src/lib/formatters/replay.ts index d1053015e8..1f6cf7ee5a 100644 --- a/src/lib/formatters/replay.ts +++ b/src/lib/formatters/replay.ts @@ -4,10 +4,11 @@ * Human-readable formatting for Session Replay data in the CLI. */ +import type { ListProjectReplayRecordingSegmentsResponse } from "@sentry/api"; + import type { ReplayActivityEvent, ReplayDetails, - ReplayRecordingSegments, ReplayRelatedIssue, ReplayRelatedTrace, } from "../../types/index.js"; @@ -34,12 +35,8 @@ export type ReplayViewData = { }; type MarkdownRow = [string, string]; - -function hasReplayEventData( - value: unknown -): value is { data: unknown; timestamp?: unknown } { - return typeof value === "object" && value !== null && "data" in value; -} +type ReplayRecordingSegments = ListProjectReplayRecordingSegmentsResponse; +type ReplayRecordingEvent = ReplayRecordingSegments[number][number]; function hasReplayTag( value: unknown @@ -208,11 +205,9 @@ function summarizeTaggedReplayEvent( return summarize ? summarize(payload) : null; } -function summarizeReplayEvent(event: unknown): ReplayActivityEvent | null { - if (!hasReplayEventData(event)) { - return null; - } - +function summarizeReplayEvent( + event: ReplayRecordingEvent +): ReplayActivityEvent | null { const timestampMs = getEventTimestampMillis(event.timestamp); if (hasReplayTag(event.data)) { const replayEvent = summarizeTaggedReplayEvent( diff --git a/src/types/index.ts b/src/types/index.ts index c3defe907a..418b9a9ca5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,7 +58,6 @@ export type { ReplayListResponse, ReplayOs, ReplayOtaUpdates, - ReplayRecordingSegments, ReplayRelatedIssue, ReplayRelatedTrace, ReplaySdk, @@ -79,7 +78,6 @@ export { ReplayListResponseSchema, ReplayOsSchema, ReplayOtaUpdatesSchema, - ReplayRecordingSegmentsSchema, ReplayRelatedIssueSchema, ReplayRelatedTraceSchema, ReplaySdkSchema, diff --git a/src/types/replay.ts b/src/types/replay.ts index 99e617405e..50eaf407b8 100644 --- a/src/types/replay.ts +++ b/src/types/replay.ts @@ -261,11 +261,6 @@ export const ReplayDetailsSchema = ReplayListItemSchema.extend({ replay_type: z.string().nullable().optional().describe("Replay type"), }).describe("Replay details"); -/** Replay recording segments downloaded from the project replay endpoint. */ -export const ReplayRecordingSegmentsSchema = z - .array(z.array(z.unknown())) - .describe("Replay recording segments"); - /** Envelope returned by the replay index endpoint. */ export const ReplayListResponseSchema = z .object({ @@ -380,9 +375,6 @@ export type ReplayDevice = z.infer; export type ReplayOtaUpdates = z.infer; export type ReplayListItem = z.infer; export type ReplayDetails = z.infer; -export type ReplayRecordingSegments = z.infer< - typeof ReplayRecordingSegmentsSchema ->; export type ReplayListResponse = z.infer; export type ReplayDetailsResponse = z.infer; export type ReplayIdsByResource = z.infer; diff --git a/test/lib/api/replays.test.ts b/test/lib/api/replays.test.ts index 65ca7fde2e..bb41055519 100644 --- a/test/lib/api/replays.test.ts +++ b/test/lib/api/replays.test.ts @@ -13,6 +13,7 @@ import { import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; useTestConfigDir("replays-api-test-"); @@ -275,11 +276,21 @@ describe("getReplayRecordingSegments", () => { expect(url.pathname).toContain( `/api/0/projects/test-org/42/replays/${REPLAY_ID}/recording-segments/` ); - expect(url.searchParams.get("download")).toBe("true"); + expect(url.searchParams.get("download")).toBeNull(); expect(url.searchParams.get("per_page")).toBe("100"); expect(segments).toEqual([[{ timestamp: 1 }]]); }); + test("rejects non-object recording events at the API boundary", async () => { + globalThis.fetch = mockFetch(async () => + recordingSegmentsResponse([[null]]) + ); + + await expect( + getReplayRecordingSegments("test-org", "42", REPLAY_ID) + ).rejects.toBeInstanceOf(ApiError); + }); + test("auto-paginates recording segments using the link cursor", async () => { const capturedUrls: string[] = []; let callIndex = 0; diff --git a/test/lib/formatters/replay.test.ts b/test/lib/formatters/replay.test.ts index 7176fcb1db..31d4ebaa69 100644 --- a/test/lib/formatters/replay.test.ts +++ b/test/lib/formatters/replay.test.ts @@ -10,15 +10,17 @@ describe("extractReplayActivityEvents", () => { const events = extractReplayActivityEvents( [ [ - { timestamp: 1, data: { href: "/checkout" } }, + { type: 4, timestamp: 1, data: { href: "/checkout" } }, { + type: 5, timestamp: 2, data: { - tag: "click", - payload: { selector: "#pay", label: "Pay now" }, + tag: "breadcrumb", + payload: { category: "ui.click", message: "#pay" }, }, }, { + type: 5, timestamp: 3, data: { tag: "performanceSpan", @@ -30,10 +32,14 @@ describe("extractReplayActivityEvents", () => { }, }, { + type: 5, timestamp: 4, data: { tag: "breadcrumb", - payload: { category: "ui.click", message: "Submitted cart" }, + payload: { + category: "navigation", + message: "Visited checkout", + }, }, }, ], @@ -49,8 +55,8 @@ describe("extractReplayActivityEvents", () => { }, { timestampMs: 2, - label: "click", - details: ["selector=#pay", "label=Pay now"], + label: "ui.click", + details: ["message=#pay"], }, { timestampMs: 3, @@ -59,18 +65,16 @@ describe("extractReplayActivityEvents", () => { }, { timestampMs: 4, - label: "ui.click", - details: ["message=Submitted cart"], + label: "navigation", + details: ["message=Visited checkout"], }, ]); }); - test("ignores malformed shapes and keeps empty click payload behavior", () => { + test("ignores unknown event fields and keeps empty click payload behavior", () => { const events = extractReplayActivityEvents( [ [ - null, - [], { timestamp: 1 }, { timestamp: 2, data: null }, { timestamp: 3, data: { href: "" } }, diff --git a/test/script/no-generic-is-record.test.ts b/test/script/no-generic-is-record.test.ts deleted file mode 100644 index b9aaa82c62..0000000000 --- a/test/script/no-generic-is-record.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Contract test for the no-generic-is-record Biome plugin. - */ - -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import "../../script/node-polyfills.js"; - -const REPO_ROOT = join(import.meta.dirname, "../.."); -const BIOME_BIN = join(REPO_ROOT, "node_modules/.bin/biome"); -const RULE_PATH = join(REPO_ROOT, "lint-rules/no-generic-is-record.grit"); -const bun = globalThis.Bun; - -describe("no-generic-is-record lint rule", () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "no-generic-is-record-")); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - test("matches generic copies but allows domain-specific guards", async () => { - const commandDir = join(tempDir, "src/commands/example"); - const libDir = join(tempDir, "src/lib"); - const configPath = join(tempDir, "biome.json"); - await Promise.all([ - mkdir(commandDir, { recursive: true }), - mkdir(libDir, { recursive: true }), - ]); - - await Promise.all([ - bun.write(configPath, JSON.stringify({ plugins: [RULE_PATH] })), - bun.write( - join(commandDir, "function-copy.ts"), - `export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} -` - ), - bun.write( - join(commandDir, "inferred-function-copy.ts"), - `function isRecord(value: unknown) { - return typeof value === "object" && value !== null; -} -` - ), - bun.write( - join(commandDir, "async-function-copy.ts"), - `async function isRecord(value: unknown) { - return typeof value === "object" && value !== null; -} -` - ), - bun.write( - join(libDir, "const-copy.ts"), - `export const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; -` - ), - bun.write( - join(libDir, "typed-const-copy.ts"), - `type Guard = (value: unknown) => value is Record; -const isRecord: Guard = (value) => - typeof value === "object" && value !== null; -` - ), - bun.write( - join(libDir, "function-expression-copy.ts"), - `const isRecord = function (value: unknown) { - return typeof value === "object" && value !== null; -}; -` - ), - bun.write( - join(libDir, "let-copy.ts"), - `let isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; -isRecord = () => false; -` - ), - bun.write( - join(libDir, "unparenthesized-copy.ts"), - `const isRecord = value => value !== null; -` - ), - bun.write( - join(libDir, "record-flag.ts"), - `const schema = { type: "record" }; -export const isRecord = schema.type === "record"; -` - ), - bun.write( - join(libDir, "workflow.ts"), - `export function hasFileMap( - value: unknown -): value is { files: Record } { - return typeof value === "object" && value !== null && "files" in value; -} -` - ), - ]); - - const result = bun.spawnSync( - [ - BIOME_BIN, - "lint", - `--config-path=${configPath}`, - "--reporter=json", - "--max-diagnostics=none", - tempDir, - ], - { cwd: REPO_ROOT, stdout: "pipe", stderr: "pipe" } - ); - - const stdout = new TextDecoder().decode(result.stdout); - const stderr = new TextDecoder().decode(result.stderr); - expect(result.exitCode, `${stderr}\n${stdout}`).toBe(1); - const report = JSON.parse(stdout) as { - summary: { errors: number }; - diagnostics: Array<{ location: { path: { file: string } } }>; - }; - expect(report.summary.errors).toBe(8); - expect( - report.diagnostics.map((diagnostic) => diagnostic.location.path.file) - ).toEqual( - expect.arrayContaining([ - expect.stringMatching(/function-copy\.ts$/), - expect.stringMatching(/inferred-function-copy\.ts$/), - expect.stringMatching(/async-function-copy\.ts$/), - expect.stringMatching(/const-copy\.ts$/), - expect.stringMatching(/typed-const-copy\.ts$/), - expect.stringMatching(/function-expression-copy\.ts$/), - expect.stringMatching(/let-copy\.ts$/), - expect.stringMatching(/unparenthesized-copy\.ts$/), - ]) - ); - expect( - report.diagnostics.some((diagnostic) => - diagnostic.location.path.file.endsWith("src/lib/workflow.ts") - ) - ).toBe(false); - expect( - report.diagnostics.some((diagnostic) => - diagnostic.location.path.file.endsWith("src/lib/record-flag.ts") - ) - ).toBe(false); - }); -});