generated from nholtware/service-template-react
Initial commit
This commit is contained in:
commit
69da1d8f34
34 changed files with 5531 additions and 0 deletions
80
src/app.test.tsx
Normal file
80
src/app.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { routes } from "./routes";
|
||||
|
||||
// Flags come from the Unleash SDK; tests control them directly.
|
||||
const flagsOn = vi.hoisted(() => new Set<string>());
|
||||
vi.mock("@unleash/proxy-client-react", () => ({
|
||||
FlagProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useFlag: (name: string) => flagsOn.has(name),
|
||||
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||
}));
|
||||
|
||||
function renderAt(path: string) {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path] });
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
flagsOn.clear();
|
||||
window.__APP_CONFIG__ = { API_BASE_URL: "/api", APP_NAME: "test-app" };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("app", () => {
|
||||
it("home shows the app name from runtime config and the build sha", async () => {
|
||||
renderAt("/");
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "test-app" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("build-sha")).toHaveTextContent("dev");
|
||||
});
|
||||
|
||||
it("home hides the banner while hello-banner is off", async () => {
|
||||
renderAt("/");
|
||||
await screen.findByRole("heading", { name: "test-app" });
|
||||
expect(screen.queryByTestId("banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("home shows the banner while hello-banner is on", async () => {
|
||||
flagsOn.add("hello-banner");
|
||||
renderAt("/");
|
||||
expect(await screen.findByTestId("banner")).toHaveTextContent(
|
||||
"hello-banner flag is on",
|
||||
);
|
||||
});
|
||||
|
||||
it("status fetches / and /readyz under API_BASE_URL and renders the JSON", async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const body =
|
||||
url === "/api/readyz" ? { status: "ready" } : { service: "svc" };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderAt("/status");
|
||||
|
||||
expect(await screen.findByText(/GET \/readyz/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/"status": "ready"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/"service": "svc"/)).toBeInTheDocument();
|
||||
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
|
||||
"/api/",
|
||||
"/api/readyz",
|
||||
]);
|
||||
});
|
||||
|
||||
it("status shows the error page when the backend is unreachable", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("network down");
|
||||
}),
|
||||
);
|
||||
renderAt("/status");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("network down");
|
||||
});
|
||||
});
|
||||
23
src/lib/api.ts
Normal file
23
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { getConfig } from "./config";
|
||||
|
||||
export interface ApiResult {
|
||||
path: string;
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
// Fetch a backend path through the same-host /api ingress route.
|
||||
export async function apiGet(path: string): Promise<ApiResult> {
|
||||
const base = getConfig().API_BASE_URL.replace(/\/$/, "");
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// non-JSON response; keep the raw text
|
||||
}
|
||||
return { path, status: res.status, body };
|
||||
}
|
||||
29
src/lib/config.ts
Normal file
29
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export interface AppConfig {
|
||||
API_BASE_URL: string;
|
||||
APP_NAME: string;
|
||||
/** Unleash base URL as seen by the browser (empty = flags off). */
|
||||
UNLEASH_URL: string;
|
||||
/** Unleash frontend token: public by design (read-only, one environment). */
|
||||
UNLEASH_FRONTEND_TOKEN: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: Partial<AppConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
const defaults: AppConfig = {
|
||||
API_BASE_URL: "/api",
|
||||
APP_NAME: "app",
|
||||
UNLEASH_URL: "",
|
||||
UNLEASH_FRONTEND_TOKEN: "",
|
||||
};
|
||||
|
||||
// Runtime config comes from /config.js (window.__APP_CONFIG__), so one image
|
||||
// serves every environment. Read it at call time so tests can override it.
|
||||
export function getConfig(): AppConfig {
|
||||
return { ...defaults, ...(window.__APP_CONFIG__ ?? {}) };
|
||||
}
|
||||
|
||||
export const buildSha: string = import.meta.env.VITE_GIT_SHA ?? "dev";
|
||||
93
src/lib/flags.test.tsx
Normal file
93
src/lib/flags.test.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the Unleash React SDK: capture provider props, control flag state.
|
||||
const sdk = vi.hoisted(() => ({
|
||||
on: new Set<string>(),
|
||||
providerProps: [] as Record<string, unknown>[],
|
||||
}));
|
||||
vi.mock("@unleash/proxy-client-react", () => ({
|
||||
FlagProvider: (props: { children: React.ReactNode }) => {
|
||||
sdk.providerProps.push(props);
|
||||
return props.children;
|
||||
},
|
||||
useFlag: (name: string) => sdk.on.has(name),
|
||||
useVariant: () => ({ name: "disabled", enabled: false }),
|
||||
}));
|
||||
|
||||
import { FlagsProvider, unleashConfig, useFlag } from "./flags";
|
||||
|
||||
function Probe() {
|
||||
return <p>{useFlag("hello-banner") ? "on" : "off"}</p>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sdk.on.clear();
|
||||
sdk.providerProps.length = 0;
|
||||
});
|
||||
|
||||
describe("unleashConfig", () => {
|
||||
const base = { API_BASE_URL: "/api", APP_NAME: "web" };
|
||||
it("is null when UNLEASH_URL or token is unset", () => {
|
||||
expect(
|
||||
unleashConfig({ ...base, UNLEASH_URL: "", UNLEASH_FRONTEND_TOKEN: "" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
unleashConfig({
|
||||
...base,
|
||||
UNLEASH_URL: "http://u",
|
||||
UNLEASH_FRONTEND_TOKEN: "",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
it("points at the Frontend API", () => {
|
||||
expect(
|
||||
unleashConfig({
|
||||
...base,
|
||||
UNLEASH_URL: "http://unleash.test/",
|
||||
UNLEASH_FRONTEND_TOKEN: "*:development.x",
|
||||
}),
|
||||
).toMatchObject({
|
||||
url: "http://unleash.test/api/frontend",
|
||||
clientKey: "*:development.x",
|
||||
appName: "web",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlagsProvider", () => {
|
||||
it("starts the client when configured", () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
APP_NAME: "web",
|
||||
UNLEASH_URL: "http://unleash.test",
|
||||
UNLEASH_FRONTEND_TOKEN: "tok",
|
||||
};
|
||||
sdk.on.add("hello-banner");
|
||||
render(
|
||||
<FlagsProvider>
|
||||
<Probe />
|
||||
</FlagsProvider>,
|
||||
);
|
||||
expect(screen.getByText("on")).toBeInTheDocument();
|
||||
expect(sdk.providerProps[0]).toMatchObject({
|
||||
startClient: true,
|
||||
config: { url: "http://unleash.test/api/frontend", clientKey: "tok" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stays offline when UNLEASH_URL is unset", () => {
|
||||
window.__APP_CONFIG__ = { APP_NAME: "web" };
|
||||
const info = vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
render(
|
||||
<FlagsProvider>
|
||||
<Probe />
|
||||
</FlagsProvider>,
|
||||
);
|
||||
expect(screen.getByText("off")).toBeInTheDocument();
|
||||
expect(sdk.providerProps[0]).toMatchObject({
|
||||
startClient: false,
|
||||
config: { disableRefresh: true, disableMetrics: true },
|
||||
});
|
||||
expect(info).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
55
src/lib/flags.tsx
Normal file
55
src/lib/flags.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
FlagProvider,
|
||||
useFlag as useUnleashFlag,
|
||||
useVariant as useUnleashVariant,
|
||||
type IConfig,
|
||||
} from "@unleash/proxy-client-react";
|
||||
import { getConfig, type AppConfig } from "./config";
|
||||
|
||||
// Offline client: no network, no metrics, every flag reads false.
|
||||
const OFFLINE: IConfig = {
|
||||
url: "http://localhost/offline",
|
||||
clientKey: "offline",
|
||||
appName: "offline",
|
||||
bootstrap: [],
|
||||
disableRefresh: true,
|
||||
disableMetrics: true,
|
||||
};
|
||||
|
||||
/** Unleash Frontend API config from runtime config, or null if unset. */
|
||||
export function unleashConfig(cfg: AppConfig): IConfig | null {
|
||||
if (!cfg.UNLEASH_URL || !cfg.UNLEASH_FRONTEND_TOKEN) return null;
|
||||
return {
|
||||
url: `${cfg.UNLEASH_URL.replace(/\/+$/, "")}/api/frontend`,
|
||||
clientKey: cfg.UNLEASH_FRONTEND_TOKEN,
|
||||
appName: cfg.APP_NAME,
|
||||
refreshInterval: 15,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the app once (main.tsx). With UNLEASH_URL/UNLEASH_FRONTEND_TOKEN unset
|
||||
* (local dev, tests) every flag reads false and nothing is fetched.
|
||||
*/
|
||||
export function FlagsProvider({ children }: { children: ReactNode }) {
|
||||
const config = useMemo(() => {
|
||||
const c = unleashConfig(getConfig());
|
||||
if (!c) console.info("feature flags disabled: UNLEASH_URL unset");
|
||||
return c;
|
||||
}, []);
|
||||
return (
|
||||
<FlagProvider config={config ?? OFFLINE} startClient={config !== null}>
|
||||
{children}
|
||||
</FlagProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** True while the flag is on; re-renders when Unleash reports a change. */
|
||||
export function useFlag(name: string): boolean {
|
||||
return useUnleashFlag(name);
|
||||
}
|
||||
|
||||
export function useVariant(name: string) {
|
||||
return useUnleashVariant(name);
|
||||
}
|
||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { FlagsProvider } from "./lib/flags";
|
||||
import { routes } from "./routes";
|
||||
|
||||
const router = createBrowserRouter(routes);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<FlagsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</FlagsProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
18
src/routes.tsx
Normal file
18
src/routes.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { RouteObject } from "react-router";
|
||||
import { ErrorPage } from "./routes/ErrorPage";
|
||||
import { Home } from "./routes/Home";
|
||||
import { Layout } from "./routes/Layout";
|
||||
import { Status, statusLoader } from "./routes/Status";
|
||||
|
||||
// Data-mode route table. Add pages here; loaders live next to their component.
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
element: <Layout />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: "status", element: <Status />, loader: statusLoader },
|
||||
],
|
||||
},
|
||||
];
|
||||
16
src/routes/ErrorPage.tsx
Normal file
16
src/routes/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { isRouteErrorResponse, useRouteError } from "react-router";
|
||||
|
||||
export function ErrorPage() {
|
||||
const error = useRouteError();
|
||||
const message = isRouteErrorResponse(error)
|
||||
? `${error.status} ${error.statusText}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
return (
|
||||
<section role="alert">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
src/routes/Home.tsx
Normal file
24
src/routes/Home.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { buildSha, getConfig } from "../lib/config";
|
||||
import { useFlag } from "../lib/flags";
|
||||
|
||||
export function Home() {
|
||||
// Demo flag (Plan 07): toggle "hello-banner" in Unleash, then reload.
|
||||
const banner = useFlag("hello-banner");
|
||||
return (
|
||||
<section>
|
||||
{banner && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="banner"
|
||||
style={{ background: "#fff3c4", padding: "0.5rem 1rem" }}
|
||||
>
|
||||
The hello-banner flag is on.
|
||||
</p>
|
||||
)}
|
||||
<h1>{getConfig().APP_NAME}</h1>
|
||||
<p>
|
||||
Build: <code data-testid="build-sha">{buildSha}</code>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
19
src/routes/Layout.tsx
Normal file
19
src/routes/Layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NavLink, Outlet } from "react-router";
|
||||
import { getConfig } from "../lib/config";
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<strong>{getConfig().APP_NAME}</strong>
|
||||
<nav>
|
||||
<NavLink to="/">Home</NavLink> |{" "}
|
||||
<NavLink to="/status">Status</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
src/routes/Status.tsx
Normal file
23
src/routes/Status.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useLoaderData } from "react-router";
|
||||
import { apiGet, type ApiResult } from "../lib/api";
|
||||
|
||||
export async function statusLoader(): Promise<ApiResult[]> {
|
||||
return Promise.all([apiGet("/"), apiGet("/readyz")]);
|
||||
}
|
||||
|
||||
export function Status() {
|
||||
const results = useLoaderData<typeof statusLoader>();
|
||||
return (
|
||||
<section>
|
||||
<h1>Backend status</h1>
|
||||
{results.map((r) => (
|
||||
<article key={r.path}>
|
||||
<h2>
|
||||
GET {r.path} → {r.status}
|
||||
</h2>
|
||||
<pre>{JSON.stringify(r.body, null, 2)}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
src/test-setup.ts
Normal file
6
src/test-setup.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// vitest globals are off, so Testing Library can't register this itself.
|
||||
afterEach(cleanup);
|
||||
5
src/vite-env.d.ts
vendored
Normal file
5
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GIT_SHA?: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue