From c172f9255a8d8f8bc1441e2c9cc089f1e3340412 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Thu, 3 Mar 2022 21:28:18 +0100 Subject: [PATCH 01/10] Rewrite tests using vscode-jsonrpc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This way of testing doesn’t require to use any delays or timeouts. This also works by sending a response, awaiting a reply, then asserting the reply, instead of sending a number of requests only to assert all responses together later. Also any logging messages sent from the connection.console are logged to the console in tests. All of these changes together make troubleshooting any tests much nicer to deal with. --- package.json | 1 + test/index.js | 334 ++++++++++++++++++++++++-------------------------- 2 files changed, 160 insertions(+), 175 deletions(-) diff --git a/package.json b/package.json index a8c3cd0..9a3bc24 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "type-coverage": "^2.0.0", "typescript": "^4.0.0", "unified": "^10.0.0", + "vscode-jsonrpc": "^6.0.0", "xo": "^0.47.0" }, "scripts": { diff --git a/test/index.js b/test/index.js index 8248927..794718c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,10 +1,18 @@ /** * @typedef {import('node:child_process').ExecException & {stdout: string, stderr: string}} ExecError + * @typedef {import('vscode-jsonrpc').MessageConnection} MessageConnection + * @typedef {import('vscode-languageserver').DidCloseTextDocumentParams} DidCloseTextDocumentParams + * @typedef {import('vscode-languageserver').DidOpenTextDocumentParams} DidOpenTextDocumentParams + * @typedef {import('vscode-languageserver').InitializeParams} InitializeParams + * @typedef {import('vscode-languageserver').InitializeResult} InitializeResult + * @typedef {import('vscode-languageserver').LogMessageParams} LogMessageParams + * @typedef {import('vscode-languageserver').PublishDiagnosticsParams} PublishDiagnosticsParams */ import assert from 'node:assert' import {Buffer} from 'node:buffer' import {promises as fs} from 'node:fs' +import {spawn} from 'node:child_process' import process from 'node:process' import {PassThrough} from 'node:stream' import {URL, fileURLToPath} from 'node:url' @@ -13,6 +21,11 @@ import {execa} from 'execa' import test from 'tape' import * as exports from 'unified-language-server' +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter +} from 'vscode-jsonrpc/node.js' const sleep = promisify(setTimeout) @@ -26,191 +39,89 @@ test('exports', (t) => { }) test('`initialize`', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['remark.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + const connection = startLanguageServer(t, 'remark.js', '.') + const initializeResponse = await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + t.deepEqual( + initializeResponse, + { + capabilities: { + textDocumentSync: 1, + documentFormattingProvider: true, + codeActionProvider: { + codeActionKinds: ['quickfix'], + resolveProvider: true + } } - }) + }, + 'should emit an introduction on `initialize`' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 1, 'should emit messages') - const parameters = messages[0].result - - t.deepEqual( - parameters, - { - capabilities: { - textDocumentSync: 1, - documentFormattingProvider: true, - codeActionProvider: { - codeActionKinds: ['quickfix'], - resolveProvider: true - } - } - }, - 'should emit an introduction on `initialize`' - ) - } - - t.end() }) test('`initialize` workspace capabilities', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['remark.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + const connection = startLanguageServer(t, 'remark.js', '.') + + const initializeResponse = await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {workspace: {workspaceFolders: true}}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {workspace: {workspaceFolders: true}}, - workspaceFolders: null + t.deepEqual( + initializeResponse, + { + capabilities: { + textDocumentSync: 1, + documentFormattingProvider: true, + codeActionProvider: { + codeActionKinds: ['quickfix'], + resolveProvider: true + }, + workspace: { + workspaceFolders: {supported: true, changeNotifications: true} + } } - }) + }, + 'should emit an introduction on `initialize`' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 1, 'should emit messages') - const parameters = messages[0].result - - t.deepEqual( - parameters, - { - capabilities: { - textDocumentSync: 1, - documentFormattingProvider: true, - codeActionProvider: { - codeActionKinds: ['quickfix'], - resolveProvider: true - }, - workspace: { - workspaceFolders: {supported: true, changeNotifications: true} - } - } - }, - 'should emit an introduction on `initialize`' - ) - } - - t.end() }) test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['remark-with-warnings.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + const connection = startLanguageServer(t, 'remark-with-warnings.js', '.') + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) + const uri = new URL('lsp.md', import.meta.url).href - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + const openDiagnostics = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - } - }) - ) - - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didClose', - /** @type {import('vscode-languageserver').DidCloseTextDocumentParams} */ - params: {textDocument: {uri: new URL('lsp.md', import.meta.url).href}} - }) - ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 3, 'should emit messages') - const open = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - const close = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[2].params - ) - - t.deepEqual( - open.diagnostics, - [ + t.deepEqual( + await openDiagnostics, + { + uri, + version: 1, + diagnostics: [ { range: {start: {line: 0, character: 0}, end: {line: 0, character: 4}}, message: 'info', @@ -255,18 +166,23 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async message: 'note\nThese are some additional notes', severity: 2 } - ], - 'should emit diagnostics on `textDocument/didOpen`' - ) + ] + }, + 'should emit diagnostics on `textDocument/didOpen`' + ) - t.deepEqual( - close.diagnostics, - [], - 'should emit empty diagnostics on `textDocument/didClose`' - ) - } + const closeDiagnostics = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didClose', + /** @type {DidCloseTextDocumentParams} */ + ({textDocument: {uri, version: 1}}) + ) - t.end() + t.deepEqual( + await closeDiagnostics, + {uri, version: 1, diagnostics: []}, + 'should emit empty diagnostics on `textDocument/didClose`' + ) }) test('uninstalled processor so `window/showMessageRequest`', async (t) => { @@ -1317,3 +1233,71 @@ function cleanStack(stack, max) { .slice(0, max) .join('\n') } + +/** + * Start a language server. + * + * It will be cleaned up automatically. + * + * Any `window/logMessage` events emitted by the language server will be logged + * to the console. + * + * @param {test.Test} t The test context to use for cleanup. + * @param {string} serverFilePath The path to the language server relative to + * this test file. + * @param {string} cwd The cwd to use for the process relative to this test + * file. + * @returns a jsonrpc connection. + */ +function startLanguageServer(t, serverFilePath, cwd) { + const proc = spawn('node', [serverFilePath, '--stdio'], { + cwd: new URL(cwd, import.meta.url) + }) + const connection = createMessageConnection( + new StreamMessageReader(proc.stdout), + new StreamMessageWriter(proc.stdin) + ) + t.teardown(() => { + connection.end() + }) + connection.onNotification( + 'window/logMessage', + /** + * @param {LogMessageParams} message + */ + ({message}) => { + console.dir(message) + } + ) + connection.listen() + return connection +} + +/** + * Initialize a language server in a type-safe manner. + * + * @param {MessageConnection} connection + * @param {InitializeParams} parameters + * @returns {Promise} + */ +async function initialize(connection, parameters) { + return connection.sendRequest('initialize', parameters) +} + +/** + * Wait for a diagnostic to be omitted. + * + * @param {MessageConnection} connection + * @returns {Promise} + */ +async function createDiagnosticsPromise(connection) { + return new Promise((resolve) => { + const disposable = connection.onNotification( + 'textDocument/publishDiagnostics', + (result) => { + disposable.dispose() + resolve(result) + } + ) + }) +} From 0ddd4343c65254e8bf5279b2f59fc3576b99716e Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 9 Mar 2022 10:58:13 +0100 Subject: [PATCH 02/10] Rewrite more tests One more test is remaining. It got stuck after rewriting. --- test/index.js | 1245 ++++++++++++++++++------------------------------- 1 file changed, 458 insertions(+), 787 deletions(-) diff --git a/test/index.js b/test/index.js index 794718c..eb0e9cc 100644 --- a/test/index.js +++ b/test/index.js @@ -1,12 +1,17 @@ /** * @typedef {import('node:child_process').ExecException & {stdout: string, stderr: string}} ExecError * @typedef {import('vscode-jsonrpc').MessageConnection} MessageConnection + * @typedef {import('vscode-languageserver').CodeActionParams} CodeActionParams + * @typedef {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} DidChangeWorkspaceFoldersParams * @typedef {import('vscode-languageserver').DidCloseTextDocumentParams} DidCloseTextDocumentParams * @typedef {import('vscode-languageserver').DidOpenTextDocumentParams} DidOpenTextDocumentParams + * @typedef {import('vscode-languageserver').DocumentFormattingParams} DocumentFormattingParams * @typedef {import('vscode-languageserver').InitializeParams} InitializeParams * @typedef {import('vscode-languageserver').InitializeResult} InitializeResult + * @typedef {import('vscode-languageserver').InitializedParams} InitializedParams * @typedef {import('vscode-languageserver').LogMessageParams} LogMessageParams * @typedef {import('vscode-languageserver').PublishDiagnosticsParams} PublishDiagnosticsParams + * @typedef {import('vscode-languageserver').ShowMessageRequestParams} ShowMessageRequestParams */ import assert from 'node:assert' @@ -102,7 +107,7 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async }) const uri = new URL('lsp.md', import.meta.url).href - const openDiagnostics = createDiagnosticsPromise(connection) + const openDiagnosticsPromise = createDiagnosticsPromise(connection) connection.sendNotification( 'textDocument/didOpen', /** @type {DidOpenTextDocumentParams} */ @@ -115,9 +120,10 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async } }) ) + const openDiagnostics = await openDiagnosticsPromise t.deepEqual( - await openDiagnostics, + openDiagnostics, { uri, version: 1, @@ -171,922 +177,544 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async 'should emit diagnostics on `textDocument/didOpen`' ) - const closeDiagnostics = createDiagnosticsPromise(connection) + const closeDiagnosticsPromise = createDiagnosticsPromise(connection) connection.sendNotification( 'textDocument/didClose', /** @type {DidCloseTextDocumentParams} */ ({textDocument: {uri, version: 1}}) ) + const closeDiagnostics = await closeDiagnosticsPromise t.deepEqual( - await closeDiagnostics, + closeDiagnostics, {uri, version: 1, diagnostics: []}, 'should emit empty diagnostics on `textDocument/didClose`' ) }) test('uninstalled processor so `window/showMessageRequest`', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['missing-package.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + const connection = startLanguageServer(t, 'missing-package.js', '.') + + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + const messageRequestPromise = createMessageRequestPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const messageRequest = await messageRequestPromise - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - } - }) + t.deepEqual( + messageRequest, + { + type: 3, + message: + 'Cannot turn on language server without `xxx-missing-yyy` locally. Run `npm install xxx-missing-yyy` to enable it', + actions: [] + }, + 'should emit a `window/showMessageRequest` when the processor can’t be found locally' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = messages[1].params - - t.deepEqual( - parameters, - { - type: 3, - message: - 'Cannot turn on language server without `xxx-missing-yyy` locally. Run `npm install xxx-missing-yyy` to enable it', - actions: [] - }, - 'should emit a `window/showMessageRequest` when the processor can’t be found locally' - ) - } - - t.end() }) test('uninstalled processor w/ `defaultProcessor`', async (t) => { - const stdin = new PassThrough() - const promise = execa( - 'node', - ['missing-package-with-default.js', '--stdio'], - { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout - } + const connection = startLanguageServer( + t, + 'missing-package-with-default.js', + '.' ) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null - } - }) - ) - - await sleep(delay) + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + }) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + const logPromise = createLogPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const log = await logPromise - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 3, 'should emit messages') - - const parameters = - /** @type {import('vscode-languageserver').LogMessageParams} */ ( - messages[1].params - ) - - t.deepEqual( - cleanStack(parameters.message, 2).replace( - /(imported from )[^\r\n]+/, - '$1zzz' - ), - "Cannot find `xxx-missing-yyy` locally but using `defaultProcessor`, original error:\nError [ERR_MODULE_NOT_FOUND]: Cannot find package 'xxx-missing-yyy' imported from zzz", - 'should work w/ `defaultProcessor`' - ) - } - - t.end() + t.deepEqual( + cleanStack(log.message, 2).replace(/(imported from )[^\r\n]+/, '$1zzz'), + "Cannot find `xxx-missing-yyy` locally but using `defaultProcessor`, original error:\nError [ERR_MODULE_NOT_FOUND]: Cannot find package 'xxx-missing-yyy' imported from zzz", + 'should work w/ `defaultProcessor`' + ) }) test('`textDocument/formatting`', async (t) => { - const stdin = new PassThrough() + const connection = startLanguageServer(t, 'remark.js', '.') - const promise = execa('node', ['remark.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('bad.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: ' # hi \n' } }) ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('bad.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: ' # hi \n' - } + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('good.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi\n' } }) ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('good.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi\n' - } - } + const resultBad = await connection.sendRequest( + 'textDocument/formatting', + /** @type {DocumentFormattingParams} */ + ({ + textDocument: {uri: new URL('bad.md', import.meta.url).href}, + options: {tabSize: 2, insertSpaces: true} }) ) - - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/formatting', - id: 1, - /** @type {import('vscode-languageserver').DocumentFormattingParams} */ - params: { - textDocument: {uri: new URL('bad.md', import.meta.url).href}, - options: {tabSize: 2, insertSpaces: true} + t.deepEqual( + resultBad, + [ + { + range: {start: {line: 0, character: 0}, end: {line: 1, character: 0}}, + newText: '# hi\n' } - }) + ], + 'should format bad documents on `textDocument/formatting`' ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/formatting', - id: 2, - /** @type {import('vscode-languageserver').DocumentFormattingParams} */ - params: { - textDocument: {uri: new URL('good.md', import.meta.url).href}, - options: {tabSize: 2, insertSpaces: true} - } + const resultGood = await connection.sendRequest( + 'textDocument/formatting', + /** @type {DocumentFormattingParams} */ + ({ + textDocument: {uri: new URL('good.md', import.meta.url).href}, + options: {tabSize: 2, insertSpaces: true} }) ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 5, 'should emit messages') - // First two are empty diagnostics. - // Third and fourth are the bad/good reformatting. - t.deepEqual( - messages[3].result, - [ - { - range: {start: {line: 0, character: 0}, end: {line: 1, character: 0}}, - newText: '# hi\n' - } - ], - 'should format bad documents on `textDocument/formatting`' - ) - t.deepEqual( - messages[4].result, - null, - 'should format good documents on `textDocument/formatting`' - ) - } - - t.end() + t.deepEqual( + resultGood, + null, + 'should format good documents on `textDocument/formatting`' + ) }) test('`workspace/didChangeWatchedFiles`', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['remark.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout - }) - - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null - } - }) - ) + const connection = startLanguageServer(t, 'remark.js', '.') - await sleep(delay) + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + }) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('a.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('a.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + await openDiagnosticsPromise - await sleep(delay) + const changeWatchDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification('workspace/didChangeWatchedFiles', {changes: []}) + const changeWatchDiagnostics = await changeWatchDiagnosticsPromise - stdin.write( - toMessage({ - method: 'workspace/didChangeWatchedFiles', - /** @type {import('vscode-languageserver').DidChangeWatchedFilesParams} */ - params: { - changes: [ - {uri: new URL('a.md', import.meta.url).href, type: 1}, - {uri: new URL('b.md', import.meta.url).href, type: 2}, - {uri: new URL('c.md', import.meta.url).href, type: 3} - ] - } - }) + t.deepEqual( + changeWatchDiagnostics, + {uri: new URL('a.md', import.meta.url).href, version: 1, diagnostics: []}, + 'should emit diagnostics for registered files on any `workspace/didChangeWatchedFiles`' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 3, 'should emit messages') - t.deepEqual( - messages[1].params, - messages[2].params, - 'should emit diagnostics for registered files on any `workspace/didChangeWatchedFiles`' - ) - } - - t.end() }) test('`initialize`, `textDocument/didOpen` (and a broken plugin)', async (t) => { - const stdin = new PassThrough() - const promise = execa('node', ['remark-with-error.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + const connection = startLanguageServer(t, 'remark-with-error.js', '.') + + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const openDiagnostics = await openDiagnosticsPromise - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + t.deepEqual( + openDiagnostics.diagnostics.map(({message, ...rest}) => ({ + message: cleanStack(message, 3), + ...rest + })), + [ + { + message: + 'Error: Whoops!\n at Function.oneError (one-error.js:1:1)\n at Function.freeze (index.js:1:1)', + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + severity: 1 } - }) + ], + 'should show stack traces on crashes' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - - t.deepEqual( - parameters.diagnostics.map(({message, ...rest}) => ({ - message: cleanStack(message, 3), - ...rest - })), - [ - { - message: - 'Error: Whoops!\n at Function.oneError (one-error.js:1:1)\n at Function.freeze (index.js:1:1)', - range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, - severity: 1 - } - ], - 'should show stack traces on crashes' - ) - } - - t.end() }) test('`textDocument/codeAction` (and diagnostics)', async (t) => { + const connection = startLanguageServer(t, 'remark.js', '.') const uri = new URL('lsp.md', import.meta.url).href - const stdin = new PassThrough() - const promise = execa('node', ['remark.js', '--stdio'], { - cwd: fileURLToPath(new URL('.', import.meta.url)), - input: stdin, - timeout + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null - } - }) - ) - - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri, - languageId: 'markdown', - version: 1, - text: '## hello' - } + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri, + languageId: 'markdown', + version: 1, + text: '## hello' } }) ) + await openDiagnosticsPromise - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/codeAction', - id: 1, - /** @type {import('vscode-languageserver').CodeActionParams} */ - params: { - textDocument: {uri}, - range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, - context: { - diagnostics: [ - // Coverage for warnings w/o `data` (which means a message w/o `expected`). - { - message: 'warning', - severity: 2, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 0} - } - }, - { - message: 'warning', - severity: 2, - data: {}, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 8} - } - }, - // Replacement: - { - message: 'warning', - severity: 2, - data: {expected: ['Hello']}, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 8} - } - }, - // Insertion (start and end in the same place): - { - message: 'warning', - severity: 2, - data: {expected: ['!']}, - range: { - start: {line: 0, character: 8}, - end: {line: 0, character: 8} - } - }, - // Deletion (empty `expected`): - { - message: 'warning', - severity: 2, - data: {expected: ['']}, - range: { - start: {line: 0, character: 1}, - end: {line: 0, character: 2} - } + const codeActions = await connection.sendRequest( + 'textDocument/codeAction', + /** @type {CodeActionParams} */ + ({ + textDocument: {uri}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + context: { + diagnostics: [ + // Coverage for warnings w/o `data` (which means a message w/o `expected`). + { + message: 'warning', + severity: 2, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 0} } - ] - } + }, + { + message: 'warning', + severity: 2, + data: {}, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 8} + } + }, + // Replacement: + { + message: 'warning', + severity: 2, + data: {expected: ['Hello']}, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 8} + } + }, + // Insertion (start and end in the same place): + { + message: 'warning', + severity: 2, + data: {expected: ['!']}, + range: { + start: {line: 0, character: 8}, + end: {line: 0, character: 8} + } + }, + // Deletion (empty `expected`): + { + message: 'warning', + severity: 2, + data: {expected: ['']}, + range: { + start: {line: 0, character: 1}, + end: {line: 0, character: 2} + } + } + ] } }) ) - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - - t.deepEqual( - messages, - [ - { - jsonrpc: '2.0', - id: 0, - result: { - capabilities: { - textDocumentSync: 1, - documentFormattingProvider: true, - codeActionProvider: { - codeActionKinds: ['quickfix'], - resolveProvider: true + t.deepEqual( + codeActions, + [ + { + title: 'Replace `hello` with `Hello`', + edit: { + changes: { + [uri]: [ + { + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 8} + }, + newText: 'Hello' } - } + ] } }, - { - jsonrpc: '2.0', - method: 'textDocument/publishDiagnostics', - params: {uri, version: 1, diagnostics: []} + kind: 'quickfix' + }, + { + title: 'Insert `!`', + edit: { + changes: { + [uri]: [ + { + range: { + start: {line: 0, character: 8}, + end: {line: 0, character: 8} + }, + newText: '!' + } + ] + } }, - { - jsonrpc: '2.0', - id: 1, - result: [ - { - title: 'Replace `hello` with `Hello`', - edit: { - changes: { - [uri]: [ - { - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 8} - }, - newText: 'Hello' - } - ] - } - }, - kind: 'quickfix' - }, - { - title: 'Insert `!`', - edit: { - changes: { - [uri]: [ - { - range: { - start: {line: 0, character: 8}, - end: {line: 0, character: 8} - }, - newText: '!' - } - ] - } - }, - kind: 'quickfix' - }, - { - title: 'Remove `#`', - edit: { - changes: { - [uri]: [ - { - range: { - start: {line: 0, character: 1}, - end: {line: 0, character: 2} - }, - newText: '' - } - ] - } - }, - kind: 'quickfix' - } - ] - } - ], - 'should emit quick fixes on a `textDocument/codeAction`' - ) - } - - t.end() -}) - -test('`initialize` w/ nothing (finds closest `package.json`)', async (t) => { - const stdin = new PassThrough() - const cwd = new URL('..', import.meta.url) - const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], { - cwd: fileURLToPath(cwd), - input: stdin, - timeout - }) - - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + kind: 'quickfix' + }, + { + title: 'Remove `#`', + edit: { + changes: { + [uri]: [ + { + range: { + start: {line: 0, character: 1}, + end: {line: 0, character: 2} + }, + newText: '' + } + ] + } + }, + kind: 'quickfix' } - }) + ], + 'should emit quick fixes on a `textDocument/codeAction`' ) +}) - await sleep(delay) +test('`initialize` w/ nothing (finds closest `package.json`)', async (t) => { + const cwd = new URL('..', import.meta.url) + const connection = startLanguageServer( + t, + 'remark-with-cwd.js', + fileURLToPath(cwd) + ) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL( - 'folder-with-package-json/folder/file.md', - import.meta.url - ).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + }) + + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('folder-with-package-json/folder/file.md', import.meta.url) + .href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const openDiagnostics = await openDiagnosticsPromise - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - const info = parameters.diagnostics[0] - t.ok(info, 'should emit the cwd') - t.deepEqual( - info.message, - fileURLToPath(new URL('folder-with-package-json', import.meta.url).href), - 'should default to a `cwd` of the parent folder of the closest `package.json`' - ) - } - - t.end() + t.deepEqual( + openDiagnostics.diagnostics[0].message, + fileURLToPath(new URL('folder-with-package-json', import.meta.url).href), + 'should default to a `cwd` of the parent folder of the closest `package.json`' + ) }) test('`initialize` w/ nothing (find closest `.git`)', async (t) => { - const stdin = new PassThrough() const cwd = new URL('..', import.meta.url) + const connection = startLanguageServer( + t, + 'remark-with-cwd.js', + fileURLToPath(cwd) + ) await fs.mkdir(new URL('folder-with-git/.git', import.meta.url), { recursive: true }) - const promise = execa('node', ['./test/remark-with-cwd.js', '--stdio'], { - cwd: fileURLToPath(cwd), - input: stdin, - timeout + + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('folder-with-git/folder/file.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const openDiagnostics = await openDiagnosticsPromise - await sleep(delay) - - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('folder-with-git/folder/file.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - } - }) + t.deepEqual( + openDiagnostics.diagnostics[0].message, + fileURLToPath(new URL('folder-with-git', import.meta.url).href), + 'should default to a `cwd` of the parent folder of the closest `.git`' ) - - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - const info = parameters.diagnostics[0] - t.ok(info, 'should emit the cwd') - t.deepEqual( - info.message, - fileURLToPath(new URL('folder-with-git', import.meta.url).href), - 'should default to a `cwd` of the parent folder of the closest `.git`' - ) - } - - t.end() }) test('`initialize` w/ `rootUri`', async (t) => { - const stdin = new PassThrough() - const cwd = new URL('./folder/', import.meta.url) + const cwd = new URL('folder/', import.meta.url) const processCwd = new URL('..', cwd) - const promise = execa('node', ['folder/remark-with-cwd.js', '--stdio'], { - cwd: fileURLToPath(processCwd), - input: stdin, - timeout - }) - - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: cwd.href, - capabilities: {}, - workspaceFolders: [] - } - }) + const connection = startLanguageServer( + t, + 'remark-with-cwd.js', + fileURLToPath(processCwd) ) - await sleep(delay) + await initialize(connection, { + processId: null, + rootUri: cwd.href, + capabilities: {}, + workspaceFolders: [] + }) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', cwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', cwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const openDiagnostics = await openDiagnosticsPromise - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - const info = parameters.diagnostics[0] - t.ok(info, 'should emit the cwd') - t.deepEqual( - info.message, - fileURLToPath(cwd).slice(0, -1), - 'should use `rootUri`' - ) - } - - t.end() + t.deepEqual( + openDiagnostics.diagnostics[0].message, + fileURLToPath(cwd).slice(0, -1), + 'should use `rootUri`' + ) }) test('`initialize` w/ `workspaceFolders`', async (t) => { - const stdin = new PassThrough() const processCwd = new URL('.', import.meta.url) - const promise = execa('node', ['remark-with-cwd.js', '--stdio'], { - cwd: fileURLToPath(processCwd), - input: stdin, - timeout - }) - - const otherCwd = new URL('./folder/', processCwd) - - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: [ - {uri: processCwd.href, name: ''}, // Farthest - {uri: otherCwd.href, name: ''} // Nearest - ] - } - }) + const connection = startLanguageServer( + t, + 'remark-with-cwd.js', + fileURLToPath(processCwd) ) - await sleep(delay) + const otherCwd = new URL('folder/', processCwd) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', otherCwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: [ + {uri: processCwd.href, name: ''}, // Farthest + {uri: otherCwd.href, name: ''} // Nearest + ] + }) + + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) + const openDiagnostics = await openDiagnosticsPromise - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.equal(messages.length, 2, 'should emit messages') - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - messages[1].params - ) - const info = parameters.diagnostics[0] - t.ok(info, 'should emit the cwd') - t.deepEqual( - info.message, - fileURLToPath(otherCwd).slice(0, -1), - 'should use `workspaceFolders`' - ) - } - - t.end() + t.deepEqual( + openDiagnostics.diagnostics[0].message, + fileURLToPath(otherCwd).slice(0, -1), + 'should use `workspaceFolders`' + ) }) test('`workspace/didChangeWorkspaceFolders`', async (t) => { @@ -1250,9 +878,13 @@ function cleanStack(stack, max) { * @returns a jsonrpc connection. */ function startLanguageServer(t, serverFilePath, cwd) { - const proc = spawn('node', [serverFilePath, '--stdio'], { - cwd: new URL(cwd, import.meta.url) - }) + const proc = spawn( + 'node', + [fileURLToPath(new URL(serverFilePath, import.meta.url)), '--stdio'], + { + cwd: new URL(cwd, import.meta.url) + } + ) const connection = createMessageConnection( new StreamMessageReader(proc.stdout), new StreamMessageWriter(proc.stdin) @@ -1284,6 +916,22 @@ async function initialize(connection, parameters) { return connection.sendRequest('initialize', parameters) } +/** + * Wait for an event name to be omitted. + * + * @param {MessageConnection} connection + * @param {string} name + * @returns {Promise} + */ +async function createNotificationPromise(connection, name) { + return new Promise((resolve) => { + const disposable = connection.onNotification(name, (result) => { + disposable.dispose() + setTimeout(() => resolve(result), 0) + }) + }) +} + /** * Wait for a diagnostic to be omitted. * @@ -1291,12 +939,35 @@ async function initialize(connection, parameters) { * @returns {Promise} */ async function createDiagnosticsPromise(connection) { + return createNotificationPromise( + connection, + 'textDocument/publishDiagnostics' + ) +} + +/** + * Wait for a diagnostic to be omitted. + * + * @param {MessageConnection} connection + * @returns {Promise} + */ +async function createLogPromise(connection) { + return createNotificationPromise(connection, 'window/logMessage') +} + +/** + * Wait for a show message request to be omitted. + * + * @param {MessageConnection} connection + * @returns {Promise} + */ +async function createMessageRequestPromise(connection) { return new Promise((resolve) => { - const disposable = connection.onNotification( - 'textDocument/publishDiagnostics', + const disposable = connection.onRequest( + 'window/showMessageRequest', (result) => { disposable.dispose() - resolve(result) + setTimeout(() => resolve(result), 0) } ) }) From 1d843c98443c2d53bbcb466c541103703a6af2dd Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 13 Mar 2022 13:20:07 +0100 Subject: [PATCH 03/10] Rewrite remaining test --- package.json | 1 - test/index.js | 174 +++++++++++++++----------------------------------- 2 files changed, 50 insertions(+), 125 deletions(-) diff --git a/package.json b/package.json index 9a3bc24..0d20371 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "devDependencies": { "@types/tape": "^4.0.0", "c8": "^7.0.0", - "execa": "^6.0.0", "prettier": "^2.0.0", "remark-cli": "^10.0.0", "remark-preset-wooorm": "^9.0.0", diff --git a/test/index.js b/test/index.js index eb0e9cc..6ed2ae2 100644 --- a/test/index.js +++ b/test/index.js @@ -14,15 +14,9 @@ * @typedef {import('vscode-languageserver').ShowMessageRequestParams} ShowMessageRequestParams */ -import assert from 'node:assert' -import {Buffer} from 'node:buffer' import {promises as fs} from 'node:fs' import {spawn} from 'node:child_process' -import process from 'node:process' -import {PassThrough} from 'node:stream' import {URL, fileURLToPath} from 'node:url' -import {promisify} from 'node:util' -import {execa} from 'execa' import test from 'tape' import * as exports from 'unified-language-server' @@ -32,11 +26,6 @@ import { StreamMessageWriter } from 'vscode-jsonrpc/node.js' -const sleep = promisify(setTimeout) - -const delay = process.platform === 'win32' ? 1000 : 400 -const timeout = 10_000 - test('exports', (t) => { t.equal(typeof exports.createUnifiedLanguageServer, 'function') @@ -718,136 +707,73 @@ test('`initialize` w/ `workspaceFolders`', async (t) => { }) test('`workspace/didChangeWorkspaceFolders`', async (t) => { - const stdin = new PassThrough() + t.timeoutAfter(3_600_000) const processCwd = new URL('.', import.meta.url) - const promise = execa('node', ['remark-with-cwd.js', '--stdio'], { - cwd: fileURLToPath(processCwd), - input: stdin, - timeout - }) - stdin.write( - toMessage({ - method: 'initialize', - id: 0, - /** @type {import('vscode-languageserver').InitializeParams} */ - params: { - processId: null, - rootUri: null, - capabilities: {workspace: {workspaceFolders: true}}, - workspaceFolders: [{uri: processCwd.href, name: ''}] - } - }) + const connection = startLanguageServer( + t, + 'remark-with-cwd.js', + fileURLToPath(processCwd) ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'initialized', - /** @type {import('vscode-languageserver').InitializedParams} */ - params: {} - }) - ) + await initialize(connection, { + processId: null, + rootUri: null, + capabilities: {workspace: {workspaceFolders: true}}, + workspaceFolders: [{uri: processCwd.href, name: ''}] + }) - await sleep(delay) + await new Promise((resolve) => { + connection.onRequest('client/registerCapability', resolve) + connection.sendNotification('initialized', {}) + }) const otherCwd = new URL('./folder/', processCwd) - stdin.write( - toMessage({ - method: 'textDocument/didOpen', - /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ - params: { - textDocument: { - uri: new URL('lsp.md', otherCwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } + const openDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'textDocument/didOpen', + /** @type {DidOpenTextDocumentParams} */ + ({ + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' } }) ) - - await sleep(delay) - - stdin.write( - toMessage({ - method: 'workspace/didChangeWorkspaceFolders', - /** @type {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} */ - params: {event: {added: [{uri: otherCwd.href, name: ''}], removed: []}} - }) + const openDiagnostics = await openDiagnosticsPromise + t.equal( + openDiagnostics.diagnostics[0].message, + fileURLToPath(processCwd).slice(0, -1) ) - await sleep(delay) - - stdin.write( - toMessage({ - method: 'workspace/didChangeWorkspaceFolders', - /** @type {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} */ - params: { - event: {added: [], removed: [{uri: otherCwd.href, name: ''}]} - } - }) + const didAddDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'workspace/didChangeWorkspaceFolders', + /** @type {DidChangeWorkspaceFoldersParams} */ + ({event: {added: [{uri: otherCwd.href, name: ''}], removed: []}}) + ) + const didAddDiagnostics = await didAddDiagnosticsPromise + t.equal( + didAddDiagnostics.diagnostics[0].message, + fileURLToPath(otherCwd).slice(0, -1) ) - await sleep(delay) - - assert(promise.stdout) - promise.stdout.on('data', () => setImmediate(() => stdin.end())) - - try { - await promise - t.fail('should reject') - } catch (error) { - const exception = /** @type {ExecError} */ (error) - const messages = fromMessages(exception.stdout) - t.deepEqual( - messages - .filter((d) => d.method === 'textDocument/publishDiagnostics') - .flatMap((d) => { - const parameters = - /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( - d.params - ) - return parameters.diagnostics - }) - .map((d) => d.message), - [ - fileURLToPath(processCwd).slice(0, -1), - fileURLToPath(otherCwd).slice(0, -1), - fileURLToPath(processCwd).slice(0, -1) - ], - 'should support `workspaceFolders`' - ) - } - - t.end() + const didRemoveDiagnosticsPromise = createDiagnosticsPromise(connection) + connection.sendNotification( + 'workspace/didChangeWorkspaceFolders', + /** @type {DidChangeWorkspaceFoldersParams} */ + ({event: {added: [], removed: [{uri: otherCwd.href, name: ''}]}}) + ) + const didRemoveDiagnostics = await didRemoveDiagnosticsPromise + t.equal( + didRemoveDiagnostics.diagnostics[0].message, + fileURLToPath(processCwd).slice(0, -1) + ) }) -/** - * @param {string} data - * @returns {Array>} - */ -function fromMessages(data) { - return data - .replace(/\r?\n/g, '\n') - .split(/Content-Length: \d+\n{2}/g) - .filter(Boolean) - .map((d) => JSON.parse(d)) -} - -/** - * @param {unknown} data - */ -function toMessage(data) { - const content = Buffer.from(JSON.stringify(data)) - return Buffer.concat([ - Buffer.from('Content-Length: ' + content.length + '\r\n\r\n'), - content - ]) -} - /** * @param {string} stack * @param {number} max From e289ed0119ed87aec9a6b54ece315ff187aa5006 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 13 Mar 2022 13:26:50 +0100 Subject: [PATCH 04/10] Fix type coverage --- test/index.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/test/index.js b/test/index.js index 6ed2ae2..abccaf9 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,7 @@ /** * @typedef {import('node:child_process').ExecException & {stdout: string, stderr: string}} ExecError * @typedef {import('vscode-jsonrpc').MessageConnection} MessageConnection + * @typedef {import('vscode-languageserver').CodeAction} CodeAction * @typedef {import('vscode-languageserver').CodeActionParams} CodeActionParams * @typedef {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} DidChangeWorkspaceFoldersParams * @typedef {import('vscode-languageserver').DidCloseTextDocumentParams} DidCloseTextDocumentParams @@ -12,6 +13,7 @@ * @typedef {import('vscode-languageserver').LogMessageParams} LogMessageParams * @typedef {import('vscode-languageserver').PublishDiagnosticsParams} PublishDiagnosticsParams * @typedef {import('vscode-languageserver').ShowMessageRequestParams} ShowMessageRequestParams + * @typedef {import('vscode-languageserver').TextEdit} TextEdit */ import {promises as fs} from 'node:fs' @@ -290,6 +292,7 @@ test('`textDocument/formatting`', async (t) => { }) ) + /** @type {TextEdit} */ const resultBad = await connection.sendRequest( 'textDocument/formatting', /** @type {DocumentFormattingParams} */ @@ -309,6 +312,7 @@ test('`textDocument/formatting`', async (t) => { 'should format bad documents on `textDocument/formatting`' ) + /** @type {null} */ const resultGood = await connection.sendRequest( 'textDocument/formatting', /** @type {DocumentFormattingParams} */ @@ -428,6 +432,7 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => { ) await openDiagnosticsPromise + /** @type {CodeAction} */ const codeActions = await connection.sendRequest( 'textDocument/codeAction', /** @type {CodeActionParams} */ @@ -851,10 +856,16 @@ async function initialize(connection, parameters) { */ async function createNotificationPromise(connection, name) { return new Promise((resolve) => { - const disposable = connection.onNotification(name, (result) => { - disposable.dispose() - setTimeout(() => resolve(result), 0) - }) + const disposable = connection.onNotification( + name, + /** + * @param result {unknown} + */ + (result) => { + disposable.dispose() + setTimeout(() => resolve(result), 0) + } + ) }) } @@ -891,6 +902,9 @@ async function createMessageRequestPromise(connection) { return new Promise((resolve) => { const disposable = connection.onRequest( 'window/showMessageRequest', + /** + * @param result {ShowMessageRequestParams} + */ (result) => { disposable.dispose() setTimeout(() => resolve(result), 0) From 21f9d27cce28fe6b1e96663af80bbaa8263170a7 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 13 Mar 2022 13:29:55 +0100 Subject: [PATCH 05/10] Fix tests for Node 12 --- test/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/index.js b/test/index.js index abccaf9..20838e9 100644 --- a/test/index.js +++ b/test/index.js @@ -812,9 +812,7 @@ function startLanguageServer(t, serverFilePath, cwd) { const proc = spawn( 'node', [fileURLToPath(new URL(serverFilePath, import.meta.url)), '--stdio'], - { - cwd: new URL(cwd, import.meta.url) - } + {cwd: fileURLToPath(new URL(cwd, import.meta.url))} ) const connection = createMessageConnection( new StreamMessageReader(proc.stdout), From ec9773907855734abaea42d6d15e6db360acea67 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Sun, 13 Mar 2022 13:39:49 +0100 Subject: [PATCH 06/10] Fix resolving paths in Windows --- test/index.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/index.js b/test/index.js index 20838e9..47b9e39 100644 --- a/test/index.js +++ b/test/index.js @@ -18,6 +18,7 @@ import {promises as fs} from 'node:fs' import {spawn} from 'node:child_process' +import path from 'node:path' import {URL, fileURLToPath} from 'node:url' import test from 'tape' @@ -811,8 +812,14 @@ function cleanStack(stack, max) { function startLanguageServer(t, serverFilePath, cwd) { const proc = spawn( 'node', - [fileURLToPath(new URL(serverFilePath, import.meta.url)), '--stdio'], - {cwd: fileURLToPath(new URL(cwd, import.meta.url))} + [ + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + serverFilePath + ), + '--stdio' + ], + {cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), cwd)} ) const connection = createMessageConnection( new StreamMessageReader(proc.stdout), From 91acc8605b250ccdb10bf435321ae23d38ea50bc Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Mon, 14 Mar 2022 17:05:39 +0100 Subject: [PATCH 07/10] Add test for formatting unsynchronized documents This check was previously deemed unreachable, but it can be reached. --- lib/index.js | 2 -- test/index.js | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 75a9f5c..db77705 100644 --- a/lib/index.js +++ b/lib/index.js @@ -399,8 +399,6 @@ export function configureUnifiedLanguageServer( connection.onDocumentFormatting(async (event) => { const document = documents.get(event.textDocument.uri) - // `vscode-languageserver` crashes for commands to format unopen documents. - /* c8 ignore next 3 */ if (!document) { return } diff --git a/test/index.js b/test/index.js index 47b9e39..38821cc 100644 --- a/test/index.js +++ b/test/index.js @@ -327,6 +327,21 @@ test('`textDocument/formatting`', async (t) => { null, 'should format good documents on `textDocument/formatting`' ) + + /** @type {null} */ + const resultUnknown = await connection.sendRequest( + 'textDocument/formatting', + /** @type {DocumentFormattingParams} */ + ({ + textDocument: {uri: new URL('unknown.md', import.meta.url).href}, + options: {tabSize: 2, insertSpaces: true} + }) + ) + t.deepEqual( + resultUnknown, + null, + 'should ignore unsynchronized documents on `textDocument/formatting`' + ) }) test('`workspace/didChangeWatchedFiles`', async (t) => { From 440d5117a783fc16f885bca82c975d15b68db2ce Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 23 Mar 2022 21:01:11 +0100 Subject: [PATCH 08/10] Use vscode-languageserver-protocol for testing This is essentially a type-safe variant of vscode-jsonrpc --- package.json | 3 +- test/index.js | 652 ++++++++++++++++++++++---------------------------- 2 files changed, 292 insertions(+), 363 deletions(-) diff --git a/package.json b/package.json index 0d20371..c1dd93a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "type-coverage": "^2.0.0", "typescript": "^4.0.0", "unified": "^10.0.0", - "vscode-jsonrpc": "^6.0.0", + "vscode-languageserver-protocol": "^3.0.0", "xo": "^0.47.0" }, "scripts": { @@ -82,6 +82,7 @@ "typeCoverage": { "atLeast": 100, "detail": true, + "ignoreNested": true, "strict": true } } diff --git a/test/index.js b/test/index.js index 38821cc..29dd640 100644 --- a/test/index.js +++ b/test/index.js @@ -1,19 +1,5 @@ /** - * @typedef {import('node:child_process').ExecException & {stdout: string, stderr: string}} ExecError - * @typedef {import('vscode-jsonrpc').MessageConnection} MessageConnection - * @typedef {import('vscode-languageserver').CodeAction} CodeAction - * @typedef {import('vscode-languageserver').CodeActionParams} CodeActionParams - * @typedef {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} DidChangeWorkspaceFoldersParams - * @typedef {import('vscode-languageserver').DidCloseTextDocumentParams} DidCloseTextDocumentParams - * @typedef {import('vscode-languageserver').DidOpenTextDocumentParams} DidOpenTextDocumentParams - * @typedef {import('vscode-languageserver').DocumentFormattingParams} DocumentFormattingParams - * @typedef {import('vscode-languageserver').InitializeParams} InitializeParams - * @typedef {import('vscode-languageserver').InitializeResult} InitializeResult - * @typedef {import('vscode-languageserver').InitializedParams} InitializedParams - * @typedef {import('vscode-languageserver').LogMessageParams} LogMessageParams - * @typedef {import('vscode-languageserver').PublishDiagnosticsParams} PublishDiagnosticsParams - * @typedef {import('vscode-languageserver').ShowMessageRequestParams} ShowMessageRequestParams - * @typedef {import('vscode-languageserver').TextEdit} TextEdit + * @typedef {import('vscode-languageserver-protocol').ProtocolConnection} ProtocolConnection */ import {promises as fs} from 'node:fs' @@ -24,10 +10,19 @@ import test from 'tape' import * as exports from 'unified-language-server' import { - createMessageConnection, + createProtocolConnection, + CodeActionRequest, + DidChangeWorkspaceFoldersNotification, + DidCloseTextDocumentNotification, + DidOpenTextDocumentNotification, + DocumentFormattingRequest, + LogMessageNotification, + InitializeRequest, + PublishDiagnosticsNotification, + ShowMessageRequest, StreamMessageReader, StreamMessageWriter -} from 'vscode-jsonrpc/node.js' +} from 'vscode-languageserver-protocol/node.js' test('exports', (t) => { t.equal(typeof exports.createUnifiedLanguageServer, 'function') @@ -37,12 +32,15 @@ test('exports', (t) => { test('`initialize`', async (t) => { const connection = startLanguageServer(t, 'remark.js', '.') - const initializeResponse = await initialize(connection, { - processId: null, - rootUri: null, - capabilities: {}, - workspaceFolders: null - }) + const initializeResponse = await connection.sendRequest( + InitializeRequest.type, + { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + } + ) t.deepEqual( initializeResponse, @@ -63,12 +61,15 @@ test('`initialize`', async (t) => { test('`initialize` workspace capabilities', async (t) => { const connection = startLanguageServer(t, 'remark.js', '.') - const initializeResponse = await initialize(connection, { - processId: null, - rootUri: null, - capabilities: {workspace: {workspaceFolders: true}}, - workspaceFolders: null - }) + const initializeResponse = await connection.sendRequest( + InitializeRequest.type, + { + processId: null, + rootUri: null, + capabilities: {workspace: {workspaceFolders: true}}, + workspaceFolders: null + } + ) t.deepEqual( initializeResponse, @@ -91,7 +92,7 @@ test('`initialize` workspace capabilities', async (t) => { test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async (t) => { const connection = startLanguageServer(t, 'remark-with-warnings.js', '.') - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, @@ -99,19 +100,18 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async }) const uri = new URL('lsp.md', import.meta.url).href - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -169,12 +169,13 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async 'should emit diagnostics on `textDocument/didOpen`' ) - const closeDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didClose', - /** @type {DidCloseTextDocumentParams} */ - ({textDocument: {uri, version: 1}}) + const closeDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: {uri} + }) const closeDiagnostics = await closeDiagnosticsPromise t.deepEqual( @@ -187,26 +188,25 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async test('uninstalled processor so `window/showMessageRequest`', async (t) => { const connection = startLanguageServer(t, 'missing-package.js', '.') - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const messageRequestPromise = createMessageRequestPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const messageRequestPromise = createOnRequestPromise( + connection, + ShowMessageRequest.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const messageRequest = await messageRequestPromise t.deepEqual( @@ -228,26 +228,25 @@ test('uninstalled processor w/ `defaultProcessor`', async (t) => { '.' ) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const logPromise = createLogPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const logPromise = createOnNotificationPromise( + connection, + LogMessageNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const log = await logPromise t.deepEqual( @@ -260,47 +259,37 @@ test('uninstalled processor w/ `defaultProcessor`', async (t) => { test('`textDocument/formatting`', async (t) => { const connection = startLanguageServer(t, 'remark.js', '.') - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('bad.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: ' # hi \n' - } - }) - ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('bad.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: ' # hi \n' + } + }) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('good.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi\n' - } - }) - ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('good.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi\n' + } + }) - /** @type {TextEdit} */ const resultBad = await connection.sendRequest( - 'textDocument/formatting', - /** @type {DocumentFormattingParams} */ - ({ + DocumentFormattingRequest.type, + { textDocument: {uri: new URL('bad.md', import.meta.url).href}, options: {tabSize: 2, insertSpaces: true} - }) + } ) t.deepEqual( resultBad, @@ -313,14 +302,12 @@ test('`textDocument/formatting`', async (t) => { 'should format bad documents on `textDocument/formatting`' ) - /** @type {null} */ const resultGood = await connection.sendRequest( - 'textDocument/formatting', - /** @type {DocumentFormattingParams} */ - ({ + DocumentFormattingRequest.type, + { textDocument: {uri: new URL('good.md', import.meta.url).href}, options: {tabSize: 2, insertSpaces: true} - }) + } ) t.deepEqual( resultGood, @@ -328,14 +315,12 @@ test('`textDocument/formatting`', async (t) => { 'should format good documents on `textDocument/formatting`' ) - /** @type {null} */ const resultUnknown = await connection.sendRequest( - 'textDocument/formatting', - /** @type {DocumentFormattingParams} */ - ({ + DocumentFormattingRequest.type, + { textDocument: {uri: new URL('unknown.md', import.meta.url).href}, options: {tabSize: 2, insertSpaces: true} - }) + } ) t.deepEqual( resultUnknown, @@ -347,29 +332,31 @@ test('`textDocument/formatting`', async (t) => { test('`workspace/didChangeWatchedFiles`', async (t) => { const connection = startLanguageServer(t, 'remark.js', '.') - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('a.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('a.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) await openDiagnosticsPromise - const changeWatchDiagnosticsPromise = createDiagnosticsPromise(connection) + const changeWatchDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type + ) connection.sendNotification('workspace/didChangeWatchedFiles', {changes: []}) const changeWatchDiagnostics = await changeWatchDiagnosticsPromise @@ -383,26 +370,25 @@ test('`workspace/didChangeWatchedFiles`', async (t) => { test('`initialize`, `textDocument/didOpen` (and a broken plugin)', async (t) => { const connection = startLanguageServer(t, 'remark-with-error.js', '.') - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -426,89 +412,83 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => { const connection = startLanguageServer(t, 'remark.js', '.') const uri = new URL('lsp.md', import.meta.url).href - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri, - languageId: 'markdown', - version: 1, - text: '## hello' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri, + languageId: 'markdown', + version: 1, + text: '## hello' + } + }) await openDiagnosticsPromise - /** @type {CodeAction} */ - const codeActions = await connection.sendRequest( - 'textDocument/codeAction', - /** @type {CodeActionParams} */ - ({ - textDocument: {uri}, - range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, - context: { - diagnostics: [ - // Coverage for warnings w/o `data` (which means a message w/o `expected`). - { - message: 'warning', - severity: 2, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 0} - } - }, - { - message: 'warning', - severity: 2, - data: {}, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 8} - } - }, - // Replacement: - { - message: 'warning', - severity: 2, - data: {expected: ['Hello']}, - range: { - start: {line: 0, character: 3}, - end: {line: 0, character: 8} - } - }, - // Insertion (start and end in the same place): - { - message: 'warning', - severity: 2, - data: {expected: ['!']}, - range: { - start: {line: 0, character: 8}, - end: {line: 0, character: 8} - } - }, - // Deletion (empty `expected`): - { - message: 'warning', - severity: 2, - data: {expected: ['']}, - range: { - start: {line: 0, character: 1}, - end: {line: 0, character: 2} - } + const codeActions = await connection.sendRequest(CodeActionRequest.type, { + textDocument: {uri}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + context: { + diagnostics: [ + // Coverage for warnings w/o `data` (which means a message w/o `expected`). + { + message: 'warning', + severity: 2, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 0} } - ] - } - }) - ) + }, + { + message: 'warning', + severity: 2, + data: {}, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 8} + } + }, + // Replacement: + { + message: 'warning', + severity: 2, + data: {expected: ['Hello']}, + range: { + start: {line: 0, character: 3}, + end: {line: 0, character: 8} + } + }, + // Insertion (start and end in the same place): + { + message: 'warning', + severity: 2, + data: {expected: ['!']}, + range: { + start: {line: 0, character: 8}, + end: {line: 0, character: 8} + } + }, + // Deletion (empty `expected`): + { + message: 'warning', + severity: 2, + data: {expected: ['']}, + range: { + start: {line: 0, character: 1}, + end: {line: 0, character: 2} + } + } + ] + } + }) t.deepEqual( codeActions, @@ -577,27 +557,26 @@ test('`initialize` w/ nothing (finds closest `package.json`)', async (t) => { fileURLToPath(cwd) ) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('folder-with-package-json/folder/file.md', import.meta.url) - .href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('folder-with-package-json/folder/file.md', import.meta.url) + .href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -618,26 +597,25 @@ test('`initialize` w/ nothing (find closest `.git`)', async (t) => { recursive: true }) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, workspaceFolders: null }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('folder-with-git/folder/file.md', import.meta.url).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('folder-with-git/folder/file.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -656,26 +634,25 @@ test('`initialize` w/ `rootUri`', async (t) => { fileURLToPath(processCwd) ) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: cwd.href, capabilities: {}, workspaceFolders: [] }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', cwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', cwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -695,7 +672,7 @@ test('`initialize` w/ `workspaceFolders`', async (t) => { const otherCwd = new URL('folder/', processCwd) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {}, @@ -705,19 +682,18 @@ test('`initialize` w/ `workspaceFolders`', async (t) => { ] }) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', otherCwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.deepEqual( @@ -737,7 +713,7 @@ test('`workspace/didChangeWorkspaceFolders`', async (t) => { fileURLToPath(processCwd) ) - await initialize(connection, { + await connection.sendRequest(InitializeRequest.type, { processId: null, rootUri: null, capabilities: {workspace: {workspaceFolders: true}}, @@ -751,43 +727,44 @@ test('`workspace/didChangeWorkspaceFolders`', async (t) => { const otherCwd = new URL('./folder/', processCwd) - const openDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'textDocument/didOpen', - /** @type {DidOpenTextDocumentParams} */ - ({ - textDocument: { - uri: new URL('lsp.md', otherCwd).href, - languageId: 'markdown', - version: 1, - text: '# hi' - } - }) + const openDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + }) const openDiagnostics = await openDiagnosticsPromise t.equal( openDiagnostics.diagnostics[0].message, fileURLToPath(processCwd).slice(0, -1) ) - const didAddDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'workspace/didChangeWorkspaceFolders', - /** @type {DidChangeWorkspaceFoldersParams} */ - ({event: {added: [{uri: otherCwd.href, name: ''}], removed: []}}) + const didAddDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, { + event: {added: [{uri: otherCwd.href, name: ''}], removed: []} + }) const didAddDiagnostics = await didAddDiagnosticsPromise t.equal( didAddDiagnostics.diagnostics[0].message, fileURLToPath(otherCwd).slice(0, -1) ) - const didRemoveDiagnosticsPromise = createDiagnosticsPromise(connection) - connection.sendNotification( - 'workspace/didChangeWorkspaceFolders', - /** @type {DidChangeWorkspaceFoldersParams} */ - ({event: {added: [], removed: [{uri: otherCwd.href, name: ''}]}}) + const didRemoveDiagnosticsPromise = createOnNotificationPromise( + connection, + PublishDiagnosticsNotification.type ) + connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, { + event: {added: [], removed: [{uri: otherCwd.href, name: ''}]} + }) const didRemoveDiagnostics = await didRemoveDiagnosticsPromise t.equal( didRemoveDiagnostics.diagnostics[0].message, @@ -836,99 +813,50 @@ function startLanguageServer(t, serverFilePath, cwd) { ], {cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), cwd)} ) - const connection = createMessageConnection( + const connection = createProtocolConnection( new StreamMessageReader(proc.stdout), new StreamMessageWriter(proc.stdin) ) t.teardown(() => { connection.end() }) - connection.onNotification( - 'window/logMessage', - /** - * @param {LogMessageParams} message - */ - ({message}) => { - console.dir(message) - } - ) + connection.onNotification(LogMessageNotification.type, ({message}) => { + console.dir(message) + }) connection.listen() return connection } /** - * Initialize a language server in a type-safe manner. - * - * @param {MessageConnection} connection - * @param {InitializeParams} parameters - * @returns {Promise} - */ -async function initialize(connection, parameters) { - return connection.sendRequest('initialize', parameters) -} - -/** - * Wait for an event name to be omitted. + * Wait for an event type to be omitted. * - * @param {MessageConnection} connection - * @param {string} name - * @returns {Promise} + * @template ReturnType + * @param {ProtocolConnection} connection + * @param {import('vscode-languageserver-protocol').NotificationType} type + * @returns {Promise} */ -async function createNotificationPromise(connection, name) { +async function createOnNotificationPromise(connection, type) { return new Promise((resolve) => { - const disposable = connection.onNotification( - name, - /** - * @param result {unknown} - */ - (result) => { - disposable.dispose() - setTimeout(() => resolve(result), 0) - } - ) + const disposable = connection.onNotification(type, (result) => { + disposable.dispose() + setTimeout(() => resolve(result), 0) + }) }) } /** - * Wait for a diagnostic to be omitted. - * - * @param {MessageConnection} connection - * @returns {Promise} - */ -async function createDiagnosticsPromise(connection) { - return createNotificationPromise( - connection, - 'textDocument/publishDiagnostics' - ) -} - -/** - * Wait for a diagnostic to be omitted. - * - * @param {MessageConnection} connection - * @returns {Promise} - */ -async function createLogPromise(connection) { - return createNotificationPromise(connection, 'window/logMessage') -} - -/** - * Wait for a show message request to be omitted. + * Wait for a request to be sent from the server to the client. * - * @param {MessageConnection} connection - * @returns {Promise} + * @template Params + * @param {ProtocolConnection} connection + * @param {import('vscode-languageserver-protocol').RequestType} type + * @returns {Promise} */ -async function createMessageRequestPromise(connection) { +async function createOnRequestPromise(connection, type) { return new Promise((resolve) => { - const disposable = connection.onRequest( - 'window/showMessageRequest', - /** - * @param result {ShowMessageRequestParams} - */ - (result) => { - disposable.dispose() - setTimeout(() => resolve(result), 0) - } - ) + const disposable = connection.onRequest(type, (result) => { + disposable.dispose() + setTimeout(() => resolve(result), 0) + }) }) } From 3dc9ae1d0961126b826d6ecf041de197bea9d47c Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 23 Mar 2022 21:26:20 +0100 Subject: [PATCH 09/10] Explain a document might be unsynchronized --- lib/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/index.js b/lib/index.js index db77705..aa47850 100644 --- a/lib/index.js +++ b/lib/index.js @@ -399,6 +399,8 @@ export function configureUnifiedLanguageServer( connection.onDocumentFormatting(async (event) => { const document = documents.get(event.textDocument.uri) + // This might happen if a client calls this function without synchronizing + // the document first. if (!document) { return } From 4e7e25d4da0bc1f0b17d13f1353595c9ead2c063 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 23 Mar 2022 22:16:06 +0100 Subject: [PATCH 10/10] Add workaround for timing issue --- test/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/index.js b/test/index.js index 29dd640..53949ea 100644 --- a/test/index.js +++ b/test/index.js @@ -856,7 +856,10 @@ async function createOnRequestPromise(connection, type) { return new Promise((resolve) => { const disposable = connection.onRequest(type, (result) => { disposable.dispose() - setTimeout(() => resolve(result), 0) + // The timeout should be 0. However, this causes random test failures. + // This will be fixed in vscode-languageserver-protocol 3.17 + // https://github.com/microsoft/vscode-languageserver-node/pull/776 + setTimeout(() => resolve(result), 100) }) }) }