Skip to content
Open
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
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 @@ -100,6 +150,7 @@ export const load: LayoutLoad<LayoutData> = async ({ url }) => {
}

return {
registryWarning,
stores: {
selectedChainIds: writable<number[]>([]),
showInactiveOrders: writable<boolean>(false),
Expand Down Expand Up @@ -235,5 +286,75 @@ if (import.meta.vitest) {
statusCallback: updateStatus
});
});

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