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..013dfabda 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, @@ -195,7 +63,11 @@ export const CallStackTrace = ({ // Ellipsis (special case) for collapsed params if (type === "ellipsis") { - return {value}; + return ( + + {value} + + ); } // Array @@ -425,6 +297,7 @@ export const CallStackTrace = ({ } className="CallStackTrace__event" data-is-error={event.isError} + data-testid="cst-event" > ) : null} -
+
{renderNested(data.callStack)}
@@ -492,40 +369,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 // ============================================================================= @@ -686,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..e2026f260 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" + "1183327750", + "1184201338", + "1184336474", + "1184791466", + "1185965462", + "1187081126", + "1188016026", + "1188662962", + "1189335086", + "1190635666", + "1191898970", + "1193372533", + "1194497289", + "1195300325", + "1196662141", + "1198151945", + "1199436157", + "1199829261", + "1200635665", + "1201827001", + "1202914737", + "1204142921", + "1204811137", + "1205710981", + "1206732457", + "1208170893", + "1209378345", + "1210385293", + "1211386920", + "1212510600" ], "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" + "2480623555", + "2481621725", + "2481791758", + "2482716799", + "2483270517", + "2484188584", + "2484600393", + "2483513888", + "2485407848", + "2477082166", + "2479502489", + "2479523889", + "2480981769", + "2481021389", + "2481069529", + "2481137953", + "2481582385", + "2484522824", + "2484880409", + "2485494810", + "2485529654", + "2485462670", + "2485390118", + "2486594959", + "2487783731", + "2487559182", + "2487761407", + "2489493686", + "2490005728", + "2492159403" ], "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" + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62255314", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538", + "62254538" ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", 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/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..485b8f19f --- /dev/null +++ b/tests/e2e/txDashCallStackTrace.test.ts @@ -0,0 +1,137 @@ +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 "Collapse params" label + // (a