diff --git a/apps/web/src/state/npmRelease.test.ts b/apps/web/src/state/npmRelease.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..512b1b16b9ecc5f1e778b8ca6899bd3b406e96e8 --- /dev/null +++ b/apps/web/src/state/npmRelease.test.ts @@ -0,0 +1,68 @@ +import { AtomRegistry } from "effect/reactivity"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; +import { afterAll, afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { npmReleasePublishedAtom } from "./npmRelease"; + +const version = "0.0.46-nightly.20261012.3021"; + +// FetchHttpClient reads globalThis.fetch once and keeps it, so every test shares this mock. +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +async function settledCheck() { + const atom = npmReleasePublishedAtom(version); + const registry = AtomRegistry.make(); + const unmount = registry.mount(atom); + try { + await vi.waitFor(() => { + const result = registry.get(atom); + expect(AsyncResult.isInitial(result) || result.waiting).toBe(false); + }); + return registry.get(atom); + } finally { + unmount(); + registry.dispose(); + } +} + +describe("npmReleasePublishedAtom", () => { + afterEach(() => { + fetchMock.mockReset(); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + + it("checks npm without tracing headers, so the browser skips the preflight npm rejects", async () => { + fetchMock.mockImplementation(async () => Response.json({ version })); + + const result = await settledCheck(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = new Request(fetchMock.mock.calls[0]?.[0] ?? "", fetchMock.mock.calls[0]?.[1]); + expect(request.url).toBe(`https://registry.npmjs.org/t3/${version}`); + expect(request.headers.get("traceparent")).toBeNull(); + expect(request.headers.get("b3")).toBeNull(); + expect(AsyncResult.getOrElse(result, () => false)).toBe(true); + }); + + it("reports a version npm does not have as unpublished", async () => { + fetchMock.mockImplementation(async () => new Response(null, { status: 404 })); + + const result = await settledCheck(); + + expect(AsyncResult.isSuccess(result)).toBe(true); + expect(AsyncResult.getOrElse(result, () => true)).toBe(false); + }); + + it("leaves a failed first check unknown, which callers read as unpublished", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + + const result = await settledCheck(); + + expect(AsyncResult.isFailure(result)).toBe(true); + expect(AsyncResult.getOrElse(result, () => false)).toBe(false); + }); +}); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index e2820089075fe187c70ccc46ee4e5fb79a748b28..fcc7a550f0516bd1e9f4048abe0bc7a60ad0f61e 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -8,7 +8,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; * registry errors stay unknown. Both re-check every few minutes, so a release * that lands later shows up without a reload. */ -const npmReleasePublishedAtom = Atom.family((version: string) => +export const npmReleasePublishedAtom = Atom.family((version: string) => Atom.make( Effect.gen(function* () { const response = yield* HttpClient.get( @@ -21,7 +21,11 @@ const npmReleasePublishedAtom = Atom.family((version: string) => return typeof body === "object" && body !== null && "version" in body ? body.version === version : false; - }).pipe(Effect.provide(FetchHttpClient.layer)), + }).pipe( + // Tracing headers would make the browser send a CORS preflight, which npm rejects. + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.provide(FetchHttpClient.layer), + ), ).pipe( Atom.withRefresh("5 minutes"), Atom.keepAlive,