Skip to content
Open
65 changes: 47 additions & 18 deletions packages/ui-components/src/__tests__/loadRegistryUrl.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import { loadRegistryUrl } from "../lib/services/loadRegistryUrl";
import { RegistryManager } from "../lib/providers/registry/RegistryManager";
import { initialRegistry } from "../__fixtures__/RegistryManager";
import { DotrainRegistry } from "@rainlanguage/raindex";

// Mock dependencies
// Mock dependencies. validate/new are spied so the tests can assert that
// loadRegistryUrl performs no registry fetch of its own. Fetching and
// validation are owned by the post-reload +layout.ts (via DotrainRegistry.new);
// loadRegistryUrl only persists the URL and reloads.
vi.mock("@rainlanguage/raindex", () => ({
DotrainRegistry: {
validate: vi.fn(),
new: vi.fn(),
},
}));

Expand Down Expand Up @@ -39,49 +42,75 @@ describe("loadRegistryUrl", () => {
).rejects.toThrow("Registry manager is required");
});

it("should successfully load registry URL and reload the page", async () => {
it("should set the registry and reload the page", async () => {
const testUrl = "https://example.com/registry";
const mockRegistryManager = initialRegistry as RegistryManager;

(DotrainRegistry.validate as Mock).mockResolvedValueOnce({ value: {} });
await loadRegistryUrl(testUrl, mockRegistryManager);
expect(DotrainRegistry.validate).toHaveBeenCalledWith(testUrl);

expect(mockRegistryManager.setRegistry).toHaveBeenCalledWith(testUrl);
expect(window.location.reload).toHaveBeenCalled();
});

it("should throw an error if fetching registry fails", async () => {
it("should NOT fetch/validate the registry itself (avoid double-fetch from GitHub)", async () => {
const testUrl = "https://example.com/registry";
const errorMessage = "Fetch failed";
const mockRegistryManager = initialRegistry as RegistryManager;

await loadRegistryUrl(testUrl, mockRegistryManager);

// loadRegistryUrl does not fetch or validate the registry/settings: that
// is owned by the post-reload +layout.ts via DotrainRegistry.new. So
// neither validate nor new is called from here.
expect(DotrainRegistry.validate).not.toHaveBeenCalled();
expect(DotrainRegistry.new).not.toHaveBeenCalled();
});

it("should reload only after persisting the registry (ordering)", async () => {
const testUrl = "https://example.com/registry";
const calls: string[] = [];
const mockRegistryManager = {
setRegistry: vi.fn(),
setRegistry: vi.fn(() => {
calls.push("setRegistry");
}),
} as unknown as RegistryManager;

(DotrainRegistry.validate as Mock).mockRejectedValueOnce(
new Error(errorMessage),
(window.location.reload as ReturnType<typeof vi.fn>).mockImplementation(
() => {
calls.push("reload");
},
);

await loadRegistryUrl(testUrl, mockRegistryManager);

expect(calls).toEqual(["setRegistry", "reload"]);
});

it("should rethrow as an Error when setRegistry throws an Error", async () => {
const testUrl = "https://example.com/registry";
const mockRegistryManager = {
setRegistry: vi.fn(() => {
throw new Error("Failed to save to localStorage");
}),
} as unknown as RegistryManager;

await expect(loadRegistryUrl(testUrl, mockRegistryManager)).rejects.toThrow(
errorMessage,
"Failed to save to localStorage",
);

expect(mockRegistryManager.setRegistry).not.toHaveBeenCalled();
expect(window.location.reload).not.toHaveBeenCalled();
});

it("should handle non-Error exception during registry fetch", async () => {
it("should map a non-Error throw to a default message", async () => {
const testUrl = "https://example.com/registry";
const mockRegistryManager = {
setRegistry: vi.fn(),
setRegistry: vi.fn(() => {
throw "String error";
}),
} as unknown as RegistryManager;

(DotrainRegistry.validate as Mock).mockRejectedValueOnce("String error");

await expect(loadRegistryUrl(testUrl, mockRegistryManager)).rejects.toThrow(
"Failed to update registry URL",
);

expect(mockRegistryManager.setRegistry).not.toHaveBeenCalled();
expect(window.location.reload).not.toHaveBeenCalled();
});
});
9 changes: 4 additions & 5 deletions packages/ui-components/src/lib/services/loadRegistryUrl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { RegistryManager } from "../providers/registry/RegistryManager";
import { DotrainRegistry } from "@rainlanguage/raindex";

export async function loadRegistryUrl(
url: string,
Expand All @@ -14,10 +13,10 @@ export async function loadRegistryUrl(
}

try {
const validationResult = await DotrainRegistry.validate(url);
if (validationResult.error) {
throw new Error(validationResult.error.readableMsg);
}
// Persist the new registry URL and reload. Page-load (+layout.ts) calls
// DotrainRegistry.new(url), which owns fetching and validating the registry
// and settings files. This function persists and reloads only; it does not
// fetch or validate.
registryManager.setRegistry(url);
window.location.reload();
} catch (e) {
Expand Down
3 changes: 2 additions & 1 deletion packages/webapp/src/lib/__mocks__/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const initialPageState = {
dotrain: 'some dotrain content',
deployment: { key: 'deploy-key' },
orderDetail: {},
errorMessage: ''
errorMessage: '',
registryWarning: ''
},
url: new URL('http://localhost:3000/deploy'),
params: {},
Expand Down
11 changes: 10 additions & 1 deletion packages/webapp/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import type { RaindexClient } from '@rainlanguage/raindex';
import { seedLocalDbSyncSnapshot } from '$lib/stores/localDbStatus';

const { errorMessage, localDb, raindexClient, registry } = $page.data;
const { errorMessage, registryWarning, localDb, raindexClient, registry } = $page.data;
const registryManager = new RegistryManager(REGISTRY_URL);

const queryClient = new QueryClient({
Expand Down Expand Up @@ -75,6 +75,15 @@
</div>
{/if}

{#if registryWarning}
<div
data-testid="registry-warning"
class="fixed bottom-4 left-1/2 z-[100] -translate-x-1/2 transform rounded-lg bg-yellow-500 px-6 py-3 text-white shadow-md"
>
{registryWarning}
</div>
{/if}

<ToastProvider>
<WalletProvider account={signerAddress}>
<QueryClientProvider client={queryClient}>
Expand Down
137 changes: 129 additions & 8 deletions packages/webapp/src/routes/+layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,55 @@ import type { LayoutLoad } from './$types';

export interface LayoutData {
errorMessage?: string;
registryWarning?: string;
stores: AppStoresInterface | null;
raindexClient: RaindexClient | null;
registry: DotrainRegistry | null;
localDb: SQLiteWasmDatabase | null;
}

/** Remove the persisted custom registry from localStorage and the URL param. */
const clearCustomRegistry = (url: URL): void => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.removeItem('registry');
} catch {
// ignore removal failure
}
}
if (typeof window !== 'undefined') {
try {
const next = new URL(window.location.href);
next.searchParams.delete('registry');
window.history.replaceState({}, '', next.toString());
} catch {
// ignore URL update failure
}
}
url.searchParams.delete('registry');
};

/** Build a DotrainRegistry, surfacing a readable message on failure. */
const buildRegistry = async (
registryUrl: string
): Promise<{ registry: DotrainRegistry | null; error?: string }> => {
try {
const registryResult = await DotrainRegistry.new(registryUrl);
if (registryResult.error) {
return {
registry: null,
error: 'Failed to load registry. ' + registryResult.error.readableMsg
};
}
return { registry: registryResult.value };
} catch (error: unknown) {
return { registry: null, error: 'Failed to load registry. ' + (error as Error).message };
}
};

export const load: LayoutLoad<LayoutData> = async ({ url }) => {
let errorMessage: string | undefined;
let registryWarning: string | undefined;

const registryParam = url.searchParams.get('registry');
let registryUrl = REGISTRY_URL;
Expand All @@ -40,16 +81,25 @@ export const load: LayoutLoad<LayoutData> = async ({ url }) => {
}

let registry: DotrainRegistry | null = null;
if (!errorMessage) {
try {
const registryResult = await DotrainRegistry.new(registryUrl);
if (registryResult.error) {
errorMessage = 'Failed to load registry. ' + registryResult.error.readableMsg;
{
const result = await buildRegistry(registryUrl);
registry = result.registry;
if (result.error) {
if (registryUrl !== REGISTRY_URL) {
// A custom registry failed to load. Clear it so the next load uses the
// default, then retry the default in this load so the app still mounts.
clearCustomRegistry(url);
const fallback = await buildRegistry(REGISTRY_URL);
registry = fallback.registry;
if (fallback.error) {
errorMessage = fallback.error;
} else {
registryWarning =
'The custom registry failed to load and has been reset to the default registry.';
}
} else {
registry = registryResult.value;
errorMessage = result.error;
}
} catch (error: unknown) {
errorMessage = 'Failed to load registry. ' + (error as Error).message;
}
}

Expand Down Expand Up @@ -97,6 +147,7 @@ export const load: LayoutLoad<LayoutData> = async ({ url }) => {
}

return {
registryWarning,
stores: {
selectedChainIds: writable<number[]>([]),
showInactiveOrders: writable<boolean>(false),
Expand Down Expand Up @@ -227,5 +278,75 @@ if (import.meta.vitest) {
expect(result.stores).not.toBeNull();
expect(result.registry).toEqual(mockRegistry);
});

it('should reset a failing custom registry from localStorage, retry the default, and warn', async () => {
localStorage.setItem('registry', 'https://custom.example/registry');
mockGetRaindexClient.mockResolvedValue({ value: { client: true } });
const defaultRegistry = { getRaindexClient: mockGetRaindexClient };
mockRegistryNew
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ value: defaultRegistry });

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await load({ url: new URL('http://localhost:3000') } as any);

expect(mockRegistryNew).toHaveBeenNthCalledWith(1, 'https://custom.example/registry');
expect(mockRegistryNew).toHaveBeenNthCalledWith(2, REGISTRY_URL);
expect(localStorage.getItem('registry')).toBeNull();
expect(result.errorMessage).toBeUndefined();
expect(result.registryWarning).toContain('custom registry');
expect(result.stores).not.toBeNull();
expect(result.registry).toEqual(defaultRegistry);
});

it('should reset a failing custom registry from the ?registry= param, retry the default, and warn', async () => {
mockGetRaindexClient.mockResolvedValue({ value: { client: true } });
const defaultRegistry = { getRaindexClient: mockGetRaindexClient };
mockRegistryNew
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ value: defaultRegistry });

const result = await load({
url: new URL('http://localhost:3000?registry=https://custom.example/registry')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);

expect(mockRegistryNew).toHaveBeenNthCalledWith(1, 'https://custom.example/registry');
expect(mockRegistryNew).toHaveBeenNthCalledWith(2, REGISTRY_URL);
expect(localStorage.getItem('registry')).toBeNull();
expect(result.errorMessage).toBeUndefined();
expect(result.registryWarning).toContain('custom registry');
expect(result.registry).toEqual(defaultRegistry);
});

it('should stay fatal when a failing custom registry resets but the default also fails', async () => {
localStorage.setItem('registry', 'https://custom.example/registry');
mockRegistryNew
.mockRejectedValueOnce(new Error('Custom network error'))
.mockRejectedValueOnce(new Error('Default network error'));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await load({ url: new URL('http://localhost:3000') } as any);

expect(mockRegistryNew).toHaveBeenNthCalledWith(1, 'https://custom.example/registry');
expect(mockRegistryNew).toHaveBeenNthCalledWith(2, REGISTRY_URL);
expect(localStorage.getItem('registry')).toBeNull();
expect(result.registryWarning).toBeUndefined();
expect(result).toHaveProperty('stores', null);
expect(result.errorMessage).toContain('Failed to load registry');
});

it('should stay fatal without resetting when the default registry fails', async () => {
mockRegistryNew.mockRejectedValueOnce(new Error('Network error'));

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await load({ url: new URL('http://localhost:3000') } as any);

expect(mockRegistryNew).toHaveBeenCalledTimes(1);
expect(mockRegistryNew).toHaveBeenCalledWith(REGISTRY_URL);
expect(result.registryWarning).toBeUndefined();
expect(result).toHaveProperty('stores', null);
expect(result.errorMessage).toContain('Failed to load registry');
});
});
}
25 changes: 25 additions & 0 deletions packages/webapp/src/routes/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,29 @@ describe('Layout component', () => {
expect(screen.getByTestId('error-page')).toBeInTheDocument();
});
});

it('shows a non-fatal registry warning banner and still renders the app', async () => {
mockPageStore.mockSetSubscribeValue({
...initialPageState,
url: new URL('http://localhost/some-page'),
data: {
...initialPageState.data,
registryWarning: 'The custom registry failed to load and has been reset to the default registry.'
}
});

render(Layout);

await waitFor(() => {
expect(screen.getByTestId('registry-warning')).toBeInTheDocument();
expect(
screen.getByText(
'The custom registry failed to load and has been reset to the default registry.'
)
).toBeInTheDocument();
// The warning is non-fatal: the app still mounts and the error page is absent.
expect(screen.getByTestId('layout-container')).toBeInTheDocument();
expect(screen.queryByTestId('error-page')).not.toBeInTheDocument();
});
});
});
Loading