From bcd1ff0db8100908813dfd7bf3364fdb018b1d33 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Fri, 28 Aug 2026 17:03:54 +0200 Subject: [PATCH 1/2] feat(frontend): render wizard previews from core, not from the backend router --- apps/frontend/astro.config.ts | 6 -- apps/frontend/e2e/app.spec.ts | 6 ++ apps/frontend/e2e/stats-rank.spec.ts | 6 ++ apps/frontend/e2e/stubCardApi.ts | 20 ++++ apps/frontend/e2e/wizard-auth.spec.ts | 6 ++ apps/frontend/package.json | 1 - .../src/wizard/components/Card/SvgInline.tsx | 26 ++--- apps/frontend/src/wizard/mock-http.ts | 96 ------------------- apps/frontend/src/wizard/renderCard.ts | 46 +++++++++ pnpm-lock.yaml | 3 - 10 files changed, 90 insertions(+), 126 deletions(-) create mode 100644 apps/frontend/e2e/stubCardApi.ts delete mode 100644 apps/frontend/src/wizard/mock-http.ts create mode 100644 apps/frontend/src/wizard/renderCard.ts diff --git a/apps/frontend/astro.config.ts b/apps/frontend/astro.config.ts index 4e0191c38d3c1..d5c44fd553ca8 100644 --- a/apps/frontend/astro.config.ts +++ b/apps/frontend/astro.config.ts @@ -116,11 +116,5 @@ export default defineConfig({ }, ], }, - // The backend code the wizard reuses imports `pg`, which never runs in the browser. - build: { - rolldownOptions: { - external: ["pg"], - }, - }, }, }); diff --git a/apps/frontend/e2e/app.spec.ts b/apps/frontend/e2e/app.spec.ts index 63b2b5e674be0..9d32cafdd1a88 100644 --- a/apps/frontend/e2e/app.spec.ts +++ b/apps/frontend/e2e/app.spec.ts @@ -1,7 +1,13 @@ import { expect, test } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + const REPO_URL = "https://github.com/stats-organization/github-stats-extended"; +test.beforeEach(async ({ page }) => { + await stubCardApi(page); +}); + test("load initial page correctly", async ({ page }) => { await page.goto(""); diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts index b1180e2f74040..67a22ba7237a1 100644 --- a/apps/frontend/e2e/stats-rank.spec.ts +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + +test.beforeEach(async ({ page }) => { + await stubCardApi(page); +}); + test("selecting 'None' progress style hides the rank circle", async ({ page, }) => { diff --git a/apps/frontend/e2e/stubCardApi.ts b/apps/frontend/e2e/stubCardApi.ts new file mode 100644 index 0000000000000..2f6f25a9ae723 --- /dev/null +++ b/apps/frontend/e2e/stubCardApi.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; + +/** + * Fulfils the card endpoints: the `Display` stage fetches `https:///api…`, which nothing serves here. + * + * @param page - The page to install the route handler on. + */ +export async function stubCardApi(page: Page): Promise { + // Pathname, not a `**/api**` glob: that also matches module URLs like `/src/wizard/api/user.ts`. + // Install before a spec's own route for one `/api` endpoint, which then wins. + await page.route( + (url) => url.pathname === "/api" || url.pathname.startsWith("/api/"), + (route) => + route.fulfill({ + status: 200, + contentType: "image/svg+xml", + body: '', + }), + ); +} diff --git a/apps/frontend/e2e/wizard-auth.spec.ts b/apps/frontend/e2e/wizard-auth.spec.ts index 5e7e054a72f18..1a25a4ebddcc6 100644 --- a/apps/frontend/e2e/wizard-auth.spec.ts +++ b/apps/frontend/e2e/wizard-auth.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { stubCardApi } from "./stubCardApi"; + /** * Puts the SPA into an authenticated state without contacting GitHub by * stubbing the OAuth code exchange and the follow-up user-access lookup: @@ -26,6 +28,10 @@ async function mockAuthEndpoints(page: Page): Promise { } test.describe("the wizard auth-driven stage transition", () => { + test.beforeEach(async ({ page }) => { + await stubCardApi(page); + }); + test("auto-advances from Login to Select a Card once authenticated", async ({ page, }) => { diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3c1bf9f115620..a4b0d1eeee596 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,7 +16,6 @@ "@astrojs/react": "^6.0.2", "@astrojs/starlight": "^0.41.7", "@reduxjs/toolkit": "^2.12.0", - "@stats-organization/github-readme-stats-backend": "workspace:^", "@stats-organization/github-readme-stats-core": "workspace:^", "@tailwindcss/vite": "^4.3.3", "astro": "^7.2.0", diff --git a/apps/frontend/src/wizard/components/Card/SvgInline.tsx b/apps/frontend/src/wizard/components/Card/SvgInline.tsx index c5cd9b47307a0..a42692132773c 100644 --- a/apps/frontend/src/wizard/components/Card/SvgInline.tsx +++ b/apps/frontend/src/wizard/components/Card/SvgInline.tsx @@ -1,5 +1,3 @@ -// @ts-expect-error type info should be added later -import { router } from "@stats-organization/github-readme-stats-backend"; import { loadConfigFromEnv } from "@stats-organization/github-readme-stats-core"; import axios from "axios"; import { useEffect, useRef, useState } from "react"; @@ -12,7 +10,7 @@ import { useIsAuthenticated, useUserToken, } from "../../../redux/selectors/userSelectors.js"; -import { createMockRequest, createMockResponse } from "../../mock-http.js"; +import { renderCard } from "../../renderCard.js"; interface SvgInlineProps { url: string; @@ -56,7 +54,6 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { setLoaded(false); let body: string; - let status; if (isAuthenticated && (!userToken || userToken === "placeholderPAT")) { // waiting for backend call to private-access @@ -65,24 +62,13 @@ export function SvgInline(props: SvgInlineProps): JSX.Element { if (stage === 4 && !isAuthenticated) { const res = await axios.get(url); + if (res.status >= 300) { + console.error("failed to fetch SVG"); + return; + } body = res.data; - status = res.status; } else { - const req = createMockRequest({ - method: "GET", - url, - }); - const res = createMockResponse(); - // will be solved by npm package - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await router(req, res); - body = res._getBody() as string; - status = res._getStatusCode(); - } - - if (status >= 300) { - console.error("failed to fetch/generate SVG"); - return; + body = (await renderCard(url)).content; } if (!isCurrent) { diff --git a/apps/frontend/src/wizard/mock-http.ts b/apps/frontend/src/wizard/mock-http.ts deleted file mode 100644 index d62af77f2a1bb..0000000000000 --- a/apps/frontend/src/wizard/mock-http.ts +++ /dev/null @@ -1,96 +0,0 @@ -type HeaderMap = Partial>; - -type CreateMockRequestOptions = { - url: string; - headers?: HeaderMap; -} & ( - | { - method: "GET"; - } - | { - method: "POST"; - body: unknown; - } -); - -type CreateMockRequestResult = { - url: string; - headers: HeaderMap; -} & ( - | { - method: "GET"; - } - | { - method: "POST"; - body: unknown; - } -); - -export function createMockRequest( - options: CreateMockRequestOptions, -): CreateMockRequestResult { - const { headers = {}, ...rest } = options; - - return { ...rest, headers }; -} - -interface CreateMockResponseResult { - statusCode: number; - chunks: Array; - - setHeader(name: string, value: string): void; - getHeader(name: string): string | undefined; - getHeaders(): HeaderMap; - - write(chunk: unknown): void; - end(chunk: unknown): void; - - // --- Helpers for inspection --- - _getStatusCode(): number; - _getHeaders(): HeaderMap; - _getBody(): unknown; -} - -export function createMockResponse(): CreateMockResponseResult { - const statusCode = 200; - const headers: HeaderMap = {}; - const chunks: Array = []; - - const res: CreateMockResponseResult = { - statusCode, - chunks, - - setHeader(name, value) { - headers[name.toLowerCase()] = value; - }, - getHeader(name) { - return headers[name.toLowerCase()]; - }, - getHeaders() { - return { ...headers }; - }, - - write(chunk) { - if (typeof chunk !== "string") { - chunk = String(chunk); - } - chunks.push(chunk); - }, - - end(chunk) { - res.write(chunk); - }, - - _getStatusCode() { - return statusCode; - }, - _getHeaders() { - return { ...headers }; - }, - _getBody() { - return chunks.join(""); - }, - }; - - return res; -} diff --git a/apps/frontend/src/wizard/renderCard.ts b/apps/frontend/src/wizard/renderCard.ts new file mode 100644 index 0000000000000..6e161bbf7dad1 --- /dev/null +++ b/apps/frontend/src/wizard/renderCard.ts @@ -0,0 +1,46 @@ +import { + api, + gist, + pin, + topLangs, + wakatime, +} from "@stats-organization/github-readme-stats-core"; + +/** + * What a core api handler returns: + * a rendered card, or a rendered error card. + */ +interface CardResult { + status: string; + content: string; +} + +/** Core's api handlers are still JavaScript, so every param they destructure is inferred as required. */ +type CardHandler = (query: Record) => Promise; + +const CARD_HANDLERS: Record = { + "/api": api as CardHandler, + "/api/gist": gist as CardHandler, + "/api/pin": pin as CardHandler, + "/api/top-langs": topLangs as CardHandler, + "/api/wakatime": wakatime as CardHandler, +}; + +/** + * Renders a card in the browser from the URL the wizard built for it. + * The wizard's PAT arrives through `loadConfigFromEnv` instead. + * + * @param url - Absolute card URL, e.g. `https://host/api/top-langs?username=x`. + * @returns The rendered card, or the rendered error card when a param is rejected. + * @throws When `url` is not one of the card endpoints. + */ +export async function renderCard(url: string): Promise { + const { pathname, searchParams } = new URL(url); + + const handler = CARD_HANDLERS[pathname]; + if (!handler) { + throw new Error(`No card renderer for "${pathname}"`); + } + + return handler(Object.fromEntries(searchParams)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fba5d942ac8c..cc848f3a4814c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,9 +121,6 @@ importers: '@reduxjs/toolkit': specifier: ^2.12.0 version: 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.8)(redux@5.0.1))(react@19.2.8) - '@stats-organization/github-readme-stats-backend': - specifier: workspace:^ - version: link:../backend '@stats-organization/github-readme-stats-core': specifier: workspace:^ version: link:../../packages/core From bb26c7ace370e6c39ce7ecf4728b9e3995e0b004 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Fri, 28 Aug 2026 17:30:33 +0200 Subject: [PATCH 2/2] test: change e2e api/ stub approach --- apps/frontend/astro.config.ts | 24 +++++++++++++++++++++++- apps/frontend/e2e/app.spec.ts | 6 ------ apps/frontend/e2e/stats-rank.spec.ts | 6 ------ apps/frontend/e2e/stubCardApi.ts | 20 -------------------- apps/frontend/e2e/wizard-auth.spec.ts | 6 ------ apps/frontend/playwright.config.ts | 9 +++++++-- 6 files changed, 30 insertions(+), 41 deletions(-) delete mode 100644 apps/frontend/e2e/stubCardApi.ts diff --git a/apps/frontend/astro.config.ts b/apps/frontend/astro.config.ts index d5c44fd553ca8..06c2f5d529e04 100644 --- a/apps/frontend/astro.config.ts +++ b/apps/frontend/astro.config.ts @@ -98,7 +98,29 @@ export default defineConfig({ }), ], vite: { - plugins: [tailwindcss()], + plugins: [ + tailwindcss(), + + // The e2e run sets `STUB_CARD_API`: answer the card endpoints here rather than + // proxy them to a `pnpm dev:backend` that is not running. + !!process.env.STUB_CARD_API && { + name: "stub-card-api", + // In `configureServer`'s body, so it runs ahead of Vite's proxy middleware. + configureServer(server) { + server.middlewares.use((req, res, next) => { + const pathname = req.url?.split("?")[0] ?? ""; + if (pathname !== "/api" && !pathname.startsWith("/api/")) { + next(); + return; + } + res.setHeader("Content-Type", "image/svg+xml"); + res.end( + '', + ); + }); + }, + }, + ], /* * On Vercel the same deployment serves `/api` and `/frontend`, so the docs can reference cards by root-relative path. * Locally the two are separate servers, so forward `/api` to `pnpm dev:backend` to keep those paths working. diff --git a/apps/frontend/e2e/app.spec.ts b/apps/frontend/e2e/app.spec.ts index 9d32cafdd1a88..63b2b5e674be0 100644 --- a/apps/frontend/e2e/app.spec.ts +++ b/apps/frontend/e2e/app.spec.ts @@ -1,13 +1,7 @@ import { expect, test } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - const REPO_URL = "https://github.com/stats-organization/github-stats-extended"; -test.beforeEach(async ({ page }) => { - await stubCardApi(page); -}); - test("load initial page correctly", async ({ page }) => { await page.goto(""); diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts index 67a22ba7237a1..b1180e2f74040 100644 --- a/apps/frontend/e2e/stats-rank.spec.ts +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -1,11 +1,5 @@ import { expect, test } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - -test.beforeEach(async ({ page }) => { - await stubCardApi(page); -}); - test("selecting 'None' progress style hides the rank circle", async ({ page, }) => { diff --git a/apps/frontend/e2e/stubCardApi.ts b/apps/frontend/e2e/stubCardApi.ts deleted file mode 100644 index 2f6f25a9ae723..0000000000000 --- a/apps/frontend/e2e/stubCardApi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Page } from "@playwright/test"; - -/** - * Fulfils the card endpoints: the `Display` stage fetches `https:///api…`, which nothing serves here. - * - * @param page - The page to install the route handler on. - */ -export async function stubCardApi(page: Page): Promise { - // Pathname, not a `**/api**` glob: that also matches module URLs like `/src/wizard/api/user.ts`. - // Install before a spec's own route for one `/api` endpoint, which then wins. - await page.route( - (url) => url.pathname === "/api" || url.pathname.startsWith("/api/"), - (route) => - route.fulfill({ - status: 200, - contentType: "image/svg+xml", - body: '', - }), - ); -} diff --git a/apps/frontend/e2e/wizard-auth.spec.ts b/apps/frontend/e2e/wizard-auth.spec.ts index 1a25a4ebddcc6..5e7e054a72f18 100644 --- a/apps/frontend/e2e/wizard-auth.spec.ts +++ b/apps/frontend/e2e/wizard-auth.spec.ts @@ -1,8 +1,6 @@ import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { stubCardApi } from "./stubCardApi"; - /** * Puts the SPA into an authenticated state without contacting GitHub by * stubbing the OAuth code exchange and the follow-up user-access lookup: @@ -28,10 +26,6 @@ async function mockAuthEndpoints(page: Page): Promise { } test.describe("the wizard auth-driven stage transition", () => { - test.beforeEach(async ({ page }) => { - await stubCardApi(page); - }); - test("auto-advances from Login to Select a Card once authenticated", async ({ page, }) => { diff --git a/apps/frontend/playwright.config.ts b/apps/frontend/playwright.config.ts index 81684b84182e9..05f1ce21fb4cd 100644 --- a/apps/frontend/playwright.config.ts +++ b/apps/frontend/playwright.config.ts @@ -79,7 +79,12 @@ export default defineConfig({ command: "pnpm run dev", url: baseURL, reuseExistingServer: !process.env["CI"], - // `astro dev` detaches itself in an AI-agent shell, which reads as exiting early. - env: { ASTRO_DEV_BACKGROUND: "1" }, + env: { + // `astro dev` detaches itself in an AI-agent shell, which reads as exiting early. + ASTRO_DEV_BACKGROUND: "1", + // `STUB_CARD_API` has the dev server answer `/api` itself, instead of proxying + // the card endpoints to a backend that is not running here. + STUB_CARD_API: "1", + }, }, });