Skip to content
7 changes: 6 additions & 1 deletion backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@ function applyDevApiRoutes(app: Application): void {
app.use("/configure", expressStatic(join(__dirname, "../../../private")));

app.use(async (req, res, next) => {
const slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
let slowdown = (await getLiveConfiguration()).dev.responseSlowdownMs;
if (req.path.includes("connection")) slowdown *= 2.2;
if (req.path.includes("presets")) slowdown *= 1.6;
if (req.path.includes("config")) slowdown *= 1.2;
if (req.path.includes("configuration")) slowdown *= 0.5;

if (slowdown > 0) {
Logger.info(
`Simulating ${slowdown}ms delay for ${req.method} ${req.path}`,
Expand Down
111 changes: 94 additions & 17 deletions frontend/__tests__/components/AsyncContent.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,12 @@ import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@solidjs/testing-library";
import { createResource, Resource } from "solid-js";
import AsyncContent from "../../src/ts/components/common/AsyncContent";
import {
createLoadingStore,
LoadingStore,
} from "../../src/ts/signals/util/loadingStore";

describe("AsyncContent", () => {
function renderWithResource<T>(
resource: Resource<T>,
errorMessage?: string,
): {
container: HTMLElement;
} {
const { container } = render(() => (
<AsyncContent resource={resource} errorMessage={errorMessage}>
{(data) => <div data-testid="content">{String(data)}</div>}
</AsyncContent>
));

return {
container,
};
}

it("renders loading state while resource is pending", () => {
const [resource] = createResource(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
Expand Down Expand Up @@ -75,4 +62,94 @@ describe("AsyncContent", () => {
expect(screen.getByText(/An error occurred/)).toBeInTheDocument();
});
});

it("renders loading state while loadingStore is pending", () => {
const loadingStore = createLoadingStore(
"test",
async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return { data: "data" };
},
() => ({}),
);

const { container } = renderWithLoadingStore(loadingStore);

const preloader = container.querySelector(".preloader");
expect(preloader).toBeInTheDocument();
expect(preloader).toHaveClass("preloader");
expect(preloader?.querySelector("i")).toHaveClass(
"fas",
"fa-fw",
"fa-spin",
"fa-circle-notch",
);
});

it("renders data when loadingStore resolves", async () => {
const loadingStore = createLoadingStore<{ data?: string }>(
"test",
async () => {
return { data: "Test Data" };
},
() => ({}),
);

renderWithLoadingStore(loadingStore);

await waitFor(() => {
expect(screen.getByTestId("content")).toHaveTextContent("Test Data");
});
});

it("renders error message when loadingStore fails", async () => {
const loadingStore = createLoadingStore(
"test",
async () => {
throw new Error("Test error");
},
() => ({}),
);

renderWithLoadingStore(loadingStore, "Custom error message");

await waitFor(() => {
expect(screen.getByText(/Custom error message/)).toBeInTheDocument();
});
});

function renderWithResource<T>(
resource: Resource<T>,
errorMessage?: string,
): {
container: HTMLElement;
} {
const { container } = render(() => (
<AsyncContent resource={resource} errorMessage={errorMessage}>
{(data) => <div data-testid="content">{String(data)}</div>}
</AsyncContent>
));

return {
container,
};
}

function renderWithLoadingStore(
loadingStore: LoadingStore<{ data?: string }>,
errorMessage?: string,
): {
container: HTMLElement;
} {
loadingStore.load();
const { container } = render(() => (
<AsyncContent loadingStore={loadingStore} errorMessage={errorMessage}>
{(data) => <div data-testid="content">{data.data}</div>}
</AsyncContent>
));

return {
container,
};
}
});
134 changes: 134 additions & 0 deletions frontend/__tests__/signals/util/loadingStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { createLoadingStore } from "../../../src/ts/signals/util/loadingStore";
import { vi, describe, it, expect, beforeEach } from "vitest";

const mockFetcher = vi.fn();
const initialValue = vi.fn(() => ({ data: null }));

describe("createLoadingStore", () => {
beforeEach(() => {
mockFetcher.mockClear();
initialValue.mockClear();
});

it("should initialize with the correct state", () => {
const store = createLoadingStore("test", mockFetcher, initialValue);

expect(store.state().state).toBe("unresolved");
expect(store.state().loading).toBe(false);
expect(store.state().ready).toBe(false);
expect(store.state().refreshing).toBe(false);
expect(store.state().error).toBeUndefined();
expect(store.store).toEqual({ data: null });
});

it("should transition to loading when load is called", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
store.load();

expect(store.state().state).toBe("pending");
expect(store.state().loading).toBe(true);
});

it("should enable loading if ready is called", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });

await store.ready();
});

it("should call the fetcher when load is called", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();

await store.ready();

expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(store.state().state).toBe("ready");
expect(store.store).toEqual({ data: "test" });
});

it("should handle error when fetcher fails", async () => {
mockFetcher.mockRejectedValueOnce(new Error("Failed to load"));
const store = createLoadingStore("test", mockFetcher, initialValue);

store.load();

await expect(store.ready()).rejects.toThrow("Failed to load");

expect(store.state().state).toBe("errored");
expect(store.state().error).toEqual(new Error("Failed to load"));
});

it("should transition to refreshing state on refresh", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();

store.refresh(); // trigger refresh
expect(store.state().state).toBe("refreshing");
expect(store.state().refreshing).toBe(true);
});

it("should trigger load when refresh is called and shouldLoad is false", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
expect(store.state().state).toBe("unresolved");

store.refresh();
expect(store.state().state).toBe("refreshing");
expect(store.state().refreshing).toBe(true);

// Wait for the store to be ready after fetching
await store.ready();

// Ensure the store's state is 'ready' after the refresh
expect(store.state().state).toBe("ready");
expect(store.store).toEqual({ data: "test" });
});

it("should reset the store to its initial value on reset", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);
mockFetcher.mockResolvedValueOnce({ data: "test" });
store.load();

await store.ready();

expect(store.store).toEqual({ data: "test" });

store.reset();
expect(store.state().state).toBe("unresolved");
expect(store.state().loading).toBe(false);
expect(store.store).toEqual({ data: null });
});

it("should handle a promise rejection during reset", async () => {
const store = createLoadingStore("test", mockFetcher, initialValue);

// Mock the fetcher to resolve with data
mockFetcher.mockResolvedValueOnce({ data: "test" });

// Trigger loading the store
store.load();

// Wait for the store to be ready
await store.ready();

// Ensure the store state after loading
expect(store.state().state).toBe("ready");
expect(store.store).toEqual({ data: "test" });

// Now call reset, which should reject the ready promise
const readyPromise = store.ready(); // Grab the current ready promise

store.reset(); // Call reset, which should reject the promise

// Ensure the promise rejects as expected
await expect(readyPromise).rejects.toThrow("Reset");

// Ensure the state is reset
expect(store.state().state).toBe("unresolved");
expect(store.state().loading).toBe(false);
expect(store.store).toEqual({ data: null });
});
});
1 change: 1 addition & 0 deletions frontend/src/html/pages/account.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<div class="page pageAccount hidden full-width" id="pageAccount">
<AccountPageLoader />
<div class="content full-width content-grid">
<div class="profile">
<div class="details both">
Expand Down
1 change: 1 addition & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<div class="bar"></div>
</div>
</div>
<PreLoader />
<div id="popups">
<load src="html/popups.html" />
</div>
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/styles/loading.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,31 @@
}
}
}

#preloader {
text-align: center;
place-self: center;
align-content: center;
display: grid;
gap: 1rem;
width: 100%;

.text {
height: 1.25em;
}

.bar {
max-width: 20rem;
width: 100%;
height: 0.5rem;
background: var(--sub-alt-color);
border-radius: var(--roundness);
justify-self: center;
.fill {
height: 100%;
width: 50%;
background: var(--main-color);
border-radius: var(--roundness);
}
}
}
30 changes: 7 additions & 23 deletions frontend/src/ts/ape/server-configuration.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
import { Configuration } from "@monkeytype/schemas/configuration";
import Ape from ".";
import { promiseWithResolvers } from "../utils/misc";
import { serverConfiguration } from "../signals/server-configuration";

let config: Configuration | undefined = undefined;

const {
promise: configurationPromise,
resolve,
reject,
} = promiseWithResolvers<boolean>();

export { configurationPromise };
export const configurationPromise = serverConfiguration.ready();

export function get(): Configuration | undefined {
return config;
return serverConfiguration.state().ready
? serverConfiguration.store
: undefined;
}

export async function sync(): Promise<void> {
const response = await Ape.configuration.get();

if (response.status !== 200) {
const message = `Could not fetch configuration: ${response.body.message}`;
console.error(message);
reject(message);
return;
} else {
config = response.body.data ?? undefined;
resolve(true);
}
serverConfiguration.refresh();
return serverConfiguration.ready();
}
Loading