diff --git a/README.md b/README.md index 8eee851..cdb3137 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Synapse -Operating system gateway for remote serverless environments. Synapse provides a WebSocket-based interface to interact with terminal sessions, manage files, and monitor system resources remotely. +Operating system gateway for remote serverless environments. Synapse provides a WebSocket-based interface to interact with terminal sessions, manage files, monitor system resources, perform Git operations, and more. ## Features @@ -137,17 +137,17 @@ await git.push(); ```typescript // Lint and format code -import { Synapse, CodeStyle } from "@appwrite.io/synapse"; +import { Synapse, Code } from "@appwrite.io/synapse"; const synapse = new Synapse(); -const codeStyle = new CodeStyle(synapse); +const code = new Code(synapse); // Format code with specific options const code = `function hello(name) { return "Hello, " + name; }`; -const formatResult = await codeStyle.format(code, { +const formatResult = await code.format(code, { language: "javascript", indent: 2, singleQuote: true, @@ -157,7 +157,7 @@ const formatResult = await codeStyle.format(code, { console.log("Formatted code:", formatResult.data); // Lint code for potential issues -const lintResult = await codeStyle.lint(code, { +const lintResult = await code.lint(code, { language: "javascript", rules: { semi: "error", diff --git a/package.json b/package.json index 10c4175..5271183 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@appwrite.io/synapse", - "version": "0.2.0", + "version": "0.3.0", "description": "Operating system gateway for remote serverless environments", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -22,23 +22,25 @@ }, "license": "MIT", "dependencies": { + "eslint": "^9.24.0", "node-pty": "^1.0.0", - "ws": "^8.18.1", - "eslint": "^9.23.0", - "prettier": "^3.5.3" + "prettier": "^3.5.3", + "ws": "^8.18.1" }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "^22.13.13", - "@types/ws": "^8.18.0", - "@typescript-eslint/eslint-plugin": "^8.28.0", - "@typescript-eslint/parser": "^8.28.0", + "@types/node": "^22.14.1", + "@types/node-fetch": "^2.6.12", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", "esbuild": "^0.25.2", "esbuild-plugin-file-path-extensions": "^2.1.4", "jest": "^29.7.0", - "ts-jest": "^29.3.0", + "node-fetch": "^3.3.2", + "ts-jest": "^29.3.2", "tsup": "^8.4.0", - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "bugs": { "url": "https://github.com/appwrite/synapse/issues" diff --git a/src/index.ts b/src/index.ts index 2b2ce5f..98f9b3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { CodeStyle } from "./services/codestyle"; +export { Code } from "./services/code"; export { Filesystem } from "./services/filesystem"; export { Git } from "./services/git"; export { System } from "./services/system"; diff --git a/src/services/code.ts b/src/services/code.ts new file mode 100644 index 0000000..1e3ec12 --- /dev/null +++ b/src/services/code.ts @@ -0,0 +1,189 @@ +import { ESLint } from "eslint"; +import { format, Options } from "prettier"; +import { Synapse } from "../synapse"; + +export interface FormatOptions { + language: string; + indent?: number; + useTabs?: boolean; + semi?: boolean; + singleQuote?: boolean; + printWidth?: number; +} + +export interface LintOptions { + language: string; + rules?: Record; +} + +export interface FormatResult { + success: boolean; + data?: string; + error?: string; +} + +export interface LintResult { + success: boolean; + data?: { + issues: Array<{ + line: number; + column: number; + severity: "error" | "warning"; + rule: string; + message: string; + }>; + }; + error?: string; +} + +export class Code { + private synapse: Synapse; + + /** + * Creates a new CodeStyle instance + * @param synapse The Synapse instance for WebSocket communication + */ + constructor(synapse: Synapse) { + this.synapse = synapse; + } + + private log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[Code][${timestamp}] ${message}`); + } + + private getParserForLanguage(language: string): string { + const languageMap: Record = { + javascript: "babel", + typescript: "typescript", + json: "json", + html: "html", + css: "css", + markdown: "markdown", + yaml: "yaml", + }; + + return languageMap[language.toLowerCase()] || "babel"; + } + + private toPrettierOptions( + language: string, + options?: FormatOptions, + ): Options { + const parser = this.getParserForLanguage(language); + + return { + parser, + tabWidth: options?.indent || 2, + useTabs: options?.useTabs || false, + semi: options?.semi !== undefined ? options.semi : true, + singleQuote: options?.singleQuote || false, + printWidth: options?.printWidth || 80, + }; + } + + /** + * Format code according to specified options + * @param code The code to format + * @param options Formatting options + * @returns A promise resolving to the formatting result + */ + async format(code: string, options: FormatOptions): Promise { + try { + if (!code || typeof code !== "string") { + return { + success: false, + error: "Invalid code input: code must be a non-empty string", + }; + } + + if (!options.language) { + return { + success: false, + error: "Language must be specified in format options", + }; + } + + this.log(`Formatting code with language: ${options.language}`); + + const prettierOptions = this.toPrettierOptions(options.language, options); + const formattedCode = await format(code, prettierOptions); + + return { + success: true, + data: formattedCode, + }; + } catch (error) { + this.log( + `Formatting failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return { + success: false, + error: `Formatting failed: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + } + + /** + * Lint code to identify issues + * @param code The code to lint + * @param options Linting options + * @returns A promise resolving to the linting result + */ + async lint(code: string, options: LintOptions): Promise { + try { + if (!code || typeof code !== "string") { + return { + success: false, + error: "Invalid code input: code must be a non-empty string", + }; + } + + if (!options.language) { + return { + success: false, + error: "Language must be specified in lint options", + }; + } + + this.log(`Linting code with language: ${options.language}`); + + const eslintOptions = { + overrideConfig: { + languageOptions: { + ecmaVersion: 2020, + sourceType: "module", + }, + rules: options.rules || {}, + }, + overrideConfigFile: true, + } as ESLint.Options; + + const linter = new ESLint(eslintOptions); + const eslintResult = await linter.lintText(code); + + return { + success: true, + data: { + issues: eslintResult.flatMap((result) => + result.messages.map((message) => ({ + line: message.line, + column: message.column, + severity: message.severity === 2 ? "error" : "warning", + rule: message.ruleId || "", + message: message.message, + })), + ), + }, + }; + } catch (error) { + this.log( + `Linting failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return { + success: false, + error: `Linting failed: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + } +} diff --git a/src/services/codestyle.ts b/src/services/codestyle.ts deleted file mode 100644 index e4aa845..0000000 --- a/src/services/codestyle.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { ESLint } from "eslint"; -import { format, Options } from "prettier"; -import { Synapse } from "../synapse"; - -export interface FormatOptions { - language: string; - indent?: number; - useTabs?: boolean; - semi?: boolean; - singleQuote?: boolean; - printWidth?: number; -} - -export interface LintOptions { - language: string; - rules?: Record; -} - -export interface FormatResult { - success: boolean; - data: string; -} - -export interface LintResult { - success: boolean; - issues: Array<{ - line: number; - column: number; - severity: "error" | "warning"; - rule: string; - message: string; - }>; -} - -export class CodeStyle { - private synapse: Synapse; - - /** - * Creates a new CodeStyle instance - * @param synapse The Synapse instance for WebSocket communication - */ - constructor(synapse: Synapse) { - this.synapse = synapse; - } - - private getParserForLanguage(language: string): string { - const languageMap: Record = { - javascript: "babel", - typescript: "typescript", - json: "json", - html: "html", - css: "css", - markdown: "markdown", - yaml: "yaml", - }; - - return languageMap[language.toLowerCase()] || "babel"; - } - - private toPrettierOptions( - language: string, - options?: FormatOptions, - ): Options { - const parser = this.getParserForLanguage(language); - - return { - parser, - tabWidth: options?.indent || 2, - useTabs: options?.useTabs || false, - semi: options?.semi !== undefined ? options.semi : true, - singleQuote: options?.singleQuote || false, - printWidth: options?.printWidth || 80, - }; - } - - /** - * Format code according to specified options - * @param code The code to format - * @param options Formatting options - * @returns A promise resolving to the formatting result - */ - async format(code: string, options: FormatOptions): Promise { - const prettierOptions = this.toPrettierOptions(options.language, options); - const formattedCode = await format(code, prettierOptions); - - return { - success: true, - data: formattedCode, - }; - } - - /** - * Lint code to identify issues - * @param code The code to lint - * @param options Linting options - * @returns A promise resolving to the linting result - */ - async lint(code: string, options: LintOptions): Promise { - const eslintOptions = { - useEslintrc: false, - overrideConfig: { - parser: "@typescript-eslint/parser", - rules: options.rules || {}, - }, - }; - - const linter = new ESLint(eslintOptions); - const eslintResult = await linter.lintText(code); - - return { - success: true, - issues: eslintResult.flatMap((result) => - result.messages.map((message) => ({ - line: message.line, - column: message.column, - severity: message.severity === 2 ? "error" : "warning", - rule: message.ruleId || "", - message: message.message, - })), - ), - }; - } -} diff --git a/src/services/filesystem.ts b/src/services/filesystem.ts index 610cffb..2375ec7 100644 --- a/src/services/filesystem.ts +++ b/src/services/filesystem.ts @@ -1,3 +1,4 @@ +import { constants as fsConstants } from "fs"; import * as fs from "fs/promises"; import * as path from "path"; import { Synapse } from "../synapse"; @@ -10,6 +11,7 @@ export type FileItem = { export type FileOperationResult = { success: boolean; data?: string | FileItem[]; + error?: string; }; export class Filesystem { @@ -23,40 +25,71 @@ export class Filesystem { this.synapse = synapse; } - private log(method: string, message: string): void { - this.synapse.logger(`[${method}] ${message}`); + private log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[Filesystem][${timestamp}] ${message}`); } /** - * Creates a new file at the specified path with optional content + * Creates a new file at the specified path with optional content. + * Fails if the file already exists. * @param filePath - The full path where the file should be created * @param content - Optional content to write to the file (defaults to empty string) * @returns Promise indicating success or failure - * @throws Error if file creation fails + * @throws Error if file creation fails for reasons other than existence */ async createFile( filePath: string, content: string = "", ): Promise { - try { - this.log("createFile", `Creating file at path: ${filePath}`); - - const dirPath = path.dirname(filePath); - this.log("createFile", `Ensuring directory exists: ${dirPath}`); + const fullPath = path.join(this.synapse.workDir, filePath); - await this.createFolder(dirPath); - - this.log("createFile", "Writing file content..."); - await fs.writeFile(filePath, content); - - this.log("createFile", "File created successfully"); - return { success: true }; - } catch (error) { - this.log( - "createFile", - `Error: ${error instanceof Error ? error.message : String(error)}`, - ); - throw error; + try { + await fs.access(fullPath, fsConstants.F_OK); + const errorMsg = `File already exists at path:`; + this.log(`Error: ${errorMsg} ${fullPath}`); + + return { success: false, error: `${errorMsg} ${filePath}` }; // file already exists + } catch (accessError: unknown) { + if ((accessError as NodeJS.ErrnoException)?.code === "ENOENT") { + try { + const dirPath = path.dirname(filePath); + const folderResult = await this.createFolder(dirPath); + if (!folderResult.success) { + this.log( + `Failed to create parent directory for ${filePath}: ${folderResult.error}`, + ); + + return { success: false, error: folderResult.error }; // failed to create parent directory + } + await fs.writeFile(fullPath, content, { flag: "wx" }); + + return { success: true, data: "File created successfully" }; // file created successfully + } catch (writeError: unknown) { + const errorMsg = + writeError instanceof Error + ? writeError.message + : String(writeError); + this.log(`Error during file write: ${errorMsg}`); + if ((writeError as NodeJS.ErrnoException)?.code === "EEXIST") { + // file already exists + return { + success: false, + error: `File already exists at path: ${filePath}`, + }; + } + + return { success: false, error: errorMsg }; // failed to write file + } + } else { + const errorMsg = + accessError instanceof Error + ? accessError.message + : String(accessError); + this.log(`Error accessing path ${filePath}: ${errorMsg}`); + + return { success: false, error: errorMsg }; // failed to access path + } } } @@ -68,18 +101,19 @@ export class Filesystem { */ async getFile(filePath: string): Promise { try { - this.log("getFile", `Reading file at path: ${filePath}`); + const fullPath = path.join(this.synapse.workDir, filePath); - const data = await fs.readFile(filePath, "utf-8"); + const data = await fs.readFile(fullPath, "utf-8"); - this.log("getFile", "File read successfully"); return { success: true, data }; } catch (error) { this.log( - "getFile", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -95,24 +129,22 @@ export class Filesystem { content: string, ): Promise { try { - this.log("updateFile", `Updating file at path: ${filePath}`); - + this.log(`Updating file at path: ${filePath}`); + const fullPath = path.join(this.synapse.workDir, filePath); const dirPath = path.dirname(filePath); - this.log("updateFile", `Ensuring directory exists: ${dirPath}`); await this.createFolder(dirPath); + await fs.writeFile(fullPath, content); - this.log("updateFile", "Writing file content..."); - await fs.writeFile(filePath, content); - - this.log("updateFile", "File updated successfully"); return { success: true }; } catch (error) { this.log( - "updateFile", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -128,18 +160,21 @@ export class Filesystem { newPath: string, ): Promise { try { - this.log("updateFilePath", `Moving file from ${oldPath} to ${newPath}`); + this.log(`Moving file from ${oldPath} to ${newPath}`); + const fullOldPath = path.join(this.synapse.workDir, oldPath); + const fullNewPath = path.join(this.synapse.workDir, newPath); - await fs.rename(oldPath, newPath); + await fs.rename(fullOldPath, fullNewPath); - this.log("updateFilePath", "File moved successfully"); return { success: true }; } catch (error) { this.log( - "updateFilePath", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -151,18 +186,20 @@ export class Filesystem { */ async deleteFile(filePath: string): Promise { try { - this.log("deleteFile", `Deleting file at path: ${filePath}`); + this.log(`Deleting file at path: ${filePath}`); + const fullPath = path.join(this.synapse.workDir, filePath); - await fs.unlink(filePath); + await fs.unlink(fullPath); - this.log("deleteFile", "File deleted successfully"); return { success: true }; } catch (error) { this.log( - "deleteFile", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -173,19 +210,27 @@ export class Filesystem { * @throws Error if directory creation fails */ async createFolder(dirPath: string): Promise { - try { - this.log("createFolder", `Creating directory at path: ${dirPath}`); + if (dirPath === "." || dirPath === "" || dirPath === "/") { + // Skip creation for root or relative '.' path + return { success: true }; + } - await fs.mkdir(dirPath, { recursive: true }); + const fullPath = path.join(this.synapse.workDir, dirPath); + + try { + await fs.mkdir(fullPath, { recursive: true }); - this.log("createFolder", "Directory created successfully"); return { success: true }; - } catch (error) { + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); this.log( - "createFolder", - `Error: ${error instanceof Error ? error.message : String(error)}`, + `Error creating directory at path ${fullPath}: ${errorMsg} (Code: ${(error as NodeJS.ErrnoException)?.code})`, ); - throw error; + + return { + success: false, + error: errorMsg, + }; } } @@ -197,25 +242,24 @@ export class Filesystem { */ async getFolder(dirPath: string): Promise { try { - this.log("getFolder", `Reading directory at path: ${dirPath}`); + this.log(`Reading directory at path: ${dirPath}`); + const fullPath = path.join(this.synapse.workDir, dirPath); - const items = await fs.readdir(dirPath, { withFileTypes: true }); + const items = await fs.readdir(fullPath, { withFileTypes: true }); const data: FileItem[] = items.map((item) => ({ name: item.name, isDirectory: item.isDirectory(), })); - this.log( - "getFolder", - `Directory read successfully, found ${items.length} items`, - ); return { success: true, data }; } catch (error) { this.log( - "getFolder", - `Error: ${error instanceof Error ? error.message : String(error)}`, + `Error reading directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -231,21 +275,23 @@ export class Filesystem { name: string, ): Promise { try { - this.log("updateFolderName", `Renaming folder at ${dirPath} to ${name}`); + this.log(`Renaming folder at ${dirPath} to ${name}`); + const fullPath = path.join(this.synapse.workDir, dirPath); - const dir = path.dirname(dirPath); + const dir = path.dirname(fullPath); const newPath = path.join(dir, name); - await fs.rename(dirPath, newPath); + await fs.rename(fullPath, newPath); - this.log("updateFolderName", "Folder renamed successfully"); return { success: true }; } catch (error) { this.log( - "updateFolderName", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -261,21 +307,21 @@ export class Filesystem { newPath: string, ): Promise { try { - this.log( - "updateFolderPath", - `Moving folder from ${oldPath} to ${newPath}`, - ); + this.log(`Moving folder from ${oldPath} to ${newPath}`); + const fullOldPath = path.join(this.synapse.workDir, oldPath); + const fullNewPath = path.join(this.synapse.workDir, newPath); - await fs.rename(oldPath, newPath); + await fs.rename(fullOldPath, fullNewPath); - this.log("updateFolderPath", "Folder moved successfully"); return { success: true }; } catch (error) { this.log( - "updateFolderPath", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } @@ -287,18 +333,20 @@ export class Filesystem { */ async deleteFolder(dirPath: string): Promise { try { - this.log("deleteFolder", `Deleting folder at path: ${dirPath}`); + this.log(`Deleting folder at path: ${dirPath}`); + const fullPath = path.join(this.synapse.workDir, dirPath); - await fs.rm(dirPath, { recursive: true, force: true }); + await fs.rm(fullPath, { recursive: true }); - this.log("deleteFolder", "Folder deleted successfully"); return { success: true }; } catch (error) { this.log( - "deleteFolder", `Error: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } } diff --git a/src/services/git.ts b/src/services/git.ts index 2054916..b9b3bb4 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -1,21 +1,32 @@ import { spawn } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; import { Synapse } from "../synapse"; +export type GitOperationResult = { + success: boolean; + data?: string; + error?: string; +}; + export class Git { private synapse: Synapse; + /** + * Creates a new Git instance + * @param synapse - The Synapse instance to use + */ constructor(synapse: Synapse) { this.synapse = synapse; } - /** - * Executes a git command and returns the output as a Promise - * @param args - The arguments to pass to the git command - * @returns The output of the git command - */ - private async execute(args: string[]): Promise { - return new Promise((resolve, reject) => { - const git = spawn("git", args); + private isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; + } + + private async execute(args: string[]): Promise { + return new Promise((resolve) => { + const git = spawn("git", args, { cwd: this.synapse.workDir }); let output = ""; let errorOutput = ""; @@ -27,29 +38,120 @@ export class Git { errorOutput += data.toString(); }); + git.on("error", (error: Error) => { + resolve({ + success: false, + error: `Failed to execute git command: ${error.message}`, + }); + }); + git.on("close", (code) => { if (code !== 0) { - reject(new Error(`Git error: ${errorOutput}`)); + resolve({ + success: false, + error: errorOutput.trim() || "Git command failed", + }); } else { - resolve(output.trim()); + resolve({ success: true, data: output.trim() }); } }); }); } + private async isGitRepository(): Promise { + try { + const gitDir = path.join(this.synapse.workDir, ".git"); + return fs.existsSync(gitDir) && fs.statSync(gitDir).isDirectory(); + } catch (error: unknown) { + if (this.isErrnoException(error)) { + console.error(`Error checking git repository: ${error.message}`); + } + return false; + } + } + + /** + * Initialize a new git repository + * @returns Result of the git init operation + */ + async init(): Promise { + try { + // Check if we're already in a git repository + if (await this.isGitRepository()) { + return { + success: false, + error: "Git repository already exists in this directory", + }; + } + + // Check if we have write permissions in the current directory + try { + await fs.promises.access(this.synapse.workDir, fs.constants.W_OK); + } catch (error: unknown) { + if (this.isErrnoException(error)) { + return { + success: false, + error: `No write permission in the current directory: ${error.message}`, + }; + } + return { + success: false, + error: "No write permission in the current directory", + }; + } + + return this.execute(["init"]); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { + success: false, + error: `Failed to initialize git repository: ${message}`, + }; + } + } + + /** + * Add a remote repository + * @param name - The name of the remote (e.g., "origin") + * @param url - The URL of the remote repository + * @returns The output of the git remote add command + */ + async addRemote(name: string, url: string): Promise { + return this.execute(["remote", "add", name, url]); + } + /** * Get the current branch name * @returns The current branch name */ - async getCurrentBranch(): Promise { + async getCurrentBranch(): Promise { return this.execute(["rev-parse", "--abbrev-ref", "HEAD"]); } + /** + * Set Git user name + * @param name - The user name to set for Git + * @returns The output of the git config command + */ + async setUserName(name: string): Promise { + return this.execute(["config", "user.name", name]); + } + + /** + * Set Git user email + * @param email - The email to set for Git + * @returns The output of the git config command + */ + async setUserEmail(email: string): Promise { + return this.execute(["config", "user.email", email]); + } + /** * Get the status of the repository * @returns The status of the repository */ - async status(): Promise { + async status(): Promise { return this.execute(["status"]); } @@ -58,7 +160,7 @@ export class Git { * @param files - The files to add to staging * @returns The output of the git add command */ - async add(files: string[]): Promise { + async add(files: string[]): Promise { return this.execute(["add", ...files]); } @@ -67,7 +169,7 @@ export class Git { * @param message - The commit message * @returns The output of the git commit command */ - async commit(message: string): Promise { + async commit(message: string): Promise { return this.execute(["commit", "-m", message]); } @@ -75,7 +177,7 @@ export class Git { * Pull changes from remote * @returns The output of the git pull command */ - async pull(): Promise { + async pull(): Promise { return this.execute(["pull"]); } @@ -83,7 +185,7 @@ export class Git { * Push changes to remote * @returns The output of the git push command */ - async push(): Promise { + async push(): Promise { return this.execute(["push"]); } } diff --git a/src/services/system.ts b/src/services/system.ts index a0c29c8..45f6505 100644 --- a/src/services/system.ts +++ b/src/services/system.ts @@ -11,7 +11,7 @@ export type CPUTimes = { export type SystemUsageData = { success: boolean; - data: { + data?: { cpuCores: number; cpuUsagePerCore: number[]; cpuUsagePercent: number; @@ -23,6 +23,7 @@ export type SystemUsageData = { memoryUsedBytes: number; memoryUsagePercent: number; }; + error?: string; }; export class System { @@ -32,6 +33,11 @@ export class System { this.synapse = synapse; } + private log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[System][${timestamp}] ${message}`); + } + private calculateCPUUsage(startUsage: CPUTimes, endUsage: CPUTimes): number { const userDiff = endUsage.user - startUsage.user; const systemDiff = endUsage.system - startUsage.system; @@ -66,7 +72,7 @@ export class System { */ async getUsage(measurementInterval: number = 3000): Promise { try { - this.synapse.logger("Starting system usage measurement"); + this.log("Starting system usage measurement"); const startMeasurements = this.getCPUUsage(); await new Promise((resolve) => setTimeout(resolve, measurementInterval)); @@ -83,7 +89,7 @@ export class System { const [loadAvg1m, loadAvg5m, loadAvg15m] = os.loadavg(); - this.synapse.logger("System usage measurement completed"); + this.log("System usage measurement completed"); return { success: true, @@ -103,10 +109,13 @@ export class System { }, }; } catch (error) { - this.synapse.logger( + this.log( `Error in getUsage: ${error instanceof Error ? error.message : String(error)}`, ); - throw error; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; } } } diff --git a/src/services/terminal.ts b/src/services/terminal.ts index 404ea75..e639639 100644 --- a/src/services/terminal.ts +++ b/src/services/terminal.ts @@ -6,13 +6,15 @@ export type TerminalOptions = { shell: string; cols?: number; rows?: number; - workdir?: string; }; export class Terminal { private synapse: Synapse; private term: pty.IPty | null = null; - private onDataCallback: ((data: string) => void) | null = null; + private onDataCallback: ((success: boolean, data: string) => void) | null = + null; + private isAlive: boolean = false; + private initializationError: Error | null = null; /** * Creates a new Terminal instance @@ -25,23 +27,74 @@ export class Terminal { shell: os.platform() === "win32" ? "powershell.exe" : "bash", cols: 80, rows: 24, - workdir: process.cwd(), }, ) { this.synapse = synapse; - this.term = pty.spawn(terminalOptions.shell, [], { - name: "xterm-color", - cols: terminalOptions.cols, - rows: terminalOptions.rows, - cwd: terminalOptions.workdir, - env: process.env, - }); - - this.term.onData((data: string) => { + this.synapse.registerTerminal(this); + + try { + this.term = pty.spawn(terminalOptions.shell, [], { + name: "xterm-color", + cols: terminalOptions.cols, + rows: terminalOptions.rows, + cwd: this.synapse.workDir, + env: process.env, + }); + + this.isAlive = true; + + this.term.onData((data: string) => { + if (this.onDataCallback) { + this.onDataCallback(true, data); + } + }); + + this.term.onExit((e?: { exitCode: number; signal?: number }) => { + this.isAlive = false; + this.term = null; + + if (this.onDataCallback) { + this.onDataCallback( + false, + `Terminal process exited with code ${e?.exitCode ?? "unknown"}`, + ); + } + }); + } catch (error) { + this.initializationError = + error instanceof Error ? error : new Error(String(error)); + console.error("Failed to spawn terminal:", error); + this.isAlive = false; + this.term = null; + if (this.onDataCallback) { - this.onDataCallback(data); + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + this.onDataCallback( + false, + `Terminal initialization failed: ${errorMessage}`, + ); } - }); + } + } + + private log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[Terminal][${timestamp}] ${message}`); + } + + private checkTerminal(): void { + if (!this.isAlive || !this.term) { + throw new Error("Terminal is not alive or has been terminated"); + } + } + + /** + * Get initialization error if any + * @returns The initialization error or null + */ + getInitializationError(): Error | null { + return this.initializationError; } /** @@ -50,7 +103,12 @@ export class Terminal { * @param rows - The number of rows */ updateSize(cols: number, rows: number): void { - this.term?.resize(Math.max(cols, 1), Math.max(rows, 1)); + try { + this.checkTerminal(); + this.term?.resize(Math.max(cols, 1), Math.max(rows, 1)); + } catch (error) { + console.error("Failed to update terminal size:", error); + } } /** @@ -58,21 +116,70 @@ export class Terminal { * @param command - The command to write */ createCommand(command: string): void { - this.term?.write(command); + try { + this.checkTerminal(); + this.log(`Writing command: ${command}`); + + // Ensure command ends with newline + if (!command.endsWith("\n")) { + command += "\n"; + } + + this.term?.write(command); + } catch (error) { + console.error("Failed to execute command:", error); + } } /** * Sets the callback for when data is received from the terminal * @param callback - The callback to set */ - onData(callback: (data: string) => void): void { - this.onDataCallback = callback; + onData(callback: (success: boolean, data: string) => void): void { + this.onDataCallback = (success: boolean, data: string) => { + callback(success, data); + }; + + if (this.initializationError && callback) { + callback( + false, + `Terminal initialization failed: ${this.initializationError.message}`, + ); + } + } + + /** + * Updates the working directory of the terminal + * @param workDir - The new working directory + */ + updateWorkDir(workDir: string): void { + try { + this.checkTerminal(); + // Send cd command to change directory + this.createCommand(`cd "${workDir}"`); + } catch (error) { + console.error("Failed to update working directory:", error); + } } /** * Kills the terminal */ kill(): void { - this.term?.kill(); + if (this.isAlive && this.term) { + this.log("Killing terminal"); + this.term.kill(); + this.isAlive = false; + this.term = null; + this.synapse.unregisterTerminal(this); + } + } + + /** + * Checks if the terminal is still alive + * @returns boolean indicating if the terminal is alive + */ + isTerminalAlive(): boolean { + return this.isAlive && this.term !== null; } } diff --git a/src/synapse.ts b/src/synapse.ts index 4e04e89..5540c3d 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -1,6 +1,8 @@ +import fs from "fs"; import { IncomingMessage } from "http"; import { Socket } from "net"; import WebSocket, { WebSocketServer } from "ws"; +import { Terminal } from "./services/terminal"; export type MessagePayload = { type: string; @@ -23,6 +25,8 @@ class Synapse { onError: (() => {}) as ErrorCallback, }; + private terminals: Set = new Set(); + private isReconnecting = false; private maxReconnectAttempts = 5; private reconnectAttempts = 0; @@ -30,19 +34,35 @@ class Synapse { private lastPath: string | null = null; private reconnectTimeout: NodeJS.Timeout | null = null; - logger: Logger = console.log; private host: string; private port: number; - constructor(host: string = "localhost", port: number = 3000) { + public workDir: string; + + constructor( + host: string = "localhost", + port: number = 3000, + workDir: string = process.cwd(), + ) { this.host = host; this.port = port; + + if (!fs.existsSync(workDir)) { + fs.mkdirSync(workDir, { recursive: true }); + } + this.workDir = workDir; + this.wss = new WebSocketServer({ noServer: true }); this.wss.on("connection", (ws: WebSocket) => { this.setupWebSocket(ws); }); } + private log(message: string): void { + const timestamp = new Date().toISOString(); + console.log(`[Synapse][${timestamp}] ${message}`); + } + private setupWebSocket(ws: WebSocket): void { this.ws = ws; this.reconnectAttempts = 0; @@ -75,17 +95,17 @@ class Synapse { this.reconnectAttempts++; this.reconnectTimeout = setTimeout(() => { - this.logger( + this.log( `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`, ); this.connect(this.lastPath || "/") .then(() => { this.isReconnecting = false; - this.logger("Reconnection successful"); + this.log("Reconnection successful"); }) .catch((error) => { this.isReconnecting = false; - this.logger(`Reconnection failed: ${error.message}`); + this.log(`Reconnection failed: ${error.message}`); if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } @@ -93,18 +113,6 @@ class Synapse { }, this.reconnectInterval); } - /** - * Cancels the reconnection process - */ - public cancelReconnect(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - this.isReconnecting = false; - this.reconnectAttempts = this.maxReconnectAttempts; - } - private handleMessage(event: WebSocket.MessageEvent): void { try { const data = event.data as string; @@ -122,7 +130,7 @@ class Synapse { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown parsing error"; - this.logger( + this.log( `Message parsing error: ${errorMessage}. Raw message: ${event.data}`, ); } @@ -132,6 +140,69 @@ class Synapse { return `ws://${this.host}:${this.port}${path}`; } + /** + * Registers a terminal instance with Synapse + * @param terminal - The terminal instance to register + */ + registerTerminal(terminal: Terminal): void { + this.terminals.add(terminal); + } + + /** + * Unregisters a terminal instance from Synapse + * @param terminal - The terminal instance to unregister + */ + unregisterTerminal(terminal: Terminal): void { + this.terminals.delete(terminal); + } + + /** + * Sets the working directory for the Synapse instance + * @param workDir - The path to the working directory + * @returns void + */ + updateWorkDir(workDir: string): { success: boolean; data: string } { + if (!fs.existsSync(workDir)) { + try { + fs.mkdirSync(workDir, { recursive: true }); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Unknown error creating directory"; + this.log(`Failed to create work directory: ${errorMessage}`); + return { + success: false, + data: `Failed to create work directory: ${errorMessage}`, + }; + } + } + + this.workDir = workDir; + this.terminals.forEach((terminal) => { + if (terminal.isTerminalAlive()) { + terminal.updateWorkDir(workDir); + } + }); + return { + success: true, + data: "Work directory updated successfully", + }; + } + + /** + * Cancels the reconnection process + * @returns void + */ + cancelReconnect(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.isReconnecting = false; + this.reconnectAttempts = this.maxReconnectAttempts; + } + /** * Establishes a WebSocket connection to the specified URL and initializes the terminal * @param path - The WebSocket endpoint path (e.g. '/' or '/terminal') @@ -181,14 +252,6 @@ class Synapse { }); } - /** - * Sets the logger function for the Synapse instance - * @param logger - The logger function to use - */ - setLogger(logger: Logger): void { - this.logger = logger; - } - /** * Sends a message to the WebSocket server * @param type - The type of message to send diff --git a/tests/services/code.test.ts b/tests/services/code.test.ts new file mode 100644 index 0000000..665a246 --- /dev/null +++ b/tests/services/code.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { ESLint } from "eslint"; +import { format } from "prettier"; +import { Code } from "../../src/services/code"; +import { Synapse } from "../../src/synapse"; + +jest.mock("prettier"); +jest.mock("eslint"); +jest.mock("../../src/synapse"); + +describe("Code", () => { + let code: Code; + let mockSynapse: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockSynapse = new Synapse() as jest.Mocked; + code = new Code(mockSynapse); + }); + + describe("format", () => { + test("should format JavaScript code correctly", async () => { + const input = "const x=1;const y=2;"; + const expected = "const x = 1;\nconst y = 2;\n"; + + (format as jest.MockedFunction).mockResolvedValue( + expected, + ); + + const result = await code.format(input, { language: "javascript" }); + + expect(result).toEqual({ + success: true, + data: expected, + }); + }); + + test("should format TypeScript code with custom options", async () => { + const input = "const x:number=1;"; + const expected = "const x: number = 1;\n"; + + (format as jest.MockedFunction).mockResolvedValue( + expected, + ); + + const result = await code.format(input, { + language: "typescript", + indent: 4, + useTabs: true, + semi: false, + singleQuote: true, + }); + + expect(result).toEqual({ + success: true, + data: expected, + }); + }); + }); + + describe("lint", () => { + test("should lint JavaScript code and return issues", async () => { + const input = "const x = 1;"; + const mockLintResult: ESLint.LintResult[] = [ + { + messages: [ + { + line: 1, + column: 1, + severity: 2, + ruleId: "no-unused-vars", + message: "x is defined but never used", + }, + ], + filePath: "", + errorCount: 1, + warningCount: 0, + fixableErrorCount: 0, + fixableWarningCount: 0, + source: input, + suppressedMessages: [], + fatalErrorCount: 0, + usedDeprecatedRules: [], + }, + ]; + + const mockLintText = jest + .fn<() => Promise>() + .mockResolvedValue(mockLintResult); + const MockESLint = jest.fn().mockImplementation(() => ({ + lintText: mockLintText, + })); + (ESLint as unknown) = MockESLint; + + const result = await code.lint(input, { language: "javascript" }); + + expect(result).toEqual({ + success: true, + data: { + issues: [ + { + line: 1, + column: 1, + severity: "error", + rule: "no-unused-vars", + message: "x is defined but never used", + }, + ], + }, + }); + }); + + test("should handle warnings in lint results", async () => { + const input = 'console.log("test");'; + const mockLintResult: ESLint.LintResult[] = [ + { + messages: [ + { + line: 1, + column: 1, + severity: 1, + ruleId: "no-console", + message: "Unexpected console statement", + }, + ], + filePath: "", + errorCount: 0, + warningCount: 1, + fixableErrorCount: 0, + fixableWarningCount: 0, + source: input, + suppressedMessages: [], + fatalErrorCount: 0, + usedDeprecatedRules: [], + }, + ]; + + const mockLintText = jest + .fn<() => Promise>() + .mockResolvedValue(mockLintResult); + const MockESLint = jest.fn().mockImplementation(() => ({ + lintText: mockLintText, + })); + (ESLint as unknown) = MockESLint; + + const result = await code.lint(input, { language: "javascript" }); + + expect(result).toEqual({ + success: true, + data: { + issues: [ + { + line: 1, + column: 1, + severity: "warning", + rule: "no-console", + message: "Unexpected console statement", + }, + ], + }, + }); + }); + }); +}); diff --git a/tests/services/codestyle.test.ts b/tests/services/codestyle.test.ts deleted file mode 100644 index b72cd1b..0000000 --- a/tests/services/codestyle.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { beforeEach, describe, expect, jest, test } from "@jest/globals"; -import { ESLint } from "eslint"; -import { format } from "prettier"; -import { CodeStyle } from "../../src/services/codestyle"; -import { Synapse } from "../../src/synapse"; - -jest.mock("prettier"); -jest.mock("eslint"); -jest.mock("../../src/synapse"); - -describe("CodeStyle", () => { - let codeStyle: CodeStyle; - let mockSynapse: jest.Mocked; - - beforeEach(() => { - jest.clearAllMocks(); - - mockSynapse = new Synapse() as jest.Mocked; - codeStyle = new CodeStyle(mockSynapse); - }); - - describe("format", () => { - test("should format JavaScript code with default options", async () => { - const input = "const x=1;const y=2;"; - const expected = "const x = 1;\nconst y = 2;\n"; - - const mockFormat = format as jest.MockedFunction; - mockFormat.mockResolvedValue(expected); - - const result = await codeStyle.format(input, { language: "javascript" }); - - expect(result.success).toBe(true); - expect(result.data).toBe(expected); - expect(format).toHaveBeenCalledWith(input, { - parser: "babel", - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: false, - printWidth: 80, - }); - }); - - test("should format TypeScript code with custom options", async () => { - const input = 'const x:number=1;const y:string="test";'; - const expected = "const x: number = 1\nconst y: string = 'test'\n"; - - const mockFormat = format as jest.MockedFunction; - mockFormat.mockResolvedValue(expected); - - const result = await codeStyle.format(input, { - language: "typescript", - indent: 4, - useTabs: true, - semi: false, - singleQuote: true, - printWidth: 100, - }); - - expect(result.success).toBe(true); - expect(result.data).toBe(expected); - expect(format).toHaveBeenCalledWith(input, { - parser: "typescript", - tabWidth: 4, - useTabs: true, - semi: false, - singleQuote: true, - printWidth: 100, - }); - }); - - test("should handle unsupported language gracefully", async () => { - const input = "some code"; - const expected = "formatted code"; - - const mockFormat = format as jest.MockedFunction; - mockFormat.mockResolvedValue(expected); - - const result = await codeStyle.format(input, { language: "unsupported" }); - - expect(result.success).toBe(true); - expect(result.data).toBe(expected); - expect(format).toHaveBeenCalledWith(input, { - parser: "babel", // Should default to babel - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: false, - printWidth: 80, - }); - }); - }); - - describe("lint", () => { - test("should lint JavaScript code with default rules", async () => { - const input = "const x = 1;\nconst y = 2;"; - const mockLintResult: ESLint.LintResult[] = [ - { - messages: [ - { - line: 1, - column: 1, - severity: 2, - ruleId: "no-unused-vars", - message: "x is defined but never used", - }, - ], - filePath: "", - errorCount: 1, - warningCount: 0, - fixableErrorCount: 0, - fixableWarningCount: 0, - source: input, - suppressedMessages: [], - fatalErrorCount: 0, - usedDeprecatedRules: [], - }, - ]; - - const mockLintText = jest - .fn<() => Promise>() - .mockResolvedValue(mockLintResult); - const MockESLint = jest.fn().mockImplementation(() => ({ - lintText: mockLintText, - })); - (ESLint as unknown) = MockESLint; - - const result = await codeStyle.lint(input, { language: "javascript" }); - - expect(result.success).toBe(true); - expect(result.issues).toHaveLength(1); - expect(result.issues[0]).toEqual({ - line: 1, - column: 1, - severity: "error", - rule: "no-unused-vars", - message: "x is defined but never used", - }); - }); - - test("should lint with custom rules", async () => { - const input = "let x = 1;"; - const customRules = { - "prefer-const": "error" as const, - }; - - const mockLintResult: ESLint.LintResult[] = [ - { - messages: [ - { - line: 1, - column: 1, - severity: 2, - ruleId: "prefer-const", - message: "Use const instead of let", - }, - ], - filePath: "", - errorCount: 1, - warningCount: 0, - fixableErrorCount: 0, - fixableWarningCount: 0, - source: input, - suppressedMessages: [], - fatalErrorCount: 0, - usedDeprecatedRules: [], - }, - ]; - - const mockLintText = jest - .fn<() => Promise>() - .mockResolvedValue(mockLintResult); - const MockESLint = jest.fn().mockImplementation(() => ({ - lintText: mockLintText, - })); - (ESLint as unknown) = MockESLint; - - const result = await codeStyle.lint(input, { - language: "javascript", - rules: customRules, - }); - - expect(result.success).toBe(true); - expect(result.issues).toHaveLength(1); - expect(result.issues[0].rule).toBe("prefer-const"); - expect(MockESLint).toHaveBeenCalledWith({ - useEslintrc: false, - overrideConfig: { - parser: "@typescript-eslint/parser", - rules: customRules, - }, - }); - }); - - test("should handle warnings correctly", async () => { - const input = 'console.log("test");'; - const mockLintResult: ESLint.LintResult[] = [ - { - messages: [ - { - line: 1, - column: 1, - severity: 1, // Warning severity - ruleId: "no-console", - message: "Unexpected console statement", - }, - ], - filePath: "", - errorCount: 0, - warningCount: 1, - fixableErrorCount: 0, - fixableWarningCount: 0, - source: input, - suppressedMessages: [], - fatalErrorCount: 0, - usedDeprecatedRules: [], - }, - ]; - - const mockLintText = jest - .fn<() => Promise>() - .mockResolvedValue(mockLintResult); - const MockESLint = jest.fn().mockImplementation(() => ({ - lintText: mockLintText, - })); - (ESLint as unknown) = MockESLint; - - const result = await codeStyle.lint(input, { language: "javascript" }); - - expect(result.success).toBe(true); - expect(result.issues[0].severity).toBe("warning"); - }); - }); -}); diff --git a/tests/services/filesystem.test.ts b/tests/services/filesystem.test.ts index 7d52336..efd4214 100644 --- a/tests/services/filesystem.test.ts +++ b/tests/services/filesystem.test.ts @@ -11,84 +11,92 @@ describe("Filesystem", () => { beforeEach(() => { mockSynapse = jest.mocked({ logger: jest.fn(), - setLogger: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - sendCommand: jest.fn(), + workDir: "/test", } as unknown as Synapse); filesystem = new Filesystem(mockSynapse); + jest.clearAllMocks(); }); describe("createFile", () => { - it("should create a file with content and verify its existence", async () => { - const filePath = "/test/file.txt"; + it("should successfully create a file", async () => { + const filePath = "/file.txt"; const content = "test content"; - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + // Mock access to indicate file does NOT exist initially + const accessError = new Error("ENOENT") as NodeJS.ErrnoException; + accessError.code = "ENOENT"; + (fs.access as jest.Mock).mockRejectedValue(accessError); + (fs.mkdir as jest.Mock).mockResolvedValue(undefined); // Assuming createFolder is called (fs.writeFile as jest.Mock).mockResolvedValue(undefined); - (fs.readFile as jest.Mock).mockResolvedValue(content); - (fs.access as jest.Mock).mockResolvedValue(undefined); - - const createResult = await filesystem.createFile(filePath, content); - expect(createResult.success).toBe(true); - expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { - recursive: true, + const result = await filesystem.createFile(filePath, content); + expect(result).toEqual({ + success: true, + data: "File created successfully", }); + }); - expect(fs.writeFile).toHaveBeenCalledWith(filePath, content); + it("should return error if file already exists", async () => { + const filePath = "/existing.txt"; + const content = "test content"; - await expect(fs.access(filePath)).resolves.toBeUndefined(); + // Mock access to indicate file DOES exist + (fs.access as jest.Mock).mockResolvedValue(undefined); - const readResult = await filesystem.getFile(filePath); - expect(readResult.success).toBe(true); - expect(readResult.data).toBe(content); + const result = await filesystem.createFile(filePath, content); + expect(fs.writeFile).not.toHaveBeenCalled(); // Should not attempt to write + expect(result).toEqual({ + success: false, + error: `File already exists at path: ${filePath}`, + }); }); - it("should handle file creation errors properly", async () => { + it("should handle file creation errors during write", async () => { const filePath = "/test/error.txt"; const content = "test content"; - const error = new Error("Failed to create file"); - (fs.mkdir as jest.Mock).mockResolvedValue(undefined); - (fs.writeFile as jest.Mock).mockRejectedValue(error); - - await expect(filesystem.createFile(filePath, content)).rejects.toThrow( - error, - ); - expect(mockSynapse.logger).toHaveBeenCalledWith( - expect.stringContaining("Error: Failed to create file"), + // Mock access to indicate file does NOT exist initially + const accessError = new Error("ENOENT") as NodeJS.ErrnoException; + accessError.code = "ENOENT"; + (fs.access as jest.Mock).mockRejectedValue(accessError); + (fs.mkdir as jest.Mock).mockResolvedValue(undefined); // Mock directory creation + (fs.writeFile as jest.Mock).mockRejectedValue( + new Error("Failed to write file"), ); + + const result = await filesystem.createFile(filePath, content); + expect(result).toEqual({ + success: false, + error: "Failed to write file", + }); }); }); describe("getFile", () => { - it("should read file content and verify data", async () => { - const filePath = "/test/file.txt"; + it("should successfully read file content", async () => { + const filePath = "/file.txt"; const content = "test content"; (fs.readFile as jest.Mock).mockResolvedValue(content); - (fs.access as jest.Mock).mockResolvedValue(undefined); - - await expect(fs.access(filePath)).resolves.toBeUndefined(); const result = await filesystem.getFile(filePath); - expect(result.success).toBe(true); - expect(result.data).toBe(content); - expect(fs.readFile).toHaveBeenCalledWith(filePath, "utf-8"); + expect(result).toEqual({ + success: true, + data: content, + }); }); - it("should handle file reading errors properly", async () => { + it("should handle file reading errors", async () => { const filePath = "/test/nonexistent.txt"; - const error = new Error("File not found"); - (fs.readFile as jest.Mock).mockRejectedValue(error); + (fs.readFile as jest.Mock).mockRejectedValue(new Error("File not found")); - await expect(filesystem.getFile(filePath)).rejects.toThrow(error); - expect(mockSynapse.logger).toHaveBeenCalledWith( - expect.stringContaining("Error: File not found"), - ); + const result = await filesystem.getFile(filePath); + expect(result).toEqual({ + success: false, + error: "File not found", + }); }); }); }); diff --git a/tests/services/git.test.ts b/tests/services/git.test.ts index ab63dcd..783912c 100644 --- a/tests/services/git.test.ts +++ b/tests/services/git.test.ts @@ -1,21 +1,44 @@ import { jest } from "@jest/globals"; import { spawn } from "child_process"; +import * as fs from "fs"; import { Git, Synapse } from "../../src"; jest.mock("child_process", () => ({ spawn: jest.fn(), })); +jest.mock("fs", () => { + const actual = jest.requireActual("fs") as typeof fs; + return { + ...actual, + existsSync: jest.fn(), + statSync: jest.fn(), + promises: { + ...actual.promises, + access: jest.fn(() => Promise.resolve()), + }, + }; +}); + describe("Git Service", () => { let git: Git; let mockSpawn: jest.Mock; let mockSynapse: jest.Mocked; + const mockWorkingDir = "/workspace/synapse"; beforeEach(() => { jest.clearAllMocks(); - - mockSynapse = {} as jest.Mocked; - + jest.spyOn(process, "cwd").mockReturnValue(mockWorkingDir); + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + mockSynapse = { + workDir: mockWorkingDir, + updateWorkDir: jest.fn((dir: string) => { + mockSynapse.workDir = dir; + return { success: true, data: "Work directory updated successfully" }; + }), + } as unknown as jest.Mocked; mockSpawn = spawn as jest.Mock; git = new Git(mockSynapse); }); @@ -55,102 +78,185 @@ describe("Git Service", () => { return mockChildProcess; }; - describe("getCurrentBranch", () => { - it("should return current branch name", async () => { - setupMockProcess("main\n"); + describe("init", () => { + it("success - new repository", async () => { + setupMockProcess("Initialized empty Git repository"); + expect(await git.init()).toEqual({ + success: true, + data: "Initialized empty Git repository", + }); + }); - const result = await git.getCurrentBranch(); + it("error - repository exists", async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.existsSync as jest.Mock).mockImplementation(function ( + this: unknown, + ...args: unknown[] + ): boolean { + const path = args[0] as string; + return path.includes(".git"); + }); + expect(await git.init()).toEqual({ + success: false, + error: "Git repository already exists in this directory", + }); + }); - expect(result).toBe("main"); - expect(mockSpawn).toHaveBeenCalledWith("git", [ - "rev-parse", - "--abbrev-ref", - "HEAD", - ]); + it("success - change workDir", async () => { + // First ensure no git repo exists + (fs.existsSync as jest.Mock).mockImplementation(function ( + this: unknown, + ...args: unknown[] + ): boolean { + return false; + }); + setupMockProcess("Initialized empty Git repository"); + + // Change workDir + mockSynapse.updateWorkDir("/workspace/another-project"); + + // Now init should succeed + const secondInit = await git.init(); + expect(secondInit).toEqual({ + success: true, + data: "Initialized empty Git repository", + }); + + // Verify workDir was changed + expect(mockSynapse.workDir).toBe("/workspace/another-project"); }); + }); - it("should handle error when getting current branch", async () => { - setupMockProcess("", "fatal: not a git repository", 128); + describe("addRemote", () => { + it("success", async () => { + setupMockProcess(""); + expect( + await git.addRemote("origin", "https://github.com/user/repo.git"), + ).toEqual({ + success: true, + data: "", + }); + }); - await expect(git.getCurrentBranch()).rejects.toThrow( - "Git error: fatal: not a git repository", - ); + it("error - remote exists", async () => { + setupMockProcess("", "remote origin already exists", 128); + expect( + await git.addRemote("origin", "https://github.com/user/repo.git"), + ).toEqual({ + success: false, + error: "remote origin already exists", + }); + }); + }); + + describe("getCurrentBranch", () => { + it("success", async () => { + setupMockProcess("main\n"); + expect(await git.getCurrentBranch()).toEqual({ + success: true, + data: "main", + }); + }); + + it("error", async () => { + setupMockProcess("", "fatal: not a git repository", 128); + expect(await git.getCurrentBranch()).toEqual({ + success: false, + error: "fatal: not a git repository", + }); }); }); describe("status", () => { - it("should return git status", async () => { + it("success", async () => { const statusOutput = "On branch main\nnothing to commit, working tree clean"; setupMockProcess(statusOutput); - - const result = await git.status(); - - expect(result).toBe(statusOutput); - expect(mockSpawn).toHaveBeenCalledWith("git", ["status"]); + expect(await git.status()).toEqual({ + success: true, + data: statusOutput, + }); }); }); describe("add", () => { - it("should add files to staging", async () => { + it("success", async () => { setupMockProcess(""); + expect(await git.add(["file1.txt"])).toEqual({ + success: true, + data: "", + }); + }); - await git.add(["file1.txt", "file2.txt"]); - - expect(mockSpawn).toHaveBeenCalledWith("git", [ - "add", - "file1.txt", - "file2.txt", - ]); + it("error - non-existent files", async () => { + setupMockProcess( + "", + "fatal: pathspec 'nonexistent.txt' did not match any files", + 128, + ); + expect(await git.add(["nonexistent.txt"])).toEqual({ + success: false, + error: "fatal: pathspec 'nonexistent.txt' did not match any files", + }); }); }); describe("commit", () => { - it("should commit changes with message", async () => { - setupMockProcess("[main abc1234] test commit\n 1 file changed"); - - const result = await git.commit("test commit"); + it("success", async () => { + const commitOutput = "[main abc1234] test commit\n 1 file changed"; + setupMockProcess(commitOutput); + expect(await git.commit("test commit")).toEqual({ + success: true, + data: commitOutput, + }); + }); - expect(result).toBe("[main abc1234] test commit\n 1 file changed"); - expect(mockSpawn).toHaveBeenCalledWith("git", [ - "commit", - "-m", - "test commit", - ]); + it("error - nothing to commit", async () => { + setupMockProcess("", "nothing to commit, working tree clean", 1); + expect(await git.commit("test commit")).toEqual({ + success: false, + error: "nothing to commit, working tree clean", + }); }); }); describe("pull", () => { - it("should pull changes from remote", async () => { + it("success", async () => { setupMockProcess("Already up to date."); + expect(await git.pull()).toEqual({ + success: true, + data: "Already up to date.", + }); + }); - const result = await git.pull(); - - expect(result).toBe("Already up to date."); - expect(mockSpawn).toHaveBeenCalledWith("git", ["pull"]); + it("error - no remote", async () => { + setupMockProcess("", "fatal: no remote repository specified", 1); + expect(await git.pull()).toEqual({ + success: false, + error: "fatal: no remote repository specified", + }); }); }); describe("push", () => { - it("should push changes to remote", async () => { + it("success", async () => { setupMockProcess("Everything up-to-date"); - - const result = await git.push(); - - expect(result).toBe("Everything up-to-date"); - expect(mockSpawn).toHaveBeenCalledWith("git", ["push"]); + expect(await git.push()).toEqual({ + success: true, + data: "Everything up-to-date", + }); }); - it("should handle push error", async () => { + it("error - no upstream", async () => { setupMockProcess( "", "fatal: The current branch has no upstream branch", 1, ); - - await expect(git.push()).rejects.toThrow( - "Git error: fatal: The current branch has no upstream branch", - ); + expect(await git.push()).toEqual({ + success: false, + error: "fatal: The current branch has no upstream branch", + }); }); }); }); diff --git a/tests/services/system.test.ts b/tests/services/system.test.ts index 2cef577..0175ff6 100644 --- a/tests/services/system.test.ts +++ b/tests/services/system.test.ts @@ -6,16 +6,12 @@ jest.mock("os"); describe("System", () => { let system: System; - let mockSynapse: jest.Mocked; + let mockSynapse: Synapse; beforeEach(() => { - mockSynapse = jest.mocked({ + mockSynapse = { logger: jest.fn(), - setLogger: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - sendCommand: jest.fn(), - } as unknown as Synapse); + } as unknown as Synapse; system = new System(mockSynapse); }); @@ -39,13 +35,29 @@ describe("System", () => { (os.freemem as jest.Mock).mockReturnValue(4000000000); (os.loadavg as jest.Mock).mockReturnValue([1.5, 1.0, 0.5]); - const result = await system.getUsage(100); // Shorter interval for testing + const result = await system.getUsage(100); expect(result.success).toBe(true); - expect(result.data).toHaveProperty("cpuCores", 1); - expect(result.data).toHaveProperty("memoryTotalBytes", 8000000000); - expect(result.data).toHaveProperty("memoryFreeBytes", 4000000000); - expect(result.data).toHaveProperty("memoryUsagePercent", 50); + expect(result.data).toBeDefined(); + + if (result.data) { + const data = result.data; + expect(data.cpuCores).toBe(1); + expect(Array.isArray(data.cpuUsagePerCore)).toBe(true); + expect(data.cpuUsagePerCore.length).toBe(1); + expect(typeof data.cpuUsagePercent).toBe("number"); + expect(data.cpuUsagePercent).toBeGreaterThanOrEqual(0); + expect(data.cpuUsagePercent).toBeLessThanOrEqual(100); + + expect(data.loadAverage1m).toBe(1.5); + expect(data.loadAverage5m).toBe(1.0); + expect(data.loadAverage15m).toBe(0.5); + + expect(data.memoryTotalBytes).toBe(8000000000); + expect(data.memoryFreeBytes).toBe(4000000000); + expect(data.memoryUsedBytes).toBe(4000000000); + expect(data.memoryUsagePercent).toBe(50); + } }); }); }); diff --git a/tests/services/terminal.test.ts b/tests/services/terminal.test.ts index 87add6a..e2b9e8b 100644 --- a/tests/services/terminal.test.ts +++ b/tests/services/terminal.test.ts @@ -1,5 +1,4 @@ import * as pty from "node-pty"; -import * as os from "os"; import { Terminal, TerminalOptions } from "../../src/services/terminal"; import { Synapse } from "../../src/synapse"; @@ -9,11 +8,20 @@ describe("Terminal", () => { let terminal: Terminal; let mockSynapse: Synapse; let mockPty: jest.Mocked; + let onDataHandler: (data: string) => void; + let onExitHandler: () => void; beforeEach(() => { mockSynapse = new Synapse(); mockPty = { - onData: jest.fn(), + onData: jest.fn((callback) => { + onDataHandler = callback; + return mockPty; + }), + onExit: jest.fn((callback) => { + onExitHandler = callback; + return mockPty; + }), write: jest.fn(), resize: jest.fn(), kill: jest.fn(), @@ -24,47 +32,28 @@ describe("Terminal", () => { (pty.spawn as jest.Mock).mockReturnValue(mockPty); }); - describe("constructor", () => { - it("should create terminal with default options", () => { - const defaultShell = - os.platform() === "win32" ? "powershell.exe" : "bash"; - + describe("basic terminal functionality", () => { + beforeEach(() => { terminal = new Terminal(mockSynapse); + }); - expect(pty.spawn).toHaveBeenCalledWith( - defaultShell, - [], - expect.objectContaining({ - name: "xterm-color", - cols: 80, - rows: 24, - cwd: process.cwd(), - env: process.env, - }), - ); + it("should initialize with correct state", () => { + expect(terminal.isTerminalAlive()).toBe(true); }); - it("should create terminal with custom options", () => { - const customOptions: TerminalOptions = { - shell: "zsh", - cols: 100, - rows: 30, - workdir: "/custom/path", - }; + it("should handle data events", () => { + let receivedData = ""; + terminal.onData((success, data) => { + receivedData = data; + }); - terminal = new Terminal(mockSynapse, customOptions); + onDataHandler("test output"); + expect(receivedData).toBe("test output"); + }); - expect(pty.spawn).toHaveBeenCalledWith( - "zsh", - [], - expect.objectContaining({ - name: "xterm-color", - cols: 100, - rows: 30, - cwd: "/custom/path", - env: process.env, - }), - ); + it("should handle terminal death", () => { + terminal.kill(); + expect(terminal.isTerminalAlive()).toBe(false); }); }); @@ -73,54 +62,28 @@ describe("Terminal", () => { terminal = new Terminal(mockSynapse); }); - it("should handle resize operation with minimum values", () => { - terminal.updateSize(0, -5); - - expect(mockPty.resize).toHaveBeenCalledWith(1, 1); - }); - - it("should handle write operation with command", () => { - const command = "ls -la\n"; - - terminal.createCommand(command); - - expect(mockPty.write).toHaveBeenCalledWith(command); + it("should update working directory", () => { + terminal.updateWorkDir("/new/path"); + expect(mockPty.write).toHaveBeenCalledWith('cd "/new/path"\n'); }); - it("should handle data callback", () => { - const mockCallback = jest.fn(); - - terminal.onData(mockCallback); - - // Simulate data event by calling the onData handler - const dataHandler = (mockPty.onData as jest.Mock).mock.calls[0][0]; - const testOutput = "test output"; - dataHandler(testOutput); - - expect(mockCallback).toHaveBeenCalledWith(testOutput); - }); - - it("should override previous data callback", () => { - const mockCallback1 = jest.fn(); - const mockCallback2 = jest.fn(); - - terminal.onData(mockCallback1); - terminal.onData(mockCallback2); - - // Simulate data event by calling the onData handler - const dataHandler = (mockPty.onData as jest.Mock).mock.calls[0][0]; - const testOutput = "test output"; - dataHandler(testOutput); + it("should not operate when terminal is dead", () => { + terminal.kill(); + terminal.updateSize(80, 24); + terminal.createCommand("test"); - // First callback should not be called, only the second one - expect(mockCallback1).not.toHaveBeenCalled(); - expect(mockCallback2).toHaveBeenCalledWith(testOutput); + expect(terminal.isTerminalAlive()).toBe(false); }); - it("should handle kill operation", () => { - terminal.kill(); + it("should handle custom initialization", () => { + const customOptions: TerminalOptions = { + shell: "zsh", + cols: 100, + rows: 30, + }; - expect(mockPty.kill).toHaveBeenCalled(); + const customTerminal = new Terminal(mockSynapse, customOptions); + expect(customTerminal.isTerminalAlive()).toBe(true); }); }); }); diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 183acef..cf35e14 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -32,157 +32,86 @@ describe("Synapse", () => { jest.restoreAllMocks(); }); - describe("connect", () => { - it("should establish websocket connection successfully", async () => { + describe("webSocket connection", () => { + it("should connect successfully", async () => { const mockWs = createMockWebSocket(); - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/terminal"); - mockWs.onopen!({} as WebSocket.Event); + setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - await expect(connectPromise).resolves.toBe(synapse); - expect(WebSocket).toHaveBeenCalledWith("ws://localhost:8080/terminal"); - }); - - it("should use default host and port when not provided", async () => { - const defaultSynapse = new Synapse(); - const mockWs = createMockWebSocket(); - - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - - const connectPromise = defaultSynapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); - - await expect(connectPromise).resolves.toBe(defaultSynapse); - expect(WebSocket).toHaveBeenCalledWith("ws://localhost:3000/"); + const result = await connectPromise; + expect(result).toBe(synapse); }); it("should reject on connection error", async () => { const mockWs = createMockWebSocket(); - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/"); - mockWs.onerror!({ - error: new Error("Connection failed"), - } as WebSocket.ErrorEvent); - - await expect(connectPromise).rejects.toThrow( - "WebSocket error: Unknown error. Failed to connect to ws://localhost:8080/", + setTimeout( + () => + mockWs.onerror!({ + error: new Error("Connection failed"), + } as WebSocket.ErrorEvent), + 0, ); + + await expect(connectPromise).rejects.toThrow("WebSocket error"); }); }); - describe("ping-pong", () => { - it("should handle ping-pong messages correctly", async () => { - const mockWs = createMockWebSocket(); + describe("message handling", () => { + let mockWs: ReturnType; + beforeEach(async () => { + mockWs = createMockWebSocket(); (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); + setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); await connectPromise; - - mockWs.onmessage!({ - data: "ping", - } as WebSocket.MessageEvent); - - expect(mockWs.send).toHaveBeenCalledWith("pong"); }); - }); - describe("message handling", () => { - it("should handle incoming messages correctly and pass exact message data", async () => { + it("should handle messages correctly", async () => { const handler = jest.fn(); synapse.onMessageType("test", handler); - const mockWs = createMockWebSocket(); - - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); - await connectPromise; - const testMessage = { type: "test", requestId: "123", - data: { foo: "bar", count: 42 }, + data: { foo: "bar" }, }; - mockWs.onmessage!({ - data: JSON.stringify(testMessage), - } as WebSocket.MessageEvent); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ - type: "test", - requestId: "123", - data: { foo: "bar", count: 42 }, - }), - ); - }); - - it("should not trigger handlers for different message types", async () => { - const testHandler = jest.fn(); - const otherHandler = jest.fn(); - synapse.onMessageType("test", testHandler); - synapse.onMessageType("other", otherHandler); - - const mockWs = createMockWebSocket(); - - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); - await connectPromise; - const testMessage = { type: "test", requestId: "123", data: "test-data" }; mockWs.onmessage!({ data: JSON.stringify(testMessage), } as WebSocket.MessageEvent); - - expect(testHandler).toHaveBeenCalledTimes(1); - expect(otherHandler).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledWith(testMessage); }); - it("should handle malformed JSON messages gracefully", async () => { + it("should ignore malformed messages", async () => { const handler = jest.fn(); synapse.onMessageType("test", handler); - const mockWs = createMockWebSocket(); - - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); - await connectPromise; - - mockWs.onmessage!({ - data: "invalid json{", - } as WebSocket.MessageEvent); - + mockWs.onmessage!({ data: "invalid json{" } as WebSocket.MessageEvent); expect(handler).not.toHaveBeenCalled(); }); }); describe("send", () => { - it("should send messages with correct format and return promise with message payload", async () => { - const mockWs = createMockWebSocket(); + let mockWs: ReturnType; + beforeEach(async () => { + mockWs = createMockWebSocket(); (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); + setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); await connectPromise; + }); + it("should send messages and return payload", async () => { const payload = { data: "test-data" }; const result = await synapse.send("test-type", payload); - expect(mockWs.send).toHaveBeenCalledTimes(1); - const sentMessage = JSON.parse(mockWs.send.mock.calls[0][0]); - expect(sentMessage).toEqual({ - type: "test-type", - requestId: expect.any(String), - data: "test-data", - }); expect(result).toEqual({ type: "test-type", requestId: expect.any(String), @@ -190,20 +119,8 @@ describe("Synapse", () => { }); }); - it("should throw error when WebSocket is not connected", () => { - expect(() => synapse.send("test-type")).toThrow( - "WebSocket is not connected", - ); - }); - - it("should throw error when WebSocket is not in OPEN state", async () => { - const mockWs = createMockWebSocket({ readyState: WebSocket.CLOSED }); - - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - mockWs.onopen!({} as WebSocket.Event); - await connectPromise; - + it("should throw when not connected", () => { + synapse.disconnect(); expect(() => synapse.send("test-type")).toThrow( "WebSocket is not connected", ); @@ -211,100 +128,35 @@ describe("Synapse", () => { }); describe("handleUpgrade", () => { - it("should handle upgrade requests and establish WebSocket connection", () => { - const mockReq = { - headers: { - upgrade: "websocket", - }, - } as IncomingMessage; - - const mockSocket = {} as Socket; - const mockHead = Buffer.alloc(0); + it("should handle upgrade and setup handlers", () => { const mockWs = createMockWebSocket(); - - const mockHandleUpgrade = jest.fn((req, socket, head, cb) => { - cb(mockWs); - }); - const mockEmit = jest.fn(); - const mockWss = { - handleUpgrade: mockHandleUpgrade, - emit: mockEmit, - on: jest.fn((event, callback) => { - if (event === "connection") { - callback(mockWs); - } - }), - close: jest.fn(), - } as unknown as WebSocketServer; - - jest.mocked(WebSocketServer).mockImplementation(() => mockWss); - - synapse = new Synapse(); // Recreate synapse with mocked WSS - synapse.handleUpgrade(mockReq, mockSocket, mockHead); - - expect(mockHandleUpgrade).toHaveBeenCalledWith( - mockReq, - mockSocket, - mockHead, - expect.any(Function), - ); - - expect(mockEmit).toHaveBeenCalledWith("connection", mockWs, mockReq); - }); - - it("should set up event handlers after successful upgrade", () => { - const mockReq = {} as IncomingMessage; - const mockSocket = {} as Socket; - const mockHead = Buffer.alloc(0); - - const mockWs = createMockWebSocket(); - - const mockHandleUpgrade = jest.fn((req, socket, head, cb) => { - cb(mockWs); - }); - const mockEmit = jest.fn((event: string, ws: WebSocket) => { - if (event === "connection") { - // Simulate the WebSocketServer connection event handler + handleUpgrade: jest.fn((req, socket, head, cb) => { + cb(mockWs); const connectionHandler = (mockWss.on as jest.Mock).mock.calls.find( ([eventName]: [string]) => eventName === "connection", )?.[1]; - connectionHandler?.(ws); - } - }); - - const mockWss = { - handleUpgrade: mockHandleUpgrade, - emit: mockEmit, - on: jest.fn(), + if (connectionHandler) { + connectionHandler(mockWs); + } + }), + emit: jest.fn(), + on: jest.fn() as jest.Mock, close: jest.fn(), } as unknown as WebSocketServer; jest.mocked(WebSocketServer).mockImplementation(() => mockWss); const onOpenMock = jest.fn(); - const onCloseMock = jest.fn(); - const onErrorMock = jest.fn(); - - synapse = new Synapse(); // Recreate synapse with mocked WSS + synapse = new Synapse(); synapse.onOpen(onOpenMock); - synapse.onClose(onCloseMock); - synapse.onError(onErrorMock); - - synapse.handleUpgrade(mockReq, mockSocket, mockHead); - - // The onOpen callback should be triggered when the connection is established - expect(onOpenMock).toHaveBeenCalled(); - - mockWs.onclose!({} as WebSocket.CloseEvent); - expect(onCloseMock).toHaveBeenCalled(); - mockWs.onerror!({} as WebSocket.ErrorEvent); - expect(onErrorMock).toHaveBeenCalledWith( - new Error( - "WebSocket error: Unknown error. Connection to localhost:3000 failed.", - ), + synapse.handleUpgrade( + {} as IncomingMessage, + {} as Socket, + Buffer.alloc(0), ); + expect(onOpenMock).toHaveBeenCalled(); }); }); });