mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* 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>
906 lines
29 KiB
TypeScript
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([]);
|
|
});
|
|
});
|