From 9fc521145da51a843fa7cc740319dae8304fc316 Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 26 Jun 2026 09:32:20 -0400 Subject: [PATCH 1/3] CallStackTrace refactor + unit tests --- .gitignore | 3 +- .../dashboard/components/CallStackTrace.tsx | 176 +----------------- .../callStackTrace/hasEllipsisAnywhere.ts | 24 +++ src/helpers/callStackTrace/isAsset.ts | 32 ++++ .../callStackTrace/renderAssetString.ts | 22 +++ src/helpers/callStackTrace/toErrorMapData.ts | 45 +++++ src/helpers/callStackTrace/truncateParams.ts | 102 ++++++++++ .../hasEllipsisAnywhere.test.ts | 39 ++++ tests/unit/callStackTrace/isAsset.test.ts | 51 +++++ .../callStackTrace/renderAssetString.test.ts | 22 +++ .../callStackTrace/toErrorMapData.test.ts | 61 ++++++ .../callStackTrace/truncateParams.test.ts | 65 +++++++ 12 files changed, 470 insertions(+), 172 deletions(-) create mode 100644 src/helpers/callStackTrace/hasEllipsisAnywhere.ts create mode 100644 src/helpers/callStackTrace/isAsset.ts create mode 100644 src/helpers/callStackTrace/renderAssetString.ts create mode 100644 src/helpers/callStackTrace/toErrorMapData.ts create mode 100644 src/helpers/callStackTrace/truncateParams.ts create mode 100644 tests/unit/callStackTrace/hasEllipsisAnywhere.test.ts create mode 100644 tests/unit/callStackTrace/isAsset.test.ts create mode 100644 tests/unit/callStackTrace/renderAssetString.test.ts create mode 100644 tests/unit/callStackTrace/toErrorMapData.test.ts create mode 100644 tests/unit/callStackTrace/truncateParams.test.ts diff --git a/.gitignore b/.gitignore index 64d93c290..ef54c7bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -45,9 +45,10 @@ next-env.d.ts /.idea/ /.vscode/ /.kiro/ +/.claude/notes # versions mise.toml # Sentry Config File -.env.sentry-build-plugin \ No newline at end of file +.env.sentry-build-plugin diff --git a/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx b/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx index e2ea5dcb6..19290adff 100644 --- a/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx +++ b/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx @@ -1,6 +1,5 @@ import React, { JSX, useState } from "react"; import { Alert, Icon, Label, Text, Toggle } from "@stellar/design-system"; -import { StrKey } from "@stellar/stellar-sdk"; import { Box } from "@/components/layout/Box"; import { SdsLink } from "@/components/SdsLink"; @@ -15,6 +14,11 @@ import { import { shortenStellarAddress } from "@/helpers/shortenStellarAddress"; import { getStellarExpertNetwork } from "@/helpers/getStellarExpertNetwork"; import { buildContractExplorerHref } from "@/helpers/buildContractExplorerHref"; +import { truncateParams } from "@/helpers/callStackTrace/truncateParams"; +import { hasEllipsisAnywhere } from "@/helpers/callStackTrace/hasEllipsisAnywhere"; +import { toErrorMapData } from "@/helpers/callStackTrace/toErrorMapData"; +import { isAsset } from "@/helpers/callStackTrace/isAsset"; +import { renderAssetString } from "@/helpers/callStackTrace/renderAssetString"; import { getContractIdError } from "@/validate/methods/getContractIdError"; import { useStore } from "@/store/useStore"; @@ -44,142 +48,6 @@ export const CallStackTrace = ({ ); } - const truncateParams = ( - data: FormattedEventData[], - maxItems: number, - ): FormattedEventData[] => { - let itemCount = 0; - let ellipsisAdded = false; - - const isContainer = (type: string): boolean => { - return type === "vec" || type === "map"; - }; - - const addEllipsisIfNeeded = (items: any[], wasTruncated: boolean) => { - if (wasTruncated && !ellipsisAdded && items.length > 0) { - items.push({ value: "...", type: "ellipsis" }); - ellipsisAdded = true; - } - }; - - const truncateArray = (items: any[]) => { - const truncatedArray: any[] = []; - let wasTruncated = false; - - for (const item of items) { - if (itemCount >= maxItems) { - wasTruncated = true; - break; - } - - const result = traverse(item); - - if (result !== undefined) { - truncatedArray.push(result); - } - } - - addEllipsisIfNeeded(truncatedArray, wasTruncated); - - return { - truncatedArray, - wasTruncated, - }; - }; - - const traverse = (node: any): any => { - if ( - node && - typeof node === "object" && - "type" in node && - "value" in node - ) { - const isContainerType = isContainer(node.type); - - if (!isContainerType) { - itemCount++; - - if (itemCount > maxItems) { - return undefined; - } - - return node; - } - - if (Array.isArray(node.value)) { - const { truncatedArray } = truncateArray(node.value); - - if (truncatedArray.length > 0) { - return { ...node, value: truncatedArray }; - } - - return undefined; - } - - return node; - } - - if (Array.isArray(node)) { - const { truncatedArray } = truncateArray(node); - - return truncatedArray.length > 0 ? truncatedArray : undefined; - } - - return node; - }; - - const result: FormattedEventData[] | undefined = traverse(data); - - return result || []; - }; - - const hasEllipsisAnywhere = (data: any): boolean => { - if (!data) return false; - - if (Array.isArray(data)) { - return data.some((item) => { - if (item?.type === "ellipsis") return true; - - if (Array.isArray(item?.value)) return hasEllipsisAnywhere(item.value); - - return false; - }); - } - - return false; - }; - - const toErrorMapData = (errorValue: unknown): FormattedEventData | null => { - if (errorValue === null || errorValue === undefined) { - return null; - } - - const toErrorNode = (node: unknown): FormattedEventData => { - if (Array.isArray(node)) { - return { - type: "vec", - value: node.map((item) => toErrorNode(item)), - }; - } - - if (node && typeof node === "object") { - return { - type: "map", - value: Object.entries(node as Record).map( - ([key, detail]) => ({ - key: { type: undefined, value: key }, - val: toErrorNode(detail), - }), - ), - }; - } - - return { type: undefined, value: node }; - }; - - return toErrorNode(errorValue); - }; - const renderData = ({ dataItem, parentId, @@ -492,40 +360,6 @@ export const CallStackTrace = ({ ); }; -// ============================================================================= -// Helpers -// ============================================================================= -const isAsset = (value: unknown) => { - if (typeof value !== "string" || !value.includes(":")) { - return false; - } - - const parts = value.split(":"); - if (parts.length !== 2) { - return false; - } - - const [code, issuer] = parts; - // Asset code should be 1-12 characters, issuer must be valid G or M address or contract ID - return ( - code.length > 0 && - code.length <= 12 && - (StrKey.isValidEd25519PublicKey(issuer) || - StrKey.isValidMed25519PublicKey(issuer) || - StrKey.isValidContract(issuer)) - ); -}; - -const renderAssetString = (value: string) => { - if (isAsset(value)) { - const [code, issuer] = value.split(":"); - - return `${code}:${shortenStellarAddress(issuer)}`; - } - - return value; -}; - // ============================================================================= // Components // ============================================================================= diff --git a/src/helpers/callStackTrace/hasEllipsisAnywhere.ts b/src/helpers/callStackTrace/hasEllipsisAnywhere.ts new file mode 100644 index 000000000..6372d2455 --- /dev/null +++ b/src/helpers/callStackTrace/hasEllipsisAnywhere.ts @@ -0,0 +1,24 @@ +/** + * Returns `true` if an ellipsis node (`{ type: "ellipsis" }`) exists anywhere + * in the given (possibly nested) array of formatted event data. Used by the + * CallStackTrace component to decide whether to hide closing brackets when + * collapsed params have been truncated. + * + * @param data - The (possibly nested) array to inspect. + * @returns Whether an ellipsis node is present at any depth. + */ +export const hasEllipsisAnywhere = (data: any): boolean => { + if (!data) return false; + + if (Array.isArray(data)) { + return data.some((item) => { + if (item?.type === "ellipsis") return true; + + if (Array.isArray(item?.value)) return hasEllipsisAnywhere(item.value); + + return false; + }); + } + + return false; +}; diff --git a/src/helpers/callStackTrace/isAsset.ts b/src/helpers/callStackTrace/isAsset.ts new file mode 100644 index 000000000..68939ce0d --- /dev/null +++ b/src/helpers/callStackTrace/isAsset.ts @@ -0,0 +1,32 @@ +import { StrKey } from "@stellar/stellar-sdk"; + +/** + * Returns `true` if the given value is a Stellar asset string in the form + * `code:issuer`, where `code` is 1–12 characters and `issuer` is a valid + * ed25519 / muxed public key or contract ID. + * + * Only used by the CallStackTrace component to detect and format asset values. + * + * @param value - The value to test. + * @returns Whether the value is a valid `code:issuer` asset string. + */ +export const isAsset = (value: unknown) => { + if (typeof value !== "string" || !value.includes(":")) { + return false; + } + + const parts = value.split(":"); + if (parts.length !== 2) { + return false; + } + + const [code, issuer] = parts; + // Asset code should be 1-12 characters, issuer must be valid G or M address or contract ID + return ( + code.length > 0 && + code.length <= 12 && + (StrKey.isValidEd25519PublicKey(issuer) || + StrKey.isValidMed25519PublicKey(issuer) || + StrKey.isValidContract(issuer)) + ); +}; diff --git a/src/helpers/callStackTrace/renderAssetString.ts b/src/helpers/callStackTrace/renderAssetString.ts new file mode 100644 index 000000000..84907436e --- /dev/null +++ b/src/helpers/callStackTrace/renderAssetString.ts @@ -0,0 +1,22 @@ +import { shortenStellarAddress } from "@/helpers/shortenStellarAddress"; + +import { isAsset } from "@/helpers/callStackTrace/isAsset"; + +/** + * Formats an asset string for display by shortening its issuer + * (`code:GABC...XYZ`). Non-asset values are returned unchanged. + * + * Only used by the CallStackTrace component. + * + * @param value - The value to format. + * @returns The asset string with a shortened issuer, or the original value. + */ +export const renderAssetString = (value: string) => { + if (isAsset(value)) { + const [code, issuer] = value.split(":"); + + return `${code}:${shortenStellarAddress(issuer)}`; + } + + return value; +}; diff --git a/src/helpers/callStackTrace/toErrorMapData.ts b/src/helpers/callStackTrace/toErrorMapData.ts new file mode 100644 index 000000000..14ce88851 --- /dev/null +++ b/src/helpers/callStackTrace/toErrorMapData.ts @@ -0,0 +1,45 @@ +import { FormattedEventData } from "@/helpers/formatDiagnosticEvents"; + +/** + * Converts an arbitrary error value into a `FormattedEventData` tree so it can + * be rendered with the same primitives as other call-stack values. Arrays map + * to `vec` nodes, plain objects map to `map` nodes (keyed entries), and + * everything else becomes an untyped primitive value. + * + * Only used by the CallStackTrace component to render `error`-typed values. + * + * @param errorValue - The raw error value to convert. + * @returns The formatted tree, or `null` when the value is null/undefined. + */ +export const toErrorMapData = ( + errorValue: unknown, +): FormattedEventData | null => { + if (errorValue === null || errorValue === undefined) { + return null; + } + + const toErrorNode = (node: unknown): FormattedEventData => { + if (Array.isArray(node)) { + return { + type: "vec", + value: node.map((item) => toErrorNode(item)), + }; + } + + if (node && typeof node === "object") { + return { + type: "map", + value: Object.entries(node as Record).map( + ([key, detail]) => ({ + key: { type: undefined, value: key }, + val: toErrorNode(detail), + }), + ), + }; + } + + return { type: undefined, value: node }; + }; + + return toErrorNode(errorValue); +}; diff --git a/src/helpers/callStackTrace/truncateParams.ts b/src/helpers/callStackTrace/truncateParams.ts new file mode 100644 index 000000000..891704e29 --- /dev/null +++ b/src/helpers/callStackTrace/truncateParams.ts @@ -0,0 +1,102 @@ +import { FormattedEventData } from "@/helpers/formatDiagnosticEvents"; + +/** + * Truncates a list of formatted function-call params to at most `maxItems` + * primitive values, recursing into containers (`vec`/`map`). When values are + * dropped, a single `{ type: "ellipsis", value: "..." }` node is appended to + * the array where truncation first occurred. + * + * Only used by the CallStackTrace component's collapsed-params view. + * + * @param data - The formatted params to truncate. + * @param maxItems - Maximum number of primitive values to keep. + * @returns The truncated params (possibly containing an ellipsis node). + */ +export const truncateParams = ( + data: FormattedEventData[], + maxItems: number, +): FormattedEventData[] => { + let itemCount = 0; + let ellipsisAdded = false; + + const isContainer = (type: string): boolean => { + return type === "vec" || type === "map"; + }; + + const addEllipsisIfNeeded = (items: any[], wasTruncated: boolean) => { + if (wasTruncated && !ellipsisAdded && items.length > 0) { + items.push({ value: "...", type: "ellipsis" }); + ellipsisAdded = true; + } + }; + + const truncateArray = (items: any[]) => { + const truncatedArray: any[] = []; + let wasTruncated = false; + + for (const item of items) { + if (itemCount >= maxItems) { + wasTruncated = true; + break; + } + + const result = traverse(item); + + if (result !== undefined) { + truncatedArray.push(result); + } + } + + addEllipsisIfNeeded(truncatedArray, wasTruncated); + + return { + truncatedArray, + wasTruncated, + }; + }; + + const traverse = (node: any): any => { + if ( + node && + typeof node === "object" && + "type" in node && + "value" in node + ) { + const isContainerType = isContainer(node.type); + + if (!isContainerType) { + itemCount++; + + if (itemCount > maxItems) { + return undefined; + } + + return node; + } + + if (Array.isArray(node.value)) { + const { truncatedArray } = truncateArray(node.value); + + if (truncatedArray.length > 0) { + return { ...node, value: truncatedArray }; + } + + return undefined; + } + + return node; + } + + if (Array.isArray(node)) { + const { truncatedArray } = truncateArray(node); + + return truncatedArray.length > 0 ? truncatedArray : undefined; + } + + return node; + }; + + const result: FormattedEventData[] | undefined = traverse(data); + + return result || []; +}; diff --git a/tests/unit/callStackTrace/hasEllipsisAnywhere.test.ts b/tests/unit/callStackTrace/hasEllipsisAnywhere.test.ts new file mode 100644 index 000000000..0917b9596 --- /dev/null +++ b/tests/unit/callStackTrace/hasEllipsisAnywhere.test.ts @@ -0,0 +1,39 @@ +import { hasEllipsisAnywhere } from "../../../src/helpers/callStackTrace/hasEllipsisAnywhere"; + +describe("hasEllipsisAnywhere", () => { + it("returns false for null/undefined input", () => { + expect(hasEllipsisAnywhere(null)).toBe(false); + expect(hasEllipsisAnywhere(undefined)).toBe(false); + }); + + it("returns false for non-array input", () => { + expect(hasEllipsisAnywhere({ type: "ellipsis" })).toBe(false); + expect(hasEllipsisAnywhere("ellipsis")).toBe(false); + }); + + it("detects an ellipsis node at the top level", () => { + const data = [{ type: "u32", value: 1 }, { type: "ellipsis", value: "..." }]; + + expect(hasEllipsisAnywhere(data)).toBe(true); + }); + + it("detects an ellipsis node nested inside a container value", () => { + const data = [ + { + type: "vec", + value: [{ type: "u32", value: 1 }, { type: "ellipsis", value: "..." }], + }, + ]; + + expect(hasEllipsisAnywhere(data)).toBe(true); + }); + + it("returns false when no ellipsis is present at any depth", () => { + const data = [ + { type: "u32", value: 1 }, + { type: "vec", value: [{ type: "u32", value: 2 }] }, + ]; + + expect(hasEllipsisAnywhere(data)).toBe(false); + }); +}); diff --git a/tests/unit/callStackTrace/isAsset.test.ts b/tests/unit/callStackTrace/isAsset.test.ts new file mode 100644 index 000000000..59465feae --- /dev/null +++ b/tests/unit/callStackTrace/isAsset.test.ts @@ -0,0 +1,51 @@ +import { isAsset } from "../../../src/helpers/callStackTrace/isAsset"; + +const G_KEY = "GA2Q3TSCKD6GBT5SL4XLAPT4TUXJ5GP7HLIM4OHBWT33GFD2UC7SRNEZ"; +const C_KEY = "CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2"; +const M_KEY = + "MC4NTEZTULYHWOCW6YSLZWAEED6ATRVX2BPKTSWZUKQKUJIMG3SLCAAAAAAAAAAAAGIQE"; + +describe("isAsset", () => { + it("accepts code:issuer with an ed25519 (G) issuer", () => { + expect(isAsset(`USDC:${G_KEY}`)).toBe(true); + }); + + it("accepts code:issuer with a contract (C) issuer", () => { + expect(isAsset(`USDC:${C_KEY}`)).toBe(true); + }); + + it("accepts code:issuer with a muxed (M) issuer", () => { + expect(isAsset(`USDC:${M_KEY}`)).toBe(true); + }); + + it("accepts a single-character asset code", () => { + expect(isAsset(`A:${G_KEY}`)).toBe(true); + }); + + it("rejects a string without a colon", () => { + expect(isAsset(G_KEY)).toBe(false); + }); + + it("rejects a string with more than one colon", () => { + expect(isAsset(`USDC:${G_KEY}:extra`)).toBe(false); + }); + + it("rejects an empty asset code", () => { + expect(isAsset(`:${G_KEY}`)).toBe(false); + }); + + it("rejects an asset code longer than 12 characters", () => { + expect(isAsset(`THIRTEENCHARS:${G_KEY}`)).toBe(false); + }); + + it("rejects an invalid issuer", () => { + expect(isAsset("USDC:not-a-valid-issuer")).toBe(false); + }); + + it("rejects non-string values", () => { + expect(isAsset(123)).toBe(false); + expect(isAsset(null)).toBe(false); + expect(isAsset(undefined)).toBe(false); + expect(isAsset({})).toBe(false); + }); +}); diff --git a/tests/unit/callStackTrace/renderAssetString.test.ts b/tests/unit/callStackTrace/renderAssetString.test.ts new file mode 100644 index 000000000..4f5ade3a9 --- /dev/null +++ b/tests/unit/callStackTrace/renderAssetString.test.ts @@ -0,0 +1,22 @@ +import { renderAssetString } from "../../../src/helpers/callStackTrace/renderAssetString"; +import { shortenStellarAddress } from "../../../src/helpers/shortenStellarAddress"; + +const G_KEY = "GA2Q3TSCKD6GBT5SL4XLAPT4TUXJ5GP7HLIM4OHBWT33GFD2UC7SRNEZ"; + +describe("renderAssetString", () => { + it("shortens the issuer of a valid asset string", () => { + expect(renderAssetString(`USDC:${G_KEY}`)).toBe( + `USDC:${shortenStellarAddress(G_KEY)}`, + ); + }); + + it("returns a non-asset string unchanged", () => { + expect(renderAssetString("hello world")).toBe("hello world"); + }); + + it("returns a colon-containing non-asset string unchanged", () => { + expect(renderAssetString("USDC:not-a-valid-issuer")).toBe( + "USDC:not-a-valid-issuer", + ); + }); +}); diff --git a/tests/unit/callStackTrace/toErrorMapData.test.ts b/tests/unit/callStackTrace/toErrorMapData.test.ts new file mode 100644 index 000000000..984d3e1ef --- /dev/null +++ b/tests/unit/callStackTrace/toErrorMapData.test.ts @@ -0,0 +1,61 @@ +import { toErrorMapData } from "../../../src/helpers/callStackTrace/toErrorMapData"; + +describe("toErrorMapData", () => { + it("returns null for null/undefined input", () => { + expect(toErrorMapData(null)).toBeNull(); + expect(toErrorMapData(undefined)).toBeNull(); + }); + + it("wraps a primitive value as an untyped node", () => { + expect(toErrorMapData(5)).toEqual({ type: undefined, value: 5 }); + expect(toErrorMapData("boom")).toEqual({ type: undefined, value: "boom" }); + }); + + it("converts an array into a vec of untyped nodes", () => { + expect(toErrorMapData([1, "a"])).toEqual({ + type: "vec", + value: [ + { type: undefined, value: 1 }, + { type: undefined, value: "a" }, + ], + }); + }); + + it("converts a plain object into a keyed map node", () => { + expect(toErrorMapData({ contract: 7 })).toEqual({ + type: "map", + value: [ + { + key: { type: undefined, value: "contract" }, + val: { type: undefined, value: 7 }, + }, + ], + }); + }); + + it("recurses into nested objects and arrays", () => { + expect(toErrorMapData({ ctx: { codes: [1, 2] } })).toEqual({ + type: "map", + value: [ + { + key: { type: undefined, value: "ctx" }, + val: { + type: "map", + value: [ + { + key: { type: undefined, value: "codes" }, + val: { + type: "vec", + value: [ + { type: undefined, value: 1 }, + { type: undefined, value: 2 }, + ], + }, + }, + ], + }, + }, + ], + }); + }); +}); diff --git a/tests/unit/callStackTrace/truncateParams.test.ts b/tests/unit/callStackTrace/truncateParams.test.ts new file mode 100644 index 000000000..e15de8f18 --- /dev/null +++ b/tests/unit/callStackTrace/truncateParams.test.ts @@ -0,0 +1,65 @@ +import { truncateParams } from "../../../src/helpers/callStackTrace/truncateParams"; +import { FormattedEventData } from "../../../src/helpers/formatDiagnosticEvents"; + +const prim = (value: number): FormattedEventData => ({ type: "u32", value }); + +const vec = (items: FormattedEventData[]): FormattedEventData => ({ + type: "vec", + value: items, +}); + +const countEllipsis = (data: unknown): number => + (JSON.stringify(data).match(/"ellipsis"/g) || []).length; + +describe("truncateParams", () => { + it("returns all items unchanged when under the limit", () => { + const input = [prim(1), prim(2)]; + + expect(truncateParams(input, 4)).toEqual([prim(1), prim(2)]); + }); + + it("returns exactly maxItems with no ellipsis when at the limit", () => { + const input = [prim(1), prim(2), prim(3), prim(4)]; + + const result = truncateParams(input, 4); + + expect(result).toHaveLength(4); + expect(countEllipsis(result)).toBe(0); + }); + + it("truncates and appends a single ellipsis node when over the limit", () => { + const input = [prim(1), prim(2), prim(3), prim(4), prim(5), prim(6)]; + + const result = truncateParams(input, 4); + + // 4 kept primitives + 1 ellipsis node + expect(result).toHaveLength(5); + expect(result.slice(0, 4)).toEqual([prim(1), prim(2), prim(3), prim(4)]); + expect(result[4]).toEqual({ type: "ellipsis", value: "..." }); + }); + + it("recurses into a nested vec and places the ellipsis inside it", () => { + const input = [vec([prim(1), prim(2), prim(3), prim(4), prim(5), prim(6)])]; + + const result = truncateParams(input, 4); + + expect(result).toHaveLength(1); + const inner = result[0] as { type: string; value: FormattedEventData[] }; + expect(inner.type).toBe("vec"); + expect(inner.value).toHaveLength(5); + expect(inner.value[4]).toEqual({ type: "ellipsis", value: "..." }); + }); + + it("adds only one ellipsis across multiple truncated containers", () => { + const six = [prim(1), prim(2), prim(3), prim(4), prim(5), prim(6)]; + const input = [vec(six), vec(six)]; + + const result = truncateParams(input, 4); + + expect(countEllipsis(result)).toBe(1); + }); + + it("returns an empty array for empty input", () => { + expect(truncateParams([], 4)).toEqual([]); + }); +}); From 318160371e84e576269a600ed0be98409fab5718 Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 26 Jun 2026 10:26:00 -0400 Subject: [PATCH 2/3] CallStackTrace e2e tests --- .../dashboard/components/CallStackTrace.tsx | 14 +- src/constants/networkLimits.ts | 180 +++++++-------- tests/e2e/mock/txCallStackTrace.ts | 213 ++++++++++++++++++ tests/e2e/txDashCallStackTrace.test.ts | 139 ++++++++++++ 4 files changed, 454 insertions(+), 92 deletions(-) create mode 100644 tests/e2e/mock/txCallStackTrace.ts create mode 100644 tests/e2e/txDashCallStackTrace.test.ts diff --git a/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx b/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx index 19290adff..013dfabda 100644 --- a/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx +++ b/src/app/(sidebar)/transaction/dashboard/components/CallStackTrace.tsx @@ -63,7 +63,11 @@ export const CallStackTrace = ({ // Ellipsis (special case) for collapsed params if (type === "ellipsis") { - return {value}; + return ( + + {value} + + ); } // Array @@ -293,6 +297,7 @@ export const CallStackTrace = ({ } className="CallStackTrace__event" data-is-error={event.isError} + data-testid="cst-event" > ) : null} -
+
{renderNested(data.callStack)}
@@ -520,6 +529,7 @@ const EventItem = ({ role="button" tabIndex={0} className="CallStackTrace__icon" + data-testid="cst-chevron" data-visible={hasNestedItems} data-is-expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} diff --git a/src/constants/networkLimits.ts b/src/constants/networkLimits.ts index 9071b20f6..1c154cb75 100644 --- a/src/constants/networkLimits.ts +++ b/src/constants/networkLimits.ts @@ -83,36 +83,36 @@ export const MAINNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "1204734873", - "1205666745", - "1206824877", - "1207791725", - "1208385825", - "1209192085", - "1210387613", - "1211372973", - "1212261241", - "1212771877", - "1213731901", - "1214994785", - "1215945137", - "1216764345", - "1217237233", - "1218407861", - "1219702673", - "1220465965", - "1221135769", - "1221843909", - "1223023941", - "1224005289", - "1224781613", - "1225426193", - "1226341573", - "1227659593", - "1228630001", - "1229546629", - "1230300821", - "1231557457" + "1305323550", + "1290357962", + "1275377550", + "1260912802", + "1246622306", + "1231643974", + "1216838402", + "1201982570", + "1187563462", + "1179672694", + "1180379858", + "1180866886", + "1181566226", + "1182679694", + "1183327750", + "1184201338", + "1184336474", + "1184791466", + "1185965462", + "1187081126", + "1188016026", + "1188662962", + "1189335086", + "1190635666", + "1191898970", + "1193372533", + "1194497289", + "1195300325", + "1196662141", + "1198151945" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -151,36 +151,36 @@ export const TESTNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "2244708454", - "2244728954", - "2245480834", - "2247067119", - "2241709356", - "2234753642", - "2239899207", - "2240088351", - "2240138327", - "2239908822", - "2239999258", - "2240758755", - "2240858591", - "2243597735", - "2244213873", - "2244248957", - "2244879425", - "2244963673", - "2245069457", - "2245098973", - "2245177489", - "2246571481", - "2248263142", - "2250712253", - "2250752297", - "2250780977", - "2250813201", - "2251197973", - "2254890791", - "2255131550" + "2470694248", + "2470715364", + "2470893125", + "2471608410", + "2471626902", + "2471645354", + "2471857510", + "2471885974", + "2471922762", + "2474712971", + "2475252340", + "2475556482", + "2478643368", + "2478930765", + "2479200731", + "2479494638", + "2480623555", + "2481621725", + "2481791758", + "2482716799", + "2483270517", + "2484188584", + "2484600393", + "2483513888", + "2485407848", + "2477082166", + "2479502489", + "2479523889", + "2480981769", + "2481021389" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -219,36 +219,36 @@ export const FUTURENET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65374983", - "65398166", - "65398166", - "65398166" + "62255842", + "62255842", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255578", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", diff --git a/tests/e2e/mock/txCallStackTrace.ts b/tests/e2e/mock/txCallStackTrace.ts new file mode 100644 index 000000000..b38360eb8 --- /dev/null +++ b/tests/e2e/mock/txCallStackTrace.ts @@ -0,0 +1,213 @@ +import { DiagnosticEventJson } from "@/helpers/formatDiagnosticEvents"; +import { AnyObject } from "@/types/types"; + +/** + * Mock `getTransaction` RPC responses for the Call stack trace tab e2e tests. + * + * The `diagnosticEventsJson` payloads below are hand-trimmed slices modelled on + * real diagnostic-event output. Each one is the smallest sequence that triggers + * the behavior under test (empty / success / partial-fail / all-fail / long + * params), so the fixtures stay small and each maps clearly to one assertion. + */ + +// Representative strkeys (valid format) reused across fixtures. +const ACCOUNT_G = "GBCPQMFNL6VMHHF4QGDAEXXTVUV3MXOVSQWLBETNRJTKPMB7QTOFFCG5"; +const CONTRACT_C = "CAM7DY53G63XA4AJRS24Z6VFYAFSSF76C3RZ45BE5YU3FQS5255OOABP"; +const CONTRACT_C2 = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; +const FN_HASH = + "19f1e3bb37b77070098cb5ccfaa5c00b2917fe16e39e7424ee29b2c25dd77ae7"; + +// A minimal Soroban (invoke_host_function) envelope so the dashboard treats the +// loaded transaction as a Soroban tx and renders the Call stack trace tab. +const baseResult = { + latestLedger: 59816335, + latestLedgerCloseTime: "1762960737", + oldestLedger: 59695376, + oldestLedgerCloseTime: "1762261943", + status: "SUCCESS", + applicationOrder: 1, + feeBump: false, + envelopeJson: { + tx: { + tx: { + source_account: ACCOUNT_G, + fee: 100, + seq_num: "1", + cond: "none", + memo: "none", + operations: [ + { + source_account: null, + body: { + invoke_host_function: { + host_function: { + invoke_contract: { + contract_address: CONTRACT_C, + function_name: "swap", + args: [], + }, + }, + auth: [], + }, + }, + }, + ], + ext: "v0", + }, + signatures: [], + }, + }, + resultJson: {}, + resultMetaJson: [], +}; + +/** + * Wraps a `diagnosticEventsJson` payload in a full `getTransaction` response, + * giving each fixture a unique `txHash` (the dashboard reads it back when the + * "Load transaction" button is clicked). + */ +const makeResponse = ( + txHash: string, + diagnosticEventsJson: DiagnosticEventJson[], +) => ({ + jsonrpc: "2.0", + id: 1, + result: { + ...baseResult, + txHash, + diagnosticEventsJson, + }, +}); + +// A diagnostic fn_call event. Defaults to a successful contract call. +const fnCall = ({ + name, + data, + contractId = null, + isSuccess = true, +}: { + name: string; + data: AnyObject | string; + contractId?: string | null; + isSuccess?: boolean; +}): DiagnosticEventJson => ({ + in_successful_contract_call: isSuccess, + event: { + ext: "v0", + contract_id: contractId, + type_: "diagnostic", + body: { + v0: { + topics: [{ symbol: "fn_call" }, { bytes: FN_HASH }, { symbol: name }], + data, + }, + }, + }, +}); + +// A diagnostic fn_return event closing a previously opened fn_call. +const fnReturn = ({ + name, + data, + contractId = null, +}: { + name: string; + data: AnyObject | string; + contractId?: string | null; +}): DiagnosticEventJson => ({ + in_successful_contract_call: true, + event: { + ext: "v0", + contract_id: contractId, + type_: "diagnostic", + body: { + v0: { + topics: [{ symbol: "fn_return" }, { symbol: name }], + data, + }, + }, + }, +}); + +// ============================================================================= +// Fixtures +// ============================================================================= + +// Empty: no diagnostic events -> empty-state message. +export const CST_EMPTY = makeResponse( + "aaaa000000000000000000000000000000000000000000000000000000000001", + [], +); + +// Success: a top-level call with one nested call (chevron expand/collapse), +// an account address param (stellar.expert account link) and a nested call on +// a contract (contract-explorer link). All calls succeed -> no alert. +export const CST_SUCCESS = makeResponse( + "aaaa000000000000000000000000000000000000000000000000000000000002", + [ + fnCall({ + name: "swap", + contractId: null, + data: { vec: [{ address: ACCOUNT_G }, { i128: "100" }] }, + }), + fnCall({ + name: "transfer", + contractId: CONTRACT_C, + data: { vec: [{ i128: "50" }] }, + }), + fnReturn({ name: "transfer", data: "void" }), + fnReturn({ name: "swap", data: { i128: "150" } }), + ], +); + +// Partial failure: one successful call, one failed nested call -> +// errorLevel "some" -> yellow "Transaction partially failed" alert. +export const CST_PARTIAL_FAIL = makeResponse( + "aaaa000000000000000000000000000000000000000000000000000000000003", + [ + fnCall({ name: "outer", data: { vec: [{ i128: "1" }] } }), + fnCall({ + name: "inner", + contractId: CONTRACT_C, + data: { vec: [{ i128: "2" }] }, + isSuccess: false, + }), + ], +); + +// All failed: every call fails -> errorLevel "all" -> +// red "Transaction failed" alert. +export const CST_ALL_FAIL = makeResponse( + "aaaa000000000000000000000000000000000000000000000000000000000004", + [ + fnCall({ name: "outer", data: { vec: [{ i128: "1" }] }, isSuccess: false }), + fnCall({ + name: "inner", + contractId: CONTRACT_C, + data: { vec: [{ i128: "2" }] }, + isSuccess: false, + }), + ], +); + +// Long params: a single call whose params vec holds 7 primitive values. The +// collapsed-params view truncates to 4 primitives and appends an ellipsis. +export const CST_LONG_PARAMS = makeResponse( + "aaaa000000000000000000000000000000000000000000000000000000000005", + [ + fnCall({ + name: "remove_liquidity", + data: { + vec: [ + { address: CONTRACT_C2 }, + { address: CONTRACT_C }, + { i128: "3920309" }, + { i128: "5000000" }, + { i128: "1000000" }, + { address: ACCOUNT_G }, + { u64: "1767119444" }, + ], + }, + }), + ], +); diff --git a/tests/e2e/txDashCallStackTrace.test.ts b/tests/e2e/txDashCallStackTrace.test.ts new file mode 100644 index 000000000..8c461042f --- /dev/null +++ b/tests/e2e/txDashCallStackTrace.test.ts @@ -0,0 +1,139 @@ +import { baseURL } from "../../playwright.config"; +import { expect, Page, test } from "@playwright/test"; +import { + CST_EMPTY, + CST_SUCCESS, + CST_PARTIAL_FAIL, + CST_ALL_FAIL, + CST_LONG_PARAMS, +} from "./mock/txCallStackTrace"; +import { mockRpcRequest } from "./mock/helpers"; + +test.describe("Transaction Dashboard: Call stack trace", () => { + test.beforeEach(async ({ page }) => { + await page.goto(`${baseURL}/transaction/dashboard`); + }); + + test("Empty state", async ({ page }) => { + await loadCallStack({ page, mockResponse: CST_EMPTY }); + + await expect( + page.getByText("This transaction has no call stack trace."), + ).toBeVisible(); + await expect(page.getByTestId("cst-root")).toBeHidden(); + }); + + test("All calls failed shows error alert", async ({ page }) => { + await loadCallStack({ page, mockResponse: CST_ALL_FAIL }); + + await expect( + page.getByRole("heading", { name: "Transaction failed" }), + ).toBeVisible(); + await expect(page.getByTestId("cst-root")).toHaveAttribute( + "data-error-level", + "all", + ); + }); + + test("Some calls failed shows warning alert", async ({ page }) => { + await loadCallStack({ page, mockResponse: CST_PARTIAL_FAIL }); + + await expect( + page.getByRole("heading", { name: "Transaction partially failed" }), + ).toBeVisible(); + await expect(page.getByTestId("cst-root")).toHaveAttribute( + "data-error-level", + "some", + ); + }); + + test("Collapse params toggle truncates params with ellipsis", async ({ + page, + }) => { + await loadCallStack({ page, mockResponse: CST_LONG_PARAMS }); + + // The SDS Toggle hides its ; click the visible toggle control + // (its