-
Notifications
You must be signed in to change notification settings - Fork 5
Add LSP Client and long running tests in tools #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,318 @@ | ||
| /* eslint-disable @typescript-eslint/no-unsafe-return */ | ||
| import { spawn, ChildProcess } from 'child_process'; | ||
| import { | ||
| createMessageConnection, | ||
| MessageConnection, | ||
| StreamMessageReader, | ||
| StreamMessageWriter, | ||
| IPCMessageReader, | ||
| IPCMessageWriter, | ||
| TextDocumentContentChangeEvent, | ||
| } from 'vscode-languageserver-protocol/node'; | ||
| import { randomBytes } from 'crypto'; | ||
| import { CompactEncrypt } from 'jose'; | ||
| import { LspClientConfig, LspConnection, InitializationFlags } from './LspConnection'; | ||
| import { ExtendedInitializeParams } from '../../src/server/InitParams'; | ||
| import { IamCredentials } from '../../src/auth/AwsLspAuthTypes'; | ||
| import { WaitFor } from '../../tst/utils/Utils'; | ||
|
|
||
| /** | ||
| * Common LSP client for CloudFormation Language Server testing. | ||
| * Handles server startup, LSP protocol communication, and external service initialization detection. | ||
| */ | ||
| export class LspClient implements LspConnection { | ||
| protected serverProcess?: ChildProcess; | ||
| protected connection?: MessageConnection; | ||
| protected initialization: InitializationFlags = { | ||
| cfnLint: false, | ||
| cfnGuard: false, | ||
| }; | ||
|
|
||
| public readonly createdAt: number; | ||
| private readonly encryptionKey: Buffer; | ||
| protected isShutdown = false; | ||
| protected workspaceConfig: Record<string, unknown>[] = [{}]; | ||
| protected availableRegions = new Set<string>(); | ||
|
|
||
| constructor(protected config: LspClientConfig) { | ||
| this.createdAt = performance.now(); | ||
| this.encryptionKey = randomBytes(32); | ||
| } | ||
|
|
||
| async initialize(): Promise<void> { | ||
| console.log('LspClient: Starting initialization...'); | ||
|
|
||
| // 1. Start server process | ||
| const args = this.config.mode === 'ipc' ? ['--node-ipc'] : ['--stdio']; | ||
| console.log(`LspClient: Spawning server with args: node ${this.config.serverPath} ${args.join(' ')}`); | ||
|
|
||
| this.serverProcess = spawn('node', [this.config.serverPath, ...args], { | ||
| stdio: this.config.mode === 'ipc' ? ['pipe', 'pipe', 'pipe', 'ipc'] : ['pipe', 'pipe', 'pipe'], | ||
| env: { ...process.env, ...this.config.env }, | ||
| }); | ||
|
|
||
| console.log(`LspClient: Server process spawned with PID: ${this.serverProcess.pid}`); | ||
|
|
||
| // 2. Setup output monitoring for external service initialization detection | ||
| this.attachOutputListeners(); | ||
|
|
||
| // 3. Create LSP connection | ||
| console.log('LspClient: Creating LSP connection...'); | ||
| const reader = | ||
| this.config.mode === 'ipc' | ||
| ? new IPCMessageReader(this.serverProcess) | ||
| : new StreamMessageReader(this.serverProcess.stdout!); | ||
|
|
||
| const writer = | ||
| this.config.mode === 'ipc' | ||
| ? new IPCMessageWriter(this.serverProcess) | ||
| : new StreamMessageWriter(this.serverProcess.stdin!); | ||
|
|
||
| this.connection = createMessageConnection(reader, writer); | ||
|
|
||
| // Handle workspace/configuration requests from server | ||
|
|
||
| this.connection.onRequest('workspace/configuration', (params: any) => { | ||
| // Extract the specific configuration section requested | ||
| if (params?.items?.length > 0) { | ||
| const results = params.items.map((item: any) => { | ||
| if (item.section === 'aws.cloudformation') { | ||
| // Return just the CloudFormation config part | ||
| const fullConfig = this.workspaceConfig[0] ?? {}; | ||
| return (fullConfig as any)['aws.cloudformation'] ?? {}; | ||
| } | ||
| return {}; | ||
| }); | ||
| return results; | ||
| } | ||
| return this.workspaceConfig; | ||
| }); | ||
|
|
||
| this.connection.listen(); | ||
| console.log('LspClient: LSP connection created and listening'); | ||
|
|
||
| // 4. Perform LSP handshake | ||
| console.log('LspClient: Performing LSP handshake...'); | ||
| try { | ||
| await this.performHandshake(); | ||
| console.log('LspClient: LSP handshake completed'); | ||
| } catch (error) { | ||
| console.error('LspClient: LSP handshake failed:', error); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| private readonly onServerOutput = (data: Buffer) => { | ||
| const output = data.toString().trim(); | ||
|
|
||
| // external service initialization detection | ||
| if (output.includes('cfn-lint version')) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently, the only way to determine if lint, guard, and the schemas are ready is through the logs. #507 Introduces a new LSP request which will return the guard, lint, and schema statuses. This addition will make testing more robust and speed up test time, but this PR is functional without this change |
||
| this.initialization.cfnLint = true; | ||
| } | ||
| if (output.includes('Loading rules from')) { | ||
| this.initialization.cfnGuard = true; | ||
| } | ||
|
|
||
| // Region-specific schema loading | ||
| const regionSchemaMatch = output.match(/public schemas downloaded for ([a-z0-9-]+)/); | ||
| if (regionSchemaMatch) { | ||
| this.availableRegions.add(regionSchemaMatch[1]); | ||
| } | ||
|
|
||
| // Log filtering | ||
| const suppressLevels = this.config.suppressLogLevels ?? ['INFO', 'DEBUG']; | ||
| const shouldSuppress = suppressLevels.some((level) => output.includes(`${level}:`)); | ||
|
|
||
| if (!shouldSuppress) { | ||
| console.error(`[LSP Server]: ${output}`); | ||
| } | ||
| }; | ||
|
|
||
| protected attachOutputListeners(): void { | ||
| this.serverProcess!.stdout?.on('data', this.onServerOutput); | ||
| this.serverProcess!.stderr?.on('data', this.onServerOutput); | ||
|
|
||
| this.serverProcess!.on('exit', (code, signal) => { | ||
| if (signal) { | ||
| console.log(`[LSP Server]: Process terminated with signal ${signal}`); | ||
| } else { | ||
| console.log(`[LSP Server]: Process exited with code ${code}`); | ||
| } | ||
| }); | ||
|
|
||
| this.serverProcess!.on('error', (error) => { | ||
| console.error(`[LSP Server]: Process error:`, error); | ||
| }); | ||
| } | ||
|
|
||
| protected async performHandshake(): Promise<void> { | ||
| const initParams: ExtendedInitializeParams = { | ||
| processId: process.pid, | ||
| rootUri: 'file:///test/workspace', | ||
| capabilities: { | ||
| textDocument: { | ||
| hover: { dynamicRegistration: true }, | ||
| completion: { dynamicRegistration: true }, | ||
| }, | ||
| }, | ||
| clientInfo: this.config.clientInfo, | ||
| initializationOptions: { | ||
| aws: { | ||
| clientInfo: { | ||
| extension: this.config.extensionInfo, | ||
| clientId: this.config.clientId, | ||
| }, | ||
| telemetryEnabled: this.config.telemetryEnabled, | ||
| storageDir: this.config.storageDir, | ||
| encryption: { | ||
| key: this.encryptionKey.toString('base64'), | ||
| mode: 'JWT', | ||
| }, | ||
| featureFlags: this.config.featureFlags, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| console.log('LspClient: Sending initialize request...'); | ||
| try { | ||
| await Promise.race([ | ||
| this.connection!.sendRequest('initialize', initParams), | ||
| new Promise((_resolve, reject) => setTimeout(() => reject(new Error('Initialize timeout')), 30_000)), | ||
| ]); | ||
| console.log('LspClient: Initialize request completed'); | ||
|
|
||
| console.log('LspClient: Sending initialized notification'); | ||
| await this.connection!.sendNotification('initialized', {}); | ||
| console.log('LspClient: Initialized notification sent'); | ||
| } catch (error) { | ||
| console.error('LspClient: Handshake error:', error); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| async openDocument(uri: string, content: string): Promise<void> { | ||
| await this.connection!.sendNotification('textDocument/didOpen', { | ||
| textDocument: { | ||
| uri, | ||
| languageId: 'yaml', | ||
| version: 1, | ||
| text: content, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| async updateDocument( | ||
| uri: string, | ||
| version: number, | ||
| changes: string | TextDocumentContentChangeEvent[], | ||
| ): Promise<void> { | ||
| const contentChanges = | ||
| typeof changes === 'string' | ||
| ? [{ text: changes }] // Full replacement | ||
| : changes; // Incremental changes | ||
|
|
||
| await this.connection!.sendNotification('textDocument/didChange', { | ||
| textDocument: { | ||
| uri, | ||
| version, | ||
| }, | ||
| contentChanges, | ||
| }); | ||
| } | ||
|
|
||
| async closeDocument(uri: string): Promise<void> { | ||
| await this.connection!.sendNotification('textDocument/didClose', { | ||
| textDocument: { uri }, | ||
| }); | ||
| } | ||
|
|
||
| async hover(uri: string, line: number, character: number): Promise<any> { | ||
| return await this.connection!.sendRequest('textDocument/hover', { | ||
| textDocument: { uri }, | ||
| position: { line, character }, | ||
| }); | ||
| } | ||
|
|
||
| async completion(uri: string, line: number, character: number): Promise<any> { | ||
| return await this.connection!.sendRequest('textDocument/completion', { | ||
| textDocument: { uri }, | ||
| position: { line, character }, | ||
| }); | ||
| } | ||
|
|
||
| async changeConfiguration(params: { settings: any }): Promise<void> { | ||
| // Store the new configuration | ||
| if (params.settings) { | ||
| const currentConfig = this.workspaceConfig[0] ?? {}; | ||
| this.workspaceConfig = [{ ...currentConfig, ...params.settings }]; | ||
| } | ||
|
|
||
| // Send the configuration change notification | ||
| await this.sendNotification('workspace/didChangeConfiguration', params); | ||
| } | ||
|
|
||
| async sendRequest(method: string, params: any): Promise<any> { | ||
| return await this.connection!.sendRequest(method, params); | ||
| } | ||
|
|
||
| async sendNotification(method: string, params: any): Promise<void> { | ||
| return await this.connection!.sendNotification(method, params); | ||
| } | ||
|
|
||
| onNotification(method: string, handler: (params: any) => void): void { | ||
| this.connection!.onNotification(method, handler); | ||
| } | ||
|
|
||
| onRequest(method: string, handler: (params: any) => any): void { | ||
| this.connection!.onRequest(method, handler); | ||
| } | ||
|
|
||
| async waitForExternalServiceInitialization(): Promise<void> { | ||
| console.log('Waiting for lint and guard initialization'); | ||
|
|
||
| await WaitFor.waitFor( | ||
| () => { | ||
| if (!this.initialization.cfnLint || !this.initialization.cfnGuard) { | ||
| throw new Error('Lint and Guard services not initialized'); | ||
| } | ||
| console.log('Lint and Guard services are initialized'); | ||
| }, | ||
| 30_000, | ||
| 500, // Check every 500ms | ||
| ); | ||
| } | ||
|
|
||
| getAvailableRegions(): ReadonlySet<string> { | ||
| return this.availableRegions; | ||
| } | ||
|
|
||
| async updateCredentials(credentials: IamCredentials): Promise<void> { | ||
| const payload = new TextEncoder().encode(JSON.stringify({ data: credentials })); | ||
| const jwt = await new CompactEncrypt(payload) | ||
| .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) | ||
| .encrypt(this.encryptionKey); | ||
|
|
||
| await this.connection!.sendRequest('aws/credentials/iam/update', { | ||
| data: jwt, | ||
| encrypted: true, | ||
| }); | ||
| } | ||
|
|
||
| async shutdown(): Promise<void> { | ||
| if (this.isShutdown) return; | ||
| this.isShutdown = true; | ||
|
|
||
| try { | ||
| if (this.connection) { | ||
| await this.connection.sendRequest('shutdown', {}); | ||
| await this.connection.sendNotification('exit', {}); | ||
| } | ||
| } catch (e) { | ||
| console.warn('Error during LSP shutdown:', e); | ||
| } | ||
|
|
||
| if (this.serverProcess) { | ||
| this.serverProcess.kill(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { ClientInfo, AwsMetadata } from '../../src/server/InitParams'; | ||
|
|
||
| export interface LspConnection { | ||
| initialize(): Promise<void>; | ||
| sendRequest(method: string, params: any): Promise<any>; | ||
| sendNotification(method: string, params: any): Promise<void>; | ||
| onNotification(method: string, handler: (params: any) => void): void; | ||
| onRequest(method: string, handler: (params: any) => any): void; | ||
| shutdown(): Promise<void>; | ||
| } | ||
|
|
||
| export type LspClientConfig = { | ||
| serverPath: string; | ||
| mode: 'stdio' | 'ipc'; | ||
| clientId: string; | ||
| clientInfo: ClientInfo; | ||
| extensionInfo: ClientInfo; | ||
| telemetryEnabled: boolean; | ||
| featureFlags: NonNullable<AwsMetadata['featureFlags']>; | ||
| storageDir?: string; | ||
| env?: NodeJS.ProcessEnv; | ||
| suppressLogLevels?: string[]; | ||
| }; | ||
|
|
||
| export type InitializationFlags = { | ||
| cfnLint: boolean; | ||
| cfnGuard: boolean; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are creating the lsp handler for this, we should not add this code if it will just be deleted in the next couple of days
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code works without the lsp handler, although it is less robust and local testing takes longer since schemas are already downloaded. We can wait until #507 gets merged in, but the code can work independently of it. The code we would have to delete and modify to use the lsp handler is minimal relative to the rest of the PR.