Skip to content

Commit fff898a

Browse files
JoaoTMDiasJoão Dias
andauthored
feat(make-cancelable): add support for cancelling promises using AbortController (#4)
* feat(make-cancelable): add support for cancelling promises using AbortController * fix(make-cancelable): removed isCancelled and added abort reasons * feat(make-cancelable): added signal as part of the returning object * docs(make-cancelable): updated documentation --------- Co-authored-by: João Dias <[email protected]>
1 parent a045b7d commit fff898a

File tree

3 files changed

+449
-54
lines changed

3 files changed

+449
-54
lines changed

cypress/test/functions/utlities/make-cancelable.cy.ts

Lines changed: 195 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,51 +3,226 @@
33
*
44
* (c) 2024 Feedzai
55
*/
6-
import { makeCancelable } from "src/functions";
6+
import { AbortPromiseError, makeCancelable, wait } from "src/functions";
7+
8+
async function expectAbort(cancelable: ReturnType<typeof makeCancelable>) {
9+
try {
10+
await cancelable.promise;
11+
throw new Error("Promise should have been rejected");
12+
} catch (error) {
13+
expect(error).to.be.instanceOf(AbortPromiseError);
14+
if (error instanceof AbortPromiseError) {
15+
expect(error.message).to.equal("Promise was aborted");
16+
}
17+
}
18+
}
719

820
describe("makeCancelable", () => {
9-
it("should return an object with a promise and a cancel function", () => {
10-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
21+
// Configure Cypress to not fail on unhandled promise rejections
22+
before(() => {
23+
cy.on("uncaught:exception", (err) => {
24+
if (err.name === "AbortError") {
25+
return false;
26+
}
27+
});
28+
});
29+
30+
it("should reject with AbortPromiseError if cancelled just before resolution", async () => {
31+
const promise = wait(10);
32+
const cancelable = makeCancelable(promise);
33+
34+
setTimeout(() => cancelable.cancel(), 5);
35+
36+
await expectAbort(cancelable);
37+
});
38+
39+
it("should return an object with a promise, cancel function, isCancelled function, and signal", () => {
40+
const promise = wait(25);
1141
const cancelable = makeCancelable(promise);
1242
expect(cancelable).to.be.an("object");
1343
expect(cancelable.promise).to.be.a("promise");
1444
expect(cancelable.cancel).to.be.a("function");
45+
expect(cancelable.isCancelled).to.be.a("function");
46+
expect(cancelable.signal).to.be.an("AbortSignal");
1547
});
1648

1749
it("should resolve the promise if not cancelled", async () => {
18-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
50+
const value = "test value";
51+
const promise = new Promise((resolve) => setTimeout(() => resolve(value), 25));
1952
const cancelable = makeCancelable(promise);
2053
const result = await cancelable.promise;
21-
expect(result).to.be.undefined; // Or any other expected resolved value
54+
expect(result).to.equal(value);
2255
});
2356

24-
it("should reject the promise with { isCanceled: true } if cancelled", async () => {
25-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
57+
it("should reject with AbortPromiseError when cancelled", async () => {
58+
const promise = wait(25);
2659
const cancelable = makeCancelable(promise);
2760
cancelable.cancel();
61+
62+
try {
63+
await cancelable.promise;
64+
throw new Error("Promise should have been rejected");
65+
} catch (error: unknown) {
66+
expect(error).to.be.instanceOf(AbortPromiseError);
67+
if (error instanceof AbortPromiseError) {
68+
expect(error.message).to.equal("Promise was aborted");
69+
}
70+
}
71+
});
72+
73+
it("should handle rejection from the original promise", async () => {
74+
const error = new Error("Original promise error");
75+
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
76+
const cancelable = makeCancelable(promise);
77+
2878
try {
2979
await cancelable.promise;
30-
} catch (error) {
31-
expect(error).to.have.property("isCanceled", true);
80+
throw new Error("Promise should have been rejected");
81+
} catch (caughtError: unknown) {
82+
expect(caughtError).to.equal(error);
3283
}
3384
});
3485

35-
it("should not resolve or reject the promise after being cancelled", async () => {
36-
cy.window().then(async (win) => {
37-
const promise = new win.Promise((resolve) => win.setTimeout(resolve, 100));
86+
it("should not reject with original error if cancelled", async () => {
87+
const error = new Error("Original promise error");
88+
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
89+
const cancelable = makeCancelable(promise);
90+
cancelable.cancel();
91+
92+
try {
93+
await cancelable.promise;
94+
throw new Error("Promise should have been rejected");
95+
} catch (caughtError: unknown) {
96+
expect(caughtError).to.be.instanceOf(AbortPromiseError);
97+
if (caughtError instanceof AbortPromiseError) {
98+
expect(caughtError.message).to.equal("Promise was aborted");
99+
}
100+
}
101+
});
102+
103+
it("should handle multiple cancel calls", async () => {
104+
const promise = wait(25);
105+
const cancelable = makeCancelable(promise);
106+
107+
cancelable.cancel();
108+
cancelable.cancel(); // Second call should be ignored
109+
110+
try {
111+
await cancelable.promise;
112+
throw new Error("Promise should have been rejected");
113+
} catch (error: unknown) {
114+
expect(error).to.be.instanceOf(AbortPromiseError);
115+
}
116+
});
117+
118+
it("should correctly report cancellation state", async () => {
119+
const promise = wait(25);
120+
const cancelable = makeCancelable(promise);
121+
122+
expect(cancelable.isCancelled()).to.be.false;
123+
cancelable.cancel();
124+
expect(cancelable.isCancelled()).to.be.true;
125+
});
126+
127+
it("should handle abort error message", async () => {
128+
const promise = wait(25);
129+
const cancelable = makeCancelable(promise);
130+
cancelable.cancel();
131+
132+
try {
133+
await cancelable.promise;
134+
throw new Error("Promise should have been rejected");
135+
} catch (error: unknown) {
136+
expect(error).to.be.instanceOf(AbortPromiseError);
137+
if (error instanceof AbortPromiseError) {
138+
expect(error.message).to.equal("Promise was aborted");
139+
}
140+
}
141+
});
142+
143+
describe("signal property", () => {
144+
it("should be an AbortSignal instance", () => {
145+
const promise = wait(25);
146+
const cancelable = makeCancelable(promise);
147+
expect(cancelable.signal).to.be.instanceOf(AbortSignal);
148+
});
149+
150+
it("should reflect cancellation state", () => {
151+
const promise = wait(25);
152+
const cancelable = makeCancelable(promise);
153+
expect(cancelable.signal.aborted).to.be.false;
154+
cancelable.cancel();
155+
expect(cancelable.signal.aborted).to.be.true;
156+
});
157+
158+
it("should be usable with fetch", async () => {
159+
const promise = wait(25);
38160
const cancelable = makeCancelable(promise);
161+
162+
// Simulate a fetch request that would use the signal
163+
const fetchPromise = new Promise((resolve, reject) => {
164+
cancelable.signal.addEventListener("abort", () => {
165+
reject(new AbortPromiseError());
166+
});
167+
setTimeout(resolve, 50);
168+
});
169+
39170
cancelable.cancel();
40-
const racePromise = win.Promise.race([
41-
cancelable.promise.then(() => "resolved"),
42-
new win.Promise((resolve) => win.setTimeout(() => resolve("not-resolved"), 200)),
43-
]);
171+
try {
172+
await fetchPromise;
173+
throw new Error("Promise should have been rejected");
174+
} catch (error: unknown) {
175+
expect(error).to.be.instanceOf(AbortPromiseError);
176+
}
177+
});
178+
179+
it("should be usable with multiple promises", async () => {
180+
const promise1 = wait(25);
181+
const cancelable1 = makeCancelable(promise1);
182+
183+
// Simulate multiple operations using the same signal
184+
const operation1 = new Promise((resolve, reject) => {
185+
cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError()));
186+
setTimeout(resolve, 50);
187+
});
188+
189+
const operation2 = new Promise((resolve, reject) => {
190+
cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError()));
191+
setTimeout(resolve, 50);
192+
});
193+
194+
cancelable1.cancel();
44195

45196
try {
46-
const result = await racePromise;
197+
await operation1;
198+
throw new Error("Promise should have been rejected");
199+
} catch (error: unknown) {
200+
expect(error).to.be.instanceOf(AbortPromiseError);
201+
}
202+
203+
try {
204+
await operation2;
205+
throw new Error("Promise should have been rejected");
206+
} catch (error: unknown) {
207+
expect(error).to.be.instanceOf(AbortPromiseError);
208+
}
209+
});
47210

48-
expect(result).to.equal("not-resolved");
49-
} catch (error) {
50-
console.log(error);
211+
it("should handle custom abort reason", async () => {
212+
const promise = wait(25);
213+
const cancelable = makeCancelable(promise);
214+
const reason = "Custom abort reason";
215+
216+
cancelable.cancel(reason);
217+
218+
try {
219+
await cancelable.promise;
220+
throw new Error("Promise should have been rejected");
221+
} catch (error: unknown) {
222+
expect(error).to.be.instanceOf(AbortPromiseError);
223+
if (error instanceof AbortPromiseError) {
224+
expect(error.message).to.equal("Promise was aborted");
225+
}
51226
}
52227
});
53228
});

0 commit comments

Comments
 (0)