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: 19 additions & 3 deletions app/tools/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findFeature } from "@/core/registry/registry";
import { findFeature, loadFeaturePanel } from "@/core/registry/registry";
import { featureSlugs, findManifest } from "@/core/registry/manifests";
import { FeatureShell } from "@/core/ui/FeatureShell";

Expand Down Expand Up @@ -40,8 +40,24 @@ export default async function ToolPage({ params }: RouteParams) {

if (!feature) notFound();

const { manifest, load } = feature;
const Panel = await load();
const { manifest } = feature;
const loaded = await loadFeaturePanel(feature);

if (!loaded.ok) {
return (
<FeatureShell manifest={manifest}>
<div role="alert" data-feature-load-error={loaded.detail?.slug}>
<p className="font-medium">This tool could not be loaded.</p>
<p className="text-sm">
A newer version may have been deployed or your connection dropped. Reload the page
to try again.
</p>
</div>
</FeatureShell>
);
}

const Panel = loaded.value;

return (
<FeatureShell manifest={manifest}>
Expand Down
28 changes: 28 additions & 0 deletions core/__tests__/amount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,31 @@ describe("formatInteger", () => {
expect(formatInteger(1017700, "de-DE")).toBe("1.017.700");
});
});

describe("formatAmount locale edge cases", () => {
it("keeps zero, negatives and full stroop precision exact", () => {
expect(formatAmount("0", { locale: "en-US" })).toBe("0");
expect(formatAmount("0.0000000", { locale: "en-US", trimZeros: false })).toBe("0.0000000");
expect(formatAmount("0.0000001", { locale: "en-US" })).toBe("0.0000001");
expect(formatAmount("-0.0000001", { locale: "en-US" })).toBe("-0.0000001");
expect(formatAmount(-INT64_MAX, { locale: "en-US" })).toBe("-922,337,203,685.4775807");
});

it("uses native digits for non-Latin locales without rounding", () => {
const arabic = formatAmount("1234.5000001", { locale: "ar-EG" });
expect(arabic).toContain("٠٠٠٠٠١");
expect(arabic).not.toMatch(/[0-9]/);
expect(formatAmount("12.3456789", { locale: "hi-IN-u-nu-deva" })).toBe("१२.३४५६७८९");
});

it("keeps an RTL negative sign attached to the value", () => {
const shown = formatAmount("-1.5", { locale: "he-IL" });
expect(shown).toMatch(/1\.5/);
expect(shown).toMatch(/-|−/);
});

it("falls back to the default locale for a malformed tag instead of throwing", () => {
expect(() => formatAmount("1.5", { locale: "not_a locale!" })).not.toThrow();
expect(formatInteger(1234n, "!!")).toBe(formatInteger(1234n));
});
});
17 changes: 17 additions & 0 deletions core/__tests__/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,20 @@ describe("formatUnixSeconds", () => {
expect(formatUnixSeconds("18446744073709551615", "en-US")).toBe("18446744073709551615");
});
});

describe("formatDateTime edge cases", () => {
it("returns invalid timestamps unchanged", () => {
expect(formatDateTime("not-a-date", "en-US")).toBe("not-a-date");
expect(formatDateTime(Number.NaN, "en-US")).toBe("NaN");
expect(formatDateTime(new Date("x"), "en-US")).toBe("Invalid Date");
});

it("always formats in UTC regardless of locale", () => {
expect(formatDateTime("2024-01-01T00:30:00Z", "en-US")).toContain("UTC");
expect(formatDateTime("2024-01-01T00:30:00Z", "ar-EG")).toContain("٢٠٢٤");
});

it("falls back to the default locale for a malformed tag", () => {
expect(formatDateTime("2024-01-01T00:00:00Z", "@@bad")).toBe(formatDateTime("2024-01-01T00:00:00Z"));
});
});
40 changes: 40 additions & 0 deletions core/__tests__/registry.lazy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { ComponentType } from "react";
import { describe, expect, it } from "vitest";

import { generatedFeatures } from "@/core/registry/registry.generated";
import { findFeature, loadFeaturePanel } from "@/core/registry/registry";
import type { FeatureEntry } from "@/core/registry/types";

function entryWith(load: FeatureEntry["load"]): FeatureEntry {
return { manifest: generatedFeatures[0].manifest, load };
}

describe("feature registry laziness", () => {
it("exposes lazy loaders instead of eager panel modules", () => {
Expand All @@ -9,4 +16,37 @@ describe("feature registry laziness", () => {
expect(generatedFeatures[0]).toHaveProperty("load");
expect(generatedFeatures[0]).not.toHaveProperty("Panel");
});

it("returns undefined for unknown slugs", () => {
expect(findFeature("definitely-not-a-tool")).toBeUndefined();
});
});

describe("loadFeaturePanel", () => {
it("returns the panel when the import resolves", async () => {
const Panel: ComponentType = () => null;
const result = await loadFeaturePanel(entryWith(async () => Panel));
expect(result).toEqual({ ok: true, value: Panel });
});

it("turns a rejected chunk import into a typed, message-free failure", async () => {
const chunkError = new Error("Loading chunk 42 failed: https://cdn/x.js?token=abc");
chunkError.name = "ChunkLoadError";
const feature = entryWith(() => Promise.reject(chunkError));

const result = await loadFeaturePanel(feature);

expect(result).toEqual({
ok: false,
code: "load_failed",
detail: { slug: feature.manifest.slug, errorName: "ChunkLoadError" }
});
expect(JSON.stringify(result)).not.toContain("token");
});

it("handles non-Error rejections", async () => {
const result = await loadFeaturePanel(entryWith(() => Promise.reject("boom")));
expect(result.ok).toBe(false);
if (!result.ok) expect(result.detail?.errorName).toBe("UnknownError");
});
});
16 changes: 15 additions & 1 deletion core/format/amount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ export const INT64_MIN = -9_223_372_036_854_775_808n;

export type Locales = string | readonly string[] | undefined;

/**
* Fallback policy: a malformed locale tag makes `Intl` throw, so it is dropped
* and the runtime default locale is used instead. An unsupported-but-valid tag
* is already negotiated down by `Intl` itself. Formatting never throws.
*/
export function safeLocales(locales: Locales): string | string[] | undefined {
if (locales === undefined) return undefined;
try {
return Intl.getCanonicalLocales(locales as string | string[]);
} catch {
return undefined;
}
}

/**
* The only accepted input shape: ASCII digits, an optional leading `-`, and an
* optional `.` followed by at least one digit. `.` is always the decimal
Expand Down Expand Up @@ -111,7 +125,7 @@ function localeSymbols(locales: Locales): LocaleSymbols {
const cached = symbolsCache.get(key);
if (cached) return cached;

const requested = locales as string | string[] | undefined;
const requested = safeLocales(locales);
const integer = new Intl.NumberFormat(requested, { maximumFractionDigits: 0 });
const parts = new Intl.NumberFormat(requested, { minimumFractionDigits: 1 }).formatToParts(-1);
const integerIndex = parts.findIndex((part) => part.type === "integer");
Expand Down
4 changes: 2 additions & 2 deletions core/format/date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Locales } from "@/core/format/amount";
import { safeLocales, type Locales } from "@/core/format/amount";

/**
* Formats ledger and request timestamps for display.
Expand All @@ -14,7 +14,7 @@ function dateTimeFormat(locales: Locales): Intl.DateTimeFormat {
const key = locales === undefined ? "" : [locales].flat().join(",");
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locales as string | string[] | undefined, {
formatter = new Intl.DateTimeFormat(safeLocales(locales), {
dateStyle: "medium",
timeStyle: "long",
timeZone: "UTC"
Expand Down
93 changes: 93 additions & 0 deletions core/lib/__tests__/clipboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClipboardError, copyText, tryCopyText } from "@/core/lib/clipboard";
import { CopyableValue } from "@/core/ui/CopyableValue";

const HASH = "a".repeat(64);

function mockClipboard(writeText?: (value: string) => Promise<void>) {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: writeText ? { writeText } : undefined
});
}

function mockExecCommand(result: boolean) {
const fn = vi.fn(() => result);
Object.defineProperty(document, "execCommand", { configurable: true, value: fn });
return fn;
}

afterEach(() => {
cleanup();
vi.restoreAllMocks();
mockClipboard();
});

describe("copyText", () => {
it("writes through the async clipboard API", async () => {
const writeText = vi.fn(async () => {});
mockClipboard(writeText);
await copyText(HASH);
expect(writeText).toHaveBeenCalledWith(HASH);
});

it("throws a typed error when the API is missing", async () => {
mockClipboard();
await expect(copyText(HASH)).rejects.toMatchObject({ reason: "unavailable" });
});

it("throws a typed error when permission is denied", async () => {
mockClipboard(() => Promise.reject(new DOMException("denied", "NotAllowedError")));
const error = await copyText(HASH).catch((e: unknown) => e);
expect(error).toBeInstanceOf(ClipboardError);
expect((error as ClipboardError).reason).toBe("denied");
});
});

describe("tryCopyText fallback selection", () => {
it("does not touch the legacy path when the API works", async () => {
mockClipboard(async () => {});
const exec = mockExecCommand(true);
await expect(tryCopyText(HASH)).resolves.toBe("copied");
expect(exec).not.toHaveBeenCalled();
});

it("uses execCommand when the API is unavailable", async () => {
mockClipboard();
const exec = mockExecCommand(true);
await expect(tryCopyText(HASH)).resolves.toBe("copied");
expect(exec).toHaveBeenCalledWith("copy");
});

it("reports the real failure when the legacy copy also fails", async () => {
mockClipboard(() => Promise.reject(new Error("nope")));
mockExecCommand(false);
await expect(tryCopyText(HASH)).resolves.toBe("denied");
mockClipboard();
await expect(tryCopyText(HASH)).resolves.toBe("unavailable");
});
});

describe("CopyableValue", () => {
it("announces success", async () => {
mockClipboard(async () => {});
render(<CopyableValue label="transaction hash" value={HASH} />);
fireEvent.click(screen.getByRole("button", { name: "Copy transaction hash" }));
expect(await screen.findByText("transaction hash copied to clipboard")).toBeTruthy();
});

it("shows an actionable fallback with the full value instead of claiming success", async () => {
mockClipboard(() => Promise.reject(new Error("denied")));
mockExecCommand(false);
render(<CopyableValue label="transaction hash" value={HASH} />);
fireEvent.click(screen.getByRole("button", { name: "Copy transaction hash" }));

const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("Could not copy transaction hash");
expect(screen.getByTestId("copy-fallback-value").textContent).toBe(HASH);
expect(screen.queryByText("transaction hash copied to clipboard")).toBeNull();
});
});
60 changes: 57 additions & 3 deletions core/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,61 @@
export type ClipboardFailure = "unavailable" | "denied";

/** Thrown by `copyText`; `reason` lets UI callers show the right guidance. */
export class ClipboardError extends Error {
constructor(readonly reason: ClipboardFailure) {
super(
reason === "unavailable"
? "Clipboard access is not available in this browser."
: "Clipboard access was denied."
);
this.name = "ClipboardError";
}
}

/** Low-level boundary: throws a `ClipboardError` on any failure. */
export async function copyText(value: string): Promise<void> {
if (typeof navigator === "undefined" || !navigator.clipboard) {
throw new Error("Clipboard access is not available in this browser.");
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new ClipboardError("unavailable");
}

try {
await navigator.clipboard.writeText(value);
} catch {
throw new ClipboardError("denied");
}
}

/**
* Legacy `execCommand("copy")` path for browsers without the async API or
* where it is denied. Reports success only when the browser says it copied.
*/
function legacyCopy(value: string): boolean {
if (typeof document === "undefined" || typeof document.execCommand !== "function") return false;
const area = document.createElement("textarea");
area.value = value;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
try {
area.select();
return document.execCommand("copy");
} catch {
return false;
} finally {
area.remove();
}
}

export type CopyOutcome = "copied" | ClipboardFailure;

await navigator.clipboard.writeText(value);
/** UI-safe copy: never throws, and only reports "copied" when a copy happened. */
export async function tryCopyText(value: string): Promise<CopyOutcome> {
try {
await copyText(value);
return "copied";
} catch (error) {
if (legacyCopy(value)) return "copied";
return error instanceof ClipboardError ? error.reason : "denied";
}
}
8 changes: 4 additions & 4 deletions core/network/NetworkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
NETWORK_LABELS,
NETWORK_PASSPHRASES,
NETWORK_STORAGE_KEY,
SOROBAN_RPC_URLS
SOROBAN_RPC_URLS,
resolveNetwork
} from "@/core/network/config";
import { isStellarNetwork, type StellarNetwork } from "@/core/network/types";
import type { StellarNetwork } from "@/core/network/types";
import {
PRUNE_HORIZON_CLIENTS_DELAY_MS,
PRUNE_HORIZON_CLIENTS_OP,
Expand Down Expand Up @@ -50,8 +51,7 @@ function subscribe(onStoreChange: () => void): () => void {

function getSnapshot(): StellarNetwork {
try {
const stored = window.localStorage.getItem(NETWORK_STORAGE_KEY);
return isStellarNetwork(stored) ? stored : DEFAULT_NETWORK;
return resolveNetwork(window.localStorage.getItem(NETWORK_STORAGE_KEY), DEFAULT_NETWORK);
} catch {
// Private windows and blocked site data both throw here.
return DEFAULT_NETWORK;
Expand Down
Loading
Loading