Skip to content

feat(make-cancelable): add support for cancelling promises using AbortController #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 195 additions & 20 deletions cypress/test/functions/utlities/make-cancelable.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,226 @@
*
* (c) 2024 Feedzai
*/
import { makeCancelable } from "src/functions";
import { AbortPromiseError, makeCancelable, wait } from "src/functions";

async function expectAbort(cancelable: ReturnType<typeof makeCancelable>) {
try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (error) {
expect(error).to.be.instanceOf(AbortPromiseError);
if (error instanceof AbortPromiseError) {
expect(error.message).to.equal("Promise was aborted");
}
}
}

describe("makeCancelable", () => {
it("should return an object with a promise and a cancel function", () => {
const promise = new Promise((resolve) => setTimeout(resolve, 100));
// Configure Cypress to not fail on unhandled promise rejections
before(() => {
cy.on("uncaught:exception", (err) => {
if (err.name === "AbortError") {
return false;
}
});
});

it("should reject with AbortPromiseError if cancelled just before resolution", async () => {
const promise = wait(10);
const cancelable = makeCancelable(promise);

setTimeout(() => cancelable.cancel(), 5);

await expectAbort(cancelable);
});

it("should return an object with a promise, cancel function, isCancelled function, and signal", () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
expect(cancelable).to.be.an("object");
expect(cancelable.promise).to.be.a("promise");
expect(cancelable.cancel).to.be.a("function");
expect(cancelable.isCancelled).to.be.a("function");
expect(cancelable.signal).to.be.an("AbortSignal");
});

it("should resolve the promise if not cancelled", async () => {
const promise = new Promise((resolve) => setTimeout(resolve, 100));
const value = "test value";
const promise = new Promise((resolve) => setTimeout(() => resolve(value), 25));
const cancelable = makeCancelable(promise);
const result = await cancelable.promise;
expect(result).to.be.undefined; // Or any other expected resolved value
expect(result).to.equal(value);
});

it("should reject the promise with { isCanceled: true } if cancelled", async () => {
const promise = new Promise((resolve) => setTimeout(resolve, 100));
it("should reject with AbortPromiseError when cancelled", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
cancelable.cancel();

try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
if (error instanceof AbortPromiseError) {
expect(error.message).to.equal("Promise was aborted");
}
}
});

it("should handle rejection from the original promise", async () => {
const error = new Error("Original promise error");
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
const cancelable = makeCancelable(promise);

try {
await cancelable.promise;
} catch (error) {
expect(error).to.have.property("isCanceled", true);
throw new Error("Promise should have been rejected");
} catch (caughtError: unknown) {
expect(caughtError).to.equal(error);
}
});

it("should not resolve or reject the promise after being cancelled", async () => {
cy.window().then(async (win) => {
const promise = new win.Promise((resolve) => win.setTimeout(resolve, 100));
it("should not reject with original error if cancelled", async () => {
const error = new Error("Original promise error");
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
const cancelable = makeCancelable(promise);
cancelable.cancel();

try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (caughtError: unknown) {
expect(caughtError).to.be.instanceOf(AbortPromiseError);
if (caughtError instanceof AbortPromiseError) {
expect(caughtError.message).to.equal("Promise was aborted");
}
}
});

it("should handle multiple cancel calls", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);

cancelable.cancel();
cancelable.cancel(); // Second call should be ignored

try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
}
});

it("should correctly report cancellation state", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);

expect(cancelable.isCancelled()).to.be.false;
cancelable.cancel();
expect(cancelable.isCancelled()).to.be.true;
});

it("should handle abort error message", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
cancelable.cancel();

try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
if (error instanceof AbortPromiseError) {
expect(error.message).to.equal("Promise was aborted");
}
}
});

describe("signal property", () => {
it("should be an AbortSignal instance", () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
expect(cancelable.signal).to.be.instanceOf(AbortSignal);
});

it("should reflect cancellation state", () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
expect(cancelable.signal.aborted).to.be.false;
cancelable.cancel();
expect(cancelable.signal.aborted).to.be.true;
});

it("should be usable with fetch", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);

// Simulate a fetch request that would use the signal
const fetchPromise = new Promise((resolve, reject) => {
cancelable.signal.addEventListener("abort", () => {
reject(new AbortPromiseError());
});
setTimeout(resolve, 50);
});

cancelable.cancel();
const racePromise = win.Promise.race([
cancelable.promise.then(() => "resolved"),
new win.Promise((resolve) => win.setTimeout(() => resolve("not-resolved"), 200)),
]);
try {
await fetchPromise;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
}
});

it("should be usable with multiple promises", async () => {
const promise1 = wait(25);
const cancelable1 = makeCancelable(promise1);

// Simulate multiple operations using the same signal
const operation1 = new Promise((resolve, reject) => {
cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError()));
setTimeout(resolve, 50);
});

const operation2 = new Promise((resolve, reject) => {
cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError()));
setTimeout(resolve, 50);
});

cancelable1.cancel();

try {
const result = await racePromise;
await operation1;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
}

try {
await operation2;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
}
});

expect(result).to.equal("not-resolved");
} catch (error) {
console.log(error);
it("should handle custom abort reason", async () => {
const promise = wait(25);
const cancelable = makeCancelable(promise);
const reason = "Custom abort reason";

cancelable.cancel(reason);

try {
await cancelable.promise;
throw new Error("Promise should have been rejected");
} catch (error: unknown) {
expect(error).to.be.instanceOf(AbortPromiseError);
if (error instanceof AbortPromiseError) {
expect(error.message).to.equal("Promise was aborted");
}
}
});
});
Expand Down
Loading