UNPKG

graphql-language-service-server

Version:
704 lines (643 loc) 23.3 kB
import { readFile, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import mockfs from 'mock-fs'; import { MockFile, MockProject } from './__utils__/MockProject'; import { FileChangeType } from 'vscode-languageserver'; import { serializeRange } from './__utils__/utils'; import { URI } from 'vscode-uri'; import { GraphQLSchema, buildASTSchema, introspectionFromSchema, parse, version, } from 'graphql'; import fetchMock from 'fetch-mock'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); jest.mock('@whatwg-node/fetch', () => { const { AbortController } = require('node-abort-controller'); return { fetch: require('fetch-mock').fetchHandler, AbortController, TextDecoder: global.TextDecoder, }; }); const mockSchema = (schema: GraphQLSchema) => { const introspectionResult = { data: introspectionFromSchema(schema, { descriptions: true, }), }; return fetchMock.mock({ matcher: '*', response: { headers: { 'Content-Type': 'application/json', }, body: introspectionResult, }, }); }; const defaultFiles = [ ['query.graphql', 'query { bar ...B }'], ['fragments.graphql', 'fragment B on Foo { bar }'], ] as MockFile[]; const schemaFile: MockFile = [ 'schema.graphql', 'type Query { foo: Foo, test: Test }\n\ntype Foo { bar: String }\n\ntype Test { test: Foo }', ]; const fooTypePosition = { start: { line: 2, character: 0 }, end: { line: 2, character: 24 }, }; const fooInlineTypePosition = { start: { line: 5, character: 0 }, end: { line: 5, character: 24 }, }; const genSchemaPath = path.join( tmpdir(), 'graphql-language-service', 'test', 'projects', 'default', 'generated-schema.graphql', ); // TODO: // - reorganize into multiple files // - potentially a high level abstraction and/or it.each() for a pathway across configs, file extensions, etc. // this may be cumbersome with offset position assertions but possible // if we can create consistency that doesn't limit variability // - convert each it() into a nested describe() block (or a top level describe() in another file), and sprinkle in it() statements to replace comments // - fix TODO comments where bugs were found that couldn't be resolved quickly (2-4hr time box) describe('MessageProcessor with no config', () => { beforeAll(async () => { await rm(path.join(tmpdir(), 'graphql-language-service'), { recursive: true, force: true, }); }); afterEach(() => { mockfs.restore(); fetchMock.restore(); }); it('fails to initialize with empty config file', async () => { const project = new MockProject({ files: [...defaultFiles, ['graphql.config.json', '']], }); await project.init(); expect(project.lsp._logger.info).toHaveBeenCalledTimes(1); expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); expect(project.lsp._logger.error).toHaveBeenCalledWith( expect.stringMatching( /GraphQL Config file is not available in the provided config directory/, ), ); expect(project.lsp._isInitialized).toEqual(false); expect(project.lsp._isGraphQLConfigMissing).toEqual(true); project.lsp.handleShutdownRequest(); }); it('fails to initialize with no config file present', async () => { const project = new MockProject({ files: [...defaultFiles], }); await project.init(); expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); expect(project.lsp._logger.error).toHaveBeenCalledWith( expect.stringMatching( /GraphQL Config file is not available in the provided config directory/, ), ); expect(project.lsp._isInitialized).toEqual(false); expect(project.lsp._isGraphQLConfigMissing).toEqual(true); project.lsp.handleShutdownRequest(); }); it('initializes when presented with a valid config later', async () => { const project = new MockProject({ files: [...defaultFiles], }); await project.init(); expect(project.lsp._isInitialized).toEqual(false); expect(project.lsp._isGraphQLConfigMissing).toEqual(true); expect(project.lsp._logger.error).toHaveBeenCalledTimes(1); project.changeFile( 'graphql.config.json', '{ "schema": "./schema.graphql" }', ); // TODO: this should work for on watched file changes as well! await project.lsp.handleDidOpenOrSaveNotification({ textDocument: { uri: project.uri('graphql.config.json'), }, }); expect(project.lsp._isInitialized).toEqual(true); expect(project.lsp._isGraphQLConfigMissing).toEqual(false); expect(project.lsp._graphQLCache).toBeDefined(); project.lsp.handleShutdownRequest(); }); }); describe('MessageProcessor with config', () => { afterEach(() => { mockfs.restore(); fetchMock.restore(); }); it('caches files and schema with .graphql file config, and the schema updates with watched file changes', async () => { const project = new MockProject({ files: [ schemaFile, [ 'graphql.config.json', '{ "schema": "./schema.graphql", "documents": "./**.graphql" }', ], ...defaultFiles, ], }); const results = await project.init('query.graphql'); expect(results.diagnostics[0].message).toEqual( 'Cannot query field "bar" on type "Query".', ); expect(results.diagnostics[1].message).toEqual( 'Fragment "B" cannot be spread here as objects of type "Query" can never be of type "Foo".', ); const initSchemaDefRequest = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('schema.graphql') }, position: { character: 19, line: 0 }, }); expect(initSchemaDefRequest.length).toEqual(1); expect(initSchemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); expect(serializeRange(initSchemaDefRequest[0].range)).toEqual( fooTypePosition, ); expect(project.lsp._logger.error).not.toHaveBeenCalled(); expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); // TODO: for some reason the cache result formats the graphql query?? const docCache = project.lsp._textDocumentCache; expect( docCache.get(project.uri('query.graphql'))!.contents[0].query, ).toContain('...B'); const schemaDefinitions = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.graphql') }, position: { character: 16, line: 0 }, }); expect(schemaDefinitions[0].uri).toEqual(project.uri('schema.graphql')); expect(serializeRange(schemaDefinitions[0].range)).toEqual(fooTypePosition); // query definition request of fragment name jumps to the fragment definition const firstQueryDefRequest = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('query.graphql') }, position: { character: 16, line: 0 }, }); expect(firstQueryDefRequest[0].uri).toEqual( project.uri('fragments.graphql'), ); expect(serializeRange(firstQueryDefRequest[0].range)).toEqual({ start: { line: 0, character: 0, }, end: { line: 0, character: 25, }, }); // change the file to make the fragment invalid project.changeFile( 'schema.graphql', // now Foo has a bad field, the fragment should be invalid 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\ntype Foo { bad: Int }', ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('schema.graphql'), type: FileChangeType.Changed }, ], }); const typeCache = project.lsp._graphQLCache._typeDefinitionsCache.get('/tmp/test-default'); expect(typeCache?.get('Test')?.definition.name.value).toEqual('Test'); // test in-file schema defs! important! const schemaDefRequest = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('schema.graphql') }, position: { character: 19, line: 0 }, }); const fooLaterTypePosition = { start: { line: 7, character: 0 }, end: { line: 7, character: 21 }, }; expect(schemaDefRequest.length).toEqual(1); expect(schemaDefRequest[0].uri).toEqual(project.uri('schema.graphql')); expect(serializeRange(schemaDefRequest[0].range)).toEqual( fooLaterTypePosition, ); expect(project.lsp._logger.error).not.toHaveBeenCalled(); // change the file to make the fragment invalid project.changeFile( 'schema.graphql', // now Foo has a bad field, the fragment should be invalid 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }', ); await project.lsp.handleDidChangeNotification({ contentChanges: [ { type: FileChangeType.Changed, text: 'type Query { foo: Foo, test: Test }\n\n type Test { test: String }\n\n\n\n\n\ntype Foo { bad: Int }', }, ], textDocument: { uri: project.uri('schema.graphql'), version: 1 }, }); const schemaDefRequest2 = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('schema.graphql') }, position: { character: 19, line: 0 }, }); const fooLaterTypePosition2 = { start: { line: 8, character: 0 }, end: { line: 8, character: 21 }, }; expect(schemaDefRequest2.length).toEqual(1); expect(schemaDefRequest2[0].uri).toEqual(project.uri('schema.graphql')); expect(serializeRange(schemaDefRequest2[0].range)).toEqual( fooLaterTypePosition2, ); // TODO: this fragment should now be invalid const result = await project.lsp.handleDidOpenOrSaveNotification({ textDocument: { uri: project.uri('fragments.graphql') }, }); expect(result.diagnostics[0].message).toEqual( 'Cannot query field "bar" on type "Foo". Did you mean "bad"?', ); const generatedFile = existsSync(genSchemaPath); // this generated file should not exist because the schema is local! expect(generatedFile).toEqual(false); // simulating codegen project.changeFile( 'fragments.graphql', 'fragment A on Foo { bad }\n\nfragment B on Test { test }', ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('fragments.graphql'), type: FileChangeType.Changed }, ], }); // TODO: this interface should maybe not be tested here but in unit tests const fragCache = project.lsp._graphQLCache._fragmentDefinitionsCache.get( '/tmp/test-default', ); expect(fragCache?.get('A')?.definition.name.value).toEqual('A'); expect(fragCache?.get('B')?.definition.name.value).toEqual('B'); const queryFieldDefRequest = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.graphql') }, position: { character: 22, line: 0 }, }); expect(queryFieldDefRequest[0].uri).toEqual(project.uri('schema.graphql')); expect(serializeRange(queryFieldDefRequest[0].range)).toEqual({ start: { line: 8, character: 11, }, end: { line: 8, character: 19, }, }); // on the second request, the position has changed const secondQueryDefRequest = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('query.graphql') }, position: { character: 16, line: 0 }, }); expect(secondQueryDefRequest[0].uri).toEqual( project.uri('fragments.graphql'), ); expect(serializeRange(secondQueryDefRequest[0].range)).toEqual({ start: { line: 2, character: 0, }, end: { line: 2, character: 27, }, }); // definitions request for fragments jumps to a different place in schema.graphql now const schemaDefinitionsAgain = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.graphql') }, position: { character: 16, line: 0 }, }); expect(schemaDefinitionsAgain[0].uri).toEqual( project.uri('schema.graphql'), ); expect(serializeRange(schemaDefinitionsAgain[0].range)).toEqual( fooLaterTypePosition2, ); expect(project.lsp._logger.error).not.toHaveBeenCalled(); project.lsp.handleShutdownRequest(); }); it('caches files and schema with a URL config', async () => { const offset = parseInt(version, 10) > 16 ? 25 : 0; mockSchema(require('../../../graphiql/test/schema')); const project = new MockProject({ files: [ ['query.graphql', 'query { test { isTest, ...T } }'], ['fragments.graphql', 'fragment T on Test {\n isTest \n}'], [ 'graphql.config.json', '{ "schema": "http://localhost:3100/graphql", "documents": "./**" }', ], ], }); const initParams = await project.init('query.graphql'); expect(project.lsp._logger.error).not.toHaveBeenCalled(); expect(initParams.diagnostics).toEqual([]); const changeParams = await project.lsp.handleDidChangeNotification({ textDocument: { uri: project.uri('query.graphql'), version: 1 }, contentChanges: [{ text: 'query { test { isTest, ...T or } }' }], }); expect(changeParams?.diagnostics[0].message).toEqual( 'Cannot query field "or" on type "Test".', ); expect(await project.lsp._graphQLCache.getSchema('default')).toBeDefined(); // schema file is present and contains schema const file = await readFile(genSchemaPath, 'utf8'); expect(file.split('\n').length).toBeGreaterThan(10); // hover works const hover = await project.lsp.handleHoverRequest({ position: { character: 10, line: 0, }, textDocument: { uri: project.uri('query.graphql') }, }); expect(hover.contents).toContain('`test` field from `Test` type.'); // ensure that fragment definitions work const definitions = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('query.graphql') }, // console.log(project.uri('query.graphql')) position: { character: 26, line: 0 }, }); expect(definitions[0].uri).toEqual(project.uri('fragments.graphql')); expect(serializeRange(definitions[0].range)).toEqual({ start: { line: 0, character: 0, }, end: { line: 2, character: 1, }, }); const typeDefinitions = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.graphql') }, position: { character: 15, line: 0 }, }); expect(typeDefinitions[0].uri).toEqual(URI.parse(genSchemaPath).toString()); expect(serializeRange(typeDefinitions[0].range)).toEqual({ start: { line: 11 + offset, character: 0, }, end: { line: 102 + offset, character: 1, }, }); const schemaDefs = await project.lsp.handleDefinitionRequest({ textDocument: { uri: URI.parse(genSchemaPath).toString() }, position: { character: 20, line: 18 + offset }, }); expect(schemaDefs[0].uri).toEqual(URI.parse(genSchemaPath).toString()); // note: if the graphiql test schema changes, // this might break, please adjust if you see a failure here expect(serializeRange(schemaDefs[0].range)).toEqual({ start: { line: 104 + offset, character: 0, }, end: { line: 112 + offset, character: 1, }, }); // lets remove the fragments file await project.deleteFile('fragments.graphql'); // and add a fragments.ts file, watched await project.addFile( 'fragments.ts', '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest } \n query { hasArgs(string: "") }\n`', true, ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('fragments.ts'), type: FileChangeType.Created }, ], }); const defsForTs = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('query.graphql') }, position: { character: 26, line: 0 }, }); // this one is really important expect(defsForTs[0].uri).toEqual(project.uri('fragments.ts')); expect(serializeRange(defsForTs[0].range)).toEqual({ start: { line: 5, character: 2, }, end: { line: 5, character: 31, }, }); const defsForArgs = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('fragments.ts') }, position: { character: 19, line: 6 }, }); expect(defsForArgs[0].uri).toEqual(URI.parse(genSchemaPath).toString()); expect(project.lsp._logger.error).not.toHaveBeenCalled(); project.lsp.handleShutdownRequest(); }); it('caches multiple projects with files and schema with a URL config and a local schema', async () => { mockSchema(require('../../../graphiql/test/schema')); const project = new MockProject({ files: [ [ 'a/fragments.ts', '\n\n\nexport const fragment = gql`\n\n fragment TestFragment on Test { isTest }\n`', ], [ 'a/query.ts', '\n\n\nexport const query = graphql`query { test { isTest ...T } }`', ], [ 'b/query.ts', 'import graphql from "graphql"\n\n\nconst a = graphql` query example { test() { isTest ...T } }`', ], [ 'b/fragments.ts', '\n\n\nexport const fragment = gql`\n\n fragment T on Test { isTest }\n`', ], [ 'b/schema.ts', `\n\nexport const schema = gql(\`\n${schemaFile[1]}\`)`, ], [ 'package.json', `{ "graphql": { "projects": { "a": { "schema": "http://localhost:3100/graphql", "documents": "./a/**" }, "b": { "schema": "./b/schema.ts", "documents": "./b/**" } } } }`, ], schemaFile, ], settings: { schemaCacheTTL: 500 }, }); const initParams = await project.init('a/query.ts'); expect(initParams.diagnostics[0].message).toEqual('Unknown fragment "T".'); expect(project.lsp._logger.error).not.toHaveBeenCalled(); expect(await project.lsp._graphQLCache.getSchema('a')).toBeDefined(); expect(project.lsp._logger.info).not.toHaveBeenCalledWith( expect.stringMatching(/SyntaxError: Unexpected token/), ); fetchMock.restore(); mockSchema( buildASTSchema( parse( 'type example100 { string: String } type Query { example: example100 }', ), ), ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('a/fragments.ts'), type: FileChangeType.Changed }, ], }); await sleep(1000); expect( (await project.lsp._graphQLCache.getSchema('a')).getType('example100'), ).toBeTruthy(); await sleep(1000); const file = await readFile(genSchemaPath.replace('default', 'a'), 'utf8'); expect(file).toContain('example100'); // add a new typescript file with empty query to the b project // and expect autocomplete to only show options for project b await project.addFile( 'b/empty.ts', 'import gql from "graphql-tag"\ngql`query a { }`', ); const completion = await project.lsp.handleCompletionRequest({ textDocument: { uri: project.uri('b/empty.ts') }, position: { character: 13, line: 1 }, }); expect(completion.items?.length).toEqual(5); expect(completion.items.map(i => i.label)).toEqual([ 'foo', 'test', '__typename', '__schema', '__type', ]); // this confirms that autocomplete respects cross-project boundaries for types. // it performs a definition request for the foo field in Query const schemaCompletion1 = await project.lsp.handleCompletionRequest({ textDocument: { uri: project.uri('b/schema.ts') }, position: { character: 21, line: 3 }, }); expect(schemaCompletion1.items.map(i => i.label)).toEqual(['Foo']); // it performs a definition request for the Foo type in Test.test const schemaDefinition = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('b/schema.ts') }, position: { character: 21, line: 6 }, }); expect(serializeRange(schemaDefinition[0].range)).toEqual( fooInlineTypePosition, ); expect(project.lsp._logger.error).not.toHaveBeenCalled(); // simulate a watched schema file change (codegen, etc) project.changeFile( 'b/schema.ts', `\n\nexport const schema = gql(\`\n${ schemaFile[1] + '\ntype Example1 { field: }' }\`\n)`, ); await project.lsp.handleWatchedFilesChangedNotification({ changes: [ { uri: project.uri('b/schema.ts'), type: FileChangeType.Changed }, ], }); // TODO: repeat this with other changes to the schema file and use a // didChange event to see if the schema updates properly as well // await project.lsp.handleDidChangeNotification({ // textDocument: { uri: project.uri('b/schema.graphql'), version: 1 }, // contentChanges: [ // { text: schemaFile[1] + '\ntype Example1 { field: }' }, // ], // }); // console.log(project.fileCache.get('b/schema.graphql')); const schemaCompletion = await project.lsp.handleCompletionRequest({ textDocument: { uri: project.uri('b/schema.ts') }, position: { character: 25, line: 8 }, }); // TODO: SDL completion still feels incomplete here... where is Int? // where is self-referential Example1? expect(schemaCompletion.items.map(i => i.label)).toEqual([ 'Query', 'Foo', 'String', 'Test', 'Boolean', ]); expect(project.lsp._logger.error).not.toHaveBeenCalled(); project.lsp.handleShutdownRequest(); }); it('correctly handles a fragment inside a TypeScript file', async () => { const project = new MockProject({ files: [ [ 'schema.graphql', ` type Item { foo: String bar: Int } type Query { items: [Item] } `, ], [ 'query.ts', ` import gql from 'graphql-tag' const query = gql\` query { items { ...ItemFragment } } \` `, ], [ 'fragments.ts', ` import gql from 'graphql-tag' export const ItemFragment = gql\` fragment ItemFragment on Item { foo bar } \` `, ], [ 'graphql.config.json', '{ "schema": "./schema.graphql", "documents": "./**.{graphql,ts}" }', ], ], }); const initParams = await project.init('query.ts'); expect(initParams.diagnostics).toEqual([]); const fragmentDefinition = await project.lsp.handleDefinitionRequest({ textDocument: { uri: project.uri('query.ts') }, position: { character: 10, line: 6 }, }); expect(fragmentDefinition[0]?.uri).toEqual(project.uri('fragments.ts')); }); });