Skip to content
Merged
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
22 changes: 22 additions & 0 deletions features/soroban-footprint-diff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Soroban Footprint Difference Inspector

Fully offline comparison of two pasted simulation-result JSON payloads.

## Behavior

1. Validate both pastes (non-empty, size-capped).
2. Require `footprint.readOnly` / `footprint.readWrite` arrays.
3. Decode ledger keys (opaque labels or LedgerKey XDR).
4. Treat the same key in both sets as an access-mode conflict.
5. Diff additions, removals and mode changes; show optional resource fields as simulation outputs only.

## Data boundaries

- No network calls (`networks: []`, `offline: true`).
- Pastes are never persisted.
- Input size capped at 100,000 characters per side.

## Design decisions

- A footprint is a proposal for one simulated transaction, not proof of a later ledger write.
- Resource fields are shown only when present and labeled as simulation output.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { SorobanFootprintDiffPanel } from "@/features/soroban-footprint-diff/components/SorobanFootprintDiffPanel";
import { copy, errorCopy } from "@/features/soroban-footprint-diff/copy";
import {
firstSimulationJson,
secondSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

type User = ReturnType<typeof renderFeature>["user"];

async function compare(user: User, first: string, second: string) {
await user.click(screen.getByLabelText(copy.firstLabel));
await user.paste(first);
await user.click(screen.getByLabelText(copy.secondLabel));
await user.paste(second);
await user.click(screen.getByRole("button", { name: copy.submit }));
}

describe("SorobanFootprintDiffPanel", () => {
it("shows empty state", () => {
renderFeature(<SorobanFootprintDiffPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("renders a successful comparison", async () => {
const { user } = renderFeature(<SorobanFootprintDiffPanel />);
await compare(user, firstSimulationJson, secondSimulationJson);
expect(await screen.findByText(copy.modeTitle)).toBeInTheDocument();
expect(screen.getByText(copy.addedTitle)).toBeInTheDocument();
});

it("explains empty second result", async () => {
const { user } = renderFeature(<SorobanFootprintDiffPanel />);
await user.click(screen.getByLabelText(copy.firstLabel));
await user.paste(firstSimulationJson);
await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByText(errorCopy.empty_second_result.title)).toBeInTheDocument();
});
});
27 changes: 27 additions & 0 deletions features/soroban-footprint-diff/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { expectNoAxeViolations } from "@/core/testing/axe";
import { SorobanFootprintDiffPanel } from "@/features/soroban-footprint-diff/components/SorobanFootprintDiffPanel";
import { copy } from "@/features/soroban-footprint-diff/copy";
import {
firstSimulationJson,
secondSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

describe("SorobanFootprintDiffPanel accessibility", () => {
it("has no WCAG A/AA violations idle", async () => {
const { container } = renderFeature(<SorobanFootprintDiffPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations after success", async () => {
const { container, user } = renderFeature(<SorobanFootprintDiffPanel />);
await user.click(screen.getByLabelText(copy.firstLabel));
await user.paste(firstSimulationJson);
await user.click(screen.getByLabelText(copy.secondLabel));
await user.paste(secondSimulationJson);
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByText(copy.modeTitle);
await expectNoAxeViolations(container);
});
});
27 changes: 27 additions & 0 deletions features/soroban-footprint-diff/__tests__/access-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { diffAccessModes } from "@/features/soroban-footprint-diff/lib/access-diff";

describe("diffAccessModes", () => {
it("reports additions, removals and mode changes", () => {
const diff = diffAccessModes(
[
{ id: "A", label: "A", mode: "read_only" },
{ id: "B", label: "B", mode: "read_only" },
{ id: "C", label: "C", mode: "read_write" }
],
[
{ id: "A", label: "A", mode: "read_only" },
{ id: "B", label: "B", mode: "read_write" },
{ id: "D", label: "D", mode: "read_write" }
]
);

expect(diff.added.map((key) => key.id)).toEqual(["D"]);
expect(diff.removed.map((key) => key.id)).toEqual(["C"]);
expect(diff.modeChanges.find((change) => change.id === "B")).toMatchObject({
kind: "mode_changed",
before: "read_only",
after: "read_write"
});
});
});
38 changes: 38 additions & 0 deletions features/soroban-footprint-diff/__tests__/footprint-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { parseSimulationFootprint } from "@/features/soroban-footprint-diff/lib/footprint-parser";
import {
conflictSimulationJson,
firstSimulationJson,
invalidXdrSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

describe("parseSimulationFootprint", () => {
it("parses labeled keys and resources", () => {
const result = parseSimulationFootprint(firstSimulationJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.keys).toHaveLength(3);
expect(result.value.resources.minResourceFee).toBe("100");
});

it("marks the same key in both sets as conflict", () => {
const result = parseSimulationFootprint(conflictSimulationJson);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.keys).toEqual([
{ id: "CONTRACT_DATA:X", label: "CONTRACT_DATA:X", mode: "conflict" }
]);
});

it("rejects invalid JSON shapes", () => {
expect(parseSimulationFootprint("{")).toEqual({ ok: false, code: "invalid_json" });
expect(parseSimulationFootprint("{}")).toEqual({ ok: false, code: "invalid_json" });
});

it("rejects undecodable XDR-looking entries", () => {
expect(parseSimulationFootprint(invalidXdrSimulationJson)).toEqual({
ok: false,
code: "invalid_footprint_xdr"
});
});
});
25 changes: 25 additions & 0 deletions features/soroban-footprint-diff/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { formatChangeLine, formatKeyLabel, formatSummary } from "@/features/soroban-footprint-diff/lib/format";

describe("format helpers", () => {
it("formats key labels and change lines", () => {
expect(
formatKeyLabel({ id: "A", label: "CONTRACT_DATA:A", mode: "read_only" })
).toContain("read-only");
expect(
formatChangeLine({
id: "B",
label: "CONTRACT_DATA:B",
before: "read_only",
after: "read_write",
kind: "mode_changed"
})
).toContain("read-only → read-write");
});

it("formats summary counts", () => {
expect(formatSummary({ added: 1, removed: 2, modeChanges: 3 })).toBe(
"1 added · 2 removed · 3 mode changes"
);
});
});
32 changes: 32 additions & 0 deletions features/soroban-footprint-diff/__tests__/resource-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { summarizeResources } from "@/features/soroban-footprint-diff/lib/resource-summary";

describe("summarizeResources", () => {
it("includes only present fields", () => {
expect(
summarizeResources({
minResourceFee: "100",
cpuInsns: null,
memBytes: "2",
labeledAsSimulation: true
})
).toEqual({
hasAny: true,
rows: [
{ label: "minResourceFee", value: "100" },
{ label: "memBytes", value: "2" }
]
});
});

it("reports empty summaries", () => {
expect(
summarizeResources({
minResourceFee: null,
cpuInsns: null,
memBytes: null,
labeledAsSimulation: true
}).hasAny
).toBe(false);
});
});
36 changes: 36 additions & 0 deletions features/soroban-footprint-diff/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { parseSorobanFootprintDiffInput } from "@/features/soroban-footprint-diff/schema";
import { MAX_INPUT_CHARS } from "@/features/soroban-footprint-diff/types";
import {
firstSimulationJson,
secondSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

describe("parseSorobanFootprintDiffInput", () => {
it("accepts two non-empty payloads under the size cap", () => {
expect(
parseSorobanFootprintDiffInput({
firstResult: firstSimulationJson,
secondResult: secondSimulationJson
}).ok
).toBe(true);
});

it("rejects empty sides", () => {
expect(
parseSorobanFootprintDiffInput({ firstResult: "", secondResult: secondSimulationJson })
).toEqual({ ok: false, code: "empty_first_result" });
expect(
parseSorobanFootprintDiffInput({ firstResult: firstSimulationJson, secondResult: " " })
).toEqual({ ok: false, code: "empty_second_result" });
});

it("rejects oversized pastes", () => {
expect(
parseSorobanFootprintDiffInput({
firstResult: "x".repeat(MAX_INPUT_CHARS + 1),
secondResult: secondSimulationJson
})
).toEqual({ ok: false, code: "too_large" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { runSorobanFootprintDiff } from "@/features/soroban-footprint-diff/lib/sorobanFootprintDiff";
import {
firstSimulationJson,
invalidXdrSimulationJson,
secondSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

describe("runSorobanFootprintDiff", () => {
it("diffs two simulation footprints", async () => {
const result = await runSorobanFootprintDiff({
firstResult: firstSimulationJson,
secondResult: secondSimulationJson
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.added.map((key) => key.id)).toEqual(["CONTRACT_DATA:D"]);
expect(result.value.removed.map((key) => key.id)).toEqual(["CONTRACT_DATA:C"]);
expect(result.value.modeChanges.some((change) => change.id === "CONTRACT_DATA:B")).toBe(true);
});

it("maps invalid JSON", async () => {
expect(
await runSorobanFootprintDiff({ firstResult: "{", secondResult: secondSimulationJson })
).toEqual({ ok: false, code: "invalid_json" });
});

it("maps invalid footprint XDR", async () => {
expect(
await runSorobanFootprintDiff({
firstResult: invalidXdrSimulationJson,
secondResult: secondSimulationJson
})
).toEqual({ ok: false, code: "invalid_footprint_xdr" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useSorobanFootprintDiff } from "@/features/soroban-footprint-diff/hooks/useSorobanFootprintDiff";
import {
firstSimulationJson,
secondSimulationJson
} from "@/features/soroban-footprint-diff/fixtures/sorobanFootprintDiff.fixture";

describe("useSorobanFootprintDiff", () => {
it("starts idle", () => {
const { result } = renderHook(() => useSorobanFootprintDiff());
expect(result.current.state).toEqual({ status: "idle" });
});

it("compares footprints", async () => {
const { result } = renderHook(() => useSorobanFootprintDiff());
await act(async () => {
await result.current.submit({
firstResult: firstSimulationJson,
secondResult: secondSimulationJson
});
});
await waitFor(() => expect(result.current.state.status).toBe("success"));
});

it("surfaces empty first result", async () => {
const { result } = renderHook(() => useSorobanFootprintDiff());
await act(async () => {
await result.current.submit({ firstResult: "", secondResult: secondSimulationJson });
});
expect(result.current.state).toEqual({ status: "error", code: "empty_first_result" });
});

it("resets", async () => {
const { result } = renderHook(() => useSorobanFootprintDiff());
await act(async () => {
await result.current.submit({
firstResult: firstSimulationJson,
secondResult: secondSimulationJson
});
});
await waitFor(() => expect(result.current.state.status).toBe("success"));
act(() => result.current.reset());
expect(result.current.state).toEqual({ status: "idle" });
});
});
24 changes: 24 additions & 0 deletions features/soroban-footprint-diff/components/AccessChanges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { copy } from "@/features/soroban-footprint-diff/copy";
import { formatChangeLine } from "@/features/soroban-footprint-diff/lib/format";
import type { AccessChange } from "@/features/soroban-footprint-diff/types";

export function AccessChanges({ changes }: { changes: AccessChange[] }) {
if (changes.length === 0) {
return <p className="text-sm text-[#68758a]">{copy.noModeChanges}</p>;
}

return (
<ul className="space-y-2">
{changes.map((change) => (
<li
key={`${change.id}-${change.kind}`}
className="rounded-md border border-[#e3ebf5] bg-white/60 px-3 py-2 font-mono text-xs text-[#172033]"
>
{formatChangeLine(change)}
</li>
))}
</ul>
);
}
Loading
Loading