Skip to content

业务模块与端到端

本页对应学习路径的阶段八,然后把前面所有阶段接成三条端到端路径,最后给出练习和 验证资料。

阶段八:业务模块与页面

场景

页面要「创建用户」,用户名重复时在表单里内联提示。

先写最直觉的那版

ts
// 幼稚版:页面直接调
try {
  await http.post("/users", { name });
} catch (e) {
  if (e.status === 409) setFieldError("name", "用户名已存在");
}

它塌在哪

页面被迫知道两件它不该知道的事:接口 URLHTTP 状态码的业务含义。 接口改路径要全局搜索;同一个 409 在三个页面各翻译一遍,措辞还不一致。

现在的写法

业务模块夹在中间,把传输概念翻译成领域概念:

ts
export async function createUser(input: CreateUserInput): Promise<User> {
  try {
    // silent 关掉全局 Toast,因为 409 要显示在表单字段旁边。
    // 注意它只关展示,不关上报——监控里照样看得到。
    return await http.post<User, CreateUserInput>("/users", input, {
      errorMode: "silent",
    });
  } catch (error) {
    // 只翻译自己认识的那一个状态码,其余原样抛出去。
    if (error instanceof HttpError && error.status === 409) {
      throw new UserAlreadyExistsError(error); // 原错误挂在 cause 上
    }
    throw error;
  }
}

页面只 catch UserAlreadyExistsError,完全不需要知道 HTTP 是什么。

最容易写错的是catch 写宽——网络断了也提示用户换个名字。只翻译认识的,其余 原样往上抛。

职责划分

负责不负责
页面领域概念、UI 反馈URL、HTTP 方法、状态码
业务模块URL、方法、类型、silent/retry 策略、领域错误传输细节、认证
HTTP 核心传输、协议、错误分类、认证、生命周期用户文案、业务语义

端到端:三条路径

前面每个阶段各看一段,这里把它们接起来。

普通成功

text
页面 createUser() → users.ts → post() 补齐 method/url/data
  → request() 指定选 response.data → execute() 建立逻辑请求、开 Loading
  → 请求链 Auth(附令牌) → RequestControl(计数+1) → 网络
  → 响应链 RequestControl(清理) → Envelope(解包成 User) → Auth(非 401 放行)
  → select(response) 取出 User → finally 关 Loading → 页面得到 User

GET 撞上 503 后重试成功

text
send() 第一次 → 503 → 归一化 HttpError(http, 503)
  → retry() 判定可重试 → 检查总预算 → 等待带抖动的退避
  → send() 第二次 → 200 → Envelope 解包 → 返回结果
整个过程 Loading 只开关一次,不触发任何错误回调

401 刷新后重放

text
业务请求带旧令牌 → 401 → Auth 响应拦截器启动或等待共享刷新
  → 刷新实例(独立、withCredentials)拿到新 Access Token → credentialVersion +1
  → 原请求标记 __authRetry → 重新灌回实例,走完整拦截器链(这时换上新令牌)
  → 200 → Envelope 解包 → execute() 返回结果
逻辑 Loading 只开关一次,物理尝试计数为 2

练习

每次只加一个能力,并为它写一个能跑的验证用例。 不要一次复制完整目录。

  1. 用原生 Axios 取一个用户,说清 responseresponse.data 的区别
  2. ApiEnvelope 的格式校验函数,处理 data: null 和缺字段两种情况
  3. 写只有 get<Result> 的最小入口
  4. post<Result, Body>,验证请求体类型约束生效
  5. raw(),比较两者返回值
  6. 把网络、超时、HTTP 错误统一成一个错误类型
  7. 接一个 Presenter,让同一个错误在两种语言下显示不同文案
  8. 加单请求取消,验证取消后不展示也不上报
  9. 加 Loading 计数,验证并发三个请求只开关一次
  10. 实现单飞刷新与重放,验证 10 个并发 401 只打一次刷新接口

第 10 项建议留到最后,它是唯一需要同时处理并发、代际和重入的练习。


验证资料

文件覆盖
test/http-client.test.ts请求流程、认证、取消、Loading、重试
test/protocol-and-utilities.test.ts协议解包、错误分类、工具函数
test/auth-session-isolation.test.ts会话代际与链路隔离
test/failure-budgets.test.ts刷新冷却窗口、重试总预算
test/session-sync.test.ts跨标签页会话同步与事件屏障
test/users-module.test.ts业务领域错误转换
test/typecheck.ts配置白名单的类型约束
browser-tests/http-browser.spec.ts必须在真实浏览器验证的行为
bash
cd docs/projects/axios-http
pnpm install
pnpm check

遇到读不懂的局部代码,先确定它属于哪个阶段,再单独分析。不要让一个底层类型声明 打断整条请求主线。


本页源码

构建时从 docs/projects/axios-http/ 的真实文件直读,和测试跑的是同一份。装配入口和 业务模块:

ts
/**
 * HTTP 模块的对外门面:整个应用只从这里拿 `http` 实例和相关类型,不直接 import
 * ./client、./auth 这些内部文件。
 *
 * 想读懂实现,入口是 client.ts 的文件头——那里画了一次请求的完整路径。
 */

import { createHttpClient } from "./client";

// 类型和实例共用一个入口,调用方不需要记住哪个东西在哪个文件里。
// 类型导出没有运行时存在,所以本模块真正的运行时导出仍然只有 http 一个。
export type {
  CreateHttpClientOptions,
  ErrorBehavior,
  ErrorMode,
  HttpClient,
  HttpRequestConfig,
  HttpRetryOptions,
  LoadingBehavior,
  RetryBehavior,
} from "./client";
export type { AuthAdapter, AuthBehavior } from "./auth";

// 下面这段是接入示例,不是最终形态。实际项目里 baseURL 换成
// import.meta.env.VITE_API_BASE_URL,并把注释掉的四个回调按需接上 UI 和监控。
//
// onError 里的 presentApiError 是这套设计的分工体现:通用核心只产出稳定的错误分类,
// 一句用户文案都不写;文案全部由 adapters/error-presenter.ts 这个项目适配器决定。
export const http = createHttpClient({
  baseURL: "/api",
  timeout: 10_000,
  // 在实际项目入口中接入 UI 和监控:
  // showLoadingByDefault: false,
  // onLoadingChange: (active) => (active ? spin.show() : spin.hide()),
  // onError: (error) => message.error(presentApiError(error)),
  // onReport: (error) => reportHttpError({
  //   name: error.name,
  //   kind: "kind" in error ? error.kind : "protocol",
  //   status: error.status,
  //   method: error.method,
  //   path: error.path,
  //   attempts: error.attempts,
  //   elapsedMs: error.elapsedMs,
  //   origin: error.origin,
  //   originMethod: error.originMethod,
  //   originPath: error.originPath,
  // }),
});
ts
/**
 * 业务模块示例。它存在的目的是演示「调用方该怎么用这套封装」,尤其是下面 createUser
 * 里那个 409 的处理——那是本封装最想表达的一种用法。
 *
 * 要点:传输层的错误分类(HttpError + status)不适合直接交给页面。页面关心的是
 * 「这个用户名被占了」,不是「HTTP 409」。所以业务模块把它翻译成领域错误,页面
 * 只 catch UserAlreadyExistsError,完全不需要知道 HTTP 是什么。
 */

import { http } from "../http";
import { HttpError } from "../http/errors";

export interface CreateUserInput {
  name: string;
}

export interface User {
  id: string;
  name: string;
}

export class UserAlreadyExistsError extends Error {
  constructor(cause: HttpError) {
    super("User already exists", { cause });
    this.name = "UserAlreadyExistsError";
  }
}

export async function createUser(input: CreateUserInput): Promise<User> {
  try {
    // errorMode: "silent" 关掉全局 Toast,因为 409 要显示在表单的用户名字段旁边,
    // 而不是飘一个全局提示。注意它只关展示,不关上报——监控里照样看得到。
    return await http.post<User, CreateUserInput>("/users", input, {
      errorMode: "silent",
    });
  } catch (error) {
    // 只翻译自己认识的那一个状态码,其余原样抛出去。这一点很容易写错成 catch 住
    // 所有错误都当成「已存在」,那样网络断了也会提示用户换个名字。
    if (error instanceof HttpError && error.status === 409) {
      // 原错误挂在 cause 上,排查时还能顺着找回 HTTP 层的现场。
      throw new UserAlreadyExistsError(error);
    }

    throw error;
  }
}

测试与配置——上面验证资料表格里的全部文件:

ts
import {
  createServer,
  type IncomingMessage,
  type Server,
  type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";

import axios, { AxiosHeaders } from "axios";
import { afterEach, describe, expect, it, vi } from "vitest";

import { type AuthAdapter } from "../src/api/http/auth";
import {
  createMemoryAuthSession,
  type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { presentApiError } from "../src/api/http/adapters/error-presenter";
import { createHttpClient } from "../src/api/http/client";
import {
  ApiEnvelopeFormatError,
  HttpError,
} from "../src/api/http/errors";
import { fetchFile } from "../src/api/http/transfer";

type TestHandler = (
  request: IncomingMessage,
  response: ServerResponse,
) => void | Promise<void>;

const openServers = new Set<Server>();

function sendJson(response: ServerResponse, status: number, body?: unknown) {
  response.statusCode = status;
  if (body === undefined) {
    response.end();
    return;
  }

  response.setHeader("content-type", "application/json");
  response.end(JSON.stringify(body));
}

function delay(milliseconds: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

async function readBody(request: IncomingMessage) {
  const chunks: Buffer[] = [];
  for await (const chunk of request) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }
  return Buffer.concat(chunks).toString("utf8");
}

async function startServer(handler: TestHandler) {
  const server = createServer((request, response) => {
    Promise.resolve(handler(request, response)).catch((error: unknown) => {
      sendJson(response, 500, {
        message: error instanceof Error ? error.message : "test server failed",
      });
    });
  });

  await new Promise<void>((resolve) => {
    server.listen(0, "127.0.0.1", resolve);
  });
  openServers.add(server);

  const address = server.address() as AddressInfo;
  return `http://127.0.0.1:${address.port}`;
}

function createTestAuth(baseURL: string, session: AuthSession) {
  return createBearerAuthAdapter({
    baseURL,
    getAccessToken: session.getAccessToken,
    setAccessToken: session.setAccessToken,
    expireSession() {
      session.clearSession();
      session.onExpired();
    },
    selectAccessToken(response) {
      const envelope = readApiEnvelope(response.data);
      const result = envelope?.data;
      if (
        !envelope?.hasData ||
        !result ||
        typeof result !== "object" ||
        !("accessToken" in result) ||
        typeof result.accessToken !== "string"
      ) {
        throw new ApiEnvelopeFormatError(response.status, response.data);
      }

      return result.accessToken;
    },
  });
}

afterEach(async () => {
  await Promise.all(
    [...openServers].map(
      (server) =>
        new Promise<void>((resolve, reject) => {
          server.close((error) => {
            if (error) {
              reject(error);
            } else {
              resolve();
            }
          });
          server.closeAllConnections();
        }),
    ),
  );
  openServers.clear();
});

describe("core HTTP Interface", () => {
  it("unwraps successful envelopes and preserves request-body types", async () => {
    const baseURL = await startServer(async (request, response) => {
      const input = JSON.parse(await readBody(request)) as { name: string };
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { id: 1, name: input.name },
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(
      http.post<{ id: number; name: string }, { name: string }>("/users", {
        name: "Ada",
      }),
    ).resolves.toEqual({ id: 1, name: "Ada" });
  });

  it("uses the HTTP status for success and treats envelope code as metadata", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 200, {
        code: 40001,
        message: "metadata only",
        data: { id: 1 },
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(http.get("/users/1")).resolves.toEqual({ id: 1 });
  });

  it("requires code, message, and data in a successful envelope", async () => {
    const baseURL = await startServer((request, response) => {
      const valid = {
        code: 0,
        message: "ok",
        data: true,
      };
      const missingField = request.url?.slice(9) as
        | "code"
        | "message"
        | "data";
      const body = { ...valid } as Partial<typeof valid>;
      delete body[missingField];
      sendJson(response, 200, body);
    });
    const http = createHttpClient({ baseURL });

    for (const field of ["code", "message", "data"]) {
      await expect(http.get(`/missing-${field}`)).rejects.toBeInstanceOf(
        ApiEnvelopeFormatError,
      );
    }
  });

  it("accepts null data and bypasses the envelope for 204 responses", async () => {
    const baseURL = await startServer((request, response) => {
      if (request.url === "/empty") {
        sendJson(response, 204);
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: null,
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(http.get("/nullable")).resolves.toBeNull();
    await expect(http.delete<void>("/empty")).resolves.toBeUndefined();
  });

  it("does not retry a format error and reports it once", async () => {
    let requestCount = 0;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      sendJson(response, 200, {
        code: 0,
        message: "missing data",
      });
    });
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(
      http.get("/broken", {
        retry: { retries: 2, baseDelayMs: 1 },
      }),
    ).rejects.toBeInstanceOf(ApiEnvelopeFormatError);
    expect(requestCount).toBe(1);
    expect(onError).toHaveBeenCalledTimes(1);
    expect(onReport).toHaveBeenCalledTimes(1);
    expect(onReport.mock.calls[0]?.[0]).toMatchObject({
      method: "GET",
      path: "/broken",
      attempts: 1,
    });
  });

  it("keeps raw response data and headers when the caller opts out of the protocol", async () => {
    const baseURL = await startServer((_request, response) => {
      response.statusCode = 200;
      response.setHeader("x-report-version", "7");
      response.end("raw report");
    });
    const http = createHttpClient({ baseURL });

    const response = await http.raw<string>({
      method: "get",
      url: "/report",
    });

    expect(response.data).toBe("raw report");
    expect(response.headers["x-report-version"]).toBe("7");
  });

  it("reports global errors once and leaves silent errors to the caller", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 500, {
        code: 50001,
        message: "temporary failure",
        data: null,
      });
    });
    const onError = vi.fn();
    const http = createHttpClient({ baseURL, onError });

    await expect(http.get("/global")).rejects.toBeInstanceOf(HttpError);
    await expect(
      http.get("/silent", { errorMode: "silent" }),
    ).rejects.toBeInstanceOf(HttpError);

    expect(onError).toHaveBeenCalledTimes(1);
  });

  it("keeps project protocol metadata out of generic HTTP errors", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 409, {
        code: 40001,
        message: "用户名已存在",
        data: null,
      });
    });
    const http = createHttpClient({ baseURL });

    const error = await http.get("/users").catch((reason: unknown) => reason);

    expect(error).toBeInstanceOf(HttpError);
    expect(error).toMatchObject({
      kind: "http",
      status: 409,
      message: "HTTP request failed with status 409",
    });
    expect(presentApiError(error as HttpError)).toBe("用户名已存在");
    expect(error).not.toHaveProperty("businessCode");
    expect(error).not.toHaveProperty("details");
  });

  it("rejects absolute and protocol-relative business URLs before networking", async () => {
    let requestCount = 0;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    const absoluteError = await http
      .get(`${baseURL}/absolute`, { errorMode: "silent" })
      .catch((error: unknown) => error);
    expect(absoluteError).toMatchObject({ kind: "configuration" });
    expect(absoluteError).toHaveProperty("cause");
    await expect(
      http.get("//example.test/protocol-relative", { errorMode: "silent" }),
    ).rejects.toMatchObject({ kind: "configuration" });
    expect(requestCount).toBe(0);
    expect(onError).not.toHaveBeenCalled();
    expect(onReport).toHaveBeenCalledTimes(2);
    expect(onReport.mock.calls.map(([error]) => error)).toEqual([
      expect.objectContaining({ path: "/absolute", attempts: 0 }),
      expect.objectContaining({ path: "/protocol-relative", attempts: 0 }),
    ]);
  });
});

describe("request control and logical loading", () => {
  it("retries an explicitly enabled safe read after a temporary failure", async () => {
    let requestCount = 0;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      if (requestCount < 3) {
        sendJson(response, 503, {
          code: 50300,
          message: "busy",
          data: null,
        });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: "ready",
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(
      http.get<string>("/report", {
        retry: { retries: 2, baseDelayMs: 1 },
      }),
    ).resolves.toBe("ready");
    expect(requestCount).toBe(3);
  });

  it("keeps one loading interval open for concurrent logical requests", async () => {
    let requestCount = 0;
    const baseURL = await startServer(async (_request, response) => {
      requestCount += 1;
      await delay(requestCount === 1 ? 60 : 10);
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: requestCount,
      });
    });
    const events: string[] = [];
    const http = createHttpClient({
      baseURL,
      onLoadingChange: (active) => events.push(active ? "open" : "close"),
    });

    const first = http.get("/slow", { showLoading: true });
    const second = http.get("/fast", { showLoading: true });

    await second;
    expect(events).toEqual(["open"]);
    await first;
    expect(events).toEqual(["open", "close"]);
  });

  it("supports a project loading default while allowing request overrides", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const events: string[] = [];
    const http = createHttpClient({
      baseURL,
      showLoadingByDefault: true,
      onLoadingChange: (active) => events.push(active ? "open" : "close"),
    });

    await http.get("/default");
    await http.get("/disabled", { showLoading: false });

    expect(events).toEqual(["open", "close"]);
  });

  it("closes loading after both failure and cancellation", async () => {
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/slow") {
        await delay(100);
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: true,
        });
        return;
      }

      sendJson(response, 500, {
        code: 50000,
        message: "internal",
        data: null,
      });
    });
    const events: string[] = [];
    const http = createHttpClient({
      baseURL,
      onLoadingChange: (active) => events.push(active ? "open" : "close"),
    });

    await expect(
      http.get("/failed", { showLoading: true, errorMode: "silent" }),
    ).rejects.toMatchObject({ status: 500 });
    const controller = new AbortController();
    const canceled = http.get("/slow", {
      showLoading: true,
      signal: controller.signal,
    });
    controller.abort();
    await expect(canceled).rejects.toMatchObject({ kind: "cancel" });

    expect(events).toEqual(["open", "close", "open", "close"]);
  });

  it("sends two direct requests even when their method and URL are identical", async () => {
    let requestCount = 0;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(
      Promise.all([http.get("/search"), http.get("/search")]),
    ).resolves.toEqual([true, true]);
    expect(requestCount).toBe(2);
  });

  it("does not send a request whose signal was already canceled", async () => {
    let requestCount = 0;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const controller = new AbortController();
    controller.abort("left page");
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(
      http.get("/never-sent", { signal: controller.signal }),
    ).rejects.toMatchObject({ kind: "cancel" });
    expect(requestCount).toBe(0);
    expect(onError).not.toHaveBeenCalled();
    expect(onReport).not.toHaveBeenCalled();
  });

  it("keeps retry disabled by default and never retries writes or 4xx errors", async () => {
    const requestCounts = new Map<string, number>();
    const baseURL = await startServer((request, response) => {
      const url = request.url ?? "";
      requestCounts.set(url, (requestCounts.get(url) ?? 0) + 1);
      sendJson(response, url === "/client-error" ? 409 : 503, {
        code: 1,
        message: "failed",
        data: null,
      });
    });
    const http = createHttpClient({ baseURL });

    await expect(
      http.get("/default", { errorMode: "silent" }),
    ).rejects.toMatchObject({ status: 503 });
    await expect(
      http.post("/write", {}, {
        errorMode: "silent",
        retry: { retries: 2, baseDelayMs: 1 },
      }),
    ).rejects.toMatchObject({ status: 503 });
    await expect(
      http.get("/client-error", {
        errorMode: "silent",
        retry: { retries: 2, baseDelayMs: 1 },
      }),
    ).rejects.toMatchObject({ status: 409 });

    expect(Object.fromEntries(requestCounts)).toEqual({
      "/default": 1,
      "/write": 1,
      "/client-error": 1,
    });
  });

  it("reports only the final failure after retry exhaustion", async () => {
    let requestCount = 0;
    let reportedError: HttpError | ApiEnvelopeFormatError | undefined;
    const baseURL = await startServer((_request, response) => {
      requestCount += 1;
      sendJson(response, 503, {
        code: 50300,
        message: "internal details",
        data: null,
      });
    });
    const onError = vi.fn((error: HttpError | ApiEnvelopeFormatError) => {
      reportedError = error;
    });
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(
      http.get("/temporary?token=must-not-leak", {
        retry: { retries: 2, baseDelayMs: 1 },
      }),
    ).rejects.toMatchObject({
      kind: "http",
      status: 503,
      message: "HTTP request failed with status 503",
    });
    expect(requestCount).toBe(3);
    expect(onError).toHaveBeenCalledTimes(1);
    expect(onReport).toHaveBeenCalledTimes(1);
    expect(onReport).toHaveBeenCalledWith(reportedError);
    expect(reportedError).toMatchObject({
      method: "GET",
      path: "/temporary",
      attempts: 3,
    });
    expect(reportedError?.elapsedMs).toBeGreaterThanOrEqual(0);
    expect(JSON.stringify(reportedError)).not.toContain("must-not-leak");
  });

  it("keeps reporting a failure when request-level display is silent", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 409, {
        code: 40900,
        message: "conflict",
        data: null,
      });
    });
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(
      http.post("/users", {}, { errorMode: "silent" }),
    ).rejects.toMatchObject({ kind: "http", status: 409 });

    expect(onError).not.toHaveBeenCalled();
    expect(onReport).toHaveBeenCalledTimes(1);
  });

  it("preserves the request failure when display or report callbacks throw", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 500, {
        code: 50000,
        message: "internal",
        data: null,
      });
    });
    const onError = vi.fn(() => {
      throw new Error("display callback failed");
    });
    const onReport = vi.fn(() => {
      throw new Error("report callback failed");
    });
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(http.get("/failed")).rejects.toMatchObject({
      kind: "http",
      status: 500,
    });
    expect(onReport).toHaveBeenCalledTimes(1);
    expect(onError).toHaveBeenCalledTimes(1);
  });

  it("cancels all currently active business requests", async () => {
    const baseURL = await startServer(async (_request, response) => {
      await delay(100);
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const http = createHttpClient({ baseURL });
    const request = http.get("/slow", { errorMode: "silent" });

    http.cancelAll();

    await expect(request).rejects.toMatchObject({ kind: "cancel" });
  });

});

describe("file transfer integration", () => {
  it("supports a POST export through the Axios Blob path", async () => {
    let receivedMethod: string | undefined;
    let receivedBody: string | undefined;
    const baseURL = await startServer(async (request, response) => {
      receivedMethod = request.method;
      receivedBody = await readBody(request);
      response.statusCode = 200;
      response.setHeader(
        "content-disposition",
        "attachment; filename*=UTF-8''report.csv",
      );
      response.setHeader("content-type", "text/csv");
      response.end("name\nAda");
    });
    const http = createHttpClient({ baseURL });

    const file = await fetchFile(http, "/exports/users", {
      method: "post",
      data: { active: true },
    });

    expect(receivedMethod).toBe("POST");
    expect(JSON.parse(receivedBody ?? "")).toEqual({ active: true });
    expect(file.filename).toBe("report.csv");
  });
});

describe("authentication capability", () => {
  it("displays and reports an unhandled 401 when no auth adapter is installed", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 401, {
        code: 40100,
        message: "sign in required",
        data: null,
      });
    });
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, onError, onReport });

    await expect(http.get("/profile")).rejects.toMatchObject({
      kind: "http",
      status: 401,
    });
    expect(onError).toHaveBeenCalledTimes(1);
    expect(onReport).toHaveBeenCalledTimes(1);
  });

  it("applies the current credential through a generic auth adapter", async () => {
    const baseURL = await startServer((request, response) => {
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: request.headers.authorization,
      });
    });
    const auth: AuthAdapter = {
      applyCredential(config) {
        config.headers.set("Authorization", "Bearer current");
      },
      refreshCredential: vi.fn(),
      shouldExpireSession: () => false,
      expireSession: vi.fn(),
    };
    const http = createHttpClient({ baseURL, auth });

    await expect(http.get("/profile")).resolves.toBe("Bearer current");
  });

  it("refreshes once for ten concurrent 401 responses and replays all requests", async () => {
    let protectedCount = 0;
    let refreshCount = 0;
    let refreshAuthorization: string | undefined;
    let releaseRefresh!: () => void;
    const refreshGate = new Promise<void>((resolve) => {
      releaseRefresh = resolve;
    });
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        refreshAuthorization = request.headers.authorization;
        await refreshGate;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      protectedCount += 1;
      if (request.headers.authorization !== "Bearer fresh") {
        sendJson(response, 401, {
          code: 40100,
          message: "expired",
          data: null,
        });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { allowed: true },
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const requests = Array.from({ length: 10 }, () =>
      http.get<{ allowed: boolean }>("/protected"),
    );

    await vi.waitFor(() => {
      expect(protectedCount).toBe(10);
      expect(refreshCount).toBe(1);
    });
    releaseRefresh();

    await expect(Promise.all(requests)).resolves.toEqual(
      Array.from({ length: 10 }, () => ({ allowed: true })),
    );
    expect(refreshCount).toBe(1);
    expect(protectedCount).toBe(20);
    expect(session.getAccessToken()).toBe("fresh");
    expect(refreshAuthorization).toBeUndefined();
  });

  it("clears and expires a failed concurrent refresh only once", async () => {
    let protectedCount = 0;
    let refreshCount = 0;
    let releaseRefresh!: () => void;
    const refreshGate = new Promise<void>((resolve) => {
      releaseRefresh = resolve;
    });
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        await refreshGate;
        sendJson(response, 401, {
          code: 40100,
          message: "refresh expired",
          data: null,
        });
        return;
      }

      protectedCount += 1;
      sendJson(response, 401, {
        code: 40100,
        message: "expired",
        data: null,
      });
    });
    const session: AuthSession = {
      getAccessToken: vi.fn(() => "expired"),
      setAccessToken: vi.fn(),
      clearSession: vi.fn(),
      onExpired: vi.fn(),
    };
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
      onError,
      onReport,
    });
    const requests = Array.from({ length: 10 }, () =>
      http.get("/protected"),
    );

    await vi.waitFor(() => {
      expect(protectedCount).toBe(10);
      expect(refreshCount).toBe(1);
    });
    releaseRefresh();

    const results = await Promise.allSettled(requests);
    expect(results.every((result) => result.status === "rejected")).toBe(true);
    expect(session.clearSession).toHaveBeenCalledTimes(1);
    expect(session.onExpired).toHaveBeenCalledTimes(1);
    expect(onError).not.toHaveBeenCalled();
    expect(onReport).toHaveBeenCalledTimes(10);
  });

  it("allows a newly established session to refresh after the previous session failed", async () => {
    let refreshCount = 0;
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        if (refreshCount === 1) {
          sendJson(response, 401, {
            code: 40100,
            message: "refresh expired",
            data: null,
          });
          return;
        }

        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh-second-session" },
        });
        return;
      }

      if (
        request.headers.authorization === "Bearer new-login-token" ||
        request.headers.authorization === "Bearer fresh-second-session"
      ) {
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: true,
        });
        return;
      }

      sendJson(response, 401, {
        code: 40100,
        message: "expired",
        data: null,
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired-first-session",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    await expect(http.get("/protected")).rejects.toMatchObject({
      status: 401,
    });
    expect(refreshCount).toBe(1);
    expect(session.onExpired).toHaveBeenCalledTimes(1);

    session.setAccessToken("new-login-token");
    http.resetAuthState();
    await expect(http.get("/profile")).resolves.toBe(true);

    session.setAccessToken("expired-second-session");
    await expect(http.get("/protected")).resolves.toBe(true);
    expect(refreshCount).toBe(2);
    expect(session.getAccessToken()).toBe("fresh-second-session");
    expect(session.onExpired).toHaveBeenCalledTimes(1);
  });

  it("discards a stale refresh so it cannot overwrite a newer login", async () => {
    let resolveRefreshArrived!: (response: ServerResponse) => void;
    const refreshArrived = new Promise<ServerResponse>((resolve) => {
      resolveRefreshArrived = resolve;
    });
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        // 挂起刷新响应模拟慢网络:测试在这个窗口里完成重新登录。
        resolveRefreshArrived(response);
        return;
      }

      if (request.headers.authorization === "Bearer old") {
        sendJson(response, 401, { code: 40100, message: "expired", data: null });
        return;
      }

      sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const inflight = http.get<{ ok: boolean }>("/protected");
    const refreshResponse = await refreshArrived;

    // 刷新在途期间用户重新登录,进入新会话。
    session.setAccessToken("relogin");
    http.resetAuthState();

    // 旧会话的刷新此刻才回来,带着一个服务端仍然认可的令牌。
    sendJson(refreshResponse, 200, {
      code: 0,
      message: "ok",
      data: { accessToken: "stale-refresh" },
    });

    await expect(inflight).resolves.toEqual({ ok: true });
    expect(session.getAccessToken()).toBe("relogin");
  });

  it("does not resurrect a session when a stale refresh lands after logout", async () => {
    let resolveRefreshArrived!: (response: ServerResponse) => void;
    const refreshArrived = new Promise<ServerResponse>((resolve) => {
      resolveRefreshArrived = resolve;
    });
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        resolveRefreshArrived(response);
        return;
      }

      // stale-refresh 是服务端真实签发的有效令牌,业务端点会认它——
      // 「登出后被复活」的危险正在于此,令牌无效的话这个 bug 反而会被 401 掩盖。
      if (request.headers.authorization === "Bearer stale-refresh") {
        sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
        return;
      }

      sendJson(response, 401, { code: 40100, message: "expired", data: null });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const inflight = http.get("/protected");
    const refreshResponse = await refreshArrived;

    // 刷新在途期间用户登出。登出与登录一样是会话边界,同样要开新代际。
    session.clearSession();
    http.resetAuthState();

    sendJson(refreshResponse, 200, {
      code: 0,
      message: "ok",
      data: { accessToken: "stale-refresh" },
    });

    await inflight.catch(() => undefined);
    expect(session.getAccessToken()).toBeNull();
  });

  it("waits for an in-flight refresh to settle before a session boundary", async () => {
    // 会话边界动作(登录、登出请求)发出前用它排空在途刷新:JS 拦不住刷新响应的
    // Set-Cookie,代际只护住内存侧;只有让旧响应先落地,边界动作拿到的 Cookie
    // 才是最后写入的那份,不会被旧会话的轮换响应回盖。
    let resolveRefreshArrived!: (response: ServerResponse) => void;
    const refreshArrived = new Promise<ServerResponse>((resolve) => {
      resolveRefreshArrived = resolve;
    });
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        resolveRefreshArrived(response);
        return;
      }

      if (request.headers.authorization === "Bearer old") {
        sendJson(response, 401, { code: 40100, message: "expired", data: null });
        return;
      }

      sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const inflight = http.get<{ ok: boolean }>("/protected");
    const refreshResponse = await refreshArrived;

    let settled = false;
    const waiting = http.waitForRefreshSettled().then(() => {
      settled = true;
    });
    // 刷新仍在途:不得提前放行
    await new Promise((resolve) => setTimeout(resolve, 20));
    expect(settled).toBe(false);

    sendJson(refreshResponse, 200, {
      code: 0,
      message: "ok",
      data: { accessToken: "fresh" },
    });
    await waiting;
    // 放行时新凭证已提交完成,边界动作可以安全发出
    expect(session.getAccessToken()).toBe("fresh");
    await expect(inflight).resolves.toEqual({ ok: true });
  });

  it("resolves immediately without an in-flight refresh and swallows refresh failures", async () => {
    let resolveRefreshArrived!: (response: ServerResponse) => void;
    const refreshArrived = new Promise<ServerResponse>((resolve) => {
      resolveRefreshArrived = resolve;
    });
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        resolveRefreshArrived(response);
        return;
      }

      sendJson(response, 401, { code: 40100, message: "expired", data: null });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    // 无在途刷新:立即落定
    await http.waitForRefreshSettled();

    const inflight = http.get("/protected").catch(() => undefined);
    const refreshResponse = await refreshArrived;
    const waiting = http.waitForRefreshSettled();

    // 刷新以失败收场(503 走熔断不终结):等待只关心「已落定」,不复抛刷新的错误
    sendJson(refreshResponse, 503, { code: 50300, message: "unavailable", data: null });
    await expect(waiting).resolves.toBeUndefined();
    await inflight;
    expect(session.getAccessToken()).toBe("old");
  });

  it("blocks new refreshes during runAuthTransition and replays with the boundary credential", async () => {
    // 光排空不够:排空返回后、边界动作的往返期间,新 401 仍可能起新刷新,
    // 其 Set-Cookie 晚到会回盖边界动作写的 Cookie。闸内产生的 401 必须等闸,
    // 且闸后复查版本——边界已换代就跳过刷新直接重放。
    let refreshHits = 0;
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        refreshHits += 1;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "via-refresh" },
        });
        return;
      }

      if (request.headers.authorization === "Bearer boundary-fresh") {
        sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
        return;
      }

      sendJson(response, 401, { code: 40100, message: "expired", data: null });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    let releaseAction!: () => void;
    const actionGate = new Promise<void>((resolve) => {
      releaseAction = resolve;
    });
    const transition = http.runAuthTransition(async () => {
      await actionGate;
      // 模拟登录成功的写入:新凭证 + 开新代际
      session.setAccessToken("boundary-fresh");
      http.resetAuthState();
      return "login-result";
    });

    // 边界进行中撞上 401:不得启动新刷新
    const inflight = http.get<{ ok: boolean }>("/protected");
    await delay(20);
    expect(refreshHits).toBe(0);

    releaseAction();
    await expect(transition).resolves.toBe("login-result");

    // 闸放开后复查版本:凭证已换代,跳过刷新,用新凭证重放
    await expect(inflight).resolves.toEqual({ ok: true });
    expect(refreshHits).toBe(0);
  });

  it("drains the in-flight refresh before running the transition action", async () => {
    let resolveRefreshArrived!: (response: ServerResponse) => void;
    const refreshArrived = new Promise<ServerResponse>((resolve) => {
      resolveRefreshArrived = resolve;
    });
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        resolveRefreshArrived(response);
        return;
      }

      if (request.headers.authorization === "Bearer old") {
        sendJson(response, 401, { code: 40100, message: "expired", data: null });
        return;
      }

      sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const inflight = http.get<{ ok: boolean }>("/protected");
    const refreshResponse = await refreshArrived;

    const order: string[] = [];
    const transition = http.runAuthTransition(async () => {
      order.push(`action:${session.getAccessToken()}`);
    });
    // 在途刷新未落定:边界动作不得开始执行
    await delay(20);
    expect(order).toEqual([]);

    sendJson(refreshResponse, 200, {
      code: 0,
      message: "ok",
      data: { accessToken: "fresh" },
    });
    await transition;
    // 动作看到的是刷新提交后的世界,它的写入必然排在旧刷新之后
    expect(order).toEqual(["action:fresh"]);
    await expect(inflight).resolves.toEqual({ ok: true });
  });

  it("releases the transition gate and rethrows when the action fails", async () => {
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      if (request.headers.authorization === "Bearer fresh") {
        sendJson(response, 200, { code: 0, message: "ok", data: { ok: true } });
        return;
      }

      sendJson(response, 401, { code: 40100, message: "expired", data: null });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    // 登录请求本身失败等场景:错误原样冒泡,不吞
    await expect(
      http.runAuthTransition(async () => {
        throw new Error("login failed");
      }),
    ).rejects.toThrow("login failed");

    // 闸已释放:老会话继续,401 照常走刷新恢复
    await expect(http.get<{ ok: boolean }>("/protected")).resolves.toEqual({
      ok: true,
    });
    expect(session.getAccessToken()).toBe("fresh");
  });

  it("lets the adapter decide which refresh failure ends the session", async () => {
    // OAuth 式后端用 400 + invalid_grant(而非 401)表示 Refresh Token 失效。
    // 「哪种失败意味着凭证已死」是后端契约,换个判定就能接入,不改引擎。
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        sendJson(response, 400, {
          code: "invalid_grant",
          message: "refresh token revoked",
          data: null,
        });
        return;
      }

      sendJson(response, 401, { code: 40100, message: "expired", data: null });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "old",
      onExpired: vi.fn(),
    });
    const auth = {
      ...createTestAuth(baseURL, session),
      shouldExpireSession: (error: unknown) =>
        axios.isAxiosError(error) &&
        error.response?.status === 400 &&
        (error.response.data as { code?: string } | undefined)?.code ===
          "invalid_grant",
    };
    const http = createHttpClient({ baseURL, auth });

    await http.get("/protected").catch(() => undefined);
    expect(session.getAccessToken()).toBeNull();
    expect(session.onExpired).toHaveBeenCalledTimes(1);
  });

  it("keeps the session on a concurrent network refresh failure without repeated display", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 401, {
        code: 40100,
        message: "expired",
        data: null,
      });
    });
    const refreshError = new axios.AxiosError(
      "offline",
      "ERR_NETWORK",
      {
        headers: new AxiosHeaders(),
        method: "post",
        url: "/auth/refresh?token=must-not-leak",
      },
    );
    const auth: AuthAdapter = {
      applyCredential(config) {
        config.headers.set("Authorization", "Bearer expired");
      },
      refreshCredential: vi.fn(async () => {
        throw refreshError;
      }),
      shouldExpireSession: () => false,
      expireSession: vi.fn(),
    };
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, auth, onError, onReport });

    const results = await Promise.allSettled(
      Array.from({ length: 10 }, () => http.get("/protected")),
    );

    expect(results).toHaveLength(10);
    expect(
      results.every(
        (result) =>
          result.status === "rejected" &&
          result.reason instanceof HttpError &&
          result.reason.kind === "network",
      ),
    ).toBe(true);
    expect(auth.refreshCredential).toHaveBeenCalledTimes(1);
    // 网络错说明刷新端点「暂时无法回答」,不是凭证失效:会话必须保留(D-65)。
    expect(auth.expireSession).not.toHaveBeenCalled();
    expect(onError).not.toHaveBeenCalled();
    expect(onReport).toHaveBeenCalledTimes(10);
    expect(onReport.mock.calls.map(([error]) => error)).toEqual(
      Array.from({ length: 10 }, () =>
        expect.objectContaining({
          origin: "auth-refresh",
          originMethod: "POST",
          originPath: "/auth/refresh",
          path: "/protected",
        }),
      ),
    );
  });

  it("keeps the session on a 5xx refresh failure and recovers after the cooldown", async () => {
    let refreshCount = 0;
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        if (refreshCount === 1) {
          sendJson(response, 503, {
            code: 50300,
            message: "refresh unavailable",
            data: null,
          });
          return;
        }

        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      if (request.headers.authorization === "Bearer fresh") {
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: true,
        });
        return;
      }

      sendJson(response, 401, {
        code: 40100,
        message: "expired",
        data: null,
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
      refreshCooldownMs: 150,
    });

    // 刷新端点 503:请求失败,但这是端点的暂时故障而不是凭证失效,会话必须保留。
    await expect(http.get("/protected")).rejects.toMatchObject({ status: 503 });
    expect(refreshCount).toBe(1);
    expect(session.onExpired).not.toHaveBeenCalled();
    expect(session.getAccessToken()).toBe("expired");

    // 冷却窗口内:熔断复用上次的失败,不再打刷新端点。
    await expect(http.get("/protected")).rejects.toMatchObject({ status: 503 });
    expect(refreshCount).toBe(1);

    // 冷却结束:放行一次新的刷新,成功后静默恢复——全程没有踢过登录。
    await new Promise((resolve) => setTimeout(resolve, 200));
    await expect(http.get("/protected")).resolves.toBe(true);
    expect(refreshCount).toBe(2);
    expect(session.onExpired).not.toHaveBeenCalled();
    expect(session.getAccessToken()).toBe("fresh");
  });

  it("does not refresh again when a replay also returns 401", async () => {
    let protectedCount = 0;
    let refreshCount = 0;
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      protectedCount += 1;
      sendJson(response, 401, {
        code: 40100,
        message: "still expired",
        data: null,
      });
    });
    const onExpired = vi.fn();
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired,
    });
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
      onError,
      onReport,
    });

    await expect(http.get("/protected")).rejects.toMatchObject({
      kind: "http",
      status: 401,
      method: "GET",
      path: "/protected",
      attempts: 2,
    });
    expect(refreshCount).toBe(1);
    expect(protectedCount).toBe(2);
    expect(onExpired).toHaveBeenCalledTimes(1);
    expect(session.getAccessToken()).toBeNull();
    expect(onError).not.toHaveBeenCalled();
    expect(onReport).toHaveBeenCalledTimes(1);
  });

  it("keeps one loading interval around refresh and replay", async () => {
    let protectedCount = 0;
    const baseURL = await startServer((request, response) => {
      if (request.url === "/auth/refresh") {
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      protectedCount += 1;
      if (request.headers.authorization !== "Bearer fresh") {
        sendJson(response, 401, {
          code: 40100,
          message: "expired",
          data: null,
        });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const events: string[] = [];
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
      onLoadingChange: (active) => events.push(active ? "open" : "close"),
    });

    await expect(
      http.get("/protected", { showLoading: true }),
    ).resolves.toBe(true);
    expect(protectedCount).toBe(2);
    expect(events).toEqual(["open", "close"]);
  });

  it("holds new requests until an active refresh has completed", async () => {
    let refreshCount = 0;
    let waitingRequestCount = 0;
    let releaseRefresh!: () => void;
    const refreshGate = new Promise<void>((resolve) => {
      releaseRefresh = resolve;
    });
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        await refreshGate;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      if (request.url === "/waiting") {
        waitingRequestCount += 1;
      }

      if (request.headers.authorization !== "Bearer fresh") {
        sendJson(response, 401, {
          code: 40100,
          message: "expired",
          data: null,
        });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const first = http.get("/first");
    await vi.waitFor(() => {
      expect(refreshCount).toBe(1);
    });
    const waiting = http.get("/waiting");
    await delay(20);
    expect(waitingRequestCount).toBe(0);

    releaseRefresh();
    await expect(Promise.all([first, waiting])).resolves.toEqual([true, true]);
    expect(waitingRequestCount).toBe(1);
  });

  it("replays a late old-credential 401 without refreshing a second time", async () => {
    let refreshCount = 0;
    let releaseLateResponse!: () => void;
    const lateResponseGate = new Promise<void>((resolve) => {
      releaseLateResponse = resolve;
    });
    let slowAttempt = 0;
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      if (request.url === "/slow") {
        slowAttempt += 1;
        if (slowAttempt === 1) {
          await lateResponseGate;
        }
      }

      if (request.headers.authorization !== "Bearer fresh") {
        sendJson(response, 401, {
          code: 40100,
          message: "expired",
          data: null,
        });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: true,
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const slow = http.get("/slow");
    await expect(http.get("/trigger")).resolves.toBe(true);
    expect(refreshCount).toBe(1);
    releaseLateResponse();

    await expect(slow).resolves.toBe(true);
    expect(refreshCount).toBe(1);
    expect(slowAttempt).toBe(2);
  });

  it("does not send a request canceled while it is waiting for refresh", async () => {
    let refreshCount = 0;
    let waitingRequestCount = 0;
    let releaseRefresh!: () => void;
    const refreshGate = new Promise<void>((resolve) => {
      releaseRefresh = resolve;
    });
    const baseURL = await startServer(async (request, response) => {
      if (request.url === "/auth/refresh") {
        refreshCount += 1;
        await refreshGate;
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "fresh" },
        });
        return;
      }

      if (request.url === "/waiting") {
        waitingRequestCount += 1;
      }

      sendJson(response, 401, {
        code: 40100,
        message: "expired",
        data: null,
      });
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL,
      auth: createTestAuth(baseURL, session),
    });

    const first = http.get("/first");
    await vi.waitFor(() => {
      expect(refreshCount).toBe(1);
    });
    const controller = new AbortController();
    const waiting = http.get("/waiting", {
      signal: controller.signal,
      errorMode: "silent",
    });
    controller.abort();
    releaseRefresh();

    await expect(waiting).rejects.toMatchObject({ kind: "cancel" });
    await expect(first).rejects.toMatchObject({ status: 401 });
    expect(waitingRequestCount).toBe(0);
  });

  it("leaves the session unchanged for a skipAuth request that returns 401", async () => {
    const baseURL = await startServer((_request, response) => {
      sendJson(response, 401, {
        code: 40100,
        message: "invalid login",
        data: null,
      });
    });
    const auth: AuthAdapter = {
      applyCredential: vi.fn(),
      refreshCredential: vi.fn(),
      shouldExpireSession: () => false,
      expireSession: vi.fn(),
    };
    const onError = vi.fn();
    const onReport = vi.fn();
    const http = createHttpClient({ baseURL, auth, onError, onReport });

    await expect(
      http.post("/login", undefined, { skipAuth: true }),
    ).rejects.toMatchObject({ kind: "http", status: 401 });
    expect(auth.applyCredential).not.toHaveBeenCalled();
    expect(auth.refreshCredential).not.toHaveBeenCalled();
    expect(auth.expireSession).not.toHaveBeenCalled();
    expect(onError).toHaveBeenCalledTimes(1);
    expect(onReport).toHaveBeenCalledTimes(1);
  });
});
ts
import axios from "axios";
import { describe, expect, it, vi } from "vitest";

import {
  presentApiError,
  readApiErrorMessage,
} from "../src/api/http/adapters/error-presenter";
import {
  ApiEnvelopeFormatError,
  assignRequestErrorContext,
  HttpError,
  normalizeHttpError,
} from "../src/api/http/errors";
import * as publicHttpEntry from "../src/api/http/index";
import { retry } from "../src/api/http/retry";
import { readDownloadFilename } from "../src/api/http/transfer";

describe("application HTTP entry", () => {
  it("exposes only the configured http instance", () => {
    expect(Object.keys(publicHttpEntry)).toEqual(["http"]);
  });
});

describe("response protocol", () => {
  it("keeps developer descriptions in the core and user copy in the presenter", async () => {
    const error = await normalizeHttpError(
      new axios.AxiosError("offline", "ERR_NETWORK"),
    );

    expect(error.message).toBe("Network request failed");
    expect(presentApiError(error)).toBe("网络异常,请检查连接后重试");
  });

  it("keeps all user-facing fallback copy inside the presenter", async () => {
    const timeout = await normalizeHttpError(
      new axios.AxiosError("slow", "ETIMEDOUT"),
    );
    const canceled = await normalizeHttpError(
      new axios.CanceledError("left page"),
    );
    const server = await normalizeHttpError({
      isAxiosError: true,
      response: {
        status: 503,
        data: { internal: "do not display" },
      },
    });
    const unknown = await normalizeHttpError(new Error("boom"));
    const format = new ApiEnvelopeFormatError(200, { broken: true });

    expect([
      timeout.message,
      canceled.message,
      server.message,
      unknown.message,
      format.message,
    ]).toEqual([
      "Request timed out",
      "Request canceled",
      "HTTP request failed with status 503",
      "Unknown request failure",
      "API response does not match the expected envelope",
    ]);
    expect([
      presentApiError(timeout),
      presentApiError(canceled),
      presentApiError(server),
      presentApiError(unknown),
      presentApiError(format),
    ]).toEqual([
      "请求超时,请稍后重试",
      "请求已取消",
      "服务暂时不可用,请稍后重试",
      "请求失败,请稍后重试",
      "接口返回格式异常,请稍后重试",
    ]);
  });

  it("keeps malformed response data out of default error serialization", () => {
    const responseData = {
      secret: "PII-IN-BODY",
    };
    const error = new ApiEnvelopeFormatError(200, responseData);

    expect(error.responseData).toBe(responseData);
    expect(Object.keys(error)).not.toContain("responseData");
    expect(JSON.stringify(error)).not.toContain("PII-IN-BODY");
  });

  it("keeps server-provided display copy out of default error serialization", async () => {
    const error = await normalizeHttpError(
      {
        isAxiosError: true,
        response: {
          status: 400,
          data: {
            code: 40010,
            message: "字段 idCard=110101199001011234 校验失败",
            data: null,
          },
        },
      },
      { readErrorMessage: readApiErrorMessage },
    );
    const contextual = assignRequestErrorContext(error, {
      method: "POST",
      path: "/users",
      attempts: 1,
      elapsedMs: 12,
      origin: "business",
    });

    expect(error.presentationHint).toBe(
      "字段 idCard=110101199001011234 校验失败",
    );
    // 写入请求上下文后展示文案仍可用,但依然不进入默认序列化
    expect(presentApiError(contextual)).toBe(
      "字段 idCard=110101199001011234 校验失败",
    );
    expect(Object.keys(error)).not.toContain("presentationHint");
    expect(JSON.stringify(error)).not.toContain("110101199001011234");
    expect(JSON.stringify(contextual)).not.toContain("110101199001011234");
  });

  it("writes the request context into the original error instead of rebuilding it", async () => {
    const cause = {
      isAxiosError: true,
      response: { status: 500, data: null },
    };
    const httpError = await normalizeHttpError(cause);
    const responseData = { broken: true };
    const formatError = new ApiEnvelopeFormatError(200, responseData);
    const context = {
      method: "GET",
      path: "/report",
      attempts: 2,
      elapsedMs: 34,
      origin: "business",
    } as const;

    // 重建会换掉对象身份,任何按身份建立的关联(WeakSet、WeakMap)都会失效,
    // 而且每新增一个载荷字段都要在重建处补一次拷贝,漏掉就是静默丢失。
    expect(assignRequestErrorContext(httpError, context)).toBe(httpError);
    expect(assignRequestErrorContext(formatError, context)).toBe(formatError);
    expect(httpError.cause).toBe(cause);
    expect(formatError.responseData).toBe(responseData);

    // 请求上下文属于安全字段,必须留在默认序列化里供上报使用。
    expect(JSON.parse(JSON.stringify(httpError))).toMatchObject(context);
    expect(JSON.parse(JSON.stringify(formatError))).toMatchObject(context);
  });

  it("reads an API error carried by a Blob response", async () => {
    const error = {
      isAxiosError: true,
      response: {
        status: 400,
        data: new Blob(
          [
            JSON.stringify({
              code: 40012,
              message: "文件格式错误",
              data: null,
            }),
          ],
          { type: "application/json" },
        ),
      },
    };

    const normalized = await normalizeHttpError(error, {
      readErrorMessage: readApiErrorMessage,
    });

    expect(normalized).toMatchObject({
        kind: "http",
        status: 400,
        message: "HTTP request failed with status 400",
      });
    expect(presentApiError(normalized)).toBe("文件格式错误");
  });

  it("classifies client-side failures and preserves their original cause", async () => {
    const failures = [
      {
        error: new axios.AxiosError("offline", "ERR_NETWORK"),
        kind: "network",
      },
      {
        error: new axios.AxiosError("slow", "ETIMEDOUT"),
        kind: "timeout",
      },
      {
        error: new axios.CanceledError("left page"),
        kind: "cancel",
      },
      {
        error: new Error("unexpected"),
        kind: "unknown",
      },
    ] as const;

    for (const { error, kind } of failures) {
      const normalized = await normalizeHttpError(error);
      expect(normalized.kind).toBe(kind);
      expect(normalized.cause).toBe(error);
    }
  });

  it("separates Axios configuration failures from network and unknown failures", async () => {
    const badOption = new axios.AxiosError(
      "bad option",
      axios.AxiosError.ERR_BAD_OPTION,
    );
    const invalidUrl = new axios.AxiosError(
      "invalid URL",
      axios.AxiosError.ERR_INVALID_URL,
    );
    const unclassified = new axios.AxiosError(
      "adapter failed",
      "ERR_UNCLASSIFIED",
    );

    const configuration = await normalizeHttpError(badOption);
    const invalidConfiguration = await normalizeHttpError(invalidUrl);
    const unknown = await normalizeHttpError(unclassified);

    expect(configuration).toMatchObject({
      kind: "configuration",
      message: "Request configuration failed",
      cause: badOption,
    });
    expect(invalidConfiguration.kind).toBe("configuration");
    expect(unknown.kind).toBe("unknown");
    expect(presentApiError(configuration)).toBe(
      "请求配置错误,请联系管理员",
    );
    expect(Object.keys(configuration)).not.toContain("cause");
  });
});

describe("retry", () => {
  it("retries temporary failures up to the configured limit", async () => {
    const task = vi
      .fn<() => Promise<string>>()
      .mockRejectedValueOnce(
        new HttpError({ kind: "network", message: "offline" }),
      )
      .mockRejectedValueOnce(
        new HttpError({ kind: "http", status: 503, message: "busy" }),
      )
      .mockResolvedValue("ok");

    await expect(retry(task, { retries: 2, baseDelay: 1 })).resolves.toBe("ok");
    expect(task).toHaveBeenCalledTimes(3);
  });

  it("does not start a retry wait that would overrun the total budget", async () => {
    const task = vi.fn(async () => {
      throw new HttpError({ kind: "network", message: "offline" });
    });

    const startedAt = Date.now();
    await expect(
      retry(task, { retries: 5, baseDelay: 40, totalTimeoutMs: 250 }),
    ).rejects.toMatchObject({ kind: "network" });
    const elapsed = Date.now() - startedAt;

    // 无预算时会跑满 6 次尝试、约 1.2 秒退避
    expect(task.mock.calls.length).toBeLessThan(6);
    expect(elapsed).toBeLessThan(600);
  });

  it("stops a retry wait when its signal is aborted", async () => {
    const controller = new AbortController();
    const task = vi.fn(async () => {
      throw new HttpError({ kind: "network", message: "offline" });
    });
    const result = retry(task, {
      retries: 2,
      baseDelay: 100,
      signal: controller.signal,
    });

    controller.abort("left page");

    await expect(result).rejects.toMatchObject({
      kind: "cancel",
      message: "Request canceled",
    });
    expect(task).toHaveBeenCalledTimes(1);
  });
});

describe("file transfer utilities", () => {
  it("prefers UTF-8 filenames and removes unsafe path characters", () => {
    expect(
      readDownloadFilename(
        "attachment; filename*=UTF-8''%E6%9C%88%E6%8A%A5%2F2026.xlsx",
      ),
    ).toBe("月报_2026.xlsx");
  });

  it("uses a safe fallback when no filename is returned", () => {
    expect(readDownloadFilename(undefined, "../report.csv")).toBe("_report.csv");
  });
});
ts
import {
  createServer,
  type IncomingMessage,
  type Server,
  type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";

import { afterEach, describe, expect, it, vi } from "vitest";

import {
  createMemoryAuthSession,
  type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { createHttpClient } from "../src/api/http/client";
import { ApiEnvelopeFormatError } from "../src/api/http/errors";

type ServerHit = { method: string; url: string; authorization?: string };

const openServers = new Set<Server>();

function sendJson(response: ServerResponse, status: number, body: unknown) {
  response.statusCode = status;
  response.setHeader("content-type", "application/json");
  response.end(JSON.stringify(body));
}

function delay(milliseconds: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

async function startServer(
  handler: (
    request: IncomingMessage,
    response: ServerResponse,
  ) => void | Promise<void>,
) {
  const server = createServer((request, response) => {
    Promise.resolve(handler(request, response)).catch(() => {
      sendJson(response, 500, { message: "test server failed" });
    });
  });

  await new Promise<void>((resolve) => {
    server.listen(0, "127.0.0.1", resolve);
  });
  openServers.add(server);

  const address = server.address() as AddressInfo;
  return `http://127.0.0.1:${address.port}`;
}

function createTestAuth(baseURL: string, session: AuthSession) {
  return createBearerAuthAdapter({
    baseURL,
    getAccessToken: session.getAccessToken,
    setAccessToken: session.setAccessToken,
    expireSession() {
      session.clearSession();
      session.onExpired();
    },
    selectAccessToken(response) {
      const envelope = readApiEnvelope(response.data);
      const result = envelope?.data;
      if (
        !envelope?.hasData ||
        !result ||
        typeof result !== "object" ||
        !("accessToken" in result) ||
        typeof result.accessToken !== "string"
      ) {
        throw new ApiEnvelopeFormatError(response.status, response.data);
      }

      return result.accessToken;
    },
  });
}

/**
 * 建立一个「首次刷新被挂起、随后失败」的会话场景。
 * 释放挂起前,测试可以模拟显式重新登录。
 */
async function startExpiringSessionServer(options: {
  secondRefreshSucceeds: boolean;
}) {
  const hits: ServerHit[] = [];
  let releaseFirstRefresh: (() => void) | undefined;
  const firstRefreshGate = new Promise<void>((resolve) => {
    releaseFirstRefresh = resolve;
  });
  let refreshCount = 0;

  const baseURL = await startServer(async (request, response) => {
    hits.push({
      method: request.method ?? "",
      url: request.url ?? "",
      authorization: request.headers.authorization,
    });

    if (request.url === "/auth/refresh") {
      refreshCount += 1;
      if (refreshCount === 1) {
        await firstRefreshGate;
        sendJson(response, 401, {
          code: 40100,
          message: "refresh expired",
          data: null,
        });
        return;
      }

      if (options.secondRefreshSucceeds) {
        sendJson(response, 200, {
          code: 0,
          message: "ok",
          data: { accessToken: "rotated-token" },
        });
        return;
      }

      sendJson(response, 401, {
        code: 40100,
        message: "refresh expired",
        data: null,
      });
      return;
    }

    if (
      request.headers.authorization === "Bearer new-login-token" ||
      request.headers.authorization === "Bearer rotated-token"
    ) {
      sendJson(response, 200, { code: 0, message: "ok", data: "fresh-data" });
      return;
    }

    sendJson(response, 401, { code: 40100, message: "expired", data: null });
  });

  return {
    baseURL,
    hits,
    refreshCount: () => refreshCount,
    releaseFirstRefresh: () => releaseFirstRefresh?.(),
  };
}

afterEach(async () => {
  await Promise.all(
    [...openServers].map(
      (server) =>
        new Promise<void>((resolve, reject) => {
          server.close((error) => {
            if (error) {
              reject(error);
            } else {
              resolve();
            }
          });
          server.closeAllConnections();
        }),
    ),
  );
  openServers.clear();
});

describe("session isolation between the business client and the refresh client", () => {
  it("never replays a refresh request through the business client", async () => {
    const scenario = await startExpiringSessionServer({
      secondRefreshSucceeds: true,
    });
    const session = createMemoryAuthSession({
      initialAccessToken: "expired-first-session",
      onExpired: vi.fn(),
    });
    const onError = vi.fn();
    const http = createHttpClient({
      baseURL: scenario.baseURL,
      auth: createTestAuth(scenario.baseURL, session),
      onError,
    });

    const expiredRequest = http.get("/protected").catch((error: unknown) => error);
    await delay(120);

    // 用户在刷新在途时完成显式登录
    session.setAccessToken("new-login-token");
    http.resetAuthState();

    const afterLogin = http.get<string>("/profile");
    await delay(60);

    scenario.releaseFirstRefresh();
    await expiredRequest;

    await expect(afterLogin).resolves.toBe("fresh-data");
    // 业务请求必须真的发出,而不是拿到刷新接口的响应体
    expect(
      scenario.hits.some((hit) => hit.method === "GET" && hit.url === "/profile"),
    ).toBe(true);
    // 刷新端点只应由无凭证的刷新客户端访问
    expect(
      scenario.hits.filter(
        (hit) => hit.url === "/auth/refresh" && hit.authorization !== undefined,
      ),
    ).toEqual([]);
    expect(scenario.refreshCount()).toBe(1);
    expect(onError).not.toHaveBeenCalled();
  });

  it("keeps a re-established session alive when the previous refresh finally fails", async () => {
    const scenario = await startExpiringSessionServer({
      secondRefreshSucceeds: false,
    });
    const onExpired = vi.fn();
    const session = createMemoryAuthSession({
      initialAccessToken: "expired-first-session",
      onExpired,
    });
    const http = createHttpClient({
      baseURL: scenario.baseURL,
      auth: createTestAuth(scenario.baseURL, session),
    });

    const expiredRequest = http.get("/protected").catch((error: unknown) => error);
    await delay(120);

    session.setAccessToken("new-login-token");
    http.resetAuthState();

    const afterLogin = http.get<string>("/profile");
    await delay(60);

    scenario.releaseFirstRefresh();
    await expiredRequest;

    await expect(afterLogin).resolves.toBe("fresh-data");
    // 上一会话的刷新失败不得再次失效已经重建的会话
    expect(onExpired).not.toHaveBeenCalled();
    expect(session.getAccessToken()).toBe("new-login-token");
  });

  it("continues a request that was already waiting for the previous session's refresh", async () => {
    const scenario = await startExpiringSessionServer({
      secondRefreshSucceeds: false,
    });
    const onExpired = vi.fn();
    const session = createMemoryAuthSession({
      initialAccessToken: "expired-first-session",
      onExpired,
    });
    const http = createHttpClient({
      baseURL: scenario.baseURL,
      auth: createTestAuth(scenario.baseURL, session),
    });

    const expiredRequest = http.get("/protected").catch((error: unknown) => error);
    await delay(120);

    // 该请求先进入等待,登录发生在它等待期间
    const waiting = http.get<string>("/profile");
    await delay(60);
    session.setAccessToken("new-login-token");
    http.resetAuthState();

    scenario.releaseFirstRefresh();
    await expiredRequest;

    await expect(waiting).resolves.toBe("fresh-data");
    expect(onExpired).not.toHaveBeenCalled();
    expect(session.getAccessToken()).toBe("new-login-token");
  });
});
ts
import {
  createServer,
  type IncomingMessage,
  type Server,
  type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";

import { afterEach, describe, expect, it, vi } from "vitest";

import {
  createMemoryAuthSession,
  type AuthSession,
} from "../src/api/session";
import { createBearerAuthAdapter } from "../src/api/http/adapters/auth";
import { readApiEnvelope } from "../src/api/http/adapters/envelope";
import { createHttpClient } from "../src/api/http/client";
import { ApiEnvelopeFormatError } from "../src/api/http/errors";

const openServers = new Set<Server>();

function sendJson(response: ServerResponse, status: number, body: unknown) {
  response.statusCode = status;
  response.setHeader("content-type", "application/json");
  response.end(JSON.stringify(body));
}

function delay(milliseconds: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

async function startServer(
  handler: (
    request: IncomingMessage,
    response: ServerResponse,
  ) => void | Promise<void>,
) {
  const server = createServer((request, response) => {
    Promise.resolve(handler(request, response)).catch(() => {
      sendJson(response, 500, { message: "test server failed" });
    });
  });

  await new Promise<void>((resolve) => {
    server.listen(0, "127.0.0.1", resolve);
  });
  openServers.add(server);

  const address = server.address() as AddressInfo;
  return `http://127.0.0.1:${address.port}`;
}

function createTestAuth(baseURL: string, session: AuthSession) {
  return createBearerAuthAdapter({
    baseURL,
    getAccessToken: session.getAccessToken,
    setAccessToken: session.setAccessToken,
    expireSession() {
      session.clearSession();
      session.onExpired();
    },
    selectAccessToken(response) {
      const envelope = readApiEnvelope(response.data);
      const result = envelope?.data;
      if (
        !envelope?.hasData ||
        !result ||
        typeof result !== "object" ||
        !("accessToken" in result) ||
        typeof result.accessToken !== "string"
      ) {
        throw new ApiEnvelopeFormatError(response.status, response.data);
      }

      return result.accessToken;
    },
  });
}

/** 刷新端点只在第一次抖动失败,之后恢复正常。 */
async function startFlakyRefreshServer() {
  let refreshCount = 0;

  const baseURL = await startServer((request, response) => {
    if (request.url === "/auth/refresh") {
      refreshCount += 1;
      if (refreshCount === 1) {
        sendJson(response, 500, { message: "refresh endpoint hiccup" });
        return;
      }

      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { accessToken: "good-token" },
      });
      return;
    }

    if (request.headers.authorization === "Bearer good-token") {
      sendJson(response, 200, { code: 0, message: "ok", data: "fresh-data" });
      return;
    }

    sendJson(response, 401, { code: 40100, message: "expired", data: null });
  });

  return { baseURL, refreshCount: () => refreshCount };
}

afterEach(async () => {
  await Promise.all(
    [...openServers].map(
      (server) =>
        new Promise<void>((resolve, reject) => {
          server.close((error) => {
            if (error) {
              reject(error);
            } else {
              resolve();
            }
          });
          server.closeAllConnections();
        }),
    ),
  );
  openServers.clear();
});

describe("refresh failure cooldown", () => {
  it("reuses the cached refresh failure inside the cooldown window", async () => {
    const scenario = await startFlakyRefreshServer();
    const onExpired = vi.fn();
    const session = createMemoryAuthSession({
      initialAccessToken: "stale-token",
      onExpired,
    });
    const http = createHttpClient({
      baseURL: scenario.baseURL,
      auth: createTestAuth(scenario.baseURL, session),
      refreshCooldownMs: 10_000,
    });

    await expect(http.get("/protected")).rejects.toMatchObject({
      kind: "http",
      status: 500,
      origin: "auth-refresh",
    });
    expect(scenario.refreshCount()).toBe(1);

    await expect(http.get("/protected")).rejects.toMatchObject({
      kind: "http",
      status: 500,
    });
    // 冷却期内不得再打刷新端点;端点抖动不是凭证失效,会话必须原样保留(D-65)
    expect(scenario.refreshCount()).toBe(1);
    expect(onExpired).not.toHaveBeenCalled();
  });

  it("retries the refresh once the cooldown has elapsed", async () => {
    const scenario = await startFlakyRefreshServer();
    const session = createMemoryAuthSession({
      initialAccessToken: "stale-token",
      onExpired: vi.fn(),
    });
    const http = createHttpClient({
      baseURL: scenario.baseURL,
      auth: createTestAuth(scenario.baseURL, session),
      refreshCooldownMs: 40,
    });

    await expect(http.get("/protected")).rejects.toMatchObject({ status: 500 });
    expect(scenario.refreshCount()).toBe(1);

    await delay(80);

    // 刷新端点恢复后客户端必须自愈,而不是终身锁死在失败缓存上
    await expect(http.get<string>("/protected")).resolves.toBe("fresh-data");
    expect(scenario.refreshCount()).toBe(2);
    expect(session.getAccessToken()).toBe("good-token");
  });
});

describe("retry total budget", () => {
  it("stops retrying a safe read once the budget cannot fit another attempt", async () => {
    let hits = 0;
    const baseURL = await startServer((_request, response) => {
      hits += 1;
      sendJson(response, 503, { code: 50300, message: "busy", data: null });
    });
    const http = createHttpClient({ baseURL });

    const startedAt = Date.now();
    await expect(
      http.get("/report", {
        retry: { retries: 5, baseDelayMs: 40, totalTimeoutMs: 250 },
      }),
    ).rejects.toMatchObject({ kind: "http", status: 503 });
    const elapsed = Date.now() - startedAt;

    // 不设预算时会跑满 6 次尝试、约 1.2 秒退避
    expect(hits).toBeLessThan(6);
    expect(elapsed).toBeLessThan(600);
  });
});
ts
import { afterEach, describe, expect, it, vi } from "vitest";

import {
  createSessionSync,
  type SessionSyncEvent,
} from "../src/api/session-sync";

interface TestSession {
  accessToken: string;
}

/** 每个用例独立频道,避免 BroadcastChannel 跨用例串音。 */
let channelSequence = 0;

function nextChannelName() {
  channelSequence += 1;
  return `session-sync-test-${channelSequence}`;
}

let cleanups: Array<() => void> = [];

function createRecorder() {
  const endedEvents: number[] = [];
  const updatedSessions: TestSession[] = [];

  return {
    endedEvents,
    updatedSessions,
    handlers: {
      onSessionEnded() {
        endedEvents.push(endedEvents.length + 1);
      },
      onSessionUpdated(session: TestSession) {
        updatedSessions.push(session);
      },
    },
  };
}

function openSync(channelName: string, recorder = createRecorder()) {
  const sync = createSessionSync<TestSession>(channelName, recorder.handlers);
  cleanups.push(() => sync.dispose());
  return { recorder, sync };
}

/** 旁路观察者:直接监听原始频道,用来断言线上报文和注入伪造事件。 */
function openProbe(channelName: string) {
  const events: Array<SessionSyncEvent<TestSession>> = [];
  const channel = new BroadcastChannel(channelName);
  channel.onmessage = (event: MessageEvent) => {
    events.push(event.data as SessionSyncEvent<TestSession>);
  };
  cleanups.push(() => channel.close());
  return { channel, events };
}

async function until(predicate: () => boolean, timeoutMs = 1000) {
  const startedAt = Date.now();
  while (!predicate()) {
    if (Date.now() - startedAt > timeoutMs) {
      throw new Error("等待条件超时");
    }
    await new Promise((resolve) => setTimeout(resolve, 5));
  }
}

afterEach(() => {
  for (const cleanup of cleanups) {
    cleanup();
  }
  cleanups = [];
  vi.unstubAllGlobals();
});

describe("createSessionSync", () => {
  it("把会话终结事件广播给其他实例,且不回传给发布者自己", async () => {
    const channelName = nextChannelName();
    const publisher = openSync(channelName);
    const receiver = openSync(channelName);

    publisher.sync.publishSessionEnded();

    await until(() => receiver.recorder.endedEvents.length === 1);
    expect(publisher.recorder.endedEvents).toHaveLength(0);
  });

  it("会话更新事件携带完整会话数据", async () => {
    const channelName = nextChannelName();
    const publisher = openSync(channelName);
    const receiver = openSync(channelName);

    publisher.sync.publishSessionUpdated({ accessToken: "token-1" });

    await until(() => receiver.recorder.updatedSessions.length === 1);
    expect(receiver.recorder.updatedSessions[0]).toEqual({
      accessToken: "token-1",
    });
  });

  it("乱序到达的过期事件被丢弃", async () => {
    const channelName = nextChannelName();
    const receiver = openSync(channelName);
    const probe = openProbe(channelName);

    const freshAt = Date.now() + 5000;
    probe.channel.postMessage({
      sentAt: freshAt,
      sourceId: "peer",
      type: "session-ended",
    });
    // 时间戳落后于已处理事件的旧事件必须被丢弃
    probe.channel.postMessage({
      sentAt: 1,
      session: { accessToken: "stale" },
      sourceId: "peer",
      type: "session-updated",
    });
    // 收尾标记:等它被处理,就能确认前一条旧事件已经走完了判定
    probe.channel.postMessage({
      sentAt: freshAt + 1,
      sourceId: "peer",
      type: "session-ended",
    });

    await until(() => receiver.recorder.endedEvents.length === 2);
    expect(receiver.recorder.updatedSessions).toHaveLength(0);
  });

  it("同一时间戳同一来源的重复投递只处理一次", async () => {
    const channelName = nextChannelName();
    const receiver = openSync(channelName);
    const probe = openProbe(channelName);

    const sentAt = Date.now() + 5000;
    probe.channel.postMessage({ sentAt, sourceId: "dup", type: "session-ended" });
    probe.channel.postMessage({ sentAt, sourceId: "dup", type: "session-ended" });
    probe.channel.postMessage({
      sentAt: sentAt + 1,
      sourceId: "other",
      type: "session-ended",
    });

    await until(() => receiver.recorder.endedEvents.length === 2);
    await new Promise((resolve) => setTimeout(resolve, 20));
    expect(receiver.recorder.endedEvents).toHaveLength(2);
  });

  it("同毫秒的终结与更新冲突,两种到达顺序都收敛为终结", async () => {
    const sentAt = Date.now() + 5000;

    // 顺序一:先终结后更新——更新在全序中更旧,必须被丢弃
    const channelA = nextChannelName();
    const receiverA = openSync(channelA);
    const probeA = openProbe(channelA);
    probeA.channel.postMessage({ sentAt, sourceId: "tab-a", type: "session-ended" });
    probeA.channel.postMessage({
      sentAt,
      session: { accessToken: "stale" },
      sourceId: "tab-b",
      type: "session-updated",
    });
    // 收尾标记:等它被处理即可确认前两条走完了判定
    probeA.channel.postMessage({
      sentAt: sentAt + 1,
      sourceId: "tab-a",
      type: "session-ended",
    });
    await until(() => receiverA.recorder.endedEvents.length === 2);
    expect(receiverA.recorder.updatedSessions).toHaveLength(0);

    // 顺序二:先更新后终结——终结在全序中更新,必须被接受,净效果同样是终结
    const channelB = nextChannelName();
    const receiverB = openSync(channelB);
    const probeB = openProbe(channelB);
    probeB.channel.postMessage({
      sentAt,
      session: { accessToken: "stale" },
      sourceId: "tab-b",
      type: "session-updated",
    });
    probeB.channel.postMessage({ sentAt, sourceId: "tab-a", type: "session-ended" });
    await until(() => receiverB.recorder.endedEvents.length === 1);
  });

  it("同毫秒不同来源的两个更新按确定性规则收敛到同一份会话", async () => {
    const sentAt = Date.now() + 5000;

    // 顺序一:来源序高的先到,序低的后到必须被丢弃
    const channelA = nextChannelName();
    const receiverA = openSync(channelA);
    const probeA = openProbe(channelA);
    probeA.channel.postMessage({
      sentAt,
      session: { accessToken: "session-bb" },
      sourceId: "tab-bb",
      type: "session-updated",
    });
    probeA.channel.postMessage({
      sentAt,
      session: { accessToken: "session-aa" },
      sourceId: "tab-aa",
      type: "session-updated",
    });
    probeA.channel.postMessage({
      sentAt: sentAt + 1,
      sourceId: "tab-bb",
      type: "session-ended",
    });
    await until(() => receiverA.recorder.endedEvents.length === 1);
    expect(receiverA.recorder.updatedSessions.map((s) => s.accessToken)).toEqual([
      "session-bb",
    ]);

    // 顺序二:序低的先到、序高的后到,最终生效的必须是同一份(序高者)
    const channelB = nextChannelName();
    const receiverB = openSync(channelB);
    const probeB = openProbe(channelB);
    probeB.channel.postMessage({
      sentAt,
      session: { accessToken: "session-aa" },
      sourceId: "tab-aa",
      type: "session-updated",
    });
    probeB.channel.postMessage({
      sentAt,
      session: { accessToken: "session-bb" },
      sourceId: "tab-bb",
      type: "session-updated",
    });
    await until(() => receiverB.recorder.updatedSessions.length === 2);
    expect(receiverB.recorder.updatedSessions.at(-1)?.accessToken).toBe("session-bb");
  });

  it("dispose 之后既不再接收也不再发布", async () => {
    const channelName = nextChannelName();
    const alive = openSync(channelName);
    const disposed = openSync(channelName);
    const probe = openProbe(channelName);

    disposed.sync.dispose();
    alive.sync.publishSessionEnded();

    await until(() => probe.events.length === 1);
    expect(disposed.recorder.endedEvents).toHaveLength(0);

    // 已释放实例的发布应当静默失效,而不是抛错或继续上线
    disposed.sync.publishSessionUpdated({ accessToken: "ghost" });
    probe.channel.postMessage({
      sentAt: Date.now() + 60_000,
      sourceId: "marker",
      type: "session-ended",
    });

    await until(() => alive.recorder.endedEvents.length === 1);
    expect(alive.recorder.updatedSessions).toHaveLength(0);
  });

  it("连续发布的事件时间戳严格递增", async () => {
    const channelName = nextChannelName();
    const publisher = openSync(channelName);
    const probe = openProbe(channelName);

    publisher.sync.publishSessionEnded();
    publisher.sync.publishSessionEnded();

    await until(() => probe.events.length === 2);
    expect(probe.events[1]!.sentAt).toBeGreaterThan(probe.events[0]!.sentAt);
    expect(probe.events[0]!.sourceId).toBe(probe.events[1]!.sourceId);
  });

  it("环境缺少 BroadcastChannel 时降级为空实现", () => {
    vi.stubGlobal("BroadcastChannel", undefined);

    const recorder = createRecorder();
    const sync = createSessionSync<TestSession>("no-channel", recorder.handlers);

    expect(() => {
      sync.publishSessionUpdated({ accessToken: "noop" });
      sync.publishSessionEnded();
      sync.dispose();
    }).not.toThrow();
  });
});
ts
import { beforeEach, expect, it, vi } from "vitest";

import { HttpError } from "../src/api/http/errors";

const { post } = vi.hoisted(() => ({
  post: vi.fn(),
}));

vi.mock("../src/api/http", () => ({
  http: { post },
}));

import {
  createUser,
  UserAlreadyExistsError,
} from "../src/api/modules/users";

beforeEach(() => {
  post.mockReset();
});

it("converts a silent 409 into a user-domain error", async () => {
  const httpError = new HttpError({
    kind: "http",
    status: 409,
    message: "HTTP request failed with status 409",
  });
  post.mockRejectedValue(httpError);

  await expect(createUser({ name: "Ada" })).rejects.toMatchObject({
    name: "UserAlreadyExistsError",
    cause: httpError,
  });
  expect(post).toHaveBeenCalledWith(
    "/users",
    { name: "Ada" },
    { errorMode: "silent" },
  );
  expect(UserAlreadyExistsError).toBeDefined();
});

it("keeps other silent failures available for page-level feedback", async () => {
  const httpError = new HttpError({
    kind: "http",
    status: 500,
    message: "HTTP request failed with status 500",
  });
  post.mockRejectedValue(httpError);

  await expect(createUser({ name: "Ada" })).rejects.toBe(httpError);
});
ts
import type { AxiosResponse } from "axios";

import type { HttpClient } from "../src/api/http/client";
// The entry re-exports types so call sites need a single import path.
import type { HttpClient as EntryHttpClient } from "../src/api/http/index";
// @ts-expect-error The entry exposes no factory, so no second client can be built.
import { createHttpClient as hiddenFactory } from "../src/api/http/index";

interface User {
  id: number;
  name: string;
}

interface CreateUserInput {
  name: string;
}

declare const http: HttpClient;

if (false) {
  const user: Promise<User> = http.get<User>("/users/1");
  const createdUser: Promise<User> = http.post<User, CreateUserInput>(
    "/users",
    {
      name: "Ada",
    },
  );

  // @ts-expect-error CreateUserInput.name must be a string.
  http.post<User, CreateUserInput>("/users", { name: 123 });

  // @ts-expect-error Swapping Result and Body makes the request body invalid.
  http.post<CreateUserInput, User>("/users", { name: "Ada" });

  // @ts-expect-error Request sharing is not part of the Axios core.
  http.get<User>("/users/1", { dedupe: true });

  // @ts-expect-error A business request cannot replace the trusted base URL.
  http.get<User>("/users/1", { baseURL: "https://evil.example" });

  // @ts-expect-error Cookie policy is fixed by the client factory.
  http.get<User>("/users/1", { withCredentials: true });

  // @ts-expect-error Success is decided by HTTP status, not by the call site.
  http.get<User>("/users/1", { validateStatus: () => true });

  // @ts-expect-error The transport layer belongs to this module.
  http.get<User>("/users/1", { adapter: () => Promise.reject(new Error()) });

  // @ts-expect-error Response bodies must reach the envelope adapter unmodified.
  http.get<User>("/users/1", { transformResponse: [] });

  // @ts-expect-error Serialization is configured once by the client factory.
  http.get<User>("/users/1", { paramsSerializer: () => "" });

  // 白名单内的请求描述字段仍然可用。
  const allowed: Promise<Blob> = http.get<Blob>("/reports/1", {
    params: { format: "pdf" },
    headers: { "x-trace": "1" },
    responseType: "blob",
    timeout: 30_000,
    signal: new AbortController().signal,
    onDownloadProgress: () => {},
  });

  const rawResponse: Promise<AxiosResponse<string, CreateUserInput>> = http.raw<
    string,
    CreateUserInput
  >({
    method: "post",
    url: "/reports",
    data: { name: "Ada" },
  });
  http.resetAuthState();

  void user;
  void createdUser;
  void rawResponse;
  void allowed;
  void hiddenFactory;
  void (undefined as unknown as EntryHttpClient);
}
ts
import {
  createServer,
  type Server,
  type ServerResponse,
} from "node:http";
import type { AddressInfo } from "node:net";

import { expect, test, type Page } from "@playwright/test";

const appOrigin = "http://localhost:5181";
let apiServer: Server;
let apiBaseURL: string;
let currentAccessToken = "";
let refreshCount = 0;
const refreshCookies: Array<string | undefined> = [];
const businessCookies: Array<string | undefined> = [];
let uploadContentType = "";
let uploadBody = "";
const browserErrors = new WeakMap<Page, string[]>();

function writeCors(response: ServerResponse) {
  response.setHeader("access-control-allow-origin", appOrigin);
  response.setHeader("access-control-allow-credentials", "true");
  response.setHeader(
    "access-control-allow-headers",
    "authorization, content-type",
  );
  response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
  response.setHeader(
    "access-control-expose-headers",
    "content-disposition, content-type",
  );
}

function sendJson(
  response: ServerResponse,
  status: number,
  body: unknown,
) {
  writeCors(response);
  response.statusCode = status;
  response.setHeader("content-type", "application/json");
  response.end(JSON.stringify(body));
}

test.beforeAll(async () => {
  apiServer = createServer(async (request, response) => {
    writeCors(response);
    if (request.method === "OPTIONS") {
      response.statusCode = 204;
      response.end();
      return;
    }

    if (request.url === "/auth/seed") {
      response.setHeader(
        "set-cookie",
        "refresh=r1; HttpOnly; Secure; SameSite=Lax; Path=/",
      );
      response.statusCode = 204;
      response.end();
      return;
    }

    if (request.url === "/auth/refresh") {
      refreshCount += 1;
      refreshCookies.push(request.headers.cookie);
      currentAccessToken = `access-${refreshCount}`;
      response.setHeader(
        "set-cookie",
        `refresh=r${refreshCount + 1}; HttpOnly; Secure; SameSite=Lax; Path=/`,
      );
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { accessToken: currentAccessToken },
      });
      return;
    }

    if (request.url === "/business") {
      businessCookies.push(request.headers.cookie);
      if (
        request.headers.authorization !== `Bearer ${currentAccessToken}` ||
        !currentAccessToken
      ) {
        sendJson(response, 401, {
          code: 40100,
          message: "expired",
          data: null,
        });
        return;
      }
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: currentAccessToken,
      });
      return;
    }

    if (request.url === "/file") {
      response.statusCode = 200;
      response.setHeader("content-type", "text/csv");
      response.setHeader(
        "content-disposition",
        "attachment; filename*=UTF-8''%E6%9C%88%E6%8A%A5.csv",
      );
      response.end("name\nAda");
      return;
    }

    if (request.url === "/slow-file") {
      await new Promise((resolve) => setTimeout(resolve, 150));
      response.statusCode = 200;
      response.setHeader("content-type", "application/octet-stream");
      response.end("slow");
      return;
    }

    if (request.url === "/file-error") {
      sendJson(response, 400, {
        code: 40012,
        message: "文件格式错误",
        data: null,
      });
      return;
    }

    if (request.url === "/upload") {
      uploadContentType = request.headers["content-type"] ?? "";
      const chunks: Buffer[] = [];
      for await (const chunk of request) {
        chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
      }
      uploadBody = Buffer.concat(chunks).toString("utf8");
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { uploaded: true },
      });
      return;
    }

    if (request.url === "/slow-upload") {
      await new Promise((resolve) => setTimeout(resolve, 150));
      sendJson(response, 200, {
        code: 0,
        message: "ok",
        data: { uploaded: true },
      });
      return;
    }

    response.statusCode = 404;
    response.end();
  });

  await new Promise<void>((resolve) => {
    apiServer.listen(0, "localhost", resolve);
  });
  const address = apiServer.address() as AddressInfo;
  apiBaseURL = `http://localhost:${address.port}`;
});

test.afterAll(async () => {
  await new Promise<void>((resolve, reject) => {
    apiServer.close((error) => {
      if (error) {
        reject(error);
      } else {
        resolve();
      }
    });
    apiServer.closeAllConnections();
  });
});

test.beforeEach(async ({ page }) => {
  const errors: string[] = [];
  browserErrors.set(page, errors);
  page.on("console", (message) => {
    if (
      message.type() === "error" &&
      !message.text().startsWith("Failed to load resource:")
    ) {
      errors.push(message.text());
    }
  });
  page.on("pageerror", (error) => {
    errors.push(error.message);
  });
  await page.goto("/browser/");
  await expect(page.locator("main")).toHaveText("Axios browser verification");
});

test.afterEach(async ({ page }) => {
  expect(browserErrors.get(page)).toEqual([]);
});

test("keeps refresh cookies out of JavaScript and business requests", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.runAuth(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({
    first: "access-1",
    second: "access-2",
    expireCount: 0,
    visibleCookie: "",
  });
  expect(refreshCookies).toEqual(["refresh=r1", "refresh=r2"]);
  expect(businessCookies.every((cookie) => cookie === undefined)).toBe(true);
});

test("downloads a Blob, sanitizes its name, and always revokes its URL", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.downloadBlob(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({
    filename: "月报.csv",
    size: 8,
    created: ["8"],
    revoked: ["blob:test-file"],
    clicked: 1,
    anchors: 0,
  });
});

test("uses a browser direct download without leaving an anchor behind", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.downloadDirect(baseURL);
  }, apiBaseURL);

  expect(result.clickedHref).toBe(`${apiBaseURL}/large-file`);
  expect(result.clickedDownload).toBe("_report.csv");
  expect(result.anchors).toBe(0);
});

test("rejects executable direct-download URLs before creating an anchor", async ({
  page,
}) => {
  const result = await page.evaluate(() => {
    return window.browserHarness.rejectUnsafeDirectDownload();
  });

  expect(result).toEqual({
    kind: "configuration",
    anchors: 0,
  });
  await page.waitForTimeout(50);
  expect(
    await page.evaluate(
      () =>
        (
          window as typeof window & {
            __unsafeDownloadExecuted?: number;
          }
        ).__unsafeDownloadExecuted,
    ),
  ).toBe(0);
});

test("cancels a Blob download without global display or file creation", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.cancelBlob(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({
    kind: "cancel",
    errorCount: 0,
    anchors: 0,
  });
});

test("recovers a JSON API error returned through a Blob response", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.readBlobError(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({
    kind: "http",
    status: 400,
    message: "HTTP request failed with status 400",
    displayed: ["文件格式错误"],
  });
});

test("lets the browser generate a multipart boundary and sends all fields", async ({
  page,
}) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.upload(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({ uploaded: true });
  expect(uploadContentType).toMatch(
    /^multipart\/form-data;\s*boundary=.+/i,
  );
  expect(uploadBody).toContain('name="file"; filename="hello.txt"');
  expect(uploadBody).toContain("hello file");
  expect(uploadBody).toContain('name="title"');
  expect(uploadBody).toContain("greeting");
});

test("cancels an upload without displaying a global error", async ({ page }) => {
  const result = await page.evaluate(async (baseURL) => {
    return window.browserHarness.cancelUpload(baseURL);
  }, apiBaseURL);

  expect(result).toEqual({
    kind: "cancel",
    errorCount: 0,
  });
});
ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["test/**/*.test.ts"],
  },
});
ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./browser-tests",
  fullyParallel: false,
  workers: 1,
  use: {
    baseURL: "http://localhost:5181",
    channel: "chrome",
    headless: true,
  },
  webServer: {
    command: "pnpm vite --host localhost --port 5181 --strictPort",
    url: "http://localhost:5181/browser/",
    reuseExistingServer: false,
  },
});
json
{
  "name": "axios-http-module-poc",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:browser": "playwright test",
    "check": "pnpm typecheck && pnpm test && pnpm test:browser"
  },
  "packageManager": "pnpm@10.26.2",
  "dependencies": {
    "axios": "^1.18.1"
  },
  "devDependencies": {
    "@playwright/test": "^1.62.0",
    "@types/node": "^26.1.1",
    "typescript": "^7.0.2",
    "vite": "^8.1.5",
    "vitest": "^4.1.10"
  }
}