diff --git a/package-lock.json b/package-lock.json index 3e97b64d9..4d40dcab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "baseui": "^14.0.0", "copy-to-clipboard": "^3.3.3", "lodash": "^4.17.21", + "lossless-json": "^4.0.2", "next": "14.2.16", "next-logger": "^5.0.0", "pino": "^9.3.2", @@ -7836,6 +7837,11 @@ "loose-envify": "cli.js" } }, + "node_modules/lossless-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.0.2.tgz", + "integrity": "sha512-+z0EaLi2UcWi8MZRxA5iTb6m4Ys4E80uftGY+yG5KNFJb5EceQXOhdW/pWJZ8m97s26u7yZZAYMcKWNztSZssA==" + }, "node_modules/lru-cache": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", diff --git a/package.json b/package.json index d87b08c6e..344296797 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "baseui": "^14.0.0", "copy-to-clipboard": "^3.3.3", "lodash": "^4.17.21", + "lossless-json": "^4.0.2", "next": "14.2.16", "next-logger": "^5.0.0", "pino": "^9.3.2", diff --git a/src/components/pretty-json/pretty-json.types.tsx b/src/components/pretty-json/pretty-json.types.tsx index 553b2f802..cc76e3d00 100644 --- a/src/components/pretty-json/pretty-json.types.tsx +++ b/src/components/pretty-json/pretty-json.types.tsx @@ -1,14 +1,15 @@ export type Props = { - json: JsonValue; + json: PrettyJsonValue; }; -export type JsonValue = +export type PrettyJsonValue = | string | number | boolean | null | JsonObject - | JsonArray; + | JsonArray + | bigint; -type JsonObject = { [key: string]: JsonValue }; -type JsonArray = JsonValue[]; +type JsonObject = { [key: string]: PrettyJsonValue }; +type JsonArray = PrettyJsonValue[]; diff --git a/src/route-handlers/query-workflow/query-workflow.ts b/src/route-handlers/query-workflow/query-workflow.ts index 67a08bb2f..0bc1b251a 100644 --- a/src/route-handlers/query-workflow/query-workflow.ts +++ b/src/route-handlers/query-workflow/query-workflow.ts @@ -46,6 +46,8 @@ export async function queryWorkflow( }, }); + // TODO @assem.hafez: we may loss numeric percision here as we are parsing a result from other languages that may include long numbers + // one options is not to parse the result and return it as string and handle the parsing on the client side return NextResponse.json({ result: res.queryResult ? JSON.parse( diff --git a/src/utils/__tests__/lossless-json-parse.test.ts b/src/utils/__tests__/lossless-json-parse.test.ts new file mode 100644 index 000000000..0bfe1ce07 --- /dev/null +++ b/src/utils/__tests__/lossless-json-parse.test.ts @@ -0,0 +1,52 @@ +import losslessJsonParse from '../lossless-json-parse'; + +describe('losslessJsonParse', () => { + it('should parse large numbers as BigInt', () => { + const json = '{"bigNumber": 90071992547409923213}'; + const result = losslessJsonParse(json); + expect(result).toEqual({ bigNumber: BigInt('90071992547409923213') }); + }); + + it('should parse strings of large numbers as string', () => { + const json = '{"bigNumber": "90071992547409923213"}'; + const result = losslessJsonParse(json); + expect(result).toEqual({ bigNumber: '90071992547409923213' }); + }); + + it('should parse a JSON string with integers correctly', () => { + const json = '{"integer": 42}'; + const result = losslessJsonParse(json); + expect(result).toEqual({ integer: 42 }); + }); + + it('should parse floating point numbers correctly', () => { + const json = '{"float": 3.14}'; + const result = losslessJsonParse(json); + expect(result).toEqual({ float: 3.14 }); + }); + + it('should use the provided reviver function', () => { + const json = '{"key": "value"}'; + const reviver = (key: string, value: any) => { + if (key === 'key') { + return 'newValue'; + } + return value; + }; + const result = losslessJsonParse(json, reviver); + expect(result).toEqual({ key: 'newValue' }); + }); + + it('should handle null and undefined reviver correctly', () => { + const json = '{"key": "value"}'; + const resultWithNullReviver = losslessJsonParse(json, null); + const resultWithUndefinedReviver = losslessJsonParse(json, undefined); + expect(resultWithNullReviver).toEqual({ key: 'value' }); + expect(resultWithUndefinedReviver).toEqual({ key: 'value' }); + }); + + it('should throw an error if the JSON is invalid', () => { + const json = '{"key": "value"'; + expect(() => losslessJsonParse(json)).toThrow(); + }); +}); diff --git a/src/utils/__tests__/lossless-json-stringify.test.ts b/src/utils/__tests__/lossless-json-stringify.test.ts new file mode 100644 index 000000000..914cad3b9 --- /dev/null +++ b/src/utils/__tests__/lossless-json-stringify.test.ts @@ -0,0 +1,65 @@ +import losslessJsonStringify from '../lossless-json-stringify'; + +describe('losslessJsonStringify', () => { + it('should stringify a JSON object with safe numbers correctly', () => { + const json = { number: 123 }; + const result = losslessJsonStringify(json); + expect(result).toBe('{"number":123}'); + }); + + it('should stringify BigInts as numbers', () => { + const json = { bigNumber: BigInt('900719925474099223423423') }; + const result = losslessJsonStringify(json); + expect(result).toBe('{"bigNumber":900719925474099223423423}'); + }); + + it('should stringify integers correctly', () => { + const json = { integer: 42 }; + const result = losslessJsonStringify(json); + expect(result).toBe('{"integer":42}'); + }); + + it('should stringify floating point numbers correctly', () => { + const json = { float: 3.14 }; + const result = losslessJsonStringify(json); + expect(result).toBe('{"float":3.14}'); + }); + + it('should use the provided reviver function', () => { + const json = { key: 'value' }; + const reviver = (key: string, value: any) => { + if (key === 'key') { + return 'newValue'; + } + return value; + }; + const result = losslessJsonStringify(json, reviver); + expect(result).toBe('{"key":"newValue"}'); + }); + + it('should handle null and undefined reviver correctly', () => { + const json = { key: 'value' }; + const resultWithNullReviver = losslessJsonStringify(json, null); + const resultWithUndefinedReviver = losslessJsonStringify(json, undefined); + expect(resultWithNullReviver).toBe('{"key":"value"}'); + expect(resultWithUndefinedReviver).toBe('{"key":"value"}'); + }); + + it('should format JSON with spaces correctly', () => { + const json = { key: 'value' }; + const result = losslessJsonStringify(json, null, 2); + expect(result).toBe('{\n "key": "value"\n}'); + }); + + it('should return an empty string for invalid JSON', () => { + const json = () => {}; + const result = losslessJsonStringify(json); + expect(result).toBe(''); + }); + + it('should remove invalid JSON keys', () => { + const json = { key: 'value', fn: () => {} }; + const result = losslessJsonStringify(json); + expect(result).toBe('{"key":"value"}'); + }); +}); diff --git a/src/utils/data-formatters/__tests__/format-payload.test.ts b/src/utils/data-formatters/__tests__/format-payload.test.ts index 61243dabe..79fb113c0 100644 --- a/src/utils/data-formatters/__tests__/format-payload.test.ts +++ b/src/utils/data-formatters/__tests__/format-payload.test.ts @@ -12,8 +12,11 @@ describe('formatPayload', () => { }); it('should parse JSON data correctly', () => { - const payload = { data: btoa(JSON.stringify({ key: 'value' })) }; - expect(formatPayload(payload)).toEqual({ key: 'value' }); + const payload = { data: btoa('{"key":"value","long":284789263475236586}') }; + expect(formatPayload(payload)).toEqual({ + key: 'value', + long: BigInt('284789263475236586'), + }); }); it('should remove double quotes from the string if JSON parsing fails', () => { diff --git a/src/utils/data-formatters/format-input-payload.ts b/src/utils/data-formatters/format-input-payload.ts index ea5ce1759..6f09a9eca 100644 --- a/src/utils/data-formatters/format-input-payload.ts +++ b/src/utils/data-formatters/format-input-payload.ts @@ -1,5 +1,6 @@ import logger from '@/utils/logger'; +import losslessJsonParse from '../lossless-json-parse'; const formatInputPayload = ( payload: { data?: string | null } | null | undefined ) => { @@ -25,7 +26,7 @@ function parseJsonLines(input: string) { try { // Try to parse the current JSON string - const jsonObject = JSON.parse(currentJson); + const jsonObject = losslessJsonParse(currentJson); // If successful, add the object to the array jsonArray.push(jsonObject); // Reset currentJson for the next JSON object @@ -38,7 +39,7 @@ function parseJsonLines(input: string) { // Handle case where the last JSON object might be malformed if (currentJson.trim() !== '') { try { - const jsonObject = JSON.parse(currentJson); + const jsonObject = losslessJsonParse(currentJson); jsonArray.push(jsonObject); } catch { logger.error( diff --git a/src/utils/data-formatters/format-payload.ts b/src/utils/data-formatters/format-payload.ts index 64230e9df..7fd01af32 100644 --- a/src/utils/data-formatters/format-payload.ts +++ b/src/utils/data-formatters/format-payload.ts @@ -1,3 +1,5 @@ +import losslessJsonParse from '../lossless-json-parse'; + const formatPayload = ( payload: { data?: string | null } | null | undefined ) => { @@ -11,7 +13,7 @@ const formatPayload = ( // try parsing as JSON try { - return JSON.parse(parsedData); + return losslessJsonParse(parsedData); } catch { // remove double quotes from the string const formattedString = parsedData.replace(/"/g, ''); diff --git a/src/utils/data-formatters/format-workflow-history-event/__tests__/index.test.ts.snapshot b/src/utils/data-formatters/format-workflow-history-event/__tests__/index.test.ts.snapshot index f1f8ae104..9c9ddeb72 100644 --- a/src/utils/data-formatters/format-workflow-history-event/__tests__/index.test.ts.snapshot +++ b/src/utils/data-formatters/format-workflow-history-event/__tests__/index.test.ts.snapshot @@ -73,7 +73,7 @@ exports[`formatWorkflowHistoryEvent should format workflow activityTaskScheduled }, "heartbeatTimeoutSeconds": 0, "input": [ - 1725747370575410000, + 1725747370575409843n, "gadence-canary-xdc", "workflow.sanity", ], @@ -523,7 +523,7 @@ exports[`formatWorkflowHistoryEvent should format workflow startChildWorkflowExe "fields": {}, }, "input": [ - 1726492751798812400, + 1726492751798812308n, 30000000000, ], "jitterStart": null, diff --git a/src/utils/lossless-json-parse.ts b/src/utils/lossless-json-parse.ts new file mode 100644 index 000000000..fa6ef56b2 --- /dev/null +++ b/src/utils/lossless-json-parse.ts @@ -0,0 +1,16 @@ +import { parse, isSafeNumber, isInteger, type Reviver } from 'lossless-json'; + +export default function losslessJsonParse( + json: string, + reviver?: Reviver | null | undefined +) { + return parse(json, reviver, (value) => { + if (!isSafeNumber(value)) { + return BigInt(value); + } + if (isInteger(value)) { + return parseInt(value); + } + return parseFloat(value); + }); +} diff --git a/src/utils/lossless-json-stringify.ts b/src/utils/lossless-json-stringify.ts new file mode 100644 index 000000000..ea7092d05 --- /dev/null +++ b/src/utils/lossless-json-stringify.ts @@ -0,0 +1,16 @@ +import { stringify, type Reviver } from 'lossless-json'; + +export default function losslessJsonStringify( + json: unknown, + reviver?: Reviver | null | undefined, + space?: string | number | undefined +) { + const stringified = stringify(json, reviver, space, [ + { + test: (value) => typeof value === 'bigint', + stringify: (value) => (value || '').toString(), + }, + ]); + + return stringified || ''; +} diff --git a/src/views/workflow-history/workflow-history-event-details-json/__tests__/workflow-summary-tab-json-view.test.tsx b/src/views/workflow-history/workflow-history-event-details-json/__tests__/workflow-summary-tab-json-view.test.tsx index 01dd1c539..347ad07b3 100644 --- a/src/views/workflow-history/workflow-history-event-details-json/__tests__/workflow-summary-tab-json-view.test.tsx +++ b/src/views/workflow-history/workflow-history-event-details-json/__tests__/workflow-summary-tab-json-view.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render } from '@/test-utils/rtl'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; + import WorkflowSummaryTabJsonView from '../workflow-history-event-details-json'; jest.mock('@/components/copy-text-button/copy-text-button', () => @@ -13,11 +15,14 @@ jest.mock('@/components/pretty-json/pretty-json', () => ); describe('WorkflowSummaryTabJsonView Component', () => { - const inputJson = { input: 'inputJson' }; + const losslessInputJson = { + input: 'inputJson', + long: BigInt('9007199254740991345435'), + }; it('renders correctly with initial props', () => { const { getByText } = render( - + ); expect(getByText('PrettyJson Mock')).toBeInTheDocument(); @@ -25,11 +30,13 @@ describe('WorkflowSummaryTabJsonView Component', () => { it('renders copy text button and pass the correct text', () => { const { getByText } = render( - + ); const copyButton = getByText(/Copy Button/); expect(copyButton).toBeInTheDocument(); - expect(copyButton.innerHTML).toMatch(JSON.stringify(inputJson, null, '\t')); + expect(copyButton.innerHTML).toMatch( + losslessJsonStringify(losslessInputJson, null, '\t') + ); }); }); diff --git a/src/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json.tsx b/src/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json.tsx index 7a695d76a..fc9963d63 100644 --- a/src/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json.tsx +++ b/src/views/workflow-history/workflow-history-event-details-json/workflow-history-event-details-json.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from 'react'; import CopyTextButton from '@/components/copy-text-button/copy-text-button'; import PrettyJson from '@/components/pretty-json/pretty-json'; import useStyletronClasses from '@/hooks/use-styletron-classes'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; import { cssStyles } from './workflow-history-event-details-json.styles'; import type { Props } from './workflow-history-event-details-json.types'; @@ -12,7 +13,7 @@ export default function WorkflowHistoryEventDetailsJson({ entryValue }: Props) { const { cls } = useStyletronClasses(cssStyles); const textToCopy = useMemo(() => { - return JSON.stringify(entryValue, null, '\t'); + return losslessJsonStringify(entryValue, null, '\t'); }, [entryValue]); return (
diff --git a/src/views/workflow-history/workflow-history-export-json-button/workflow-history-export-json-button.tsx b/src/views/workflow-history/workflow-history-export-json-button/workflow-history-export-json-button.tsx index 17937b98a..0cfa09ec3 100644 --- a/src/views/workflow-history/workflow-history-export-json-button/workflow-history-export-json-button.tsx +++ b/src/views/workflow-history/workflow-history-export-json-button/workflow-history-export-json-button.tsx @@ -11,6 +11,7 @@ import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-h import formatWorkflowHistoryEvent from '@/utils/data-formatters/format-workflow-history-event'; import { type FormattedHistoryEvent } from '@/utils/data-formatters/schema/format-history-event-schema'; import logger from '@/utils/logger'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; import request from '@/utils/request'; import { RequestError } from '@/utils/request/request-error'; @@ -23,7 +24,7 @@ export default function WorkflowHistoryExportJsonButton(props: Props) { >('idle'); const downloadJSON = (jsonData: any) => { - const blob = new Blob([JSON.stringify(jsonData, null, '\t')], { + const blob = new Blob([losslessJsonStringify(jsonData, null, '\t')], { type: 'application/json', }); const url = window.URL.createObjectURL(blob); diff --git a/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.tsx b/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.tsx index d912f3416..dc0105843 100644 --- a/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.tsx +++ b/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import CopyTextButton from '@/components/copy-text-button/copy-text-button'; import PrettyJson from '@/components/pretty-json/pretty-json'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; import getQueryJsonContent from './helpers/get-query-json-content'; import { styled } from './workflow-queries-result-json.styles'; @@ -15,7 +16,7 @@ export default function WorkflowQueriesResultJson(props: Props) { ); const textToCopy = useMemo(() => { - return JSON.stringify(content, null, '\t'); + return losslessJsonStringify(content, null, '\t'); }, [content]); return ( diff --git a/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.types.ts b/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.types.ts index 053398eda..d35e34a12 100644 --- a/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.types.ts +++ b/src/views/workflow-queries/workflow-queries-result-json/workflow-queries-result-json.types.ts @@ -1,4 +1,4 @@ -import { type JsonValue } from '@/components/pretty-json/pretty-json.types'; +import { type PrettyJsonValue } from '@/components/pretty-json/pretty-json.types'; import { type QueryWorkflowResponse } from '@/route-handlers/query-workflow/query-workflow.types'; import { type RequestError } from '@/utils/request/request-error'; @@ -9,6 +9,6 @@ export type Props = { }; export type QueryJsonContent = { - content: JsonValue | undefined; + content: PrettyJsonValue | undefined; isError: boolean; }; diff --git a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/__tests__/workflow-summary-tab-json-view.test.tsx b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/__tests__/workflow-summary-tab-json-view.test.tsx index 6290b5456..8f206b818 100644 --- a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/__tests__/workflow-summary-tab-json-view.test.tsx +++ b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/__tests__/workflow-summary-tab-json-view.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { render, fireEvent, screen } from '@/test-utils/rtl'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; + import WorkflowSummaryTabJsonView from '../workflow-summary-tab-json-view'; jest.mock('@/components/copy-text-button/copy-text-button', () => @@ -25,14 +27,20 @@ jest.mock('@/components/pretty-json-skeleton/pretty-json-skeleton', () => ); describe('WorkflowSummaryTabJsonView Component', () => { - const inputJson = { input: 'inputJson' }; - const resultJson = { result: 'resultJson' }; + const losslessInputJson = { + input: 'inputJson', + long: BigInt('12345678901234567890'), + }; + const losselessResultJson = { + result: 'resultJson', + long: BigInt('12345678901234567891'), + }; it('renders correctly with initial props', () => { const { getByText } = render( ); @@ -44,8 +52,8 @@ describe('WorkflowSummaryTabJsonView Component', () => { it('handles tab change', () => { render( ); @@ -59,8 +67,8 @@ describe('WorkflowSummaryTabJsonView Component', () => { it('renders loading state correctly', () => { const { getByText } = render( ); @@ -74,14 +82,15 @@ describe('WorkflowSummaryTabJsonView Component', () => { it('renders copy text button and pass the correct text', () => { const { getByText } = render( ); - const copyButton = getByText(/Copy Button/); expect(copyButton).toBeInTheDocument(); - expect(copyButton.innerHTML).toMatch(JSON.stringify(inputJson, null, '\t')); + expect(copyButton.innerHTML).toMatch( + losslessJsonStringify(losslessInputJson, null, '\t') + ); }); }); diff --git a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.tsx b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.tsx index 6ac9c3948..addec7819 100644 --- a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.tsx +++ b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.tsx @@ -3,10 +3,11 @@ import React, { useMemo, useState } from 'react'; import CopyTextButton from '@/components/copy-text-button/copy-text-button'; import PrettyJson from '@/components/pretty-json/pretty-json'; -import { type JsonValue } from '@/components/pretty-json/pretty-json.types'; +import { type PrettyJsonValue } from '@/components/pretty-json/pretty-json.types'; import PrettyJsonSkeleton from '@/components/pretty-json-skeleton/pretty-json-skeleton'; import SegmentedControlRounded from '@/components/segmented-control-rounded/segmented-control-rounded'; import useStyletronClasses from '@/hooks/use-styletron-classes'; +import losslessJsonStringify from '@/utils/lossless-json-stringify'; import { jsonViewTabsOptions } from './workflow-summary-tab-json-view.constants'; import { cssStyles } from './workflow-summary-tab-json-view.styles'; @@ -18,7 +19,7 @@ export default function WorkflowSummaryTabJsonView({ isWorkflowRunning, }: Props) { const { cls } = useStyletronClasses(cssStyles); - const jsonMap: Record = useMemo( + const jsonMap: Record = useMemo( () => ({ input: inputJson, result: resultJson, @@ -30,7 +31,7 @@ export default function WorkflowSummaryTabJsonView({ ); const textToCopy = useMemo(() => { - return JSON.stringify(jsonMap[activeTab], null, '\t'); + return losslessJsonStringify(jsonMap[activeTab], null, '\t'); }, [jsonMap, activeTab]); return ( diff --git a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.types.ts b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.types.ts index 0ff9b1bda..0e9449219 100644 --- a/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.types.ts +++ b/src/views/workflow-summary-tab/workflow-summary-tab-json-view/workflow-summary-tab-json-view.types.ts @@ -1,7 +1,7 @@ -import { type JsonValue } from '@/components/pretty-json/pretty-json.types'; +import { type PrettyJsonValue } from '@/components/pretty-json/pretty-json.types'; export type Props = { - inputJson: JsonValue; - resultJson: JsonValue; + inputJson: PrettyJsonValue; + resultJson: PrettyJsonValue; isWorkflowRunning: boolean; }; diff --git a/src/views/workflow-summary-tab/workflow-summary-tab.tsx b/src/views/workflow-summary-tab/workflow-summary-tab.tsx index b50d8d0a9..b268e5397 100644 --- a/src/views/workflow-summary-tab/workflow-summary-tab.tsx +++ b/src/views/workflow-summary-tab/workflow-summary-tab.tsx @@ -5,6 +5,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import queryString from 'query-string'; import PageSection from '@/components/page-section/page-section'; +import { type PrettyJsonValue } from '@/components/pretty-json/pretty-json.types'; import useStyletronClasses from '@/hooks/use-styletron-classes'; import { type GetWorkflowHistoryResponse } from '@/route-handlers/get-workflow-history/get-workflow-history.types'; import formatWorkflowHistory from '@/utils/data-formatters/format-workflow-history'; @@ -86,7 +87,7 @@ export default function WorkflowSummaryTab({