import { describe, it } from 'node:test';
import { expect } from 'chai';
import { expectPromise } from '../../__testUtils__/expectPromise.ts';
import { spyOnMethod } from '../../__testUtils__/spyOn.ts';
import { withAsyncUsing } from '../../__testUtils__/withAsyncUsing.ts';
import { withConcurrentAbruptClose } from '../withConcurrentAbruptClose.ts';
const asyncDispose: typeof Symbol.asyncDispose =
Symbol.asyncDispose ?? Symbol.for('Symbol.asyncDispose');
describe('withConcurrentAbruptClose', () => {
it('calls function when returned', async () => {
async function* source() {
yield 1;
}
let done = false;
const generator = withConcurrentAbruptClose(source(), () => {
done = true;
});
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
expect(done).to.equal(false);
expect(await generator.return()).to.deep.equal({
value: undefined,
done: true,
});
expect(done).to.equal(true);
});
it('ignores sync errors when returned', async () => {
async function* source() {
yield 1;
}
const generator = withConcurrentAbruptClose(source(), () => {
throw new Error('Oops');
});
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
expect(await generator.return()).to.deep.equal({
value: undefined,
done: true,
});
});
it('ignores async errors when returned', async () => {
async function* source() {
yield 1;
}
const generator = withConcurrentAbruptClose(source(), () =>
Promise.reject(new Error('Oops')),
);
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
expect(await generator.return()).to.deep.equal({
value: undefined,
done: true,
});
});
it('tracks completion when next() rejects', async () => {
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return Promise.reject(new Error('Oops'));
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
return Promise.reject(reason);
},
async [Symbol.asyncDispose]() {
await this.return();
},
};
const generator = withConcurrentAbruptClose(source, () => undefined);
await expectPromise(generator.next()).toRejectWith('Oops');
});
it('calls function when thrown', async () => {
async function* source() {
yield 1;
}
let done = false;
let error;
const generator = withConcurrentAbruptClose(
source(),
() => {
done = true;
},
(err) => {
done = true;
error = err;
},
);
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
expect(done).to.equal(false);
const oops = new Error('Oops');
await expectPromise(generator.throw(oops)).toRejectWith('Oops');
expect(done).to.equal(true);
expect(error).to.equal(oops);
});
it('ignores sync errors when thrown', async () => {
async function* source() {
yield 1;
}
const generator = withConcurrentAbruptClose(
source(),
() => {
throw new Error('Ignored');
},
() => {
throw new Error('Ignored');
},
);
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
const oops = new Error('Oops');
await expectPromise(generator.throw(oops)).toRejectWith('Oops');
});
it('ignores async errors when thrown', async () => {
async function* source() {
yield 1;
}
const generator = withConcurrentAbruptClose(
source(),
() => Promise.reject(new Error('Ignored')),
() => Promise.reject(new Error('Ignored')),
);
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
const oops = new Error('Oops');
await expectPromise(generator.throw(oops)).toRejectWith('Oops');
});
it('calls cleanup function when disposed', async () => {
const items = [1, 2, 3];
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
const value = items.shift();
if (value !== undefined) {
return Promise.resolve({ done: false, value });
}
return Promise.resolve({ done: true, value: undefined });
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(): Promise<IteratorResult<number, void>> {
return Promise.reject(new Error());
},
async [Symbol.asyncDispose]() {
await this.return();
},
};
const returnSpy = spyOnMethod(source, 'return');
const cleanupHooks = {
cleanup: () => undefined,
};
const cleanupSpy = spyOnMethod(cleanupHooks, 'cleanup');
await withAsyncUsing(
withConcurrentAbruptClose(source, cleanupHooks.cleanup),
async (generator) => {
expect(await generator.next()).to.deep.equal({ value: 1, done: false });
expect(await generator.next()).to.deep.equal({ value: 2, done: false });
},
);
expect(cleanupSpy.callCount).to.equal(1);
expect(returnSpy.callCount).to.equal(1);
});
it('calls the abrupt-close function at most once before completion is observed', async () => {
let resolveNext!: (result: IteratorResult<number, void>) => void;
const nextPromise = new Promise<IteratorResult<number, void>>((resolve) => {
resolveNext = resolve;
});
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return nextPromise;
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
return Promise.reject(reason);
},
async [asyncDispose]() {
await this.return();
},
};
const cleanupHooks = {
cleanup: () => undefined,
};
const cleanupSpy = spyOnMethod(cleanupHooks, 'cleanup');
const generator = withConcurrentAbruptClose(source, cleanupHooks.cleanup);
const pendingNext = generator.next();
await generator.return();
await generator[asyncDispose]();
resolveNext({ done: true, value: undefined });
await pendingNext;
expect(cleanupSpy.callCount).to.equal(1);
});
it('does not call cleanup function again when returned after completion', async () => {
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
return Promise.reject(reason);
},
async [asyncDispose]() {
await this.return();
},
};
const returnSpy = spyOnMethod(source, 'return');
const cleanupHooks = {
cleanup: () => undefined,
};
const cleanupSpy = spyOnMethod(cleanupHooks, 'cleanup');
const generator = withConcurrentAbruptClose(source, cleanupHooks.cleanup);
expect(await generator.next()).to.deep.equal({
value: undefined,
done: true,
});
expect(await generator.return()).to.deep.equal({
value: undefined,
done: true,
});
expect(cleanupSpy.callCount).to.equal(0);
expect(returnSpy.callCount).to.equal(1);
});
it('does not call cleanup function again when thrown after completion', async () => {
let thrownReason: unknown;
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
thrownReason = reason;
return Promise.reject(reason);
},
async [Symbol.asyncDispose]() {
await this.return();
},
};
const cleanupHooks = {
cleanup() {
},
};
const cleanupSpy = spyOnMethod(cleanupHooks, 'cleanup');
const generator = withConcurrentAbruptClose(source, cleanupHooks.cleanup);
expect(await generator.next()).to.deep.equal({
value: undefined,
done: true,
});
const oops = new Error('Oops');
await expectPromise(generator.throw(oops)).toRejectWith('Oops');
expect(cleanupSpy.callCount).to.equal(0);
expect(thrownReason).to.equal(oops);
});
it('does not call cleanup function again when disposed after completion', async () => {
const source: AsyncGenerator<number, void, void> = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
return Promise.reject(reason);
},
async [Symbol.asyncDispose]() {
await this.return();
},
};
const returnSpy = spyOnMethod(source, 'return');
const cleanupHooks = {
cleanup: () => undefined,
};
const cleanupSpy = spyOnMethod(cleanupHooks, 'cleanup');
await withAsyncUsing(
withConcurrentAbruptClose(source, cleanupHooks.cleanup),
async (generator) => {
expect(await generator.next()).to.deep.equal({
value: undefined,
done: true,
});
},
);
expect(cleanupSpy.callCount).to.equal(0);
expect(returnSpy.callCount).to.equal(1);
});
it('handles disposal when wrapped generator has no async-dispose hook', async () => {
let cleanupCalls = 0;
const source = {
[Symbol.asyncIterator]() {
return this;
},
next(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
return(): Promise<IteratorResult<number, void>> {
return Promise.resolve({ done: true, value: undefined });
},
throw(reason?: unknown): Promise<IteratorResult<number, void>> {
return Promise.reject(reason);
},
};
const wrapped = withConcurrentAbruptClose(
source as AsyncGenerator<number, void, void>,
() => {
cleanupCalls += 1;
},
);
await wrapped[asyncDispose]();
expect(cleanupCalls).to.equal(1);
});
it('returns the generator itself when the `Symbol.asyncIterator` method is called', async () => {
async function* source() {
yield 1;
}
const generator = withConcurrentAbruptClose(source(), () => {
});
expect(generator[Symbol.asyncIterator]()).to.equal(generator);
});
it('awaits beforeThrow so an abrupt close can set the rejection reason', async () => {
const abortReason = new Error('aborted');
let storedReason: unknown;
const generator = {
[Symbol.asyncIterator]() {
return this;
},
next() {
return Promise.resolve({ value: undefined, done: true });
},
throw() {
return storedReason === undefined
? Promise.resolve({ value: undefined, done: true })
:
Promise.reject(storedReason);
},
return() {
return Promise.resolve({ value: undefined, done: true });
},
async [Symbol.asyncDispose]() {
await this.return();
},
};
const wrapped = withConcurrentAbruptClose(
generator,
() => undefined,
async () => {
await Promise.resolve();
storedReason = abortReason;
},
);
await expectPromise(wrapped.throw(abortReason)).toRejectWith('aborted');
expect(storedReason).to.equal(abortReason);
});
});