b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
import {
|
|
ASTRO_COOKIES_SYMBOL,
|
|
finishScoped,
|
|
wrapResponseForScopedClose,
|
|
} from "../../../src/astro/middleware/scoped-db.js";
|
|
|
|
/** Build a streaming Response whose body emits the given chunks. */
|
|
function streamingResponse(chunks: string[], init?: ResponseInit): Response {
|
|
const encoder = new TextEncoder();
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(body, init);
|
|
}
|
|
|
|
/** Fully drain a response body so any stream-end hooks fire. */
|
|
async function drain(response: Response): Promise<string> {
|
|
return response.body ? await new Response(response.body).text() : "";
|
|
}
|
|
|
|
describe("wrapResponseForScopedClose", () => {
|
|
it("closes immediately for a bodyless response", () => {
|
|
const close = vi.fn();
|
|
const response = new Response(null, { status: 302, headers: { location: "/" } });
|
|
|
|
const wrapped = wrapResponseForScopedClose(response, close);
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
// Bodyless responses pass through unchanged.
|
|
expect(wrapped).toBe(response);
|
|
});
|
|
|
|
it("defers close until the body has fully streamed", async () => {
|
|
const close = vi.fn();
|
|
const wrapped = wrapResponseForScopedClose(streamingResponse(["a", "b"]), close);
|
|
|
|
// Not closed yet — the body hasn't been read.
|
|
expect(close).not.toHaveBeenCalled();
|
|
|
|
const text = await drain(wrapped);
|
|
|
|
expect(text).toBe("ab");
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("closes when the body stream is cancelled (client disconnect)", async () => {
|
|
const close = vi.fn();
|
|
const wrapped = wrapResponseForScopedClose(streamingResponse(["chunk"]), close);
|
|
|
|
await wrapped.body!.cancel();
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is idempotent across flush and cancel", async () => {
|
|
const close = vi.fn();
|
|
const wrapped = wrapResponseForScopedClose(streamingResponse(["x"]), close);
|
|
|
|
await drain(wrapped);
|
|
await wrapped.body!.cancel().catch(() => {});
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("swallows a throwing close so the stream still completes", async () => {
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const close = vi.fn(() => {
|
|
throw new Error("boom");
|
|
});
|
|
const wrapped = wrapResponseForScopedClose(streamingResponse(["data"]), close);
|
|
|
|
const text = await drain(wrapped);
|
|
|
|
expect(text).toBe("data");
|
|
expect(errorSpy).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it("forwards the Astro cookies symbol onto the wrapped response", async () => {
|
|
const close = vi.fn();
|
|
const response = streamingResponse(["hi"]);
|
|
const cookies = { marker: true };
|
|
Reflect.set(response, ASTRO_COOKIES_SYMBOL, cookies);
|
|
|
|
const wrapped = wrapResponseForScopedClose(response, close);
|
|
|
|
expect(Reflect.get(wrapped, ASTRO_COOKIES_SYMBOL)).toBe(cookies);
|
|
await drain(wrapped);
|
|
});
|
|
|
|
it("drops a stale Content-Length on the wrapped streaming response", () => {
|
|
const close = vi.fn();
|
|
const response = streamingResponse(["hello"], { headers: { "content-length": "5" } });
|
|
|
|
const wrapped = wrapResponseForScopedClose(response, close);
|
|
|
|
expect(wrapped.headers.has("content-length")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("finishScoped", () => {
|
|
it("commits then defers close to stream-end for a streaming response", async () => {
|
|
const order: string[] = [];
|
|
const commit = vi.fn(() => order.push("commit"));
|
|
const close = vi.fn(() => order.push("close"));
|
|
|
|
const response = await finishScoped({ commit, close }, async () => streamingResponse(["body"]));
|
|
|
|
// commit runs before the response is returned; close is still pending.
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
expect(close).not.toHaveBeenCalled();
|
|
|
|
await drain(response);
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(order).toEqual(["commit", "close"]);
|
|
});
|
|
|
|
it("commits and closes immediately when there is no body", async () => {
|
|
const commit = vi.fn();
|
|
const close = vi.fn();
|
|
|
|
await finishScoped({ commit, close }, async () => new Response(null, { status: 204 }));
|
|
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes the response through unchanged for adapters without close (D1)", async () => {
|
|
const commit = vi.fn();
|
|
const original = streamingResponse(["d1"]);
|
|
|
|
const result = await finishScoped({ commit }, async () => original);
|
|
|
|
expect(result).toBe(original);
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("commits and closes once before rethrowing when run() throws", async () => {
|
|
const commit = vi.fn();
|
|
const close = vi.fn();
|
|
const boom = new Error("render failed");
|
|
|
|
await expect(
|
|
finishScoped({ commit, close }, async () => {
|
|
throw boom;
|
|
}),
|
|
).rejects.toBe(boom);
|
|
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("still closes the connection when commit() throws on the success path", async () => {
|
|
// Regression: a previous version called commit() unguarded on the
|
|
// success path, so a throwing commit skipped close() and leaked the
|
|
// connection.
|
|
const close = vi.fn();
|
|
const commit = vi.fn(() => {
|
|
throw new Error("commit failed");
|
|
});
|
|
|
|
await expect(
|
|
finishScoped({ commit, close }, async () => streamingResponse(["body"])),
|
|
).rejects.toThrow("commit failed");
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not mask a run() error when commit() also throws on the error path", async () => {
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const close = vi.fn();
|
|
const commit = vi.fn(() => {
|
|
throw new Error("commit failed");
|
|
});
|
|
const renderError = new Error("render failed");
|
|
|
|
await expect(
|
|
finishScoped({ commit, close }, async () => {
|
|
throw renderError;
|
|
}),
|
|
).rejects.toBe(renderError);
|
|
|
|
// close still runs even though commit threw during error handling.
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(errorSpy).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it("does not mask a run() error when close() throws on the error path", async () => {
|
|
// Regression: close() on the error path was unguarded, so a throwing
|
|
// teardown would replace the render error the caller needs to see.
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const commit = vi.fn();
|
|
const close = vi.fn(() => {
|
|
throw new Error("close failed");
|
|
});
|
|
const renderError = new Error("render failed");
|
|
|
|
await expect(
|
|
finishScoped({ commit, close }, async () => {
|
|
throw renderError;
|
|
}),
|
|
).rejects.toBe(renderError);
|
|
|
|
expect(commit).toHaveBeenCalledTimes(1);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(errorSpy).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it("surfaces the commit error, not a later close error, on the success path", async () => {
|
|
// When commit() fails after a successful render, the caller must see the
|
|
// commit failure; a throwing close() during cleanup must not mask it.
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const commit = vi.fn(() => {
|
|
throw new Error("commit failed");
|
|
});
|
|
const close = vi.fn(() => {
|
|
throw new Error("close failed");
|
|
});
|
|
|
|
await expect(
|
|
finishScoped({ commit, close }, async () => streamingResponse(["body"])),
|
|
).rejects.toThrow("commit failed");
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(errorSpy).toHaveBeenCalled();
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|