Ctrl K
ComunidadGuías…

tests/clientnet.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// THE CLIENT'S SAFETY NET (v1.8 client-solidity audit, section B).
//
// Four failures shared one shape: the client noticed something had gone wrong
// and then said nothing, for ever. A save refused by a precondition it could
// not re-base; a `fetch` with no deadline; a 2xx that was somebody else's login
// page; a rejected promise with no handler above it. Each is a policy now, and
// a policy is a pure function with a test — the same argument revalidate.ts
// makes one file over.

// Both dictionaries, installed: under Node there is no chunk to fetch
// (client/i18n.ts loads one language at a time in a page).
import "../client/i18n/both.ts";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { staleRetryStep } from "../client/editor/saveRetry.ts";
import { ApiError, getNote, requestSignal, timeoutError } from "../client/api.ts";
import { errorSentence } from "../client/safety.ts";
import { setLang } from "../client/i18n.ts";

/** Wait, honestly — these are millisecond deadlines and there is nothing to
 *  fake: `AbortSignal.timeout` runs on a timer no mock reaches. */
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

describe("the stale-save backoff", () => {
  it("speaks on the FIRST failure — the whole point of the fix", () => {
    // Before v1.8 this state announced nothing at all, ever: the save loop
    // 409'd, the re-read failed, the branch returned, and a reader kept typing
    // into a buffer that would never reach disk.
    const first = staleRetryStep(0);
    assert.equal(first.announce, true);
    assert.equal(first.waitMs, 1_000);
  });

  it("backs off, and goes quiet between the two things worth saying", () => {
    const waits = [0, 1, 2, 3, 4].map((n) => staleRetryStep(n).waitMs);
    assert.deepEqual(waits, [1_000, 2_000, 4_000, 8_000, 15_000]);
    assert.equal(staleRetryStep(1).announce, false);
    assert.equal(staleRetryStep(2).announce, false);
  });

  it("says it again once the backoff has reached its ceiling", () => {
    // By 15s this has stopped being a blip and the first toast is long gone.
    assert.equal(staleRetryStep(4).announce, true);
  });

  it("never gives up, and never becomes a heartbeat", () => {
    // The buffer holds the reader's only copy: a client that stops trying has
    // decided on their behalf that the server is not coming back.
    for (const attempt of [5, 20, 500]) {
      assert.equal(staleRetryStep(attempt).waitMs, 15_000);
    }
    const announced = [];
    for (let n = 4; n < 40; n++) if (staleRetryStep(n).announce) announced.push(n);
    assert.deepEqual(announced, [4, 12, 20, 28, 36]); // ~every two minutes
  });

  it("is total — a negative attempt is still an answer, not a crash", () => {
    assert.equal(staleRetryStep(-1).waitMs, 1_000);
  });
});

describe("the request deadline", () => {
  it("aborts a request nobody answered", async () => {
    const signal = requestSignal(null, 10);
    assert.equal(signal.aborted, false);
    await sleep(40);
    assert.equal(signal.aborted, true);
    assert.equal((signal.reason as Error).name, "TimeoutError");
  });

  it("does not clobber the caller's own signal", async () => {
    // Search aborts its in-flight request on every keystroke. A deadline that
    // REPLACED init.signal would have made that a request per character with
    // nothing able to cancel it.
    const caller = new AbortController();
    const signal = requestSignal(caller.signal, 60_000);
    caller.abort();
    assert.equal(signal.aborted, true);
  });

  it("leaves a healthy request alone until its time is up", async () => {
    const caller = new AbortController();
    const signal = requestSignal(caller.signal, 5_000);
    await sleep(30);
    assert.equal(signal.aborted, false);
  });

  it("becomes an ApiError, not a bare DOMException", () => {
    const err = timeoutError(30_000);
    assert.ok(err instanceof ApiError);
    assert.equal(err.code, "timeout");
    assert.equal(err.status, 0); // no server answered
  });
});

describe("a 2xx that is not this API", () => {
  it("throws instead of handing every caller a null typed as data", async () => {
    // The shape: an auth proxy in front of Astrolabe answers the expired XHR with
    // its own 200 HTML login page. `return body as T` used to make that an
    // empty vault, or a crash three frames later inside a component that had
    // every right to assume its data existed.
    const real = globalThis.fetch;
    globalThis.fetch = (async () =>
      new Response("<!doctype html><title>Sign in</title>", {
        status: 200,
        headers: { "content-type": "text/html" },
      })) as typeof fetch;
    try {
      await assert.rejects(
        () => getNote("Welcome.md"),
        (err: unknown) => err instanceof ApiError && err.code === "notJson" && err.status === 200,
      );
    } finally {
      globalThis.fetch = real;
    }
  });

  it("still passes a real JSON answer through untouched", async () => {
    const real = globalThis.fetch;
    globalThis.fetch = (async () =>
      new Response(JSON.stringify({ path: "Welcome.md", content: "hi", mtimeMs: 7 }), {
        status: 200,
        headers: { "content-type": "application/json" },
      })) as typeof fetch;
    try {
      const note = await getNote("Welcome.md");
      assert.equal(note.content, "hi");
      assert.equal(note.mtimeMs, 7);
    } finally {
      globalThis.fetch = real;
    }
  });
});

describe("what the net says", () => {
  it("names the two failures a reader can act on, in their own language", () => {
    setLang("en");
    assert.equal(errorSentence(timeoutError(30_000)), "The server did not answer in time");
    setLang("ar");
    assert.equal(errorSentence(timeoutError(30_000)), "لم يستجب الخادم في الوقت المتاح");
    // `ApiError.message` is the server's ENGLISH prose; an Arabic-only
    // operator reading it inside a fully Arabic panel is the failure
    // `ApiError.code` exists to end.
    assert.notEqual(errorSentence(timeoutError(30_000)), timeoutError(30_000).message);
    setLang("en");
  });

  it("falls back to one honest sentence for everything it has not understood", () => {
    setLang("en");
    assert.equal(
      errorSentence(new TypeError("x is not a function")),
      "Something went wrong — the details are in the browser console",
    );
    assert.equal(
      errorSentence(new ApiError("nope", 500, "somethingNewNextYear")),
      "Something went wrong — the details are in the browser console",
    );
  });
});