GitNexus/gitnexus/test/unit/group/graphql-extractor.test.ts
Gergő Magyar b60c21d05d
fix(group): extract NestJS GraphQL contracts on real indexes (#3201) (#3227)
* fix(group): extract NestJS GraphQL contracts against real 0-based indexes (#3201)

Provider lookup used 1-based startLine while the graph stores tree-sitter rows, so every resolver missed. Also try PascalCased Document names and inline sibling FragmentDoc interpolations from graphql-codegen output.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): bind arrow-field providers and fail closed on interpolations

Match Method startLine to the public_field_definition wrapper, decode template escape sequences, and reject FragmentDoc names that mix static and dynamic declarators.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): only inline interpolated templates under a gql tag

Cooked reconstruction is not the runtime value for String.raw or unknown tags, so those interpolations stay fail-closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): tighten gql-tag trust and PascalCase Document lookup

Only the identifier `gql` is a trusted interpolating tag. Underscored operation names now try the full pascal-case Document candidate graphql-codegen emits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): memoize GraphQL interpolation source resolution

Avoid exponential re-walks when the same fragment name is declared twice at each layer of a ${FragmentDoc} chain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): fail closed on invalid tagged-template escapes

Treat line continuations as empty cooked text and reject \8/\9 plus legacy octals so reconstructed gql source matches runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 17:30:00 +01:00

906 lines
29 KiB
TypeScript

import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { parse } from 'graphql';
import Parser from 'tree-sitter';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js';
import {
GraphqlExtractor,
indexGeneratedDeclarators,
} from '../../../src/core/group/extractors/graphql-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
import { cleanupTempDir } from '../../helpers/test-db.js';
const tempDirs: string[] = [];
async function makeRepo(
files: Record<string, string>,
): Promise<{ root: string; repo: RepoHandle }> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-graphql-'));
tempDirs.push(root);
for (const [relative, content] of Object.entries(files)) {
const absolute = path.join(root, relative);
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, content, 'utf8');
}
return {
root,
repo: { id: 'test', path: 'app', repoPath: root, storagePath: path.join(root, '.gitnexus') },
};
}
function executor(symbols: Record<string, Array<Record<string, unknown>>>): CypherExecutor {
return async (_query, params = {}) => {
const name = String(params.name ?? '');
const filePath = params.filePath ? `@${String(params.filePath)}` : '';
return symbols[`${name}${filePath}`] ?? symbols[name] ?? [];
};
}
function generatedDocument(
operation: 'query' | 'mutation' | 'subscription',
name: string,
fields: string[],
): string {
const selections = fields
.map((field) => `{ kind: 'Field', name: { kind: 'Name', value: '${field}' } }`)
.join(', ');
return `{ kind: 'Document', definitions: [{
kind: 'OperationDefinition',
operation: '${operation}',
name: { kind: 'Name', value: '${name}' },
selectionSet: { kind: 'SelectionSet', selections: [${selections}] }
}] }`;
}
describe('GraphqlExtractor', () => {
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => cleanupTempDir(dir)));
});
it('anchors NestJS providers and document consumers to exact real symbols', async () => {
const { root, repo } = await makeRepo({
'src/widget.resolver.ts': `
import { Resolver, Query as GqlQuery, Mutation, Subscription } from '@nestjs/graphql';
@Resolver()
class WidgetResolver {
@GqlQuery(() => Widget, { name: 'widget' })
fetchWidget() { return null; }
@Mutation('saveWidget')
save() { return null; }
@Subscription()
widgetChanged() { return null; }
}`,
'src/widget.graphql': `
fragment WidgetRoot on Query { widget }
query GetWidget { alias: widget ...WidgetRoot }
mutation SaveWidget { saveWidget }
subscription WatchWidget { widgetChanged }
`,
'src/generated.ts': `
export const GetWidgetDocument = ${generatedDocument('query', 'GetWidget', ['widget'])};
export const SaveWidgetDocument = ${generatedDocument('mutation', 'SaveWidget', ['saveWidget'])};
export const WatchWidgetDocument = ${generatedDocument('subscription', 'WatchWidget', ['widgetChanged'])};
`,
});
const run = executor({
'fetchWidget@src/widget.resolver.ts': [
{ uid: 'method:fetch', name: 'fetchWidget', filePath: 'src/widget.resolver.ts' },
],
'save@src/widget.resolver.ts': [
{ uid: 'method:save', name: 'save', filePath: 'src/widget.resolver.ts' },
],
'widgetChanged@src/widget.resolver.ts': [
{ uid: 'method:watch', name: 'widgetChanged', filePath: 'src/widget.resolver.ts' },
],
GetWidgetDocument: [
{ uid: 'const:get', name: 'GetWidgetDocument', filePath: 'src/generated.ts' },
],
SaveWidgetDocument: [
{ uid: 'const:save', name: 'SaveWidgetDocument', filePath: 'src/generated.ts' },
],
WatchWidgetDocument: [
{ uid: 'const:watch', name: 'WatchWidgetDocument', filePath: 'src/generated.ts' },
],
});
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(
contracts.map((contract) => [contract.contractId, contract.role, contract.symbolUid]),
).toEqual([
['graphql::query::widget', 'provider', 'method:fetch'],
['graphql::mutation::saveWidget', 'provider', 'method:save'],
['graphql::subscription::widgetChanged', 'provider', 'method:watch'],
['graphql::query::widget', 'consumer', 'const:get'],
['graphql::mutation::saveWidget', 'consumer', 'const:save'],
['graphql::subscription::widgetChanged', 'consumer', 'const:watch'],
]);
});
it('skips unproven decorators, ambiguous anchors, anonymous operations, and invalid documents', async () => {
const { root, repo } = await makeRepo({
'src/not-nest.ts': `
function Query(): MethodDecorator { return () => undefined; }
class LocalResolver { @Query() localOnly() {} }`,
'src/ambiguous.resolver.ts': `
import { Query, Resolver } from '@nestjs/graphql';
@Resolver()
class AmbiguousResolver { @Query() widget() {} }`,
'src/anonymous.graphql': `query { widget }`,
'src/invalid.graphql': `query Broken {`,
'src/missing.graphql': `query MissingGenerated { widget }`,
});
const ambiguous = [
{ uid: 'method:a', name: 'widget', filePath: 'src/ambiguous.resolver.ts' },
{ uid: 'method:b', name: 'widget', filePath: 'src/ambiguous.resolver.ts' },
];
const contracts = await new GraphqlExtractor().extract(
executor({ 'widget@src/ambiguous.resolver.ts': ambiguous }),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('rejects provider fields that are not GraphQL Names', async () => {
const { root, repo } = await makeRepo({
'src/invalid.resolver.ts': `
import { Query, Resolver } from '@nestjs/graphql';
@Resolver()
class InvalidResolver {
@Query('bad::field') separator() {}
@Query('line\\nfeed') newline() {}
@Query('right\\u202etoLeft') bidi() {}
@Query('9startsWithDigit') digit() {}
}`,
});
let lookups = 0;
const run: CypherExecutor = async () => {
lookups++;
return [{ uid: 'method:invalid', name: 'invalid', filePath: 'src/invalid.resolver.ts' }];
};
expect(await new GraphqlExtractor().extract(run, root, repo)).toEqual([]);
expect(lookups).toBe(0);
});
it('skips a provider file whose AST exceeds the traversal depth cap', async () => {
const nested = `${'['.repeat(300)}0${']'.repeat(300)}`;
const { root, repo } = await makeRepo({
'src/deep.resolver.ts': `
import { Query, Resolver } from '@nestjs/graphql';
const nested = ${nested};
@Resolver()
class DeepResolver { @Query() health() {} }`,
});
let lookups = 0;
expect(
await new GraphqlExtractor().extract(
async () => {
lookups++;
return [{ uid: 'method:health', name: 'health', filePath: 'src/deep.resolver.ts' }];
},
root,
repo,
),
).toEqual([]);
expect(lookups).toBe(0);
});
it('uses an exact unique Document symbol when no generated hook exists', async () => {
const { root, repo } = await makeRepo({
'src/health.graphql': `query Health { health }`,
'src/generated/graphql.ts': `export const HealthDocument = ${generatedDocument('query', 'Health', ['health'])};`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
HealthDocument: [
{ uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' },
],
}),
root,
repo,
);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::health',
role: 'consumer',
symbolUid: 'var:health',
symbolRef: { filePath: 'src/generated/graphql.ts', name: 'HealthDocument' },
}),
]);
});
it('skips matching tokens that occur only in unrelated initializer metadata', async () => {
const { root, repo } = await makeRepo({
'src/health.graphql': `query Health { health }`,
'src/generated/graphql.ts': `export const HealthDocument = {
metadata: { operation: 'Health', field: 'health' },
kind: 'NotADocument'
};`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
HealthDocument: [
{ uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' },
],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('fails closed when a generated Document initializer exceeds the AST depth budget', async () => {
const { root, repo } = await makeRepo({
'src/deep.graphql': `query Deep { health }`,
'src/generated.ts': `export const DeepDocument = ${'('.repeat(300)}${generatedDocument(
'query',
'Deep',
['health'],
)}${')'.repeat(300)};`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
DeepDocument: [{ uid: 'const:deep', name: 'DeepDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('fails closed for oversized, deeply nested, and excessive-operation documents', async () => {
const nested = `${'... on Query { '.repeat(65)}health${' }'.repeat(65)}`;
const manyOperations = Array.from(
{ length: 501 },
(_, index) => `query Op${index} { field${index} }`,
).join('\n');
const { root, repo } = await makeRepo({
'src/oversized.graphql': `${' '.repeat(1_000_001)}query Huge { huge }`,
'src/deep.graphql': `query Deep { ${nested} }`,
'src/many.graphql': manyOperations,
});
let lookups = 0;
const run: CypherExecutor = async (_query, params = {}) => {
lookups++;
const name = String(params.name ?? '');
return name.startsWith('useOp')
? [{ uid: `fn:${name}`, name, filePath: 'src/generated.ts' }]
: name === 'useDeepQuery'
? [{ uid: 'fn:deep', name, filePath: 'src/generated.ts' }]
: [];
};
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(contracts).toEqual([]);
expect(contracts).not.toContainEqual(expect.objectContaining({ symbolUid: 'fn:deep' }));
expect(lookups).toBe(0);
});
it('keeps decorators across comments and supports decorated resolver properties', async () => {
const { root, repo } = await makeRepo({
'src/commented.resolver.ts': `
import { Query, Mutation, Resolver } from '@nestjs/graphql';
@Resolver()
export class CommentedResolver {
@Query()
/** Public schema description. */
health() { return true; }
@Mutation()
// The comment is not an ownership boundary.
save = async () => true;
}`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
'health@src/commented.resolver.ts': [
{ uid: 'method:health', name: 'health', filePath: 'src/commented.resolver.ts' },
],
'save@src/commented.resolver.ts': [
{ uid: 'property:save', name: 'save', filePath: 'src/commented.resolver.ts' },
],
}),
root,
repo,
);
expect(contracts.map((contract) => contract.contractId)).toEqual([
'graphql::query::health',
'graphql::mutation::save',
]);
});
it('requires a top-level imported Resolver and skips dynamic field names', async () => {
const { root, repo } = await makeRepo({
'src/scoped.resolver.ts': `
import { Query, Resolver } from '@nestjs/graphql';
const FIELD = 'viewer';
const NAMES = { viewer: FIELD };
const name = FIELD;
const opts = { name: FIELD };
class Helper { @Query() helperOnly() {} }
function factory() {
@Resolver()
class NestedResolver { @Query() nestedOnly() {} }
return NestedResolver;
}
@Resolver()
class RealResolver {
@Query(() => String, { name: FIELD }) dynamicName() {}
@Query(() => String, { name: NAMES.viewer }) memberName() {}
@Query(() => String, { name }) shorthandName() {}
@Query(() => String, { ...opts }) spreadOptions() {}
@Query(() => String, { name: \`get\${FIELD}\` }) interpolatedName() {}
@Query(() => String, { name: 'get' + 'Widget' }) concatenatedName() {}
@Query(() => String) stableName() {}
}`,
});
const lookedUp: string[] = [];
const run: CypherExecutor = async (_query, params = {}) => {
lookedUp.push(String(params.name));
return [
{
uid: `method:${String(params.name)}`,
name: String(params.name),
filePath: 'src/scoped.resolver.ts',
},
];
};
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(lookedUp).toEqual(['stableName']);
expect(contracts).toEqual([
expect.objectContaining({ contractId: 'graphql::query::stableName' }),
]);
});
it('does not extract co-located spec resolvers', async () => {
const { root, repo } = await makeRepo({
'src/widget.resolver.spec.ts': `
import { Query, Resolver } from '@nestjs/graphql';
@Resolver()
class MockResolver { @Query() widget() {} }`,
});
let lookups = 0;
const contracts = await new GraphqlExtractor().extract(
async () => {
lookups++;
return [{ uid: 'method:mock', name: 'widget', filePath: 'src/widget.resolver.spec.ts' }];
},
root,
repo,
);
expect(contracts).toEqual([]);
expect(lookups).toBe(0);
});
it('indexes a generated declaration after more than 100000 earlier AST nodes', () => {
const filler = { type: 'identifier', namedChildren: [] } as unknown as Parser.SyntaxNode;
const name = { text: 'LateDocument' } as Parser.SyntaxNode;
const value = { type: 'object', namedChildren: [] } as unknown as Parser.SyntaxNode;
const declaration = {
type: 'variable_declarator',
namedChildren: [],
childForFieldName: (field: string) =>
field === 'name' ? name : field === 'value' ? value : null,
} as unknown as Parser.SyntaxNode;
const root = {
type: 'program',
namedChildren: [...Array<Parser.SyntaxNode>(100_001).fill(filler), declaration],
} as unknown as Parser.SyntaxNode;
expect(indexGeneratedDeclarators(root).get('LateDocument')).toEqual([value]);
});
it('proves generated root fields through fragment spreads and inline fragments', async () => {
const { root, repo } = await makeRepo({
'src/widgets.graphql': `
query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } }
fragment MoreRoots on Query { gadget }
`,
'src/generated.ts': `
export const GetWidgetsDocument = ${JSON.stringify(
parse(`
query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } }
fragment MoreRoots on Query { gadget }
`),
)};`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
GetWidgetsDocument: [
{ uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts.map((contract) => contract.contractId).sort()).toEqual([
'graphql::query::gadget',
'graphql::query::inlineRoot',
'graphql::query::widget',
]);
});
it('accepts static gql tags and TypedDocumentString initializers', async () => {
const { root, repo } = await makeRepo({
'src/tagged.graphql': `query Tagged { tagged }`,
'src/string.graphql': `query StringMode { stringMode }`,
'src/generated.ts': `
export const TaggedDocument = gql\`query Tagged { tagged }\`;
export const StringModeDocument = new TypedDocumentString("query StringMode {\\n stringMode\\n}");
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
TaggedDocument: [
{ uid: 'const:tagged', name: 'TaggedDocument', filePath: 'src/generated.ts' },
],
StringModeDocument: [
{ uid: 'const:string', name: 'StringModeDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([
'const:string',
'const:tagged',
]);
});
it('parses one generated module once and continues past a shadowed candidate', async () => {
const { root, repo } = await makeRepo({
'src/one.graphql': `query One { one }`,
'src/two.graphql': `query Two { two }`,
'src/generated.ts': `
function shadow() { const OneDocument = { kind: 'NotADocument' }; }
export const OneDocument = ${generatedDocument('query', 'One', ['one'])};
export const TwoDocument = ${generatedDocument('query', 'Two', ['two'])};
`,
});
const parseSpy = vi.spyOn(Parser.prototype, 'parse');
const contracts = await new GraphqlExtractor().extract(
executor({
OneDocument: [{ uid: 'const:one', name: 'OneDocument', filePath: 'src/generated.ts' }],
TwoDocument: [{ uid: 'const:two', name: 'TwoDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([
'const:one',
'const:two',
]);
expect(parseSpy).toHaveBeenCalledTimes(1);
});
it('binds NestJS providers at the 0-based graph startLine (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/health.resolver.ts': `import { Query, Mutation, Resolver } from '@nestjs/graphql';
@Resolver()
class HealthResolver {
@Query()
health() { return 'ok'; }
@Mutation()
save = async () => true;
}`,
});
const graphStartLine: Record<string, number> = { health: 4, save: 6 };
const startLines: Array<{ name: string; startLine: number }> = [];
const run: CypherExecutor = async (_query, params = {}) => {
const name = String(params.name);
const startLine = Number(params.startLine);
startLines.push({ name, startLine });
if (graphStartLine[name] !== startLine) return [];
return [{ uid: `sym:${name}`, name, filePath: 'src/health.resolver.ts' }];
};
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(startLines).toEqual([
{ name: 'health', startLine: 4 },
{ name: 'save', startLine: 6 },
]);
expect(contracts.map((contract) => contract.contractId)).toEqual([
'graphql::query::health',
'graphql::mutation::save',
]);
});
it('tries the PascalCased Document name graphql-codegen emits (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/timeline.graphql': `query widgetTimeline { getWidget }`,
'src/generated.ts': `export const WidgetTimelineDocument = ${generatedDocument(
'query',
'widgetTimeline',
['getWidget'],
)};`,
});
const lookedUp: string[] = [];
const run: CypherExecutor = async (_query, params = {}) => {
const name = String(params.name ?? '');
lookedUp.push(name);
return name === 'WidgetTimelineDocument'
? [
{
uid: 'const:timeline',
name: 'WidgetTimelineDocument',
filePath: 'src/generated.ts',
},
]
: [];
};
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(lookedUp).toEqual(['widgetTimelineDocument', 'WidgetTimelineDocument']);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::getWidget',
role: 'consumer',
symbolUid: 'const:timeline',
}),
]);
});
it('tries the full PascalCased Document name for underscored operations (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/widget.graphql': `query get_widget { getWidget }`,
'src/generated.ts': `export const GetWidgetDocument = ${generatedDocument(
'query',
'get_widget',
['getWidget'],
)};`,
});
const lookedUp: string[] = [];
const run: CypherExecutor = async (_query, params = {}) => {
const name = String(params.name ?? '');
lookedUp.push(name);
return name === 'GetWidgetDocument'
? [
{
uid: 'const:widget',
name: 'GetWidgetDocument',
filePath: 'src/generated.ts',
},
]
: [];
};
const contracts = await new GraphqlExtractor().extract(run, root, repo);
expect(lookedUp).toEqual(['get_widgetDocument', 'GetWidgetDocument']);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::getWidget',
role: 'consumer',
symbolUid: 'const:widget',
}),
]);
});
it('inlines sibling ${FragmentDoc} interpolations to prove generated documents (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/get-widget.graphql': `
fragment widget on Widget { id }
query GetWidget { getWidget { ...widget } }
`,
'src/root-spread.graphql': `
fragment MoreRoots on Query { gadget }
query GetWidgets { ...MoreRoots }
`,
'src/generated.ts': `
export const WidgetFragmentDoc = /*#__PURE__*/ \`
fragment widget on Widget { id }
\`;
export const GetWidgetDocument = /*#__PURE__*/ \`
query GetWidget {
getWidget {
...widget
}
}
\${WidgetFragmentDoc}\`;
export const MoreRootsFragmentDoc = /*#__PURE__*/ \`
fragment MoreRoots on Query { gadget }
\`;
export const GetWidgetsDocument = gql\`
query GetWidgets { ...MoreRoots }
\${MoreRootsFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
GetWidgetDocument: [
{ uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' },
],
GetWidgetsDocument: [
{ uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts.map((contract) => [contract.contractId, contract.symbolUid]).sort()).toEqual([
['graphql::query::gadget', 'const:widgets'],
['graphql::query::getWidget', 'const:widget'],
]);
});
it('fails closed for dynamic or cyclic generated interpolations (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/dynamic.graphql': `query Dynamic { dynamic }`,
'src/cycle.graphql': `query Cycle { cycle }`,
'src/generated.ts': `
export const DynamicDocument = \`query Dynamic { dynamic }\${foo.bar}\`;
export const CycleFragmentDoc = \`\${CycleDocument}\`;
export const CycleDocument = \`query Cycle { cycle }\${CycleFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
DynamicDocument: [
{ uid: 'const:dynamic', name: 'DynamicDocument', filePath: 'src/generated.ts' },
],
CycleDocument: [
{ uid: 'const:cycle', name: 'CycleDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('fails closed when sibling ${FragmentDoc} names resolve to two static sources (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/get-widget.graphql': `query GetWidget { getWidget }`,
'src/generated.ts': `
{
const WidgetFragmentDoc = /*#__PURE__*/ \`
fragment widget on Widget { id }
\`;
}
export const WidgetFragmentDoc = /*#__PURE__*/ \`
fragment widget on Widget { name }
\`;
export const GetWidgetDocument = /*#__PURE__*/ \`
query GetWidget { getWidget { ...widget } }
\${WidgetFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
GetWidgetDocument: [
{ uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('fails closed when a FragmentDoc name has a static and a dynamic declarator (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/get-widget.graphql': `query GetWidget { getWidget }`,
'src/generated.ts': `
{
const WidgetFragmentDoc = \`\${foo.bar}\`;
}
export const WidgetFragmentDoc = /*#__PURE__*/ \`
fragment widget on Widget { id }
\`;
export const GetWidgetDocument = /*#__PURE__*/ \`
query GetWidget { getWidget { ...widget } }
\${WidgetFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
GetWidgetDocument: [
{ uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' },
],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('treats template line continuations as empty while inlining ${FragmentDoc} (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': `
export const QFragmentDoc = /*#__PURE__*/ \`
fragment extra on Query { q }
\`;
export const QDocument = gql\`query Q { q }\\
\${QFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::q',
role: 'consumer',
symbolUid: 'const:q',
}),
]);
});
it('fails closed for NonOctalDecimalEscapeSequence in interpolated gql templates (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': `
export const QFragmentDoc = /*#__PURE__*/ \`
fragment extra on Query { q }
\`;
export const QDocument = gql\`query Q { q }\\8\${QFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('decodes escape_sequence nodes while inlining ${FragmentDoc} (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': `
export const QFragmentDoc = /*#__PURE__*/ \`
fragment extra on Query { q }
\`;
export const QDocument = gql\`query Q { q }\\n\${QFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::q',
role: 'consumer',
symbolUid: 'const:q',
}),
]);
});
it('fails closed for interpolated templates under a non-gql tag (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': `
export const QFragmentDoc = /*#__PURE__*/ \`
fragment extra on Query { q }
\`;
export const QDocument = String.raw\`query Q { q }\\n\${QFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
it('memoizes interpolation sources so duplicated fragment layers stay linear (#3201)', async () => {
const layers = 16;
let generated = `
export const F0 = \`fragment f0 on Query { q }\`;
{ const F0 = \`fragment f0 on Query { q }\`; }
`;
for (let i = 1; i <= layers; i++) {
const prev = `F${i - 1}`;
const cur = `F${i}`;
generated += `
export const ${cur} = \`\${${prev}}\`;
{ const ${cur} = \`\${${prev}}\`; }
`;
}
generated += `
export const QDocument = gql\`query Q { q }\${F${layers}}\`;
`;
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': generated,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([
expect.objectContaining({
contractId: 'graphql::query::q',
role: 'consumer',
symbolUid: 'const:q',
}),
]);
});
it('fails closed for interpolated templates under a member .gql tag (#3201)', async () => {
const { root, repo } = await makeRepo({
'src/q.graphql': `query Q { q }`,
'src/generated.ts': `
export const QFragmentDoc = /*#__PURE__*/ \`
fragment extra on Query { q }
\`;
export const QDocument = formatter.gql\`query Q { q }\\n\${QFragmentDoc}\`;
`,
});
const contracts = await new GraphqlExtractor().extract(
executor({
QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }],
}),
root,
repo,
);
expect(contracts).toEqual([]);
});
});