import { describe, it } from 'node:test';
import { assert, expect } from 'chai';
import { expectPromise } from '../../__testUtils__/expectPromise.ts';
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.ts';
import { spyOn } from '../../__testUtils__/spyOn.ts';
import { isPromise } from '../../jsutils/isPromise.ts';
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts';
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.ts';
import { parse } from '../../language/parser.ts';
import type { GraphQLResolveInfo } from '../../type/definition.ts';
import { GraphQLObjectType } from '../../type/definition.ts';
import { GraphQLString } from '../../type/scalars.ts';
import { GraphQLSchema } from '../../type/schema.ts';
import { buildSchema } from '../../utilities/buildASTSchema.ts';
import type { SharedExecutionContext } from '../createSharedExecutionContext.ts';
import { execute, experimentalExecuteIncrementally } from '../execute.ts';
import type {
ExecutionArgs,
ValidatedExecutionArgs,
} from '../ExecutionArgs.ts';
import type { ExecutionResult } from '../Executor.ts';
import { runAsyncWorkFinishedHook } from '../hooks.ts';
const executeHookSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: GraphQLString,
resolve: () => 'ok',
},
},
}),
});
const cancellationHookSchema = buildSchema(`
type Todo {
id: ID
items: [String]
}
type Query {
todo: Todo
}
`);
function executeAndWaitForAsyncWorkFinished(
args: ExecutionArgs,
): PromiseOrValue<ExecutionResult> {
const userAsyncWorkFinishedHook = args.hooks?.asyncWorkFinished;
const { promise: hookFinished, resolve: resolveHookFinished } =
promiseWithResolvers<void>();
let hookHasFired = false;
const result = execute({
...args,
hooks: {
...args.hooks,
asyncWorkFinished(info) {
try {
userAsyncWorkFinishedHook?.(info);
} finally {
hookHasFired = true;
resolveHookFinished();
}
},
},
});
return hookHasFired ? result : hookFinished.then(() => result);
}
describe('Execute: Hooks', () => {
it('ignores errors thrown by hooks', async () => {
const calls: Array<string> = [];
const { promise: hooksFinished, resolve: resolveHooksFinished } =
promiseWithResolvers<undefined>();
const result = execute({
schema: executeHookSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
resolveHooksFinished(undefined);
throw new Error('asyncWorkFinished failed');
},
},
});
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
await hooksFinished;
expect(calls).to.deep.equal(['asyncWork']);
});
it('runs post execution hooks synchronously when no async work is tracked', () => {
const calls: Array<string> = [];
const result = execute({
schema: executeHookSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
},
},
});
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
expect(calls).to.deep.equal(['asyncWork']);
});
it('runs post execution hooks for asynchronous execution', async () => {
const { promise: resolvedValue, resolve } = promiseWithResolvers<string>();
const calls: Array<string> = [];
const { promise: hooksFinished, resolve: resolveHooksFinished } =
promiseWithResolvers<undefined>();
const asyncSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: GraphQLString,
resolve: () => resolvedValue,
},
},
}),
});
const resultPromise = execute({
schema: asyncSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
resolveHooksFinished(undefined);
},
},
});
expect(calls).to.deep.equal([]);
resolve('ok');
const result = await resultPromise;
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
await hooksFinished;
expect(calls).to.deep.equal(['asyncWork']);
});
it('ignores async-work tracker wait rejection', async () => {
const validatedExecutionArgs = {} as unknown as ValidatedExecutionArgs;
const sharedExecutionContext = {
asyncWorkTracker: {
wait() {
return Promise.reject(new Error('tracker failed'));
},
},
} as unknown as SharedExecutionContext;
let hookCalled = false;
runAsyncWorkFinishedHook(
validatedExecutionArgs,
sharedExecutionContext,
() => {
hookCalled = true;
},
);
await resolveOnNextTick();
expect(hookCalled).to.equal(false);
});
it('does not wait for un-awaited promiseAll helper usage before asyncWorkFinished', async () => {
const { promise: pendingCleanup, resolve: resolveCleanup } =
promiseWithResolvers<undefined>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
const sideEffectPromiseAllSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: GraphQLString,
resolve: (_source, _args, _context, info: GraphQLResolveInfo) => {
info
.getAsyncHelpers()
.promiseAll([Promise.reject(new Error('bad')), pendingCleanup])
.catch(() => undefined);
return 'ok';
},
},
},
}),
});
const result = execute({
schema: sideEffectPromiseAllSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
resolveAsyncWorkFinished(undefined);
},
},
});
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
await asyncWorkFinished;
let cleanupSettled = false;
const cleanupObserved = pendingCleanup.then(() => {
cleanupSettled = true;
});
await resolveOnNextTick();
expect(cleanupSettled).to.equal(false);
resolveCleanup(undefined);
await cleanupObserved;
});
it('waits for track(...) helper usage before asyncWorkFinished', async () => {
const { promise: pendingCleanup, resolve: resolveCleanup } =
promiseWithResolvers<undefined>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
const trackedSideEffectSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: GraphQLString,
resolve: (_source, _args, _context, info: GraphQLResolveInfo) => {
info
.getAsyncHelpers()
.track([pendingCleanup.catch(() => undefined)]);
return 'ok';
},
},
},
}),
});
const result = execute({
schema: trackedSideEffectSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
resolveAsyncWorkFinished(undefined);
},
},
});
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
let hookSettled = false;
const hookObserved = asyncWorkFinished.then(() => {
hookSettled = true;
});
await resolveOnNextTick();
expect(hookSettled).to.equal(false);
resolveCleanup(undefined);
await hookObserved;
expect(hookSettled).to.equal(true);
});
it('wrapper returns synchronously when asyncWorkFinished fires during execute', () => {
const calls: Array<string> = [];
const wrappedResult = executeAndWaitForAsyncWorkFinished({
schema: executeHookSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
},
},
});
assert(!isPromise(wrappedResult));
expect(wrappedResult).to.deep.equal({
data: {
test: 'ok',
},
});
expect(calls).to.deep.equal(['asyncWork']);
});
it('wrapper returns a promise and resolves after asyncWorkFinished for track(...) side effects', async () => {
const { promise: pendingCleanup, resolve: resolveCleanup } =
promiseWithResolvers<undefined>();
const calls: Array<string> = [];
const trackedSideEffectSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
test: {
type: GraphQLString,
resolve: (_source, _args, _context, info: GraphQLResolveInfo) => {
info.getAsyncHelpers().track([pendingCleanup]);
return 'ok';
},
},
},
}),
});
const wrappedResult = executeAndWaitForAsyncWorkFinished({
schema: trackedSideEffectSchema,
document: parse('{ test }'),
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
},
},
});
assert(isPromise(wrappedResult));
expect(calls).to.deep.equal([]);
let settled = false;
wrappedResult.then(
() => {
settled = true;
},
() => undefined,
);
await resolveOnNextTick();
expect(settled).to.equal(false);
expect(calls).to.deep.equal([]);
resolveCleanup(undefined);
const result = await wrappedResult;
expect(result).to.deep.equal({
data: {
test: 'ok',
},
});
expect(calls).to.deep.equal(['asyncWork']);
});
it('runs post execution hooks for aborted execution', async () => {
const abortController = new AbortController();
const { promise: pendingCleanup, resolve: resolveCleanup } =
promiseWithResolvers<string>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
const calls: Array<string> = [];
const document = parse(`
query {
todo {
id
}
}
`);
const resultPromise = execute({
document,
schema: cancellationHookSchema,
abortSignal: abortController.signal,
hooks: {
asyncWorkFinished() {
calls.push('asyncWork');
resolveAsyncWorkFinished(undefined);
},
},
rootValue: {
todo: (_args: any, _context: any, info: GraphQLResolveInfo) => {
const abortSignal = info.getAbortSignal();
assert(abortSignal instanceof AbortSignal);
const abortPromise = new Promise<never>((_resolve, reject) => {
abortSignal.addEventListener('abort', () => {
reject(new Error('This operation was aborted'));
});
});
return info
.getAsyncHelpers()
.promiseAll([abortPromise, pendingCleanup]);
},
},
});
abortController.abort();
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
expect(calls).to.deep.equal([]);
resolveCleanup('done');
await asyncWorkFinished;
expect(calls).to.deep.equal(['asyncWork']);
});
it('fires asyncWorkFinished after async iterator return cleanup', async () => {
const abortController = new AbortController();
const document = parse(`
query {
todo {
items
}
}
`);
const { promise: nextReturned, resolve: resolveNextReturned } =
promiseWithResolvers<IteratorResult<string>>();
const { promise: nextStarted, resolve: resolveNextStarted } =
promiseWithResolvers<void>();
const { promise: returnStarted, resolve: resolveReturnStarted } =
promiseWithResolvers<void>();
const { promise: returnFinished, resolve: resolveReturnFinished } =
promiseWithResolvers<IteratorResult<string>>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
const asyncIterator = {
[Symbol.asyncIterator]() {
return this;
},
next() {
resolveNextStarted(undefined);
return nextReturned;
},
return() {
resolveReturnStarted(undefined);
return returnFinished;
},
};
const resultPromise = execute({
schema: cancellationHookSchema,
document,
abortSignal: abortController.signal,
hooks: {
asyncWorkFinished() {
resolveAsyncWorkFinished(undefined);
},
},
rootValue: {
todo: {
items: asyncIterator,
},
},
});
await nextStarted;
abortController.abort();
resolveNextReturned({ value: 'value', done: false });
await expectPromise(resultPromise).toRejectWith(
'This operation was aborted',
);
await returnStarted;
let asyncWorkHookCalled = false;
const asyncWorkObserved = asyncWorkFinished.then(() => {
asyncWorkHookCalled = true;
});
await resolveOnNextTick();
expect(asyncWorkHookCalled).to.equal(false);
resolveReturnFinished({ value: undefined, done: true });
await asyncWorkFinished;
await asyncWorkObserved;
});
it('fires asyncWorkFinished after all incremental payloads are delivered', async () => {
const { promise: deferredItems, resolve: resolveDeferredItems } =
promiseWithResolvers<ReadonlyArray<string>>();
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
promiseWithResolvers<undefined>();
const asyncWorkFinishedSpy = spyOn(() =>
resolveAsyncWorkFinished(undefined),
);
const result = await experimentalExecuteIncrementally({
schema: cancellationHookSchema,
document: parse(`
query {
todo {
id
... @defer {
items
}
}
}
`),
enableEarlyExecution: true,
hooks: {
asyncWorkFinished: asyncWorkFinishedSpy,
},
rootValue: {
todo: {
id: '1',
items: () => deferredItems,
},
},
});
assert('initialResult' in result);
expect(result.initialResult.hasNext).to.equal(true);
let asyncWorkHookCalled = false;
const asyncWorkObserved = asyncWorkFinished.then(() => {
asyncWorkHookCalled = true;
});
await resolveOnNextTick();
expect(asyncWorkHookCalled).to.equal(false);
const nextPromise = result.subsequentResults.next();
let nextSettled = false;
nextPromise.then(
() => {
nextSettled = true;
},
() => {
nextSettled = true;
},
);
await resolveOnNextTick();
expect(nextSettled).to.equal(false);
expect(asyncWorkHookCalled).to.equal(false);
resolveDeferredItems(['a']);
const nextResult = await nextPromise;
expect(nextResult.done).to.equal(false);
if (nextResult.done) {
throw new Error('Expected an incremental payload.');
}
expect(nextResult.value.hasNext).to.equal(false);
await asyncWorkFinished;
await asyncWorkObserved;
expect(asyncWorkFinishedSpy.callCount).to.equal(1);
});
});