feat: add popup protected api dev test

This commit is contained in:
admin123 2026-04-22 10:51:39 +08:00
parent cbcc06380d
commit 668aec45c5
3 changed files with 403 additions and 0 deletions

155
src/popup/index.ts Normal file
View File

@ -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<AuthConfig>;
document?: Document;
fetchProtectedApi?: () => Promise<unknown>;
sendMessage?: (message: unknown) => Promise<unknown>;
}
export async function bootPopup(options: BootPopupOptions = {}): Promise<void> {
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<unknown>;
};
};
}
).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<unknown>,
fetchProtectedApi: () => Promise<unknown>
): Promise<void> {
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<unknown>,
options: {
actionMessage: { type: "auth:sign-in" } | { type: "auth:sign-out" };
fetchProtectedApi: () => Promise<unknown>;
}
): Promise<void> {
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<AuthResponseMessage, { ok: false }> {
return (
isAuthResponseMessage(response) &&
!response.ok &&
response.type === "auth:error"
);
}
async function runProtectedApiProbe(
root: HTMLElement,
fetchProtectedApi: () => Promise<unknown>
): Promise<void> {
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();
}

60
src/popup/view.ts Normal file
View File

@ -0,0 +1,60 @@
import type { AuthStateValue } from "../shared/auth-messages";
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
root.innerHTML = `
<section data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1>
<p>使</p>
${error ? `<p data-popup-error="true">${error}</p>` : ""}
<button type="button" data-popup-sign-in="button"> Logto</button>
</section>
`;
}
export function renderLoggedIn(
root: HTMLElement,
authState: AuthStateValue
): void {
const userInfo = authState.userInfo;
root.innerHTML = `
<section data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1>
<p></p>
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
<p>${userInfo?.email ?? ""}</p>
<button type="button" data-popup-sign-out="button">退</button>
</section>
`;
}
export function renderDevPanel(
root: HTMLElement,
authState: AuthStateValue
): void {
const panel = root.ownerDocument.createElement("section");
panel.dataset.popupDevPanel = "root";
panel.innerHTML = `
<h2>dev auth panel</h2>
<p>resource: ${authState.resource ?? ""}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
<p>error: ${authState.lastError ?? ""}</p>
<button type="button" data-popup-test-protected-api="button"></button>
<pre data-popup-protected-api-result="output"></pre>
`;
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;
}

188
tests/popup-entry.test.ts Normal file
View File

@ -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("<!doctype html><html><body></body></html>");
});
test("renders a sign-in button when unauthenticated", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
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 = "<main id='app'></main>";
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 = "<main id='app'></main>";
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 = "<main id='app'></main>";
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 = "<main id='app'></main>";
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 = "<main id='app'></main>";
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"
);
});
});