Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ interface Props extends TimelineCommandProps<ApiResponsePayload> {
initialTab?: Tab
}

type ApiRequest = Partial<ApiResponsePayload["request"]>
type ApiResponse = Partial<ApiResponsePayload["response"]>
type SafeApiResponsePayload = Omit<ApiResponsePayload, "request" | "response"> & {
request: ApiRequest
response: ApiResponse
}

function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}

function createTabBuilder(onTab: Tab, setOnTab: (tab: Tab) => void) {
const tabBuilder = (currentTab: Tab, text: string) => {
return (
Expand All @@ -57,7 +68,10 @@ function createTabBuilder(onTab: Tab, setOnTab: (tab: Tab) => void) {
return tabBuilder
}

function buildToolbar(commandPayload, copyToClipboard: (text: string) => void) {
function buildToolbar(
commandPayload: SafeApiResponsePayload,
copyToClipboard: (text: string) => void
) {
if (!copyToClipboard) return []

const toolbarItems = []
Expand Down Expand Up @@ -128,12 +142,22 @@ const ApiResponseCommand: FunctionComponent<Props> = ({
const [onTab, setOnTab] = useState<Tab>(initialTab || null)

const { payload, date, deltaTime } = command
const { duration, request, response } = payload

const cleanedUrl = request.url.replace(/^http(s):\/\/[^/]+/i, "").replace(/\?.*$/i, "")
const operationName = formatOperationName(request.data)

const preview = [(request.method || "").toUpperCase(), cleanedUrl, operationName]
const { duration } = payload
const request: ApiRequest = isObject(payload.request) ? payload.request : {}
const response: ApiResponse = isObject(payload.response) ? payload.response : {}
const safePayload = { ...payload, request, response }

const cleanedUrl =
typeof request.url === "string"
? request.url.replace(/^http(s):\/\/[^/]+/i, "").replace(/\?.*$/i, "")
: ""
const operationName = typeof request.data === "string" ? formatOperationName(request.data) : ""

const preview = [
typeof request.method === "string" ? request.method.toUpperCase() : "",
cleanedUrl,
operationName,
]
.filter(Boolean)
.join(" ")

Expand All @@ -145,7 +169,7 @@ const ApiResponseCommand: FunctionComponent<Props> = ({

const tabBuilder = createTabBuilder(onTab, setOnTab)

const toolbar = buildToolbar(payload, copyToClipboard)
const toolbar = buildToolbar(safePayload, copyToClipboard)

return (
<TimelineCommand
Expand All @@ -156,9 +180,9 @@ const ApiResponseCommand: FunctionComponent<Props> = ({
toolbar={toolbar}
isOpen={isOpen}
setIsOpen={setIsOpen}
responseStatusCode={payload.response?.status}
responseStatusCode={response.status}
>
<NameContainer>{payload.request.url}</NameContainer>
<NameContainer>{request.url}</NameContainer>
<ContentView value={summary} />
<TabsContainer>
{tabBuilder(Tab.ResponseBody, "Response")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ const AsyncStorageMutationCommand: FunctionComponent<Props> = ({ command, isOpen
const { payload, date, deltaTime } = command

let preview = payload.action
const hasKey = payload.data !== null && typeof payload.data === "object" && "key" in payload.data

if (["setItem", "removeItem", "mergeItem"].indexOf(payload.action) > -1) {
if (["setItem", "removeItem", "mergeItem"].indexOf(payload.action) > -1 && hasKey) {
preview = `${payload.action}: ${payload.data.key}`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,25 @@ interface Props extends TimelineCommandProps<BenchmarkReportPayload> {}
const BenchmarkReportCommand: FunctionComponent<Props> = ({ command, isOpen, setIsOpen }) => {
const { payload, date, deltaTime } = command

const totalDuration = payload.steps[payload.steps.length - 1].time
const steps = Array.isArray(payload.steps) ? payload.steps : []
const lastStep = steps[steps.length - 1]
const totalDuration = typeof lastStep?.time === "number" ? lastStep.time : null
const preview =
totalDuration === null
? payload.title
: `${payload.title} in ${(totalDuration / 1000).toFixed(3)}s`

return (
<TimelineCommand
date={date}
deltaTime={deltaTime}
title="BENCHMARK"
preview={`${payload.title} in ${(totalDuration / 1000).toFixed(3)}s`}
preview={preview}
isOpen={isOpen}
setIsOpen={setIsOpen}
>
<NameContainer>{payload.title}</NameContainer>
{payload.steps.map((step, idx) => {
{steps.map((step, idx) => {
if (idx === 0) return null

const startPercent = Number((((step.time - step.delta) / totalDuration) * 100).toFixed(0))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ const SagaTaskCompleteCommand: FunctionComponent<Props> = ({
const { payload, date, deltaTime } = command

const toolbar = buildToolbar(isDetailsOpen, setIsDetailsOpen)
const children = Array.isArray(payload.children) ? payload.children : []

const effectTitle = `${payload.children.length} Effect${payload.children.length === 1 ? "" : "s"}`
const effectTitle = `${children.length} Effect${children.length === 1 ? "" : "s"}`

return (
<TimelineCommand
Expand All @@ -146,7 +147,7 @@ const SagaTaskCompleteCommand: FunctionComponent<Props> = ({
<DurationMs>ms</DurationMs>
</Duration>
</EffectTitle>
{payload.children.map((effect) => renderEffect(effect, isDetailsOpen))}
{children.map((effect) => renderEffect(effect, isDetailsOpen))}
</TimelineCommand>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ interface StateValuesChangePayload {

interface Props extends TimelineCommandProps<StateValuesChangePayload> {}

function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}

const StateValuesChangeCommand: FunctionComponent<Props> = ({ command, isOpen, setIsOpen }) => {
const { payload, date, deltaTime } = command

const hasChanged = !!payload.changed && !Array.isArray(payload.changed)
const hasAdded = !!payload.added && !Array.isArray(payload.added)
const hasRemoved = !!payload.removed && !Array.isArray(payload.removed)
const hasChanged = isObject(payload.changed)
const hasAdded = isObject(payload.added)
const hasRemoved = isObject(payload.removed)

const changes = []

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import React, { ComponentType } from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import { ThemeProvider } from "styled-components"

import { themes } from "../themes"
import type { TimelineCommandPropsEx } from "./BaseCommand"
import AsyncStorageMutationCommand from "./AsyncStorageMutationCommand"
import ApiResponseCommand from "./ApiResponseCommand"
import BenchmarkReportCommand from "./BenchmarkReportCommand"
import SagaTaskCompleteCommand from "./SagaTaskCompleteCommand"
import StateValuesChangeCommand from "./StateValuesChangeCommand"

type CommandComponent = ComponentType<TimelineCommandPropsEx<unknown>>

function renderCommand(Component: CommandComponent, type: string, payload: unknown) {
return render(
<ThemeProvider theme={themes.dark}>
<Component
command={{
connectionId: 1,
date: new Date("2026-08-18T00:00:00.000Z"),
deltaTime: 0,
important: false,
messageId: 1,
payload,
type,
}}
copyToClipboard={jest.fn()}
/>
</ThemeProvider>
)
}

function expectRenderedWithoutFallback(title: string) {
expect(screen.getByText(title)).toBeTruthy()
expect(screen.queryByText("RENDER ERROR")).toBeNull()
}

describe("timeline commands with unexpected payloads", () => {
test.each([
["data", { action: "setItem" }],
["key", { action: "setItem", data: { value: "abc" } }],
])("renders an AsyncStorage mutation when %s is missing", (_label, payload) => {
renderCommand(AsyncStorageMutationCommand, "asyncStorage.mutation", payload)

expectRenderedWithoutFallback("ASYNC STORAGE")
expect(screen.getByText("setItem")).toBeTruthy()
})

test("preserves a valid AsyncStorage mutation preview", () => {
renderCommand(AsyncStorageMutationCommand, "asyncStorage.mutation", {
action: "setItem",
data: { key: "session", value: "abc" },
})

expectRenderedWithoutFallback("ASYNC STORAGE")
expect(screen.getByText("setItem: session")).toBeTruthy()
})

test.each([
[
"request",
{ duration: 12, response: { body: "ok", headers: {}, status: 200 } },
"API RESPONSE (200)",
],
[
"response",
{
duration: 12,
request: { data: null, headers: {}, method: "get", params: null, url: "/health" },
},
"API RESPONSE",
],
["request and response", { duration: 12 }, "API RESPONSE"],
])("renders an API response when %s is missing", (_label, payload, title) => {
renderCommand(ApiResponseCommand, "api.response", payload)

expectRenderedWithoutFallback(title)
})

test("preserves a valid API response preview", () => {
renderCommand(ApiResponseCommand, "api.response", {
duration: 25,
request: {
data: JSON.stringify({ operationName: "GetUser" }),
headers: {},
method: "post",
params: null,
url: "https://example.com/graphql?debug=true",
},
response: { body: "ok", headers: {}, status: 201 },
})

expectRenderedWithoutFallback("API RESPONSE (201)")
expect(screen.getByText("POST /graphql GetUser")).toBeTruthy()
})

test("ignores non-object state subscription changes", () => {
renderCommand(StateValuesChangeCommand, "state.values.change", {
added: [],
changed: "bad",
changes: [],
removed: [],
})
fireEvent.click(screen.getByText("SUBSCRIPTIONS"))

expectRenderedWithoutFallback("SUBSCRIPTIONS")
expect(screen.queryByText("3 changed")).toBeNull()
})

test("preserves valid state subscription counts", () => {
renderCommand(StateValuesChangeCommand, "state.values.change", {
added: { profile: true },
changed: { token: "next" },
changes: [],
removed: { legacy: true },
})
fireEvent.click(screen.getByText("SUBSCRIPTIONS"))

expectRenderedWithoutFallback("SUBSCRIPTIONS")
expect(screen.getByText("1 changed 1 added 1 removed")).toBeTruthy()
})

test.each([
["missing", { title: "startup" }],
["empty", { title: "startup", steps: [] }],
])("renders a benchmark with %s steps", (_label, payload) => {
renderCommand(BenchmarkReportCommand, "benchmark.report", payload)

expectRenderedWithoutFallback("BENCHMARK")
expect(screen.getByText("startup")).toBeTruthy()
})

test("preserves a valid benchmark duration", () => {
renderCommand(BenchmarkReportCommand, "benchmark.report", {
title: "startup",
steps: [
{ delta: 0, time: 0, title: "start" },
{ delta: 250, time: 250, title: "ready" },
],
})

expectRenderedWithoutFallback("BENCHMARK")
expect(screen.getByText("startup in 0.250s")).toBeTruthy()
})

test.each([
["missing", { duration: 10, triggerType: "ACTION" }],
["non-array", { children: {}, duration: 10, triggerType: "ACTION" }],
])("renders a saga completion with %s children", (_label, payload) => {
renderCommand(SagaTaskCompleteCommand, "saga.task.complete", payload)
fireEvent.click(screen.getByText("SAGA"))

expectRenderedWithoutFallback("SAGA")
expect(screen.getByText("0 Effects")).toBeTruthy()
})

test("preserves valid saga completion children", () => {
renderCommand(SagaTaskCompleteCommand, "saga.task.complete", {
children: [
{
depth: 0,
description: "Call API",
duration: 5,
effectId: 1,
extra: null,
loser: null,
name: "CALL",
parentEffectId: 0,
result: null,
status: "RESOLVED",
winner: null,
},
],
description: "Load user",
duration: 5,
triggerType: "ACTION",
})
fireEvent.click(screen.getByText("SAGA"))

expectRenderedWithoutFallback("SAGA")
expect(screen.getByText("1 Effect")).toBeTruthy()
expect(screen.getByText("CALL")).toBeTruthy()
})
})