import { describe, it } from 'node:test';
import { expect } from 'chai';
import type { TracingSubChannelRecord } from '../../__testUtils__/diagnosticsTracing.ts';
import { tracingSubChannels } from '../../__testUtils__/diagnosticsTracing.ts';
import { expectEvents } from '../../__testUtils__/expectEvents.ts';
import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts';
import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts';
import { parse } from '../../language/parser.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 { GraphQLResolveContext } from '../../diagnostics.ts';
import { execute } from '../execute.ts';
const schema = buildSchema(`
type Query {
sync: String
async: String
fail: String
asyncFail: String
plain: String
nested: Nested
}
type Nested {
leaf: String
}
type Mutation {
first: String
second: String
}
`);
const resolveChannel = getTracingChannel('graphql:resolve');
describe('resolve diagnostics channel', () => {
it('emits start and end around a synchronous resolver', async () => {
const document = parse('{ sync }');
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: { sync: () => 'hello' },
}),
() => [
{
channel: 'start',
context: {
fieldName: 'sync',
alias: 'sync',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'sync',
},
},
{
channel: 'end',
context: {
fieldName: 'sync',
alias: 'sync',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'sync',
result: 'hello',
},
},
],
);
});
it('emits the full async lifecycle when a resolver returns a promise', async () => {
const document = parse('{ async }');
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: { async: () => Promise.resolve('hello-async') },
}),
() => [
{
channel: 'start',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
},
},
{
channel: 'end',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
},
},
{
channel: 'asyncStart',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
result: 'hello-async',
},
},
{
channel: 'asyncEnd',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
result: 'hello-async',
},
},
],
);
});
it('emits the full async lifecycle when a resolver returns a thenable', async () => {
const document = parse('{ async }');
const thenable = {
then<TResult1 = string, TResult2 = never>(
onfulfilled?:
| ((value: string) => TResult1 | PromiseLike<TResult1>)
| null,
onrejected?:
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
| null,
): PromiseLike<TResult1 | TResult2> {
return Promise.resolve('hello-thenable').then(onfulfilled, onrejected);
},
};
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: { async: () => thenable },
}),
() => [
{
channel: 'start',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
},
},
{
channel: 'end',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
},
},
{
channel: 'asyncStart',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
result: 'hello-thenable',
},
},
{
channel: 'asyncEnd',
context: {
fieldName: 'async',
alias: 'async',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'async',
result: 'hello-thenable',
},
},
],
);
});
it('emits start, error, end when a sync resolver throws', async () => {
const document = parse('{ fail }');
const error = new Error('boom');
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
fail: () => {
throw error;
},
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'fail',
alias: 'fail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'fail',
},
},
{
channel: 'error',
context: {
fieldName: 'fail',
alias: 'fail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'fail',
error,
},
},
{
channel: 'end',
context: {
fieldName: 'fail',
alias: 'fail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'fail',
error,
},
},
],
);
});
it('emits full async lifecycle with error when a resolver rejects', async () => {
const document = parse('{ asyncFail }');
const error = new Error('async-boom');
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
asyncFail: () => Promise.reject(error),
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'asyncFail',
alias: 'asyncFail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'asyncFail',
},
},
{
channel: 'end',
context: {
fieldName: 'asyncFail',
alias: 'asyncFail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'asyncFail',
},
},
{
channel: 'error',
context: {
fieldName: 'asyncFail',
alias: 'asyncFail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'asyncFail',
error,
},
},
{
channel: 'asyncStart',
context: {
fieldName: 'asyncFail',
alias: 'asyncFail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'asyncFail',
error,
},
},
{
channel: 'asyncEnd',
context: {
fieldName: 'asyncFail',
alias: 'asyncFail',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'asyncFail',
error,
},
},
],
);
});
it('reports isDefaultResolver based on field.resolve presence', async () => {
const trivialSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
trivial: { type: GraphQLString },
custom: {
type: GraphQLString,
resolve: () => 'explicit',
},
},
}),
});
await expectEvents(
resolveChannel,
() =>
execute({
schema: trivialSchema,
document: parse('{ trivial custom }'),
rootValue: { trivial: 'value' },
}),
() => [
{
channel: 'start',
context: {
fieldName: 'trivial',
alias: 'trivial',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'trivial',
},
},
{
channel: 'end',
context: {
fieldName: 'trivial',
alias: 'trivial',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'trivial',
result: 'value',
},
},
{
channel: 'start',
context: {
fieldName: 'custom',
alias: 'custom',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: false,
fieldPath: 'custom',
},
},
{
channel: 'end',
context: {
fieldName: 'custom',
alias: 'custom',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: false,
fieldPath: 'custom',
result: 'explicit',
},
},
],
);
});
it('serializes fieldPath lazily, joining path keys with dots', async () => {
const document = parse('{ nested { leaf } }');
const nested = { leaf: 'leaf-value' };
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
nested,
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'nested',
alias: 'nested',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'nested',
},
},
{
channel: 'end',
context: {
fieldName: 'nested',
alias: 'nested',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'nested',
result: nested,
},
},
{
channel: 'start',
context: {
fieldName: 'leaf',
alias: 'leaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'nested.leaf',
},
},
{
channel: 'end',
context: {
fieldName: 'leaf',
alias: 'leaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'nested.leaf',
result: 'leaf-value',
},
},
],
);
});
it('reports the response key as alias, using it in fieldPath', async () => {
const document = parse('{ renamed: nested { aliasedLeaf: leaf } }');
const nested = { leaf: 'leaf-value' };
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
nested,
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'nested',
alias: 'renamed',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'renamed',
},
},
{
channel: 'end',
context: {
fieldName: 'nested',
alias: 'renamed',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'renamed',
result: nested,
},
},
{
channel: 'start',
context: {
fieldName: 'leaf',
alias: 'aliasedLeaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'renamed.aliasedLeaf',
},
},
{
channel: 'end',
context: {
fieldName: 'leaf',
alias: 'aliasedLeaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'renamed.aliasedLeaf',
result: 'leaf-value',
},
},
],
);
});
it('fires once per field, not per schema walk', async () => {
const document = parse('{ sync plain nested { leaf } }');
const nested = { leaf: 'leaf-value' };
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
sync: () => 'hello',
plain: 'plain-value',
nested,
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'sync',
alias: 'sync',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'sync',
},
},
{
channel: 'end',
context: {
fieldName: 'sync',
alias: 'sync',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'sync',
result: 'hello',
},
},
{
channel: 'start',
context: {
fieldName: 'plain',
alias: 'plain',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'plain',
},
},
{
channel: 'end',
context: {
fieldName: 'plain',
alias: 'plain',
parentType: 'Query',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'plain',
result: 'plain-value',
},
},
{
channel: 'start',
context: {
fieldName: 'nested',
alias: 'nested',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'nested',
},
},
{
channel: 'end',
context: {
fieldName: 'nested',
alias: 'nested',
parentType: 'Query',
fieldType: 'Nested',
args: {},
isDefaultResolver: true,
fieldPath: 'nested',
result: nested,
},
},
{
channel: 'start',
context: {
fieldName: 'leaf',
alias: 'leaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'nested.leaf',
},
},
{
channel: 'end',
context: {
fieldName: 'leaf',
alias: 'leaf',
parentType: 'Nested',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'nested.leaf',
result: 'leaf-value',
},
},
],
);
});
it('emits per-field for serial mutation execution', async () => {
const document = parse('mutation M { first second }');
await expectEvents(
resolveChannel,
() =>
execute({
schema,
document,
rootValue: {
first: () => 'one',
second: () => 'two',
},
}),
() => [
{
channel: 'start',
context: {
fieldName: 'first',
alias: 'first',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'first',
},
},
{
channel: 'end',
context: {
fieldName: 'first',
alias: 'first',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'first',
result: 'one',
},
},
{
channel: 'start',
context: {
fieldName: 'second',
alias: 'second',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'second',
},
},
{
channel: 'end',
context: {
fieldName: 'second',
alias: 'second',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'second',
result: 'two',
},
},
],
);
});
it('observes subscribers added before the next async serial mutation field', async () => {
const document = parse('mutation M { first second }');
const events: Array<{
channel: string;
context: GraphQLResolveContext;
}> = [];
const handler = {} as TracingSubChannelRecord<
(context: GraphQLResolveContext) => void
>;
for (const tracingSubChannel of tracingSubChannels) {
handler[tracingSubChannel] = (context) => {
events.push({
channel: tracingSubChannel,
context: { ...context },
});
};
}
let subscribed = false;
try {
const result = await execute({
schema,
document,
rootValue: {
first: () => {
resolveChannel.subscribe(handler);
subscribed = true;
return Promise.resolve('one');
},
second: () => 'two',
},
});
expect(result).to.deep.equal({ data: { first: 'one', second: 'two' } });
expect(events).to.deep.equal([
{
channel: 'start',
context: {
fieldName: 'second',
alias: 'second',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'second',
},
},
{
channel: 'end',
context: {
fieldName: 'second',
alias: 'second',
parentType: 'Mutation',
fieldType: 'String',
args: {},
isDefaultResolver: true,
fieldPath: 'second',
result: 'two',
},
},
]);
} finally {
if (subscribed) {
resolveChannel.unsubscribe(handler);
}
}
});
it('does not call tracing methods when no subscribers are attached', async () => {
const result = await expectNoTracingActivity(resolveChannel, () =>
execute({
schema,
document: parse('{ sync }'),
rootValue: { sync: () => 'hello' },
}),
);
expect(result).to.deep.equal({ data: { sync: 'hello' } });
});
});