From 668aec45c5601e2d925db71ce91758aa659ae076 Mon Sep 17 00:00:00 2001 From: admin123 Date: Wed, 22 Apr 2026 10:51:39 +0800 Subject: [PATCH] feat: add popup protected api dev test --- src/popup/index.ts | 155 +++++++++++++++++++++++++++++++ src/popup/view.ts | 60 ++++++++++++ tests/popup-entry.test.ts | 188 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 src/popup/index.ts create mode 100644 src/popup/view.ts create mode 100644 tests/popup-entry.test.ts diff --git a/src/popup/index.ts b/src/popup/index.ts new file mode 100644 index 0000000..c00292a --- /dev/null +++ b/src/popup/index.ts @@ -0,0 +1,155 @@ +import { + renderDevPanel, + renderLoggedIn, + renderLoggedOut, + setProtectedApiResult +} from "./view"; +import { readAuthConfig, type AuthConfig } from "../shared/auth-config"; +import { + isAuthResponseMessage, + type AuthResponseMessage +} from "../shared/auth-messages"; +import { createProtectedApiClient } from "../shared/protected-api-client"; + +interface BootPopupOptions { + config?: Partial; + document?: Document; + fetchProtectedApi?: () => Promise; + sendMessage?: (message: unknown) => Promise; +} + +export async function bootPopup(options: BootPopupOptions = {}): Promise { + const currentDocument = options.document ?? document; + const popupConfig = readAuthConfig(options.config); + const root = currentDocument.querySelector("#app"); + const HTMLElementCtor = currentDocument.defaultView?.HTMLElement; + + if (!root || (HTMLElementCtor && !(root instanceof HTMLElementCtor))) { + throw new Error("popup root #app is required"); + } + + const sendMessage = + options.sendMessage ?? + ((message: unknown) => + Promise.resolve( + ( + globalThis as typeof globalThis & { + chrome?: { + runtime?: { + sendMessage?: (payload: unknown) => Promise; + }; + }; + } + ).chrome?.runtime?.sendMessage?.(message) + )); + const fetchProtectedApi = + options.fetchProtectedApi ?? + createProtectedApiClient({ + baseUrl: "http://127.0.0.1:4319", + sendMessage + }).loadProtectedMockData; + + await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi); +} + +async function renderCurrentAuthState( + root: HTMLElement, + popupConfig: AuthConfig, + sendMessage: (message: unknown) => Promise, + fetchProtectedApi: () => Promise +): Promise { + const response = await sendMessage({ type: "auth:get-state" }); + if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") { + renderLoggedOut(root, "认证状态读取失败"); + return; + } + + if (!response.value.isAuthenticated) { + renderLoggedOut(root, response.value.lastError); + root + .querySelector('[data-popup-sign-in="button"]') + ?.addEventListener("click", () => { + void runAuthAction(root, popupConfig, sendMessage, { + actionMessage: { type: "auth:sign-in" }, + fetchProtectedApi + }); + }); + return; + } + + renderLoggedIn(root, response.value); + root + .querySelector('[data-popup-sign-out="button"]') + ?.addEventListener("click", () => { + void runAuthAction(root, popupConfig, sendMessage, { + actionMessage: { type: "auth:sign-out" }, + fetchProtectedApi + }); + }); + if (popupConfig.enableDevAuthPanel) { + renderDevPanel(root, response.value); + root + .querySelector('[data-popup-test-protected-api="button"]') + ?.addEventListener("click", () => { + void runProtectedApiProbe(root, fetchProtectedApi); + }); + } +} + +async function runAuthAction( + root: HTMLElement, + popupConfig: AuthConfig, + sendMessage: (message: unknown) => Promise, + options: { + actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" }; + fetchProtectedApi: () => Promise; + } +): Promise { + const response = await sendMessage(options.actionMessage); + + if (isActionError(response)) { + renderLoggedOut(root, response.error); + root + .querySelector('[data-popup-sign-in="button"]') + ?.addEventListener("click", () => { + void runAuthAction(root, popupConfig, sendMessage, options); + }); + return; + } + + await renderCurrentAuthState( + root, + popupConfig, + sendMessage, + options.fetchProtectedApi + ); +} + +function isActionError(response: unknown): response is Extract { + return ( + isAuthResponseMessage(response) && + !response.ok && + response.type === "auth:error" + ); +} + +async function runProtectedApiProbe( + root: HTMLElement, + fetchProtectedApi: () => Promise +): Promise { + setProtectedApiResult(root, "请求中..."); + + try { + const result = await fetchProtectedApi(); + setProtectedApiResult(root, JSON.stringify(result, null, 2)); + } catch (error) { + setProtectedApiResult( + root, + error instanceof Error ? error.message : String(error) + ); + } +} + +if (typeof document !== "undefined") { + void bootPopup(); +} diff --git a/src/popup/view.ts b/src/popup/view.ts new file mode 100644 index 0000000..eb51477 --- /dev/null +++ b/src/popup/view.ts @@ -0,0 +1,60 @@ +import type { AuthStateValue } from "../shared/auth-messages"; + +export function renderLoggedOut(root: HTMLElement, error?: string | null): void { + root.innerHTML = ` +
+

Star Chart Search Enhancer

+

登录后才能使用星图增强功能

+ ${error ? `

${error}

` : ""} + +
+ `; +} + +export function renderLoggedIn( + root: HTMLElement, + authState: AuthStateValue +): void { + const userInfo = authState.userInfo; + + root.innerHTML = ` +
+

Star Chart Search Enhancer

+

已登录

+

${userInfo?.name ?? userInfo?.username ?? "未知用户"}

+

${userInfo?.email ?? ""}

+ +
+ `; +} + +export function renderDevPanel( + root: HTMLElement, + authState: AuthStateValue +): void { + const panel = root.ownerDocument.createElement("section"); + panel.dataset.popupDevPanel = "root"; + panel.innerHTML = ` +

dev auth panel

+

resource: ${authState.resource ?? ""}

+

scopes: ${(authState.scopes ?? []).join(", ")}

+

token: ${authState.tokenAvailable ? "available" : "missing"}

+

expires: ${authState.accessTokenExpiresAt ?? "unknown"}

+

error: ${authState.lastError ?? ""}

+ +

+  `;
+  root.appendChild(panel);
+}
+
+export function setProtectedApiResult(root: HTMLElement, value: string): void {
+  const output = root.querySelector(
+    '[data-popup-protected-api-result="output"]'
+  );
+
+  if (!output) {
+    return;
+  }
+
+  output.textContent = value;
+}
diff --git a/tests/popup-entry.test.ts b/tests/popup-entry.test.ts
new file mode 100644
index 0000000..42289a1
--- /dev/null
+++ b/tests/popup-entry.test.ts
@@ -0,0 +1,188 @@
+import { JSDOM } from "jsdom";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { bootPopup } from "../src/popup/index";
+
+describe("popup-entry", () => {
+  let dom: JSDOM;
+
+  beforeEach(() => {
+    dom = new JSDOM("");
+  });
+
+  test("renders a sign-in button when unauthenticated", async () => {
+    dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + document: dom.window.document, + sendMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { isAuthenticated: false } + })) + }); + + expect(dom.window.document.querySelector("button")?.textContent).toContain( + "登录" + ); + }); + + test("renders the dev auth panel when enabled", async () => { + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + config: { enableDevAuthPanel: true }, + document: dom.window.document, + sendMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { + accessTokenExpiresAt: 1700000000000, + isAuthenticated: true, + resource: "https://api.example.test", + scopes: ["openid", "profile"], + tokenAvailable: true, + userInfo: { email: "dev@example.com", name: "Dev" } + } + })) + }); + + expect(dom.window.document.body.textContent).toContain("resource"); + expect(dom.window.document.body.textContent).toContain("token"); + }); + + test("renders a protected api test button in the dev panel", async () => { + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + config: { enableDevAuthPanel: true }, + document: dom.window.document, + sendMessage: vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: true, + tokenAvailable: true + } + })) + }); + + expect( + dom.window.document.querySelector('[data-popup-test-protected-api="button"]') + ).not.toBeNull(); + }); + + test("clicking the dev button runs the protected api client and prints the result", async () => { + const fetchProtectedApi = vi.fn(async () => ({ + message: "authorized", + ok: true, + source: "mock-protected-api" + })); + const sendMessage = vi.fn(async () => ({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: true, + tokenAvailable: true + } + })); + + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + config: { enableDevAuthPanel: true }, + document: dom.window.document, + fetchProtectedApi, + sendMessage + }); + + ( + dom.window.document.querySelector( + '[data-popup-test-protected-api="button"]' + ) as HTMLButtonElement | null + )?.click(); + + await Promise.resolve(); + + expect(fetchProtectedApi).toHaveBeenCalledTimes(1); + expect(dom.window.document.body.textContent).toContain("authorized"); + expect(dom.window.document.body.textContent).toContain("mock-protected-api"); + }); + + test("clicking sign-out sends the auth:sign-out message", async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: true, + userInfo: { email: "dev@example.com", name: "Dev" } + } + }) + .mockResolvedValueOnce({ + ok: true, + type: "auth:ack" + }) + .mockResolvedValueOnce({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: false + } + }); + + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + document: dom.window.document, + sendMessage + }); + + ( + dom.window.document.querySelector('[data-popup-sign-out="button"]') as + | HTMLButtonElement + | null + )?.click(); + + await Promise.resolve(); + + expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" }); + }); + + test("shows the auth error when sign-in fails", async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + type: "auth:state", + value: { + isAuthenticated: false + } + }) + .mockResolvedValueOnce({ + error: "redirect_uri_mismatch", + ok: false, + type: "auth:error" + }); + + dom.window.document.body.innerHTML = "
"; + + await bootPopup({ + document: dom.window.document, + sendMessage + }); + + ( + dom.window.document.querySelector('[data-popup-sign-in="button"]') as + | HTMLButtonElement + | null + )?.click(); + + await Promise.resolve(); + + expect(dom.window.document.body.textContent).toContain( + "redirect_uri_mismatch" + ); + }); +});