diff --git a/Directory.Packages.props b/Directory.Packages.props index 808f3e69..30017475 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,12 +27,12 @@ - + - + - + diff --git a/DxWorks.ScriptBee.Plugin.Api/CHANGELOG.md b/DxWorks.ScriptBee.Plugin.Api/CHANGELOG.md index 9d84895f..6d8d041c 100644 --- a/DxWorks.ScriptBee.Plugin.Api/CHANGELOG.md +++ b/DxWorks.ScriptBee.Plugin.Api/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- update Westwind.Utilities to 5.3.0 + ## [2.0.0] ### Changed diff --git a/Integrations/VS-Code/CHANGELOG.md b/Integrations/VS-Code/CHANGELOG.md index dc68bbda..42a80ad9 100644 --- a/Integrations/VS-Code/CHANGELOG.md +++ b/Integrations/VS-Code/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `scriptbee.enableLiveUpdates` to enable live updates for project script events of creation, update, and deletion. + ## [1.0.0] ### Added diff --git a/Integrations/VS-Code/README.md b/Integrations/VS-Code/README.md index b8176441..e5352fed 100644 --- a/Integrations/VS-Code/README.md +++ b/Integrations/VS-Code/README.md @@ -17,6 +17,7 @@ For full documentation, visit [dxworks.org/scriptbee](https://dxworks.org/script - **Compare with Remote**: Open a native VS Code diff view to see exactly what changed on the server. - **Project Folder Management**: Easily open your project's script folder in VS Code. - **Generate Classes**: Trigger model class generation from a running ScriptBee instance. +- **Live Updates**: Automatically sync changes from the server in real-time. ### Script Synchronization @@ -35,21 +36,27 @@ For full documentation, visit [dxworks.org/scriptbee](https://dxworks.org/script Sync metadata (`.sb.meta` files) is stored directly alongside your scripts within the project folder. This ensures that: - Metadata is natively tied to your files, making synchronization reliable even in complex multi-root workspaces. -- **Hidden from VS Code**: These files are automatically added to VS Code's `files.exclude` setting, keeping your sidebar clean. -- **Important**: You should add `**/*.sb.meta` to your `.gitignore` file. These files are meant for local synchronization only and should not be committed to version control. -- The metadata maps local files to their remote counterparts on the ScriptBee server, allowing for efficient syncing and change detection. +- **Hidden from VS Code**: These files are automatically added to VS Code's `files.exclude` setting, keeping your + sidebar clean. +- **Important**: You should add `**/*.sb.meta` to your `.gitignore` file. These files are meant for local + synchronization only and should not be committed to version control. +- The metadata maps local files to their remote counterparts on the ScriptBee server, allowing for efficient syncing and + change detection. ### Comparison and Diffs -**Compare with Remote**: Right-click any script in the explorer or editor and select `ScriptBee: Compare with Remote`. This opens a native VS Code diff view, allowing you to see exactly what changed on the server before you decide to pull or push. +**Compare with Remote**: Right-click any script in the explorer or editor and select `ScriptBee: Compare with Remote`. +This opens a native VS Code diff view, allowing you to see exactly what changed on the server before you decide to pull +or push. ## Configuration This extension can be configured via VS Code settings: -| Setting | Description | Default | -| ------------------------- | ----------------------------------------------------------------------- | -------------- | -| `scriptbee.workspaceRoot` | The root folder where ScriptBee projects and generated code are stored. | `~/.scriptbee` | +| Setting | Description | Default | +| ----------------------------- | ---------------------------------------------------------------------------------------------- | -------------- | +| `scriptbee.workspaceRoot` | The root folder where ScriptBee projects and generated code are stored. | `~/.scriptbee` | +| `scriptbee.enableLiveUpdates` | Enable real-time updates for script lifecycle events (creation, update, deletion) via SignalR. | `true` | ## Getting Started @@ -81,7 +88,8 @@ This extension can be configured via VS Code settings: ## Requirements -You need a running instance of the ScriptBee Backend API. See the [installation guide](https://dxworks.org/scriptbee/installation/installation) for setup instructions. +You need a running instance of the ScriptBee Backend API. See +the [installation guide](https://dxworks.org/scriptbee/installation/installation) for setup instructions. ## Known Issues diff --git a/Integrations/VS-Code/package-lock.json b/Integrations/VS-Code/package-lock.json index 53beaf8b..6d6c6c83 100644 --- a/Integrations/VS-Code/package-lock.json +++ b/Integrations/VS-Code/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { + "@microsoft/signalr": "^10.0.0", "axios": "^1.15.0" }, "devDependencies": { @@ -690,6 +691,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1091,6 +1104,17 @@ "node": ">=16" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1904,6 +1928,22 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1922,6 +1962,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2754,6 +2803,25 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3025,15 +3093,30 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3079,6 +3162,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3122,6 +3210,11 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -3447,6 +3540,25 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3522,6 +3634,14 @@ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3531,6 +3651,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3551,6 +3680,20 @@ "node": ">=10.12.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3669,6 +3812,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/Integrations/VS-Code/package.json b/Integrations/VS-Code/package.json index f6ae8d1a..fe52d71d 100644 --- a/Integrations/VS-Code/package.json +++ b/Integrations/VS-Code/package.json @@ -30,6 +30,11 @@ "type": "string", "default": "~/.scriptbee", "description": "The root folder where ScriptBee projects and generated code are stored." + }, + "scriptbee.enableLiveUpdates": { + "type": "boolean", + "default": true, + "description": "Enable real-time updates for script lifecycle events (creation, update, deletion)." } } }, @@ -235,6 +240,7 @@ "prettier:fix": "prettier --write ." }, "dependencies": { + "@microsoft/signalr": "^10.0.0", "axios": "^1.15.0" }, "devDependencies": { diff --git a/Integrations/VS-Code/src/api/axiosInstance.ts b/Integrations/VS-Code/src/api/axiosInstance.ts index 272740ca..4751b75a 100644 --- a/Integrations/VS-Code/src/api/axiosInstance.ts +++ b/Integrations/VS-Code/src/api/axiosInstance.ts @@ -1,5 +1,12 @@ import axios from 'axios'; +import { ClientIdService } from '../services/clientIdService'; const axiosInstance = axios.create(); +axiosInstance.interceptors.request.use((config) => { + config.headers = config.headers || {}; + config.headers['X-Client-Id'] = ClientIdService.clientId; + return config; +}); + export default axiosInstance; diff --git a/Integrations/VS-Code/src/commands/commandIds.ts b/Integrations/VS-Code/src/commands/commandIds.ts index 97e016d8..0058c944 100644 --- a/Integrations/VS-Code/src/commands/commandIds.ts +++ b/Integrations/VS-Code/src/commands/commandIds.ts @@ -14,6 +14,7 @@ export const COMMAND_OPEN_PROJECT_FOLDER = 'scriptbee.openProjectFolder'; export const COMMAND_COMPARE_WITH_REMOTE = 'scriptbee.compareWithRemote'; export const COMMAND_SELECT_INSTANCE = 'scriptbee.selectInstance'; export const COMMAND_GENERATE_CLASSES = 'scriptbee.generateClasses'; +export const COMMAND_ON_PROJECT_SELECTED = 'scriptbee.onProjectSelected'; export const VIEW_ID_CONNECTIONS = 'scriptbee-connections'; export const VIEW_CONTAINER_ID = 'scriptbee-explorer'; diff --git a/Integrations/VS-Code/src/commands/connection/switchConnection.ts b/Integrations/VS-Code/src/commands/connection/switchConnection.ts index c228b80d..c52c29f7 100644 --- a/Integrations/VS-Code/src/commands/connection/switchConnection.ts +++ b/Integrations/VS-Code/src/commands/connection/switchConnection.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { connectionService } from '../../services/connectionService'; -import { COMMAND_ADD_CONNECTION, COMMAND_REFRESH_UI } from '../commandIds'; +import { COMMAND_ADD_CONNECTION, COMMAND_ON_PROJECT_SELECTED, COMMAND_REFRESH_UI } from '../commandIds'; import { CommandConnectionArg, getConnectionId } from '../commandUtils'; export async function switchConnection(connectionArg: CommandConnectionArg) { @@ -9,6 +9,7 @@ export async function switchConnection(connectionArg: CommandConnectionArg) { if (connectionId) { await connectionService.setActiveConnection(connectionId); vscode.commands.executeCommand(COMMAND_REFRESH_UI); + vscode.commands.executeCommand(COMMAND_ON_PROJECT_SELECTED); return; } @@ -38,5 +39,6 @@ export async function switchConnection(connectionArg: CommandConnectionArg) { await connectionService.setActiveConnection(selected.connectionId); vscode.window.setStatusBarMessage(`Switched to connection: ${selected.label}`, 3000); vscode.commands.executeCommand(COMMAND_REFRESH_UI); + vscode.commands.executeCommand(COMMAND_ON_PROJECT_SELECTED); } } diff --git a/Integrations/VS-Code/src/commands/project/compareWithRemote.ts b/Integrations/VS-Code/src/commands/project/compareWithRemote.ts index 3607bafb..4fc7a352 100644 --- a/Integrations/VS-Code/src/commands/project/compareWithRemote.ts +++ b/Integrations/VS-Code/src/commands/project/compareWithRemote.ts @@ -36,6 +36,6 @@ export async function compareWithRemote(uri?: vscode.Uri) { await vscode.commands.executeCommand('vscode.diff', fileUri, remoteUri, `${fileName} (Local <-> Remote)`); } catch (error) { logger.error('Failed to open diff view', error); - showErrorWithCopy('Failed to open diff view', error).catch(console.error); + showErrorWithCopy('Failed to open diff view', error).catch(logger.error); } } diff --git a/Integrations/VS-Code/src/commands/project/pullScript.ts b/Integrations/VS-Code/src/commands/project/pullScript.ts index 3710abac..5da8c69e 100644 --- a/Integrations/VS-Code/src/commands/project/pullScript.ts +++ b/Integrations/VS-Code/src/commands/project/pullScript.ts @@ -28,7 +28,7 @@ export async function pullScript(uri?: vscode.Uri) { vscode.window.setStatusBarMessage(`$(check) Successfully pulled script ${fileName}`, 5000); } catch (error: any) { logger.error(`Failed to pull script ${fileName}`, error); - showErrorWithCopy(`Failed to pull script ${fileName}`, error).catch(console.error); + showErrorWithCopy(`Failed to pull script ${fileName}`, error).catch(logger.error); } } ); diff --git a/Integrations/VS-Code/src/commands/project/pullScripts.ts b/Integrations/VS-Code/src/commands/project/pullScripts.ts index c20b8637..124748b4 100644 --- a/Integrations/VS-Code/src/commands/project/pullScripts.ts +++ b/Integrations/VS-Code/src/commands/project/pullScripts.ts @@ -25,7 +25,7 @@ export async function pullScripts(item?: ScriptBeeTreeItem) { vscode.window.setStatusBarMessage(`$(check) Successfully pulled scripts for project ${connection.projectId}`, 5000); } catch (error: any) { logger.error(`Failed to pull scripts for project ${connection.projectId}`, error); - showErrorWithCopy('Failed to pull scripts', error).catch(console.error); + showErrorWithCopy('Failed to pull scripts', error).catch(logger.error); } } ); diff --git a/Integrations/VS-Code/src/commands/project/pushScript.ts b/Integrations/VS-Code/src/commands/project/pushScript.ts index 1f10d062..e4d75326 100644 --- a/Integrations/VS-Code/src/commands/project/pushScript.ts +++ b/Integrations/VS-Code/src/commands/project/pushScript.ts @@ -28,7 +28,7 @@ export async function pushScript(uri?: vscode.Uri) { vscode.window.setStatusBarMessage(`$(check) Successfully pushed script ${fileName}`, 5000); } catch (error: any) { logger.error(`Failed to push script ${fileName}`, error); - showErrorWithCopy(`Failed to push script ${fileName}`, error).catch(console.error); + showErrorWithCopy(`Failed to push script ${fileName}`, error).catch(logger.error); } } ); diff --git a/Integrations/VS-Code/src/commands/project/pushScripts.ts b/Integrations/VS-Code/src/commands/project/pushScripts.ts index 4c288d20..38226bd0 100644 --- a/Integrations/VS-Code/src/commands/project/pushScripts.ts +++ b/Integrations/VS-Code/src/commands/project/pushScripts.ts @@ -25,7 +25,7 @@ export async function pushScripts(item?: ScriptBeeTreeItem) { vscode.window.setStatusBarMessage(`$(check) Successfully pushed scripts for project ${connection.projectId}`, 5000); } catch (error: any) { logger.error(`Failed to push scripts for project ${connection.projectId}`, error); - showErrorWithCopy('Failed to push scripts', error).catch(console.error); + showErrorWithCopy('Failed to push scripts', error).catch(logger.error); } } ); diff --git a/Integrations/VS-Code/src/commands/project/selectProject.ts b/Integrations/VS-Code/src/commands/project/selectProject.ts index d56a20e3..e6da564e 100644 --- a/Integrations/VS-Code/src/commands/project/selectProject.ts +++ b/Integrations/VS-Code/src/commands/project/selectProject.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { connectionService } from '../../services/connectionService'; import { projectService } from '../../services/projectService'; import { Connection } from '../../utils/storage'; -import { COMMAND_ADD_CONNECTION, COMMAND_REFRESH_UI } from '../commandIds'; +import { COMMAND_ADD_CONNECTION, COMMAND_ON_PROJECT_SELECTED, COMMAND_REFRESH_UI } from '../commandIds'; import { CommandConnectionArg, getConnectionId } from '../commandUtils'; import { logger } from '../../utils/logger'; import { showErrorWithCopy } from '../../utils/errorUtils'; @@ -69,6 +69,7 @@ export async function selectProject(connectionArg?: CommandConnectionArg) { const message = selected.projectId ? `Selected project: ${selected.label}` : 'Project disassociated.'; vscode.window.setStatusBarMessage(message, 3000); vscode.commands.executeCommand(COMMAND_REFRESH_UI); + vscode.commands.executeCommand(COMMAND_ON_PROJECT_SELECTED); } } catch (error) { logger.error(`Failed to fetch projects for ${connection.name}`, error); diff --git a/Integrations/VS-Code/src/commands/project/syncScripts.ts b/Integrations/VS-Code/src/commands/project/syncScripts.ts index ffdba519..7bb1af46 100644 --- a/Integrations/VS-Code/src/commands/project/syncScripts.ts +++ b/Integrations/VS-Code/src/commands/project/syncScripts.ts @@ -25,7 +25,7 @@ export async function syncScripts(item?: ScriptBeeTreeItem) { vscode.window.setStatusBarMessage(`$(check) Successfully synced scripts for project ${connection.projectId}`, 5000); } catch (error: any) { logger.error(`Failed to sync scripts for project ${connection.projectId}`, error); - showErrorWithCopy('Failed to sync scripts', error).catch(console.error); + showErrorWithCopy('Failed to sync scripts', error).catch(logger.error); } } ); diff --git a/Integrations/VS-Code/src/commands/ui/refreshUI.ts b/Integrations/VS-Code/src/commands/ui/refreshUI.ts index ae84b721..4f7b6bf8 100644 --- a/Integrations/VS-Code/src/commands/ui/refreshUI.ts +++ b/Integrations/VS-Code/src/commands/ui/refreshUI.ts @@ -3,6 +3,6 @@ import { COMMAND_REFRESH_TREE_VIEW } from '../commandIds'; import { updateStatusBar } from '../../ui/statusBar'; export function refreshUI() { - vscode.commands.executeCommand(COMMAND_REFRESH_TREE_VIEW); - updateStatusBar(); + void vscode.commands.executeCommand(COMMAND_REFRESH_TREE_VIEW); + void updateStatusBar(); } diff --git a/Integrations/VS-Code/src/extension.ts b/Integrations/VS-Code/src/extension.ts index 639f27de..519500a3 100644 --- a/Integrations/VS-Code/src/extension.ts +++ b/Integrations/VS-Code/src/extension.ts @@ -1,23 +1,40 @@ import * as vscode from 'vscode'; import { storage } from './utils/storage'; -import { registerCommands } from './commands/index'; +import { registerCommands } from './commands'; import { initStatusBar } from './ui/statusBar'; import { initTreeView } from './views/ScriptBeeTreeView'; import { logger } from './utils/logger'; import { hideMetaFiles } from './utils/workspaceUtils'; import { RemoteScriptProvider } from './providers/RemoteScriptProvider'; +import { liveUpdatesService } from './services/liveUpdatesService'; +import { COMMAND_ON_PROJECT_SELECTED } from './commands/commandIds'; export function activate(context: vscode.ExtensionContext) { logger.init(); logger.log('ScriptBee VS Code Extension activated.'); storage.setContext(context); - hideMetaFiles(); + void hideMetaFiles(); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(RemoteScriptProvider.scheme, new RemoteScriptProvider())); registerCommands(context); initStatusBar(context); initTreeView(context); + + void liveUpdatesService.start(); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('scriptbee.enableLiveUpdates')) { + void liveUpdatesService.start(); + } + }), + vscode.commands.registerCommand(COMMAND_ON_PROJECT_SELECTED, () => { + void liveUpdatesService.start(); + }) + ); } -export function deactivate() {} +export function deactivate() { + void liveUpdatesService.stop(); +} diff --git a/Integrations/VS-Code/src/services/clientIdService.ts b/Integrations/VS-Code/src/services/clientIdService.ts new file mode 100644 index 00000000..65af319a --- /dev/null +++ b/Integrations/VS-Code/src/services/clientIdService.ts @@ -0,0 +1,12 @@ +import { randomUUID } from 'crypto'; + +export class ClientIdService { + private static _clientId: string | undefined; + + public static get clientId(): string { + if (!this._clientId) { + this._clientId = randomUUID(); + } + return this._clientId; + } +} diff --git a/Integrations/VS-Code/src/services/generateClassesService.ts b/Integrations/VS-Code/src/services/generateClassesService.ts index aa65e025..2c9c4a18 100644 --- a/Integrations/VS-Code/src/services/generateClassesService.ts +++ b/Integrations/VS-Code/src/services/generateClassesService.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import axiosInstance from '../api/axiosInstance'; import { getProjectGeneratedPath } from '../utils/workspaceUtils'; +import { logger } from '../utils/logger'; export class GenerateClassesService { public async generate(baseUrl: string, projectId: string, instanceId: string, languages: string[] = []): Promise { @@ -18,8 +19,8 @@ export class GenerateClassesService { } ); - console.log(`Received ${response.data.byteLength} bytes from backend for class generation.`); - console.log(`Target generated path: ${generatedPath}`); + logger.log(`Received ${response.data.byteLength} bytes from backend for class generation.`); + logger.log(`Target generated path: ${generatedPath}`); await this.writeClassesToFiles(Buffer.from(response.data), generatedPath); } @@ -57,7 +58,7 @@ export class GenerateClassesService { offset += contentLength; const fullPath = path.join(generatedPath, relativePath); - console.log(`Writing generated class: ${fullPath} (${content.length} bytes)`); + logger.log(`Writing generated class: ${fullPath} (${content.length} bytes)`); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content); } diff --git a/Integrations/VS-Code/src/services/liveUpdatesService.ts b/Integrations/VS-Code/src/services/liveUpdatesService.ts new file mode 100644 index 00000000..fd7a491e --- /dev/null +++ b/Integrations/VS-Code/src/services/liveUpdatesService.ts @@ -0,0 +1,149 @@ +import * as vscode from 'vscode'; +import * as signalR from '@microsoft/signalr'; +import { connectionService } from './connectionService'; +import { scriptSyncService } from './scriptSyncService'; +import { ClientIdService } from './clientIdService'; +import { logger } from '../utils/logger'; +import * as CommandIds from '../commands/commandIds'; + +export interface ScriptLiveUpdateEvent { + projectId: string; + scriptId: string; + clientId?: string; +} + +export class LiveUpdatesService { + private hubConnection: signalR.HubConnection | null = null; + private currentProjectId: string | null = null; + private currentBaseUrl: string | null = null; + private scriptIdToUriCache: Map = new Map(); + + public async start() { + const connection = await connectionService.getActiveConnection(); + if (!connection || !connection.projectId) { + await this.stop(); + return; + } + + const enableLiveUpdates = vscode.workspace.getConfiguration('scriptbee').get('enableLiveUpdates', true); + if (!enableLiveUpdates) { + await this.stop(); + return; + } + + if ( + this.currentProjectId === connection.projectId && + this.currentBaseUrl === connection.url && + this.hubConnection?.state === signalR.HubConnectionState.Connected + ) { + return; + } + + await this.stop(); + + this.currentProjectId = connection.projectId; + this.currentBaseUrl = connection.url; + + await this.populateCache(); + + const hubUrl = new URL('/api/projectLiveUpdates', connection.url).toString(); + + this.hubConnection = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build(); + + this.hubConnection.on('ScriptCreated', async (event: ScriptLiveUpdateEvent) => { + if (this.shouldIgnore(event)) { + return; + } + logger.log(`Live Update: Script Created ${event.scriptId}`); + // TODO: here we can call addNode instead of refreshing the entire tree if we know the parentId + await vscode.commands.executeCommand(CommandIds.COMMAND_REFRESH_TREE_VIEW); + }); + + this.hubConnection.on('ScriptUpdated', async (event: ScriptLiveUpdateEvent) => { + if (this.shouldIgnore(event)) { + return; + } + const scriptId = event.scriptId; + logger.log(`Live Update: Script Updated ${scriptId}`); + + const fileUri = this.scriptIdToUriCache.get(scriptId); + if (fileUri) { + await scriptSyncService.pullFileByUri(fileUri); + } + }); + + this.hubConnection.on('ScriptDeleted', async (event: ScriptLiveUpdateEvent) => { + if (this.shouldIgnore(event)) { + return; + } + const scriptId = event.scriptId; + logger.log(`Live Update: Script Deleted ${scriptId}`); + const fileUri = this.scriptIdToUriCache.get(scriptId); + + if (fileUri) { + await scriptSyncService.deleteFileByUri(fileUri); + this.scriptIdToUriCache.delete(scriptId); + } + await vscode.commands.executeCommand(CommandIds.COMMAND_REFRESH_TREE_VIEW); + }); + + try { + await this.hubConnection.start(); + await this.hubConnection.invoke('JoinChannel', this.currentProjectId, 'scripts'); + logger.log(`SignalR connected to ${hubUrl} for project ${this.currentProjectId}`); + } catch (err) { + logger.error('Error while starting SignalR connection', err); + } + } + + public async stop() { + if (this.hubConnection) { + if (this.currentProjectId) { + try { + await this.hubConnection.invoke('LeaveChannel', this.currentProjectId, 'scripts'); + } catch (err) {} + } + await this.hubConnection.stop(); + this.hubConnection = null; + } + this.currentProjectId = null; + this.currentBaseUrl = null; + this.scriptIdToUriCache.clear(); + } + + public updateCacheEntry(scriptId: string, uri: vscode.Uri) { + this.scriptIdToUriCache.set(scriptId, uri); + } + + private async populateCache() { + this.scriptIdToUriCache.clear(); + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return; + } + + for (const folder of workspaceFolders) { + const pattern = new vscode.RelativePattern(folder, '**/*.sb.meta'); + const metas = await vscode.workspace.findFiles(pattern, null); + + await Promise.all( + metas.map(async (metaUri) => { + try { + const content = await vscode.workspace.fs.readFile(metaUri); + const meta = JSON.parse(content.toString()); + if (meta.id) { + const filePath = metaUri.fsPath.replace('.sb.meta', ''); + this.scriptIdToUriCache.set(meta.id, vscode.Uri.file(filePath)); + } + } catch (err) {} + }) + ); + } + } + + private shouldIgnore(event: ScriptLiveUpdateEvent): boolean { + return event.clientId === ClientIdService.clientId; + } +} + +export const liveUpdatesService = new LiveUpdatesService(); diff --git a/Integrations/VS-Code/src/services/projectService.ts b/Integrations/VS-Code/src/services/projectService.ts index 0c354950..b599624b 100644 --- a/Integrations/VS-Code/src/services/projectService.ts +++ b/Integrations/VS-Code/src/services/projectService.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import { getAllProjects, ProjectResponse } from '../api/projects'; import { connectionService } from './connectionService'; -import { getProjectSrcPath, getProjectRootPath, getProjectGeneratedPath } from '../utils/workspaceUtils'; +import { getProjectGeneratedPath, getProjectRootPath, getProjectSrcPath } from '../utils/workspaceUtils'; export class ProjectService { public async fetchProjects(baseUrl: string): Promise { @@ -35,12 +35,6 @@ export class ProjectService { } } - public async getSelectedInstanceId(connectionId: string): Promise { - const connections = await connectionService.getConnections(); - const connection = connections.find((c) => c.id === connectionId); - return connection?.instanceId; - } - public async openProjectFolder(projectId: string, inWorkspace: boolean = false): Promise { const rootPath = getProjectRootPath(projectId); const srcPath = getProjectSrcPath(projectId); diff --git a/Integrations/VS-Code/src/services/scriptSyncService.ts b/Integrations/VS-Code/src/services/scriptSyncService.ts index d6636441..ef365bef 100644 --- a/Integrations/VS-Code/src/services/scriptSyncService.ts +++ b/Integrations/VS-Code/src/services/scriptSyncService.ts @@ -1,13 +1,14 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; -import { getProjectFiles, getScriptContent, updateScriptContent, createScript, deleteProjectFile, WebProjectFileNode } from '../api/projectFiles'; +import { createScript, deleteProjectFile, getProjectFiles, getScriptContent, updateScriptContent, WebProjectFileNode } from '../api/projectFiles'; import { connectionService } from './connectionService'; import { getProjectSrcPath } from '../utils/workspaceUtils'; import { Dirent } from 'fs'; -import { storage, ScriptMeta } from '../utils/storage'; +import { storage } from '../utils/storage'; import { showErrorWithCopy } from '../utils/errorUtils'; import { logger } from '../utils/logger'; +import { liveUpdatesService } from './liveUpdatesService'; export class ScriptSyncService { public async sync(connectionId: string): Promise { @@ -23,7 +24,7 @@ export class ScriptSyncService { try { await this.pullRecursive(url, projectId!, undefined, srcPath); } catch (error) { - showErrorWithCopy('Failed to pull scripts', error); + await showErrorWithCopy('Failed to pull scripts', error); throw error; } } @@ -45,6 +46,7 @@ export class ScriptSyncService { const localPath = path.join(currentLocalPath, node.name); const fileUri = vscode.Uri.file(localPath); await storage.saveScriptMeta(fileUri, { id: node.id, type: node.type }); + liveUpdatesService.updateCacheEntry(node.id, fileUri); if (node.type === 'folder') { await fs.mkdir(localPath, { recursive: true }); @@ -91,18 +93,7 @@ export class ScriptSyncService { } public async pullFileByUri(uri: vscode.Uri): Promise { - const connectionId = connectionService.getActiveConnectionId(); - if (!connectionId) { - throw new Error('No active connection found'); - } - - const { url, projectId } = await this.getConnectionOrThrow(connectionId); - const srcPath = path.normalize(getProjectSrcPath(projectId!)); - const localPath = path.normalize(uri.fsPath); - - if (!localPath.toLowerCase().startsWith(srcPath.toLowerCase())) { - throw new Error('File is not within the current project source folder'); - } + const { url, projectId, srcPath, localPath } = await this.getConnectionAndPaths(uri); const meta = await storage.getScriptMeta(uri); if (!meta) { @@ -114,29 +105,8 @@ export class ScriptSyncService { await this.pullFileContent(url, projectId!, meta.id, localPath); } - private async pullFileContent(baseUrl: string, projectId: string, fileId: string, localPath: string): Promise { - try { - const content = await getScriptContent(baseUrl, projectId, fileId); - await fs.writeFile(localPath, content, 'utf8'); - } catch (error) { - logger.error(`Failed to pull script content from ${fileId}`, error); - throw error; - } - } - public async pushFileByUri(uri: vscode.Uri): Promise { - const connectionId = connectionService.getActiveConnectionId(); - if (!connectionId) { - throw new Error('No active connection found'); - } - - const { url, projectId } = await this.getConnectionOrThrow(connectionId); - const srcPath = path.normalize(getProjectSrcPath(projectId!)); - const localPath = path.normalize(uri.fsPath); - - if (!localPath.toLowerCase().startsWith(srcPath.toLowerCase())) { - throw new Error('File is not within the current project source folder'); - } + const { url, projectId, srcPath, localPath } = await this.getConnectionAndPaths(uri); const relativePath = path.relative(srcPath, localPath).replace(/\\/g, '/'); const fileName = path.basename(localPath); @@ -160,6 +130,11 @@ export class ScriptSyncService { await this.pushFile(url, projectId!, srcPath, relativePath, fileName, remoteNode); } + public async deleteFileByUri(fileUri: vscode.Uri) { + await storage.deleteScriptMeta(fileUri); + await fs.rm(fileUri.fsPath, { recursive: true, force: true }); + } + public async push(connectionId: string): Promise { const { url, projectId } = await this.getConnectionOrThrow(connectionId); const srcPath = getProjectSrcPath(projectId!); @@ -170,11 +145,37 @@ export class ScriptSyncService { try { await this.pushRecursive(url, projectId!, srcPath, '', undefined); } catch (error) { - showErrorWithCopy('Failed to push scripts', error); + void showErrorWithCopy('Failed to push scripts', error); + throw error; + } + } + + private async pullFileContent(baseUrl: string, projectId: string, fileId: string, localPath: string): Promise { + try { + const content = await getScriptContent(baseUrl, projectId, fileId); + await fs.writeFile(localPath, content, 'utf8'); + } catch (error) { + logger.error(`Failed to pull script content from ${fileId}`, error); throw error; } } + private async getConnectionAndPaths(uri: vscode.Uri) { + const connectionId = connectionService.getActiveConnectionId(); + if (!connectionId) { + throw new Error('No active connection found'); + } + + const { url, projectId } = await this.getConnectionOrThrow(connectionId); + const srcPath = path.normalize(getProjectSrcPath(projectId!)); + const localPath = path.normalize(uri.fsPath); + + if (!localPath.toLowerCase().startsWith(srcPath.toLowerCase())) { + throw new Error('File is not within the current project source folder'); + } + return { url, projectId: projectId!, srcPath, localPath }; + } + private async pushRecursive(baseUrl: string, projectId: string, srcRoot: string, relativeDirPath: string, folderId: string | undefined): Promise { const currentLocalPath = path.join(srcRoot, relativeDirPath); const localEntries = await fs.readdir(currentLocalPath, { withFileTypes: true }); @@ -221,10 +222,10 @@ export class ScriptSyncService { try { await deleteProjectFile(baseUrl, projectId, remoteNode.id); const fileUri = vscode.Uri.file(path.join(localDir, remoteNode.name)); - await storage.deleteScriptMeta(fileUri); + await this.deleteFileByUri(fileUri); } catch (error) { logger.error(`Failed to delete remote node ${remoteNode.name}`, error); - showErrorWithCopy(`Failed to delete remote node ${remoteNode.name}`, error); + void showErrorWithCopy(`Failed to delete remote node ${remoteNode.name}`, error); } } } @@ -270,6 +271,7 @@ export class ScriptSyncService { const lang = this.getLanguage(fileName); const script = await createScript(baseUrl, projectId, relativePath, lang); await storage.saveScriptMeta(fileUri, { id: script.id, type: 'file' }); + liveUpdatesService.updateCacheEntry(script.id, fileUri); await updateScriptContent(baseUrl, projectId, script.id, content); } } catch (error) { diff --git a/Integrations/VS-Code/src/test/commands/commandUtils.test.ts b/Integrations/VS-Code/src/test/commands/commandUtils.test.ts index ef60a649..426992e8 100644 --- a/Integrations/VS-Code/src/test/commands/commandUtils.test.ts +++ b/Integrations/VS-Code/src/test/commands/commandUtils.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { getConnectionId, CommandConnectionArg } from '../../commands/commandUtils'; +import { CommandConnectionArg, getConnectionId } from '../../commands/commandUtils'; suite('getConnectionId()', () => { test('should return the string itself when a string is passed', () => { diff --git a/Integrations/VS-Code/src/test/extension.test.ts b/Integrations/VS-Code/src/test/extension.test.ts index 74593cfa..6e728302 100644 --- a/Integrations/VS-Code/src/test/extension.test.ts +++ b/Integrations/VS-Code/src/test/extension.test.ts @@ -10,13 +10,17 @@ import { storage } from '../utils/storage'; import { generateClassesService } from '../services/generateClassesService'; import * as fs from 'fs/promises'; import * as path from 'path'; +import * as workspaceUtils from '../utils/workspaceUtils'; import { getProjectGeneratedPath } from '../utils/workspaceUtils'; +import { liveUpdatesService } from '../services/liveUpdatesService'; suite('ScriptBee Extension UI Command Integration Tests', () => { let showInputBoxStub: sinon.SinonStub; let showQuickPickStub: sinon.SinonStub; let showInformationMessageStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; let axiosMock: axiosMockAdapter; + let liveUpdateStartStub: sinon.SinonStub; setup(async () => { const mockContext: any = { @@ -36,7 +40,12 @@ suite('ScriptBee Extension UI Command Integration Tests', () => { showInputBoxStub = sinon.stub(vscode.window, 'showInputBox'); showQuickPickStub = sinon.stub(vscode.window, 'showQuickPick'); - showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage'); + showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(); + showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); + + liveUpdateStartStub = sinon.stub(liveUpdatesService, 'start').resolves(); + sinon.stub(liveUpdatesService, 'stop').resolves(); + sinon.stub(liveUpdatesService, 'updateCacheEntry').returns(); await storage.reset(); @@ -69,9 +78,7 @@ suite('ScriptBee Extension UI Command Integration Tests', () => { const testConnection = await connectionService.addConnection('SelectProjTest', testUrl); await connectionService.setActiveConnection(testConnection.id); - axiosMock.onGet(`${testUrl}/api/projects`).reply(() => { - return [200, { data: [{ id: 'proj-a', name: 'Project A' }] }]; - }); + axiosMock.onGet(`${testUrl}/api/projects`).reply(200, { data: [{ id: 'proj-a', name: 'Project A' }] }); showQuickPickStub.resolves({ label: 'Project A', projectId: 'proj-a' }); @@ -107,15 +114,10 @@ suite('ScriptBee Extension UI Command Integration Tests', () => { axiosError.response = undefined; sinon.stub(scriptSyncService, 'pull').rejects(axiosError); - const showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); await vscode.commands.executeCommand(CommandIds.COMMAND_PULL_SCRIPTS, { connection: conn }); - assert.ok( - showErrorMessageStub.calledWithMatch(sinon.match(/Failed to pull scripts: Network Error/)), - 'Should display correct network error instead of undefined' - ); - showErrorMessageStub.restore(); + assert.ok(showErrorMessageStub.calledWithMatch(sinon.match(/Failed to pull scripts: Network Error/))); }); test('Command scriptbee.pushScripts should trigger with progress UI', async () => { @@ -152,7 +154,6 @@ suite('ScriptBee Extension UI Command Integration Tests', () => { }); test('Command scriptbee.selectInstance should fetch instances and update connection', async function () { - this.timeout(10000); const conn = await setupTestConnection('SelectInstTest', 'proj-inst-1'); axiosMock.onGet(`${conn.url}/api/projects/proj-inst-1/instances`).reply(200, { @@ -182,30 +183,42 @@ suite('ScriptBee Extension UI Command Integration Tests', () => { generateStub.restore(); }); - test('Command scriptbee.generateClasses should write files to disk (Full Flow)', async () => { - const conn = await setupTestConnection('FullGenTest', 'proj-full-1'); - conn.instanceId = 'inst-full-1'; - await connectionService.updateConnection(conn); - - const filePath = 'models/User.cs'; - const content = 'public class User {}'; - const buffer = createBinaryResponse([{ path: filePath, content }]); - - axiosMock.onPost(`${conn.url}/api/projects/proj-full-1/instances/inst-full-1/context/generate-classes`).reply(200, buffer); + test('Command scriptbee.onProjectSelected should restart liveUpdatesService', async () => { + await vscode.commands.executeCommand(CommandIds.COMMAND_ON_PROJECT_SELECTED); - await vscode.commands.executeCommand(CommandIds.COMMAND_GENERATE_CLASSES, { connection: conn }); - - const generatedPath = getProjectGeneratedPath('proj-full-1'); - const fullFilePath = path.join(generatedPath, filePath); - - const exists = await fs - .access(fullFilePath) - .then(() => true) - .catch(() => false); - assert.ok(exists, `File should be written to ${fullFilePath}`); + assert.ok(liveUpdateStartStub.calledOnce); + }); - const actualContent = await fs.readFile(fullFilePath, 'utf8'); - assert.strictEqual(actualContent, content); + test('Command scriptbee.generateClasses should write files to disk (Full Flow)', async () => { + const tempGenPath = path.join(__dirname, 'test-output'); + const pathStub = sinon.stub(workspaceUtils, 'getProjectGeneratedPath').returns(tempGenPath); + + try { + const conn = await setupTestConnection('FullGenTest', 'proj-full-1'); + conn.instanceId = 'inst-full-1'; + await connectionService.updateConnection(conn); + + const filePath = 'models/User.cs'; + const content = 'public class User {}'; + const buffer = createBinaryResponse([{ path: filePath, content }]); + + axiosMock.onPost(`${conn.url}/api/projects/proj-full-1/instances/inst-full-1/context/generate-classes`).reply(200, buffer); + + await vscode.commands.executeCommand(CommandIds.COMMAND_GENERATE_CLASSES, { connection: conn }); + + const fullFilePath = path.join(tempGenPath, filePath); + const exists = await fs + .access(fullFilePath) + .then(() => true) + .catch(() => false); + + assert.ok(exists, `File should be written to ${fullFilePath}`); + const actualContent = await fs.readFile(fullFilePath, 'utf8'); + assert.strictEqual(actualContent, content); + } finally { + pathStub.restore(); + await fs.rm(tempGenPath, { recursive: true, force: true }); + } }); async function setupTestConnection(name: string, projectId: string) { diff --git a/Integrations/VS-Code/src/test/services/clientIdService.test.ts b/Integrations/VS-Code/src/test/services/clientIdService.test.ts new file mode 100644 index 00000000..f7c7d5ae --- /dev/null +++ b/Integrations/VS-Code/src/test/services/clientIdService.test.ts @@ -0,0 +1,20 @@ +import * as assert from 'assert'; +import { ClientIdService } from '../../services/clientIdService'; + +suite('ClientIdService Tests', () => { + test('clientId should be a non-empty string', () => { + assert.ok(ClientIdService.clientId); + assert.strictEqual(typeof ClientIdService.clientId, 'string'); + }); + + test('clientId should look like a UUID', () => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + assert.ok(uuidRegex.test(ClientIdService.clientId)); + }); + + test('clientId should be stable across calls (singleton behavior)', () => { + const id1 = ClientIdService.clientId; + const id2 = ClientIdService.clientId; + assert.strictEqual(id1, id2); + }); +}); diff --git a/Integrations/VS-Code/src/test/services/liveUpdatesService.test.ts b/Integrations/VS-Code/src/test/services/liveUpdatesService.test.ts new file mode 100644 index 00000000..92d7cc46 --- /dev/null +++ b/Integrations/VS-Code/src/test/services/liveUpdatesService.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as sinon from 'sinon'; +import * as signalR from '@microsoft/signalr'; +import { LiveUpdatesService, liveUpdatesService, ScriptLiveUpdateEvent } from '../../services/liveUpdatesService'; +import { connectionService } from '../../services/connectionService'; +import { ClientIdService } from '../../services/clientIdService'; +import { scriptSyncService } from '../../services/scriptSyncService'; + +suite('LiveUpdatesService Tests', () => { + let getActiveConnectionStub: sinon.SinonStub; + let pullStub: sinon.SinonStub; + let mockHubConnection: { + on: sinon.SinonStub<[string, (event: ScriptLiveUpdateEvent) => Promise], void>; + start: sinon.SinonStub>; + stop: sinon.SinonStub>; + invoke: sinon.SinonStub>; + state: signalR.HubConnectionState; + }; + + setup(async () => { + await liveUpdatesService.stop(); + + mockHubConnection = { + on: sinon.stub(), + start: sinon.stub().resolves(), + stop: sinon.stub().resolves(), + invoke: sinon.stub().resolves(), + state: signalR.HubConnectionState.Disconnected, + }; + + sinon.stub(signalR.HubConnectionBuilder.prototype, 'withUrl').returnsThis(); + sinon.stub(signalR.HubConnectionBuilder.prototype, 'withAutomaticReconnect').returnsThis(); + sinon.stub(signalR.HubConnectionBuilder.prototype, 'build').returns(mockHubConnection as unknown as signalR.HubConnection); + + getActiveConnectionStub = sinon.stub(connectionService, 'getActiveConnection'); + pullStub = sinon.stub(scriptSyncService, 'pullFileByUri').resolves(); + + sinon.stub(vscode.commands, 'executeCommand').resolves(); + sinon.stub(vscode.workspace, 'findFiles').resolves([]); + + const mockConfig = { + get: sinon.stub<[string, boolean], boolean>().returns(true), + } as unknown as vscode.WorkspaceConfiguration; + sinon.stub(vscode.workspace, 'getConfiguration').returns(mockConfig); + + sinon.stub(LiveUpdatesService.prototype as unknown as { populateCache: () => Promise }, 'populateCache').resolves(); + }); + + teardown(async () => { + await liveUpdatesService.stop(); + sinon.restore(); + }); + + test('start should connect to hub if connection is active', async () => { + getActiveConnectionStub.resolves({ id: 'c1', name: 'T', url: 'http://loc', projectId: 'p1' }); + + await liveUpdatesService.start(); + + assert.strictEqual(mockHubConnection.start.calledOnce, true); + }); + + test('start should not connect if no active connection', async () => { + getActiveConnectionStub.resolves(undefined); + + await liveUpdatesService.start(); + + assert.strictEqual(mockHubConnection.start.notCalled, true); + }); + + test('should handle ScriptUpdated event', async () => { + getActiveConnectionStub.resolves({ id: 'c1', name: 'T', url: 'http://loc', projectId: 'p1' }); + + await liveUpdatesService.start(); + + const call = mockHubConnection.on.getCalls().find((c) => c.args[0] === 'ScriptUpdated'); + assert.ok(call); + + const callback = call.args[1]; + const uri = vscode.Uri.file('/p/s.js'); + liveUpdatesService.updateCacheEntry('s1', uri); + + await callback({ scriptId: 's1', clientId: 'other', projectId: 'p1' }); + + assert.strictEqual(pullStub.calledWith(uri), true); + }); + + test('should ignore self events', async () => { + getActiveConnectionStub.resolves({ id: 'c1', name: 'T', url: 'http://loc', projectId: 'p1' }); + + await liveUpdatesService.start(); + + const call = mockHubConnection.on.getCalls().find((c) => c.args[0] === 'ScriptUpdated'); + assert.ok(call); + + const callback = call.args[1]; + + await callback({ + scriptId: 's1', + clientId: ClientIdService.clientId, + projectId: 'p1', + }); + + assert.strictEqual(pullStub.notCalled, true); + }); +}); diff --git a/Integrations/VS-Code/src/test/services/scriptSyncService.test.ts b/Integrations/VS-Code/src/test/services/scriptSyncService.test.ts index 8a0fb8d7..2321e014 100644 --- a/Integrations/VS-Code/src/test/services/scriptSyncService.test.ts +++ b/Integrations/VS-Code/src/test/services/scriptSyncService.test.ts @@ -8,6 +8,7 @@ import { scriptSyncService } from '../../services/scriptSyncService'; import * as projectFiles from '../../api/projectFiles'; import { storage } from '../../utils/storage'; import { connectionService } from '../../services/connectionService'; +import { liveUpdatesService } from '../../services/liveUpdatesService'; suite('ScriptSyncService Tests', function () { this.timeout(10000); @@ -31,6 +32,7 @@ suite('ScriptSyncService Tests', function () { sinon.stub(projectFiles, 'createScript').resolves({ id: 'new-id', name: 'new', path: 'new', absolutePath: '', language: 'javascript' }); sinon.stub(projectFiles, 'updateScriptContent').resolves(); deleteProjectFileSpy = sinon.stub(projectFiles, 'deleteProjectFile').resolves(); + sinon.stub(liveUpdatesService, 'updateCacheEntry'); }); teardown(async () => { diff --git a/Integrations/VS-Code/src/test/suite/api/projectFiles.test.ts b/Integrations/VS-Code/src/test/suite/api/projectFiles.test.ts index 3dfcb1bd..9bfd76d2 100644 --- a/Integrations/VS-Code/src/test/suite/api/projectFiles.test.ts +++ b/Integrations/VS-Code/src/test/suite/api/projectFiles.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import axiosMockAdapter from 'axios-mock-adapter'; import axiosInstance from '../../../api/axiosInstance'; -import { getProjectFiles, createScript, updateScriptContent, getScriptContent } from '../../../api/projectFiles'; +import { createScript, getProjectFiles, getScriptContent, updateScriptContent } from '../../../api/projectFiles'; suite('Project Files API Test Suite', () => { let mock: axiosMockAdapter; diff --git a/Integrations/VS-Code/src/test/suite/integration.test.ts b/Integrations/VS-Code/src/test/suite/integration.test.ts index bc58d6a1..0914e360 100644 --- a/Integrations/VS-Code/src/test/suite/integration.test.ts +++ b/Integrations/VS-Code/src/test/suite/integration.test.ts @@ -1,5 +1,4 @@ import * as assert from 'assert'; -import * as vscode from 'vscode'; import { connectionService } from '../../services/connectionService'; import { projectService } from '../../services/projectService'; import { storage } from '../../utils/storage'; diff --git a/Integrations/VS-Code/src/utils/logger.ts b/Integrations/VS-Code/src/utils/logger.ts index 4d9cda98..d2587c9e 100644 --- a/Integrations/VS-Code/src/utils/logger.ts +++ b/Integrations/VS-Code/src/utils/logger.ts @@ -24,20 +24,34 @@ class Logger { } } + public warn(message: string, error?: unknown) { + if (this.outputChannel) { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [WARN] ${message}`); + if (error) { + this.printError(this.outputChannel, timestamp, error); + } + } + } + public error(message: string, error?: unknown) { if (this.outputChannel) { const timestamp = new Date().toISOString(); this.outputChannel.appendLine(`[${timestamp}] [ERROR] ${message}`); if (error) { - if (error instanceof Error) { - this.outputChannel.appendLine(`[${timestamp}] [ERROR] Details: ${error.stack || error.message}`); - } else { - this.outputChannel.appendLine(`[${timestamp}] [ERROR] Details: ${String(error)}`); - } + this.printError(this.outputChannel, timestamp, error); } } } + private printError(outputChannel: vscode.OutputChannel, timestamp: string, error: any) { + if (error instanceof Error) { + outputChannel.appendLine(`[${timestamp}] [ERROR] Details: ${error.stack || error.message}`); + } else { + outputChannel.appendLine(`[${timestamp}] [ERROR] Details: ${String(error)}`); + } + } + public show() { if (this.outputChannel) { this.outputChannel.show(); diff --git a/Integrations/VS-Code/src/utils/storage.ts b/Integrations/VS-Code/src/utils/storage.ts index 5857e3f3..08f03bff 100644 --- a/Integrations/VS-Code/src/utils/storage.ts +++ b/Integrations/VS-Code/src/utils/storage.ts @@ -1,5 +1,4 @@ import * as vscode from 'vscode'; -import * as path from 'path'; export interface Connection { id: string; diff --git a/Integrations/VS-Code/src/utils/workspaceUtils.ts b/Integrations/VS-Code/src/utils/workspaceUtils.ts index 2a47d20d..ca4a9e00 100644 --- a/Integrations/VS-Code/src/utils/workspaceUtils.ts +++ b/Integrations/VS-Code/src/utils/workspaceUtils.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as os from 'os'; +import { logger } from './logger'; export function getWorkspaceRoot(): string { const config = vscode.workspace.getConfiguration('scriptbee'); @@ -31,7 +32,7 @@ export async function hideMetaFiles() { const config = vscode.workspace.getConfiguration('files'); const exclude = config.get>('exclude') || {}; - if (exclude['**/*.sb.meta'] === true) { + if (exclude['**/*.sb.meta']) { return; } @@ -44,6 +45,6 @@ export async function hideMetaFiles() { await config.update('exclude', newExclude, vscode.ConfigurationTarget.Global); } } catch (error) { - console.warn('Failed to update workspace exclude bindings:', error); + logger.warn('Failed to update workspace exclude bindings:', error); } } diff --git a/Integrations/VS-Code/tsconfig.json b/Integrations/VS-Code/tsconfig.json index 79c2f254..8572c720 100644 --- a/Integrations/VS-Code/tsconfig.json +++ b/Integrations/VS-Code/tsconfig.json @@ -3,7 +3,7 @@ "module": "Node16", "target": "ES2022", "outDir": "out", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "sourceMap": true, "rootDir": "src", "strict": true, diff --git a/Plugins/CHANGELOG.md b/Plugins/CHANGELOG.md index ddfed96f..74b17754 100644 --- a/Plugins/CHANGELOG.md +++ b/Plugins/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- update Jint to 4.8.0 + ## [2.0.0] ### Changed diff --git a/ScriptBee.slnx b/ScriptBee.slnx index d2d31729..d845a801 100644 --- a/ScriptBee.slnx +++ b/ScriptBee.slnx @@ -44,10 +44,12 @@ + + @@ -76,5 +78,6 @@ + diff --git a/ScriptBeeClient/package-lock.json b/ScriptBeeClient/package-lock.json index 5444afe5..51b6b593 100644 --- a/ScriptBeeClient/package-lock.json +++ b/ScriptBeeClient/package-lock.json @@ -19,6 +19,7 @@ "@angular/platform-browser-dynamic": "^21.2.8", "@angular/router": "^21.2.8", "@mdi/angular-material": "^7.2.96", + "@microsoft/signalr": "^10.0.0", "angular-split": "^20.0.0", "monaco-editor": "^0.55.1", "ngx-monaco-editor-v2": "^21.1.4", @@ -36,10 +37,10 @@ "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.0.2", - "prettier": "^3.8.1", + "prettier": "^3.8.2", "prettier-eslint": "^16.4.2", "typescript": "~5.9.3", - "typescript-eslint": "8.58.1", + "typescript-eslint": "8.58.2", "vitest": "^4.1.4" } }, @@ -2521,6 +2522,46 @@ "resolved": "https://registry.npmjs.org/@mdi/angular-material/-/angular-material-7.2.96.tgz", "integrity": "sha512-eBefrpwq+nbxSmQ866ph4XkNqHct0iYfuxWJpmvKkvcOoTfjQxZemRy1uRmbSz8Du++oO3TF66rLUX9S8Fmodw==" }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -4233,16 +4274,16 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4255,21 +4296,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "engines": { @@ -4285,13 +4326,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "engines": { @@ -4306,13 +4347,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4323,9 +4364,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4339,14 +4380,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4363,9 +4404,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4376,15 +4417,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4403,15 +4444,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4426,12 +4467,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4644,6 +4685,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -6037,6 +6089,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -6234,6 +6294,29 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7850,6 +7933,44 @@ "dev": true, "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", @@ -8535,9 +8656,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -9025,11 +9146,21 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -9049,6 +9180,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9133,6 +9269,11 @@ "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==", "dev": true }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9493,6 +9634,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10119,15 +10265,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", "dev": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10150,6 +10296,14 @@ "node": ">=20.18.1" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10198,6 +10352,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", diff --git a/ScriptBeeClient/package.json b/ScriptBeeClient/package.json index c9842e83..64365602 100644 --- a/ScriptBeeClient/package.json +++ b/ScriptBeeClient/package.json @@ -25,6 +25,7 @@ "@angular/platform-browser-dynamic": "^21.2.8", "@angular/router": "^21.2.8", "@mdi/angular-material": "^7.2.96", + "@microsoft/signalr": "^10.0.0", "angular-split": "^20.0.0", "monaco-editor": "^0.55.1", "ngx-monaco-editor-v2": "^21.1.4", @@ -42,10 +43,10 @@ "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.0.2", - "prettier": "^3.8.1", + "prettier": "^3.8.2", "prettier-eslint": "^16.4.2", "typescript": "~5.9.3", - "typescript-eslint": "8.58.1", + "typescript-eslint": "8.58.2", "vitest": "^4.1.4" } } diff --git a/ScriptBeeClient/src/app/app.config.ts b/ScriptBeeClient/src/app/app.config.ts index 8aef4b22..e39a2171 100644 --- a/ScriptBeeClient/src/app/app.config.ts +++ b/ScriptBeeClient/src/app/app.config.ts @@ -1,14 +1,15 @@ import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { routes, withErrorNavigation } from './app.routes'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideMonacoEditor } from 'ngx-monaco-editor-v2'; +import { clientIdInterceptor } from './utils/client-id.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), provideRouter(routes, withComponentInputBinding(), withErrorNavigation), - provideHttpClient(), + provideHttpClient(withInterceptors([clientIdInterceptor])), provideMonacoEditor(), ], }; diff --git a/ScriptBeeClient/src/app/components/tree/lazy-file-tree/lazy-file-tree.component.ts b/ScriptBeeClient/src/app/components/tree/lazy-file-tree/lazy-file-tree.component.ts index 2f3a1a1c..2abf470c 100644 --- a/ScriptBeeClient/src/app/components/tree/lazy-file-tree/lazy-file-tree.component.ts +++ b/ScriptBeeClient/src/app/components/tree/lazy-file-tree/lazy-file-tree.component.ts @@ -159,6 +159,18 @@ export class LazyFileTreeComponent implements OnInit { }); } + public addNode(parentId: string | null, node: TreeNode) { + this.folderStates.update((map) => { + const newMap = new Map(map); + const state = newMap.get(parentId); + if (state) { + const updatedData = [...state.data, node]; + newMap.set(parentId, { ...state, data: updatedData, totalCount: state.totalCount + 1 }); + } + return newMap; + }); + } + public reloadFolder(parentId: string | null = null) { this.folderStates.update((map) => { const newMap = new Map(map); diff --git a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.spec.ts b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.spec.ts index 38d458ea..6d478a1b 100644 --- a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.spec.ts +++ b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.spec.ts @@ -1,15 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ScriptTreeComponent } from './script-tree.component'; import { ProjectStructureService } from '../../../../../services/projects/project-structure.service'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { of, Subject, throwError } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { GetProjectFilesResponse, ProjectFileNode } from '../../../../../types/project'; -import { HttpErrorResponse } from '@angular/common/http'; import { By } from '@angular/platform-browser'; import { ProjectContextService } from '../../../../../services/projects/project-context.service'; import { ProjectStateService } from '../../../../../services/projects/project-state.service'; +import { ProjectLiveUpdatesService } from '../../../../../services/projects/project-live-updates.service'; +import { ScriptCreateEvent, ScriptDeletedEvent, ScriptUpdatedEvent } from '../../../../../types/live-updates'; const makeFileNode = (overrides: Partial = {}): ProjectFileNode => ({ id: 'file-1', @@ -21,244 +22,112 @@ const makeFileNode = (overrides: Partial = {}): ProjectFileNode ...overrides, }); -const makeFolderNode = (overrides: Partial = {}): ProjectFileNode => ({ - id: 'folder-1', - name: 'scripts', - path: '/scripts', - absolutePath: '/abs/scripts', - type: 'folder', - hasChildren: true, - ...overrides, -}); - -const makeResponse = (data: ProjectFileNode[], totalCount?: number): GetProjectFilesResponse => ({ +const makeResponse = (data: ProjectFileNode[]): GetProjectFilesResponse => ({ data, - totalCount: totalCount ?? data.length, + totalCount: data.length, offset: 0, limit: 50, }); -function createProjectStructureServiceMock() { - return { - getProjectFiles: vi.fn().mockReturnValue(of(makeResponse([]))), - deleteProjectStructureNode: vi.fn().mockReturnValue(of(null)), - updateProjectScript: vi.fn().mockReturnValue(of({ id: 'file-1', name: 'renamed.cs' })), +interface MockServices { + projectStructure: { + getProjectFiles: Mock; + deleteProjectStructureNode: Mock; + updateProjectScript: Mock; + getProjectScript: Mock; }; -} - -function createDialogMock() { - const dialogRef = { afterClosed: vi.fn().mockReturnValue(of(undefined)) }; - return { - open: vi.fn().mockReturnValue(dialogRef), - dialogRef, + dialog: { + open: Mock; + }; + snackbar: { + open: Mock; + }; + liveUpdates: { + scriptCreated$: Subject; + scriptDeleted$: Subject; + scriptUpdated$: Subject; }; -} - -function getRenameButton() { - const menuButtons = document.querySelectorAll('.mat-mdc-menu-item'); - return Array.from(menuButtons).find((b) => b.textContent?.includes('Rename')) as HTMLButtonElement | undefined; -} - -function getDeleteButton() { - const menuButtons = document.querySelectorAll('.mat-mdc-menu-item'); - return Array.from(menuButtons).find((b) => b.textContent?.includes('Delete')) as HTMLButtonElement | undefined; -} - -function clickActionsMenu(fixture: ComponentFixture) { - const menuTrigger = fixture.debugElement.query(By.css('app-tree-actions-menu button')); - menuTrigger.nativeElement.click(); - fixture.detectChanges(); } describe('ScriptTreeComponent', () => { let component: ScriptTreeComponent; let fixture: ComponentFixture; - let projectStructureService: ReturnType; - let projectContextService: { generateClasses: ReturnType }; - let projectStateService: { currentInstanceId: ReturnType }; - let dialog: ReturnType; - let snackbar: { open: ReturnType }; + let mocks: MockServices; beforeEach(async () => { - projectStructureService = createProjectStructureServiceMock(); - projectContextService = { generateClasses: vi.fn().mockReturnValue(of(undefined)) }; - projectStateService = { currentInstanceId: vi.fn().mockReturnValue('instance-123') }; - dialog = createDialogMock(); - snackbar = { open: vi.fn() }; + mocks = { + projectStructure: { + getProjectFiles: vi.fn().mockReturnValue(of(makeResponse([]))), + deleteProjectStructureNode: vi.fn().mockReturnValue(of(null)), + updateProjectScript: vi.fn(), + getProjectScript: vi.fn(), + }, + dialog: { + open: vi.fn().mockReturnValue({ afterClosed: () => of(undefined) }), + }, + snackbar: { + open: vi.fn(), + }, + liveUpdates: { + scriptCreated$: new Subject(), + scriptDeleted$: new Subject(), + scriptUpdated$: new Subject(), + }, + }; await TestBed.configureTestingModule({ imports: [ScriptTreeComponent], providers: [ - { provide: ProjectStructureService, useValue: projectStructureService }, - { provide: ProjectContextService, useValue: projectContextService }, - { provide: ProjectStateService, useValue: projectStateService }, - { provide: MatDialog, useValue: dialog }, - { provide: MatSnackBar, useValue: snackbar }, + { provide: ProjectStructureService, useValue: mocks.projectStructure }, + { provide: ProjectContextService, useValue: { generateClasses: vi.fn().mockReturnValue(of(undefined)) } }, + { provide: ProjectStateService, useValue: { currentInstanceId: vi.fn().mockReturnValue('inst-1') } }, + { provide: MatDialog, useValue: mocks.dialog }, + { provide: MatSnackBar, useValue: mocks.snackbar }, + { provide: ProjectLiveUpdatesService, useValue: mocks.liveUpdates }, ], }).compileComponents(); fixture = TestBed.createComponent(ScriptTreeComponent); component = fixture.componentInstance; - + fixture.componentRef.setInput('projectId', 'proj-1'); fixture.componentRef.setInput('selectedFileId', null); + fixture.detectChanges(); }); - describe('Initialization', () => { - it('should render the create new script button', () => { - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - - const createButton = fixture.debugElement.query(By.css('button[aria-label="create new script"]')); - expect(createButton).toBeTruthy(); - }); - - it('should render the reload tree button', () => { - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - - const reloadButton = fixture.debugElement.query(By.css('button[aria-label="reload tree"]')); - expect(reloadButton).toBeTruthy(); - }); - - it('should pass fetchData successfully to lazy file tree', () => { - const file = makeFileNode(); - const folder = makeFolderNode(); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([folder, file]))); - fixture.componentRef.setInput('projectId', 'project-abc'); - - fixture.detectChanges(); - - const treeText = fixture.nativeElement.textContent; - expect(treeText).toContain('script.cs'); - expect(treeText).toContain('scripts'); - expect(projectStructureService.getProjectFiles).toHaveBeenCalledWith('project-abc', undefined, 0, 50); - }); - }); - - describe('Interaction', () => { - it('should open the create script dialog when the create button is clicked', () => { - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - const createButton = fixture.debugElement.query(By.css('button[aria-label="create new script"]')); - - createButton.nativeElement.click(); - - expect(dialog.open).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ data: { projectId: 'project-abc' } })); - }); - - it('should fetch folder children and render them when a folder is expanded', () => { - const folder = makeFolderNode({ id: 'folder-a' }); - projectStructureService.getProjectFiles.mockReturnValueOnce(of(makeResponse([folder]))); - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - const toggleButton = fixture.debugElement.query(By.css('button[matTreeNodeToggle]')); - projectStructureService.getProjectFiles.mockReturnValueOnce(of(makeResponse([makeFileNode({ name: 'child.cs' })]))); - - toggleButton.nativeElement.click(); - fixture.detectChanges(); + it('should render the control buttons', () => { + const createBtn = fixture.debugElement.query(By.css('button[aria-label="create new script"]')); + const reloadBtn = fixture.debugElement.query(By.css('button[aria-label="reload tree"]')); - expect(projectStructureService.getProjectFiles).toHaveBeenCalledWith('project-abc', 'folder-a', 0, 50); - expect(fixture.nativeElement.textContent).toContain('child.cs'); - }); - - it('should emit fileSelected event when a file node is clicked', () => { - const file = makeFileNode(); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([file]))); - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - const fileSelectedSpy = vi.spyOn(component.fileSelected, 'emit'); - const fileNode = fixture.debugElement.query(By.css('.leaf-node')); - - fileNode.nativeElement.parentElement.click(); - - expect(fileSelectedSpy).toHaveBeenCalledWith(file); - }); + expect(createBtn).toBeTruthy(); + expect(reloadBtn).toBeTruthy(); }); - describe('Actions', () => { - it('should call deleteProjectStructureNode and remove node from tree', () => { - const file = makeFileNode(); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([file]))); - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - clickActionsMenu(fixture); - const deleteButton = getDeleteButton(); - projectStructureService.getProjectFiles.mockClear(); - - deleteButton!.click(); - fixture.detectChanges(); + describe('Live Updates', () => { + it('should reload root on ScriptCreated event', () => { + const reloadFolderSpy = vi.spyOn(component.lazyTree(), 'reloadFolder'); - expect(projectStructureService.deleteProjectStructureNode).toHaveBeenCalledWith('project-abc', 'file-1'); - expect(projectStructureService.getProjectFiles).not.toHaveBeenCalled(); - expect(fixture.nativeElement.textContent).not.toContain('script.cs'); - }); - - it('should display the loading progress bar when a deletion is in progress', async () => { - const file = makeFileNode(); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([file]))); - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - clickActionsMenu(fixture); - const deleteButton = getDeleteButton(); - const deletionObservable = new Subject(); - projectStructureService.deleteProjectStructureNode.mockReturnValue(deletionObservable.asObservable()); - - deleteButton!.click(); - fixture.detectChanges(); - - let progressBar = fixture.debugElement.query(By.css('app-loading-progress-bar')); - expect(progressBar).toBeTruthy(); - - deletionObservable.next(); - deletionObservable.complete(); - - await fixture.whenStable(); - fixture.detectChanges(); + mocks.liveUpdates.scriptCreated$.next({ scriptId: 'new-id', projectId: 'proj-1', clientId: 'other' }); - progressBar = fixture.debugElement.query(By.css('app-loading-progress-bar')); - expect(progressBar).toBeFalsy(); + expect(reloadFolderSpy).toHaveBeenCalledWith(null); }); - it('should update the node name in the lazy tree when rename succeeds', async () => { - const file = makeFileNode({ id: 'file-1', name: 'original.cs' }); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([file]))); - projectStructureService.updateProjectScript.mockReturnValue(of({ ...file, name: 'renamed.cs' })); + it('should remove node on ScriptDeleted event', () => { + const removeNodeSpy = vi.spyOn(component.lazyTree(), 'removeNode'); - dialog.dialogRef.afterClosed.mockReturnValue(of('renamed.cs')); + mocks.liveUpdates.scriptDeleted$.next({ scriptId: 'del-id', projectId: 'proj-1', clientId: 'other' }); - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - - clickActionsMenu(fixture); - const renameButton = getRenameButton(); - const initialCallCount = projectStructureService.getProjectFiles.mock.calls.length; - - renameButton!.click(); - fixture.detectChanges(); - - expect(projectStructureService.getProjectFiles.mock.calls.length).toBe(initialCallCount); - expect(fixture.nativeElement.textContent).toContain('renamed.cs'); - expect(fixture.nativeElement.textContent).not.toContain('original.cs'); + expect(removeNodeSpy).toHaveBeenCalledWith('del-id'); }); - it('should show a snackbar when rename fails', () => { - const file = makeFileNode(); - projectStructureService.getProjectFiles.mockReturnValue(of(makeResponse([file]))); - const httpError = new HttpErrorResponse({ error: { title: 'Conflict Error', status: 409 }, status: 409 }); - projectStructureService.updateProjectScript.mockReturnValue(throwError(() => httpError)); - - dialog.dialogRef.afterClosed.mockReturnValue(of('renamed.cs')); - - fixture.componentRef.setInput('projectId', 'project-abc'); - fixture.detectChanges(); - - clickActionsMenu(fixture); - const renameButton = getRenameButton(); + it('should update node on ScriptUpdated event', () => { + const updateNodeSpy = vi.spyOn(component.lazyTree(), 'updateNode'); + const scriptData = makeFileNode({ id: 'upd-id', name: 'updated.cs' }); + mocks.projectStructure.getProjectScript.mockReturnValue(of(scriptData)); - renameButton!.click(); - fixture.detectChanges(); + mocks.liveUpdates.scriptUpdated$.next({ scriptId: 'upd-id', projectId: 'proj-1', clientId: 'other' }); - expect(snackbar.open).toHaveBeenCalledWith(expect.stringContaining('Conflict Error'), 'Dismiss', expect.anything()); + expect(mocks.projectStructure.getProjectScript).toHaveBeenCalledWith('proj-1', 'upd-id'); + expect(updateNodeSpy).toHaveBeenCalledWith('upd-id', expect.any(Function)); }); }); }); diff --git a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.ts b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.ts index 1f36763e..4acffab7 100644 --- a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.ts +++ b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/script-tree/script-tree.component.ts @@ -14,6 +14,8 @@ import { TreeAction, TreeNode } from '../../../../../types/tree-node'; import { RenameFileDialog } from '../../../../../components/dialogs/rename-file-dialog/rename-file-dialog.component'; import { HttpErrorResponse } from '@angular/common/http'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { ProjectLiveUpdatesService } from '../../../../../services/projects/project-live-updates.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-script-tree', @@ -52,9 +54,41 @@ export class ScriptTreeComponent { selectedAccessor = (node: TreeNode) => node.data.id === this.selectedFileId(); private projectStructureService = inject(ProjectStructureService); + private projectLiveUpdatesService = inject(ProjectLiveUpdatesService); private dialog = inject(MatDialog); private snackbar = inject(MatSnackBar); + constructor() { + this.projectLiveUpdatesService.scriptUpdated$.pipe(takeUntilDestroyed()).subscribe((event) => { + if (event.projectId === this.projectId()) { + this.projectStructureService.getProjectScript(this.projectId(), event.scriptId).subscribe((script) => { + this.lazyTree().updateNode(event.scriptId, (oldNode) => ({ + ...oldNode, + data: { + ...oldNode.data, + name: script.name, + path: script.path, + absolutePath: script.absolutePath, + }, + })); + }); + } + }); + + this.projectLiveUpdatesService.scriptDeleted$.pipe(takeUntilDestroyed()).subscribe((event) => { + if (event.projectId === this.projectId()) { + this.lazyTree().removeNode(event.scriptId); + } + }); + + this.projectLiveUpdatesService.scriptCreated$.pipe(takeUntilDestroyed()).subscribe((event) => { + if (event.projectId === this.projectId()) { + // TODO: here we can call addNode instead of refreshing the entire tree if we know the parentId + this.lazyTree().reloadFolder(null); + } + }); + } + fetchData = (parentId: string | null, offset: number, limit: number) => { return this.projectStructureService.getProjectFiles(this.projectId(), parentId || undefined, offset, limit).pipe( map((response) => { diff --git a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.spec.ts b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.spec.ts index b4c4f1e5..0503f0f5 100644 --- a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.spec.ts +++ b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.spec.ts @@ -13,21 +13,35 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { AnalysisService } from '../../../../../../services/analysis/analysis.service'; import { provideHttpClient } from '@angular/common/http'; import { provideMonacoEditor } from 'ngx-monaco-editor-v2'; +import { ProjectLiveUpdatesService } from '../../../../../../services/projects/project-live-updates.service'; +import { ScriptUpdatedEvent } from '../../../../../../types/live-updates'; -describe('SelectedScriptComponent', () => { - let component: SelectedScriptComponent; - let fixture: ComponentFixture; - let projectStructureServiceSpy: { +interface MockServices { + projectStructure: { getProjectScript: Mock; getScriptContent: Mock; updateScriptContent: Mock; }; + liveUpdates: { + scriptUpdated$: Subject; + }; +} + +describe('SelectedScriptComponent', () => { + let component: SelectedScriptComponent; + let fixture: ComponentFixture; + let mocks: MockServices; beforeEach(async () => { - projectStructureServiceSpy = { - getProjectScript: vi.fn().mockReturnValue(of({ scriptLanguage: { name: 'csharp' } })), - getScriptContent: vi.fn().mockReturnValue(of('initial content')), - updateScriptContent: vi.fn().mockReturnValue(of({})), + mocks = { + projectStructure: { + getProjectScript: vi.fn().mockReturnValue(of({ scriptLanguage: { name: 'csharp' } })), + getScriptContent: vi.fn().mockReturnValue(of('initial content')), + updateScriptContent: vi.fn().mockReturnValue(of({})), + }, + liveUpdates: { + scriptUpdated$: new Subject(), + }, }; await TestBed.configureTestingModule({ @@ -36,11 +50,12 @@ describe('SelectedScriptComponent', () => { provideHttpClient(), provideHttpClientTesting(), provideMonacoEditor(), - { provide: ProjectStructureService, useValue: projectStructureServiceSpy }, + { provide: ProjectStructureService, useValue: mocks.projectStructure }, { provide: ThemeService, useValue: { darkMode: signal(false) } }, { provide: MatDialog, useValue: { open: vi.fn() } }, { provide: MatSnackBar, useValue: { open: vi.fn() } }, { provide: AnalysisService, useValue: { triggerAnalysis: vi.fn() } }, + { provide: ProjectLiveUpdatesService, useValue: mocks.liveUpdates }, ], }).compileComponents(); @@ -67,7 +82,7 @@ describe('SelectedScriptComponent', () => { it('should show Saving and then Saved after debounce', async () => { const updateSubject = new Subject(); - projectStructureServiceSpy.updateScriptContent.mockReturnValue(updateSubject); + mocks.projectStructure.updateScriptContent.mockReturnValue(updateSubject); vi.useFakeTimers(); const editor = fixture.debugElement.query(By.css('ngx-monaco-editor')); @@ -78,12 +93,12 @@ describe('SelectedScriptComponent', () => { updateSubject.next({}); expect(component.saveStatus()).toBe('Saved'); - expect(projectStructureServiceSpy.updateScriptContent).toHaveBeenCalledWith('proj-1', 'script-1', 'new content'); + expect(mocks.projectStructure.updateScriptContent).toHaveBeenCalledWith('proj-1', 'script-1', 'new content'); }); it('should handle errors and allow subsequent updates', async () => { vi.useFakeTimers(); - projectStructureServiceSpy.updateScriptContent.mockReturnValue(throwError(() => new Error('failed'))); + mocks.projectStructure.updateScriptContent.mockReturnValue(throwError(() => new Error('failed'))); const editor = fixture.debugElement.query(By.css('ngx-monaco-editor')); editor.triggerEventHandler('ngModelChange', 'bad content'); @@ -91,11 +106,19 @@ describe('SelectedScriptComponent', () => { await vi.advanceTimersByTimeAsync(1001); expect(component.saveStatus()).toBe('Error'); - projectStructureServiceSpy.updateScriptContent.mockReturnValue(of({})); + mocks.projectStructure.updateScriptContent.mockReturnValue(of({})); editor.triggerEventHandler('ngModelChange', 'good content'); await vi.advanceTimersByTimeAsync(1001); expect(component.saveStatus()).toBe('Saved'); - expect(projectStructureServiceSpy.updateScriptContent).toHaveBeenCalledWith('proj-1', 'script-1', 'good content'); + expect(mocks.projectStructure.updateScriptContent).toHaveBeenCalledWith('proj-1', 'script-1', 'good content'); + }); + + it('should reload content when script is updated remotely', async () => { + mocks.projectStructure.getScriptContent.mockReturnValue(of('remote content')); + + mocks.liveUpdates.scriptUpdated$.next({ scriptId: 'script-1', projectId: 'proj-1', clientId: 'other' }); + + expect(mocks.projectStructure.getScriptContent).toHaveBeenCalledWith('proj-1', 'script-1'); }); }); diff --git a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.ts b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.ts index 5cc16dbc..8f828de6 100644 --- a/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.ts +++ b/ScriptBeeClient/src/app/pages/projects/project-details/analysis/scripts-content/selected-script/selected-script.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, input, output, signal } from '@angular/core'; +import { Component, computed, effect, inject, input, output, signal } from '@angular/core'; import { rxResource, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { catchError, debounceTime, delay, of, Subject, switchMap, tap } from 'rxjs'; import { EditorComponent } from 'ngx-monaco-editor-v2'; @@ -12,6 +12,7 @@ import { ErrorStateComponent } from '../../../../../../components/error-state/er import { LoadingProgressBarComponent } from '../../../../../../components/loading-progress-bar/loading-progress-bar.component'; import { convertError } from '../../../../../../utils/api'; import { RunScriptLoadingComponent } from './run-script-loading/run-script-loading.component'; +import { ProjectLiveUpdatesService } from '../../../../../../services/projects/project-live-updates.service'; @Component({ selector: 'app-selected-script', @@ -40,8 +41,10 @@ export class SelectedScriptComponent { private themeService = inject(ThemeService); private projectStructureService = inject(ProjectStructureService); + private projectLiveUpdatesService = inject(ProjectLiveUpdatesService); private updateContent$ = new Subject(); + private remoteContent = signal(undefined); constructor() { this.updateContent$ @@ -66,6 +69,20 @@ export class SelectedScriptComponent { takeUntilDestroyed() ) .subscribe(); + + effect(() => { + const content = this.scriptContentResource.value(); + if (content !== undefined) { + this.remoteContent.set(content); + } + }); + + this.projectLiveUpdatesService.scriptUpdated$.pipe(takeUntilDestroyed()).subscribe((event) => { + if (event.projectId === this.projectId() && event.scriptId === this.scriptId()) { + this.scriptResource.reload(); + this.scriptContentResource.reload(); + } + }); } editorOptions = computed(() => { @@ -106,6 +123,9 @@ export class SelectedScriptComponent { } protected onContentChange(newContent: string) { + if (newContent === this.remoteContent()) { + return; + } this.updateContent$.next(newContent); } } diff --git a/ScriptBeeClient/src/app/pages/projects/project-details/project-details-page.component.ts b/ScriptBeeClient/src/app/pages/projects/project-details/project-details-page.component.ts index 7c0447b1..8b369666 100644 --- a/ScriptBeeClient/src/app/pages/projects/project-details/project-details-page.component.ts +++ b/ScriptBeeClient/src/app/pages/projects/project-details/project-details-page.component.ts @@ -7,6 +7,7 @@ import { convertError } from '../../../utils/api'; import { ProjectStateService } from '../../../services/projects/project-state.service'; import { of } from 'rxjs'; import { RouterOutlet } from '@angular/router'; +import { ProjectLiveUpdatesService } from '../../../services/projects/project-live-updates.service'; @Component({ selector: 'app-project-details', @@ -29,12 +30,16 @@ export class ProjectDetailsPage { private projectService = inject(ProjectService); private projectStateService = inject(ProjectStateService); + private projectLiveUpdatesService = inject(ProjectLiveUpdatesService); constructor() { effect(() => { const project = this.projectResource.value(); if (project) { this.projectStateService.currentProject.set(project); + void this.projectLiveUpdatesService.connect(project.id); + } else { + void this.projectLiveUpdatesService.disconnect(); } }); } diff --git a/ScriptBeeClient/src/app/services/client-id.service.ts b/ScriptBeeClient/src/app/services/client-id.service.ts new file mode 100644 index 00000000..0f309917 --- /dev/null +++ b/ScriptBeeClient/src/app/services/client-id.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class ClientIdService { + public readonly clientId: string; + + constructor() { + this.clientId = crypto.randomUUID(); + } +} diff --git a/ScriptBeeClient/src/app/services/projects/project-live-updates.service.spec.ts b/ScriptBeeClient/src/app/services/projects/project-live-updates.service.spec.ts new file mode 100644 index 00000000..dcd5077a --- /dev/null +++ b/ScriptBeeClient/src/app/services/projects/project-live-updates.service.spec.ts @@ -0,0 +1,105 @@ +import { TestBed } from '@angular/core/testing'; +import { ProjectLiveUpdatesService } from './project-live-updates.service'; +import { ClientIdService } from '../client-id.service'; +import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; +import { BaseScriptEvent } from '../../types/live-updates'; + +interface MockHubConnection { + on: MockInstance<(methodName: string, newMethod: (...args: unknown[]) => void) => void>; + start: MockInstance<() => Promise>; + stop: MockInstance<() => Promise>; + invoke: MockInstance<(methodName: string, ...args: unknown[]) => Promise>; +} + +const hubMocks = vi.hoisted(() => { + return { + instance: { + on: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + invoke: vi.fn(), + } as MockHubConnection, + }; +}); + +vi.mock('@microsoft/signalr', () => { + const builderMock = { + withUrl: vi.fn().mockReturnThis(), + withAutomaticReconnect: vi.fn().mockReturnThis(), + build: vi.fn(() => hubMocks.instance), + }; + + return { + HubConnectionBuilder: vi.fn().mockImplementation(function () { + return builderMock; + }), + HubConnectionState: { + Connected: 'Connected', + Disconnected: 'Disconnected', + }, + }; +}); + +describe('ProjectLiveUpdatesService', () => { + let service: ProjectLiveUpdatesService; + const mockHubConnection = hubMocks.instance; + + beforeEach(() => { + vi.clearAllMocks(); + + mockHubConnection.on.mockImplementation(() => undefined); + mockHubConnection.start.mockResolvedValue(undefined); + mockHubConnection.stop.mockResolvedValue(undefined); + mockHubConnection.invoke.mockResolvedValue(undefined); + + TestBed.configureTestingModule({ + providers: [ProjectLiveUpdatesService, { provide: ClientIdService, useValue: { clientId: 'test-client-id' } }], + }); + + service = TestBed.inject(ProjectLiveUpdatesService); + }); + + it('should establishing connection and register handlers on connect', async () => { + await service.connect('proj-1'); + + expect(mockHubConnection.start).toHaveBeenCalled(); + expect(mockHubConnection.on).toHaveBeenCalledWith('ScriptCreated', expect.any(Function)); + expect(mockHubConnection.on).toHaveBeenCalledWith('ScriptUpdated', expect.any(Function)); + expect(mockHubConnection.on).toHaveBeenCalledWith('ScriptDeleted', expect.any(Function)); + }); + + it('should disconnect on disconnect()', async () => { + await service.connect('proj-1'); + await service.disconnect(); + + expect(mockHubConnection.stop).toHaveBeenCalled(); + }); + + describe('Event Filtering', () => { + it('should emit events from other clients', async () => { + await service.connect('proj-1'); + const spy = vi.fn(); + service.scriptCreated$.subscribe(spy); + + const scriptCreatedHandler = mockHubConnection.on.mock.calls.find((c) => c[0] === 'ScriptCreated')?.[1]; + expect(scriptCreatedHandler).toBeDefined(); + + const event: BaseScriptEvent = { scriptId: 's1', projectId: 'p1', clientId: 'other-client-id' }; + scriptCreatedHandler!(event); + + expect(spy).toHaveBeenCalledWith(event); + }); + + it('should ignore events from self', async () => { + await service.connect('proj-1'); + const spy = vi.fn(); + service.scriptCreated$.subscribe(spy); + + const scriptCreatedHandler = mockHubConnection.on.mock.calls.find((c) => c[0] === 'ScriptCreated')?.[1]; + + scriptCreatedHandler!({ scriptId: 's1', projectId: 'p1', clientId: 'test-client-id' }); + + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ScriptBeeClient/src/app/services/projects/project-live-updates.service.ts b/ScriptBeeClient/src/app/services/projects/project-live-updates.service.ts new file mode 100644 index 00000000..c6ee5923 --- /dev/null +++ b/ScriptBeeClient/src/app/services/projects/project-live-updates.service.ts @@ -0,0 +1,81 @@ +import { inject, Injectable } from '@angular/core'; +import * as signalR from '@microsoft/signalr'; +import { Subject } from 'rxjs'; +import { ClientIdService } from '../client-id.service'; +import { ScriptCreateEvent, ScriptDeletedEvent, ScriptUpdatedEvent } from '../../types/live-updates'; + +@Injectable({ + providedIn: 'root', +}) +export class ProjectLiveUpdatesService { + private hubConnection: signalR.HubConnection | null = null; + private currentProjectId: string | null = null; + private clientIdService = inject(ClientIdService); + + public readonly scriptCreated$ = new Subject(); + public readonly scriptUpdated$ = new Subject(); + public readonly scriptDeleted$ = new Subject(); + + async connect(projectId: string) { + if (this.currentProjectId === projectId && this.hubConnection?.state === signalR.HubConnectionState.Connected) { + return; + } + + if (this.hubConnection) { + await this.disconnect(); + } + + this.currentProjectId = projectId; + this.hubConnection = new signalR.HubConnectionBuilder().withUrl('/api/projectLiveUpdates').withAutomaticReconnect().build(); + + this.hubConnection.on('ScriptCreated', (event: ScriptCreateEvent) => { + if (event.clientId !== this.clientIdService.clientId) { + this.scriptCreated$.next(event); + } + }); + + this.hubConnection.on('ScriptUpdated', (event: ScriptUpdatedEvent) => { + if (event.clientId !== this.clientIdService.clientId) { + this.scriptUpdated$.next(event); + } + }); + + this.hubConnection.on('ScriptDeleted', (event: ScriptDeletedEvent) => { + if (event.clientId !== this.clientIdService.clientId) { + this.scriptDeleted$.next(event); + } + }); + + try { + await this.hubConnection.start(); + await this.joinChannel(projectId, 'scripts'); + } catch (err) { + console.error('Error while starting live updates connection: ', err); + } + } + + async disconnect() { + if (this.hubConnection && this.currentProjectId) { + try { + await this.leaveChannel(this.currentProjectId, 'scripts'); + await this.hubConnection.stop(); + } catch (err) { + console.error('Error while stopping live updates connection: ', err); + } + this.hubConnection = null; + this.currentProjectId = null; + } + } + + private async joinChannel(projectId: string, channelName: string) { + if (this.hubConnection) { + await this.hubConnection.invoke('JoinChannel', projectId, channelName); + } + } + + private async leaveChannel(projectId: string, channelName: string) { + if (this.hubConnection) { + await this.hubConnection.invoke('LeaveChannel', projectId, channelName); + } + } +} diff --git a/ScriptBeeClient/src/app/types/live-updates.ts b/ScriptBeeClient/src/app/types/live-updates.ts new file mode 100644 index 00000000..7d044cd7 --- /dev/null +++ b/ScriptBeeClient/src/app/types/live-updates.ts @@ -0,0 +1,9 @@ +export interface BaseScriptEvent { + projectId: string; + scriptId: string; + clientId: string; +} + +export type ScriptUpdatedEvent = BaseScriptEvent; +export type ScriptDeletedEvent = BaseScriptEvent; +export type ScriptCreateEvent = BaseScriptEvent; diff --git a/ScriptBeeClient/src/app/utils/client-id.interceptor.ts b/ScriptBeeClient/src/app/utils/client-id.interceptor.ts new file mode 100644 index 00000000..86ece4f5 --- /dev/null +++ b/ScriptBeeClient/src/app/utils/client-id.interceptor.ts @@ -0,0 +1,15 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { ClientIdService } from '../services/client-id.service'; + +export const clientIdInterceptor: HttpInterceptorFn = (req, next) => { + const clientIdService = inject(ClientIdService); + + const clonedRequest = req.clone({ + setHeaders: { + 'X-Client-Id': clientIdService.clientId, + }, + }); + + return next(clonedRequest); +}; diff --git a/ScriptBeeWebApp/Extensions/FileWatcherExtensions.cs b/ScriptBeeWebApp/Extensions/FileWatcherExtensions.cs deleted file mode 100644 index 52aa8b45..00000000 --- a/ScriptBeeWebApp/Extensions/FileWatcherExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using ScriptBee.ProjectContext; -using ScriptBeeWebApp.Services; - -namespace ScriptBeeWebApp.Extensions; - -public static class FileWatcherExtensions -{ - public static IServiceCollection AddFileWatcherServices(this IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); - return services; - } -} diff --git a/ScriptBeeWebApp/Extensions/PluginRegistrationExtensions.cs b/ScriptBeeWebApp/Extensions/PluginRegistrationExtensions.cs deleted file mode 100644 index 79dc9e13..00000000 --- a/ScriptBeeWebApp/Extensions/PluginRegistrationExtensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; -using DxWorks.ScriptBee.Plugin.Api; -using Microsoft.Extensions.DependencyInjection; -using ScriptBee.FileManagement; -using ScriptBee.Plugin; -using ScriptBee.Plugin.Installer; -using ScriptBee.Plugin.Manifest; -using ScriptBeeWebApp.Services; - -namespace ScriptBeeWebApp.Extensions; - -public static class PluginRegistrationExtensions -{ - public static IServiceCollection AddPluginServices(this IServiceCollection services) - { - return services - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton(_ => RegisterPluginTypes()); - } - - private static IPluginRegistrationService RegisterPluginTypes() - { - var pluginRegistrationService = new PluginRegistrationService(); - - pluginRegistrationService.Add( - PluginKind.Loader, - new HashSet { typeof(IModelLoader) } - ); - pluginRegistrationService.Add( - PluginKind.Linker, - new HashSet { typeof(IModelLinker) } - ); - pluginRegistrationService.Add( - PluginKind.ScriptGenerator, - new HashSet { typeof(IScriptGeneratorStrategy) } - ); - pluginRegistrationService.Add( - PluginKind.ScriptRunner, - new HashSet { typeof(IScriptRunner) } - ); - pluginRegistrationService.Add( - PluginKind.HelperFunctions, - new HashSet { typeof(IHelperFunctions) } - ); - - // todo see how to start the plugin via node or http-server or something like that if not already started - pluginRegistrationService.Add(PluginKind.Ui, new HashSet()); - - return pluginRegistrationService; - } -} diff --git a/ScriptBeeWebApp/Hubs/FileWatcherHub.cs b/ScriptBeeWebApp/Hubs/FileWatcherHub.cs deleted file mode 100644 index f88ad8ed..00000000 --- a/ScriptBeeWebApp/Hubs/FileWatcherHub.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; -using ScriptBeeWebApp.Data; - -namespace ScriptBeeWebApp.Hubs; - -public class FileWatcherHub : Hub -{ - public override Task OnConnectedAsync() - { - return Task.CompletedTask; - } - - public override Task OnDisconnectedAsync(Exception? exception) - { - return Task.CompletedTask; - } - - public Task SendFileWatch(WatchedFile watchedFile) - { - return Clients.All.ReceiveFileWatch(watchedFile); - } -} diff --git a/ScriptBeeWebApp/Hubs/IFileWatcherClient.cs b/ScriptBeeWebApp/Hubs/IFileWatcherClient.cs deleted file mode 100644 index b3844432..00000000 --- a/ScriptBeeWebApp/Hubs/IFileWatcherClient.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Threading.Tasks; -using ScriptBeeWebApp.Data; - -namespace ScriptBeeWebApp.Hubs; - -public interface IFileWatcherClient -{ - Task ReceiveFileWatch(WatchedFile watchedFile); -} diff --git a/ScriptBeeWebApp/Services/GenerateScriptService.cs b/ScriptBeeWebApp/Services/GenerateScriptService.cs deleted file mode 100644 index fa54d857..00000000 --- a/ScriptBeeWebApp/Services/GenerateScriptService.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.IO.Compression; -using DxWorks.ScriptBee.Plugin.Api; -using ScriptBee.Plugin; -using ScriptBee.Plugin.Manifest; -using ScriptBee.Scripts.ScriptSampleGenerators; -using ScriptBeeWebApp.EndpointDefinitions.DTO; - -namespace ScriptBeeWebApp.Services; - -// todo add tests -public class GenerateScriptService : IGenerateScriptService -{ - private readonly ILoadersService _loadersService; - private readonly IPluginRepository _pluginRepository; - - public GenerateScriptService(ILoadersService loadersService, IPluginRepository pluginRepository) - { - _loadersService = loadersService; - _pluginRepository = pluginRepository; - } - - public IEnumerable GetSupportedLanguages() - { - return _pluginRepository - .GetLoadedPluginExtensionPoints() - .Select(extensionPoint => new ScriptLanguage( - extensionPoint.Language, - extensionPoint.Extension - )); - } - - public IScriptGeneratorStrategy? GetGenerationStrategy(string scriptType) - { - return _pluginRepository.GetPlugin(strategy => - strategy.Language == scriptType - ); - } - - public async Task GenerateClassesZip( - IEnumerable classes, - IScriptGeneratorStrategy scriptGeneratorStrategy, - CancellationToken cancellationToken = default - ) - { - var acceptedModules = _loadersService.GetAcceptedModules(); - - var sampleCode = await new SampleCodeGenerator( - scriptGeneratorStrategy, - acceptedModules - ).GetSampleCode(classes, cancellationToken); - - return CreateFileZipStream(sampleCode, scriptGeneratorStrategy.Extension); - } - - private static Stream CreateFileZipStream( - IEnumerable sampleCode, - string extension - ) - { - var zipStream = new MemoryStream(); - using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create, true)) - { - foreach (var sampleCodeFile in sampleCode) - { - var zipArchiveEntry = zip.CreateEntry(sampleCodeFile.Name + extension); - - using var writer = new StreamWriter(zipArchiveEntry.Open()); - writer.Write(sampleCodeFile.Content); - } - } - - zipStream.Position = 0; - return zipStream; - } -} diff --git a/ScriptBeeWebApp/Services/IFileWatcherHubService.cs b/ScriptBeeWebApp/Services/IFileWatcherHubService.cs deleted file mode 100644 index 644c1a78..00000000 --- a/ScriptBeeWebApp/Services/IFileWatcherHubService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using ScriptBeeWebApp.Data; - -namespace ScriptBeeWebApp.Services; - -public interface IFileWatcherHubService -{ - Task SendFileWatch(WatchedFile watchedFile); -} diff --git a/ScriptBeeWebApp/Services/IGenerateScriptService.cs b/ScriptBeeWebApp/Services/IGenerateScriptService.cs deleted file mode 100644 index 4c45eaaa..00000000 --- a/ScriptBeeWebApp/Services/IGenerateScriptService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using DxWorks.ScriptBee.Plugin.Api; -using ScriptBeeWebApp.EndpointDefinitions.DTO; - -namespace ScriptBeeWebApp.Services; - -public interface IGenerateScriptService -{ - IEnumerable GetSupportedLanguages(); - IScriptGeneratorStrategy? GetGenerationStrategy(string scriptType); - - Task GenerateClassesZip( - IEnumerable classes, - IScriptGeneratorStrategy scriptGeneratorStrategy, - CancellationToken cancellationToken = default - ); -} diff --git a/ScriptBeeWebApp/Services/IScriptsService.cs b/ScriptBeeWebApp/Services/IScriptsService.cs deleted file mode 100644 index 21099302..00000000 --- a/ScriptBeeWebApp/Services/IScriptsService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using OneOf; -using ScriptBeeWebApp.Data; -using ScriptBeeWebApp.EndpointDefinitions.Arguments; -using ScriptBeeWebApp.EndpointDefinitions.DTO; - -namespace ScriptBeeWebApp.Services; - -public interface IScriptsService -{ - IEnumerable GetSupportedLanguages(); - - Task, ProjectMissing>> GetScriptsStructureAsync( - string projectId, - CancellationToken cancellationToken = default - ); - - Task> GetScriptByFilePathAsync( - string filepath, - string projectId, - CancellationToken cancellationToken = default - ); - - Task> GetScriptByIdAsync( - string scriptId, - string projectId, - CancellationToken cancellationToken = default - ); - - Task> GetScriptContentAsync( - string scriptId, - string projectId, - CancellationToken cancellationToken = default - ); - - Task< - OneOf - > CreateScriptAsync(CreateScript createScript, CancellationToken cancellationToken = default); - - Task> UpdateScriptAsync( - UpdateScript updateScript, - CancellationToken cancellationToken = default - ); - - Task> DeleteScriptAsync( - string scriptId, - string projectId, - CancellationToken cancellationToken = default - ); -} diff --git a/ScriptBeeWebApp/src/Common/Application.Model/Services/ClientIdProvider.cs b/ScriptBeeWebApp/src/Common/Application.Model/Services/ClientIdProvider.cs new file mode 100644 index 00000000..65027152 --- /dev/null +++ b/ScriptBeeWebApp/src/Common/Application.Model/Services/ClientIdProvider.cs @@ -0,0 +1,6 @@ +namespace ScriptBee.Application.Model.Services; + +public class ClientIdProvider : IClientIdProvider +{ + public string? ClientId { get; set; } +} diff --git a/ScriptBeeWebApp/src/Common/Application.Model/Services/IClientIdProvider.cs b/ScriptBeeWebApp/src/Common/Application.Model/Services/IClientIdProvider.cs new file mode 100644 index 00000000..8c9f9f9c --- /dev/null +++ b/ScriptBeeWebApp/src/Common/Application.Model/Services/IClientIdProvider.cs @@ -0,0 +1,6 @@ +namespace ScriptBee.Application.Model.Services; + +public interface IClientIdProvider +{ + string? ClientId { get; set; } +} diff --git a/ScriptBeeWebApp/src/Common/Common.Web/Common.Web.csproj b/ScriptBeeWebApp/src/Common/Common.Web/Common.Web.csproj index 07bf4165..e457c8f1 100644 --- a/ScriptBeeWebApp/src/Common/Common.Web/Common.Web.csproj +++ b/ScriptBeeWebApp/src/Common/Common.Web/Common.Web.csproj @@ -1,4 +1,4 @@ - + ScriptBee.Common.Web @@ -16,5 +16,6 @@ + diff --git a/ScriptBeeWebApp/src/Common/Common.Web/Services/ClientIdMiddleware.cs b/ScriptBeeWebApp/src/Common/Common.Web/Services/ClientIdMiddleware.cs new file mode 100644 index 00000000..f41e783d --- /dev/null +++ b/ScriptBeeWebApp/src/Common/Common.Web/Services/ClientIdMiddleware.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; +using ScriptBee.Application.Model.Services; + +namespace ScriptBee.Common.Web.Services; + +public class ClientIdMiddleware(RequestDelegate next) +{ + private const string ClientIdHeaderName = "X-Client-Id"; + + public async Task InvokeAsync(HttpContext context, IClientIdProvider clientIdProvider) + { + if (context.Request.Headers.TryGetValue(ClientIdHeaderName, out var clientId)) + { + clientIdProvider.ClientId = clientId; + } + + await next(context); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptCreatedEvent.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptCreatedEvent.cs new file mode 100644 index 00000000..7dda5091 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptCreatedEvent.cs @@ -0,0 +1,15 @@ +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Adapters.Notifications.SignalR.Contracts; + +public record SignalRScriptCreatedEvent(string ProjectId, string ScriptId, string ClientId = "") +{ + public static SignalRScriptCreatedEvent Map(ScriptCreatedEvent @event, string clientId) + { + return new SignalRScriptCreatedEvent( + @event.ProjectId.ToString(), + @event.ScriptId.ToString(), + clientId + ); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptDeletedEvent.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptDeletedEvent.cs new file mode 100644 index 00000000..7fbb3b86 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptDeletedEvent.cs @@ -0,0 +1,15 @@ +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Adapters.Notifications.SignalR.Contracts; + +public record SignalRScriptDeletedEvent(string ProjectId, string ScriptId, string ClientId = "") +{ + public static SignalRScriptDeletedEvent Map(ScriptDeletedEvent @event, string clientId) + { + return new SignalRScriptDeletedEvent( + @event.ProjectId.ToString(), + @event.ScriptId.ToString(), + clientId + ); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptUpdatedEvent.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptUpdatedEvent.cs new file mode 100644 index 00000000..533227de --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Contracts/SignalRScriptUpdatedEvent.cs @@ -0,0 +1,15 @@ +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Adapters.Notifications.SignalR.Contracts; + +public record SignalRScriptUpdatedEvent(string ProjectId, string ScriptId, string ClientId = "") +{ + public static SignalRScriptUpdatedEvent Map(ScriptUpdatedEvent @event, string clientId) + { + return new SignalRScriptUpdatedEvent( + @event.ProjectId.ToString(), + @event.ScriptId.ToString(), + clientId + ); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Hubs/ProjectLiveUpdatesHub.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Hubs/ProjectLiveUpdatesHub.cs new file mode 100644 index 00000000..2292e18a --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Hubs/ProjectLiveUpdatesHub.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ScriptBee.Adapters.Notifications.SignalR.Hubs; + +public class ProjectLiveUpdatesHub : Hub +{ + public Task JoinChannel(string projectId, string channelName) + { + var groupName = $"{projectId}_{channelName}"; + return Groups.AddToGroupAsync(Context.ConnectionId, groupName); + } + + public Task LeaveChannel(string projectId, string channelName) + { + var groupName = $"{projectId}_{channelName}"; + return Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/NotificationExtensions.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/NotificationExtensions.cs new file mode 100644 index 00000000..92385073 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/NotificationExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using ScriptBee.Adapters.Notifications.SignalR.Services; +using ScriptBee.Ports.Notifications; + +namespace ScriptBee.Adapters.Notifications.SignalR; + +public static class NotificationExtensions +{ + public static IServiceCollection AddProjectLiveUpdates(this IServiceCollection services) + { + services.AddSignalR(); + services.AddSingleton(); + return services; + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Notifications.SignalR.csproj b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Notifications.SignalR.csproj new file mode 100644 index 00000000..093b5c48 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Notifications.SignalR.csproj @@ -0,0 +1,15 @@ + + + ScriptBee.Adapters.Notifications.SignalR + + + + + + + + + + + + diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Services/ProjectNotificationsService.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Services/ProjectNotificationsService.cs new file mode 100644 index 00000000..9962f12c --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Notifications.SignalR/Services/ProjectNotificationsService.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using ScriptBee.Adapters.Notifications.SignalR.Contracts; +using ScriptBee.Adapters.Notifications.SignalR.Hubs; +using ScriptBee.Application.Model.Services; +using ScriptBee.Domain.Model.Project; +using ScriptBee.Ports.Notifications; +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Adapters.Notifications.SignalR.Services; + +public class ProjectNotificationsService( + IHubContext hubContext, + IHttpContextAccessor httpContextAccessor +) : IProjectNotificationsService +{ + private const string ScriptsChannel = "scripts"; + + public Task NotifyScriptCreated( + ScriptCreatedEvent scriptCreatedEvent, + CancellationToken cancellationToken + ) + { + var groupName = GetGroupName(scriptCreatedEvent.ProjectId, ScriptsChannel); + var message = SignalRScriptCreatedEvent.Map(scriptCreatedEvent, GetClientId()); + return hubContext + .Clients.Group(groupName) + .SendAsync("ScriptCreated", message, cancellationToken); + } + + public Task NotifyScriptUpdated( + ScriptUpdatedEvent scriptUpdatedEvent, + CancellationToken cancellationToken + ) + { + var groupName = GetGroupName(scriptUpdatedEvent.ProjectId, ScriptsChannel); + var message = SignalRScriptUpdatedEvent.Map(scriptUpdatedEvent, GetClientId()); + return hubContext + .Clients.Group(groupName) + .SendAsync("ScriptUpdated", message, cancellationToken); + } + + public Task NotifyScriptDeleted( + ScriptDeletedEvent scriptDeletedEvent, + CancellationToken cancellationToken + ) + { + var groupName = GetGroupName(scriptDeletedEvent.ProjectId, ScriptsChannel); + var message = SignalRScriptDeletedEvent.Map(scriptDeletedEvent, GetClientId()); + return hubContext + .Clients.Group(groupName) + .SendAsync("ScriptDeleted", message, cancellationToken); + } + + private static string GetGroupName(ProjectId projectId, string channelName) => + $"{projectId.Value}_{channelName}"; + + private string GetClientId() + { + var clientIdProvider = + httpContextAccessor.HttpContext?.RequestServices.GetService(); + return clientIdProvider?.ClientId ?? ""; + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/CommonServicesExtensions.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/CommonServicesExtensions.cs index 5b87a2a5..0101ebbd 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/CommonServicesExtensions.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/CommonServicesExtensions.cs @@ -1,4 +1,5 @@ -using ScriptBee.Common; +using ScriptBee.Application.Model.Services; +using ScriptBee.Common; using ScriptBee.Service.Project.Plugin; using ScriptBee.Service.Project.ProjectStructure; using ScriptBee.UseCases.Project.ProjectStructure; @@ -12,6 +13,7 @@ public static IServiceCollection AddCommonServices(this IServiceCollection servi return services .AddSingleton() .AddSingleton() + .AddScoped() .AddSingleton() .AddSingleton(); } diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs index 9a672770..69d365ff 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs @@ -1,9 +1,12 @@ using FluentValidation; using Microsoft.AspNetCore.Server.Kestrel.Core; +using ScriptBee.Adapters.Notifications.SignalR; +using ScriptBee.Adapters.Notifications.SignalR.Hubs; using ScriptBee.Artifacts.Extensions; using ScriptBee.Common.Web; using ScriptBee.Common.Web.EndpointDefinition; using ScriptBee.Common.Web.Extensions; +using ScriptBee.Common.Web.Services; using ScriptBee.Marketplace.Client.Extensions; using ScriptBee.Rest.Extensions; using ScriptBee.Web.EndpointDefinitions; @@ -17,6 +20,7 @@ builder .Services.AddConfiguredHealthChecks() + .AddHttpContextAccessor() .AddSerilog() .AddOpenApi() .AddValidatorsFromAssemblyContaining() @@ -28,8 +32,7 @@ .AddAnalysisConfig(builder.Configuration) .AddScriptBeeMarketplaceClient() .AddInstallPluginsForAllocatedInstancesServices() - // .AddFileWatcherServices() - .AddSignalR(); + .AddProjectLiveUpdates(); builder.Services.AddEndpointDefinitions( typeof(IEndpointDefinition), @@ -54,6 +57,7 @@ app.UseDefaultFiles(); app.UseStaticFiles(); app.UseRouting(); +app.UseMiddleware(); app.MapHealthChecksEndpoint(); @@ -63,10 +67,10 @@ app.UseEndpoints(_ => { }); +app.MapHub("/api/projectLiveUpdates"); + app.UseEndpointDefinitions(); app.MapFallbackToFile("index.html"); app.Run(); - -public partial class Program; diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj index 60e0282e..43386acd 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj @@ -21,5 +21,6 @@ + diff --git a/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptCreatedEvent.cs b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptCreatedEvent.cs new file mode 100644 index 00000000..fc256936 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptCreatedEvent.cs @@ -0,0 +1,6 @@ +using ScriptBee.Domain.Model.Project; +using ScriptBee.Domain.Model.ProjectStructure; + +namespace ScriptBee.Ports.Notifications.Events; + +public record ScriptCreatedEvent(ProjectId ProjectId, ScriptId ScriptId); diff --git a/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptDeletedEvent.cs b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptDeletedEvent.cs new file mode 100644 index 00000000..851bf001 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptDeletedEvent.cs @@ -0,0 +1,6 @@ +using ScriptBee.Domain.Model.Project; +using ScriptBee.Domain.Model.ProjectStructure; + +namespace ScriptBee.Ports.Notifications.Events; + +public record ScriptDeletedEvent(ProjectId ProjectId, ScriptId ScriptId); diff --git a/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptUpdatedEvent.cs b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptUpdatedEvent.cs new file mode 100644 index 00000000..f8cf9da2 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Events/ScriptUpdatedEvent.cs @@ -0,0 +1,6 @@ +using ScriptBee.Domain.Model.Project; +using ScriptBee.Domain.Model.ProjectStructure; + +namespace ScriptBee.Ports.Notifications.Events; + +public record ScriptUpdatedEvent(ProjectId ProjectId, ScriptId ScriptId); diff --git a/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/IProjectNotificationsService.cs b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/IProjectNotificationsService.cs new file mode 100644 index 00000000..c11591af --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/IProjectNotificationsService.cs @@ -0,0 +1,21 @@ +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Ports.Notifications; + +public interface IProjectNotificationsService +{ + Task NotifyScriptCreated( + ScriptCreatedEvent scriptCreatedEvent, + CancellationToken cancellationToken + ); + + Task NotifyScriptUpdated( + ScriptUpdatedEvent scriptUpdatedEvent, + CancellationToken cancellationToken + ); + + Task NotifyScriptDeleted( + ScriptDeletedEvent scriptDeletedEvent, + CancellationToken cancellationToken + ); +} diff --git a/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Ports.Notifications.csproj b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Ports.Notifications.csproj new file mode 100644 index 00000000..0578e42b --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Application/Ports.Notifications/Ports.Notifications.csproj @@ -0,0 +1,9 @@ + + + ScriptBee.Ports.Notifications + + + + + + diff --git a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/CreateScriptService.cs b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/CreateScriptService.cs index 93b04910..679e134e 100644 --- a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/CreateScriptService.cs +++ b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/CreateScriptService.cs @@ -1,4 +1,4 @@ -using DxWorks.ScriptBee.Plugin.Api; +using DxWorks.ScriptBee.Plugin.Api; using OneOf; using ScriptBee.Artifacts; using ScriptBee.Common; @@ -6,6 +6,8 @@ using ScriptBee.Domain.Model.Errors; using ScriptBee.Domain.Model.Project; using ScriptBee.Domain.Model.ProjectStructure; +using ScriptBee.Ports.Notifications; +using ScriptBee.Ports.Notifications.Events; using ScriptBee.Ports.Project; using ScriptBee.Service.Project.Plugin; using ScriptBee.UseCases.Project.ProjectStructure; @@ -24,6 +26,7 @@ public sealed class CreateScriptService( ICreateFile createFile, IGuidProvider guidProvider, ICreateScript createScript, + IProjectNotificationsService projectNotificationsService, ScriptGeneratorStrategyFactory scriptGeneratorStrategyFactory ) : ICreateScriptUseCase { @@ -106,6 +109,11 @@ CancellationToken cancellationToken await createScript.Create(script, cancellationToken); + await projectNotificationsService.NotifyScriptCreated( + new ScriptCreatedEvent(projectDetails.Id, script.Id), + cancellationToken + ); + return script; } } diff --git a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/DeleteProjectFilesService.cs b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/DeleteProjectFilesService.cs index cb5a3044..94d5e7de 100644 --- a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/DeleteProjectFilesService.cs +++ b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/DeleteProjectFilesService.cs @@ -2,6 +2,8 @@ using OneOf.Types; using ScriptBee.Artifacts; using ScriptBee.Domain.Model.Errors; +using ScriptBee.Ports.Notifications; +using ScriptBee.Ports.Notifications.Events; using ScriptBee.Ports.Project; using ScriptBee.UseCases.Project.ProjectStructure; @@ -12,7 +14,8 @@ namespace ScriptBee.Service.Project.ProjectStructure; public sealed class DeleteProjectFilesService( IGetProject getProject, IDeleteScript deleteScript, - IDeleteFileOrFolder deleteFileOrFolder + IDeleteFileOrFolder deleteFileOrFolder, + IProjectNotificationsService projectNotificationsService ) : IDeleteProjectFilesUseCase { public async Task Delete( @@ -38,6 +41,11 @@ CancellationToken cancellationToken if (projectStructureEntry is not null) { deleteFileOrFolder.Delete(command.ProjectId, projectStructureEntry.File.Path); + + await projectNotificationsService.NotifyScriptDeleted( + new ScriptDeletedEvent(command.ProjectId, command.Id), + cancellationToken + ); } return new Success(); diff --git a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/UpdateScriptService.cs b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/UpdateScriptService.cs index 69740d5f..cef6731a 100644 --- a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/UpdateScriptService.cs +++ b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/ProjectStructure/UpdateScriptService.cs @@ -1,8 +1,10 @@ -using OneOf; +using OneOf; using OneOf.Types; using ScriptBee.Artifacts; using ScriptBee.Domain.Model.Errors; using ScriptBee.Domain.Model.ProjectStructure; +using ScriptBee.Ports.Notifications; +using ScriptBee.Ports.Notifications.Events; using ScriptBee.Ports.Project; using ScriptBee.UseCases.Project.ProjectStructure; @@ -15,7 +17,8 @@ public class UpdateScriptService( IGetProject getProject, IGetScripts getScripts, IUpdateScript updateScript, - IUpdateFile updateFile + IUpdateFile updateFile, + IProjectNotificationsService projectNotificationsService ) : IUpdateScriptUseCase { public async Task Update( @@ -88,7 +91,14 @@ CancellationToken cancellationToken updateFile.RenameFile(command.ProjectId, script.File.Path, updatedScript.File.Path); } - return await updateScript.Update(updatedScript, cancellationToken); + var result = await updateScript.Update(updatedScript, cancellationToken); + + await projectNotificationsService.NotifyScriptUpdated( + new ScriptUpdatedEvent(command.ProjectId, script.Id), + cancellationToken + ); + + return result; } private async Task UpdateScriptContent( @@ -107,6 +117,12 @@ await updateFile.UpdateContent( command.Content, cancellationToken ); + + await projectNotificationsService.NotifyScriptUpdated( + new ScriptUpdatedEvent(command.ProjectId, script.Id), + cancellationToken + ); + return new Success(); }, error => Task.FromResult(error) diff --git a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/Service.Project.csproj b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/Service.Project.csproj index ca597d52..6bac8996 100644 --- a/ScriptBeeWebApp/src/Gateway/Application/Service.Project/Service.Project.csproj +++ b/ScriptBeeWebApp/src/Gateway/Application/Service.Project/Service.Project.csproj @@ -14,6 +14,7 @@ + diff --git a/ScriptBeeWebApp/src/Gateway/Application/UseCases.Project/ProjectStructure/CreateScriptCommand.cs b/ScriptBeeWebApp/src/Gateway/Application/UseCases.Project/ProjectStructure/CreateScriptCommand.cs index 43618272..893ec309 100644 --- a/ScriptBeeWebApp/src/Gateway/Application/UseCases.Project/ProjectStructure/CreateScriptCommand.cs +++ b/ScriptBeeWebApp/src/Gateway/Application/UseCases.Project/ProjectStructure/CreateScriptCommand.cs @@ -1,4 +1,4 @@ -using DxWorks.ScriptBee.Plugin.Api.Model; +using DxWorks.ScriptBee.Plugin.Api.Model; using ScriptBee.Domain.Model.Project; namespace ScriptBee.UseCases.Project.ProjectStructure; diff --git a/ScriptBeeWebApp/test/Analysis/Application/Service.Analysis.Tests/FileBundlerTests.cs b/ScriptBeeWebApp/test/Analysis/Application/Service.Analysis.Tests/FileBundlerTests.cs index 82435e8b..93785df2 100644 --- a/ScriptBeeWebApp/test/Analysis/Application/Service.Analysis.Tests/FileBundlerTests.cs +++ b/ScriptBeeWebApp/test/Analysis/Application/Service.Analysis.Tests/FileBundlerTests.cs @@ -21,7 +21,7 @@ public async Task WriteToStream_ShouldEncodeFilesCorrectly() using var stream = new MemoryStream(); // Act - await _fileBundler.WriteToStream(files, stream, CancellationToken.None); + await _fileBundler.WriteToStream(files, stream, TestContext.Current.CancellationToken); stream.Position = 0; // Assert @@ -51,16 +51,15 @@ public async Task WriteToStream_ShouldEncodeFilesCorrectly() public async Task WriteToStream_EmptyList_ShouldWriteOnlyEndMarker() { // Arrange - var files = new List(); using var stream = new MemoryStream(); // Act - await _fileBundler.WriteToStream(files, stream, CancellationToken.None); + await _fileBundler.WriteToStream([], stream, TestContext.Current.CancellationToken); stream.Position = 0; // Assert - byte[] buffer = new byte[4]; - var read = await stream.ReadAsync(buffer); + var buffer = new byte[4]; + var read = await stream.ReadAsync(buffer, TestContext.Current.CancellationToken); Assert.Equal(4, read); Assert.Equal(0, BinaryPrimitives.ReadInt32BigEndian(buffer)); Assert.Equal(stream.Length, stream.Position); diff --git a/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Notifications.SignalR.Tests.csproj b/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Notifications.SignalR.Tests.csproj new file mode 100644 index 00000000..9c3244ea --- /dev/null +++ b/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Notifications.SignalR.Tests.csproj @@ -0,0 +1,8 @@ + + + ScriptBee.Adapters.Notifications.SignalR.Tests + + + + + diff --git a/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Services/ProjectNotificationsServiceTests.cs b/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Services/ProjectNotificationsServiceTests.cs new file mode 100644 index 00000000..8c89147f --- /dev/null +++ b/ScriptBeeWebApp/test/Gateway/Adapters/Notifications.SignalR.Tests/Services/ProjectNotificationsServiceTests.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using NSubstitute; +using ScriptBee.Adapters.Notifications.SignalR.Contracts; +using ScriptBee.Adapters.Notifications.SignalR.Hubs; +using ScriptBee.Adapters.Notifications.SignalR.Services; +using ScriptBee.Application.Model.Services; +using ScriptBee.Domain.Model.Project; +using ScriptBee.Domain.Model.ProjectStructure; +using ScriptBee.Ports.Notifications.Events; + +namespace ScriptBee.Adapters.Notifications.SignalR.Tests.Services; + +public class ProjectNotificationsServiceTests +{ + private readonly IClientIdProvider _clientIdProvider = Substitute.For(); + private readonly IHubClients _hubClients = Substitute.For(); + private readonly IClientProxy _clientProxy = Substitute.For(); + private readonly ProjectNotificationsService _service; + + public ProjectNotificationsServiceTests() + { + var hubContext = Substitute.For>(); + var httpContextAccessor = Substitute.For(); + var httpContext = Substitute.For(); + var serviceProvider = Substitute.For(); + + hubContext.Clients.Returns(_hubClients); + _hubClients.Group(Arg.Any()).Returns(_clientProxy); + _clientIdProvider.ClientId.Returns("test-client"); + + httpContextAccessor.HttpContext.Returns(httpContext); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetService(typeof(IClientIdProvider)).Returns(_clientIdProvider); + + _service = new ProjectNotificationsService(hubContext, httpContextAccessor); + } + + [Fact] + public async Task NotifyScriptCreated_SendsMessageToCorrectGroup() + { + // Arrange + var projectId = ProjectId.FromValue("test-project"); + var scriptId = new ScriptId(Guid.NewGuid()); + var ev = new ScriptCreatedEvent(projectId, scriptId); + + // Act + await _service.NotifyScriptCreated(ev, TestContext.Current.CancellationToken); + + // Assert + _hubClients.Received(1).Group("test-project_scripts"); + await _clientProxy + .Received(1) + .SendCoreAsync( + "ScriptCreated", + Arg.Is(args => + ((SignalRScriptCreatedEvent)args[0]).ProjectId == ev.ProjectId.ToString() + && ((SignalRScriptCreatedEvent)args[0]).ScriptId == ev.ScriptId.ToString() + && ((SignalRScriptCreatedEvent)args[0]).ClientId == "test-client" + ), + TestContext.Current.CancellationToken + ); + } + + [Fact] + public async Task NotifyScriptUpdated_SendsMessageToCorrectGroup() + { + // Arrange + var projectId = ProjectId.FromValue("test-project"); + var scriptId = new ScriptId(Guid.NewGuid()); + var ev = new ScriptUpdatedEvent(projectId, scriptId); + + // Act + await _service.NotifyScriptUpdated(ev, TestContext.Current.CancellationToken); + + // Assert + _hubClients.Received(1).Group("test-project_scripts"); + await _clientProxy + .Received(1) + .SendCoreAsync( + "ScriptUpdated", + Arg.Is(args => + ((SignalRScriptUpdatedEvent)args[0]).ProjectId == ev.ProjectId.ToString() + && ((SignalRScriptUpdatedEvent)args[0]).ScriptId == ev.ScriptId.ToString() + && ((SignalRScriptUpdatedEvent)args[0]).ClientId == "test-client" + ), + TestContext.Current.CancellationToken + ); + } + + [Fact] + public async Task NotifyScriptDeleted_SendsMessageToCorrectGroup() + { + // Arrange + var projectId = ProjectId.FromValue("test-project"); + var scriptId = new ScriptId(Guid.NewGuid()); + var ev = new ScriptDeletedEvent(projectId, scriptId); + + // Act + await _service.NotifyScriptDeleted(ev, TestContext.Current.CancellationToken); + + // Assert + _hubClients.Received(1).Group("test-project_scripts"); + await _clientProxy + .Received(1) + .SendCoreAsync( + "ScriptDeleted", + Arg.Is(args => + ((SignalRScriptDeletedEvent)args[0]).ProjectId == ev.ProjectId.ToString() + && ((SignalRScriptDeletedEvent)args[0]).ScriptId == ev.ScriptId.ToString() + && ((SignalRScriptDeletedEvent)args[0]).ClientId == "test-client" + ), + TestContext.Current.CancellationToken + ); + } +} diff --git a/ScriptBeeWebApp/test/Gateway/Application/Service.Project.Tests/ProjectStructure/CreateScriptServiceTest.cs b/ScriptBeeWebApp/test/Gateway/Application/Service.Project.Tests/ProjectStructure/CreateScriptServiceTest.cs index fb43211f..775d0816 100644 --- a/ScriptBeeWebApp/test/Gateway/Application/Service.Project.Tests/ProjectStructure/CreateScriptServiceTest.cs +++ b/ScriptBeeWebApp/test/Gateway/Application/Service.Project.Tests/ProjectStructure/CreateScriptServiceTest.cs @@ -1,4 +1,4 @@ -using DxWorks.ScriptBee.Plugin.Api.Model; +using DxWorks.ScriptBee.Plugin.Api.Model; using NSubstitute; using OneOf; using ScriptBee.Artifacts; @@ -6,6 +6,8 @@ using ScriptBee.Domain.Model.Errors; using ScriptBee.Domain.Model.Project; using ScriptBee.Domain.Model.ProjectStructure; +using ScriptBee.Ports.Notifications; +using ScriptBee.Ports.Notifications.Events; using ScriptBee.Ports.Project; using ScriptBee.Service.Project.Plugin; using ScriptBee.Service.Project.ProjectStructure; @@ -22,6 +24,9 @@ public class CreateScriptServiceTest private readonly IGuidProvider _guidProvider = Substitute.For(); private readonly ICreateScript _createScript = Substitute.For(); + private readonly IProjectNotificationsService _projectNotificationsService = + Substitute.For(); + private readonly CreateScriptService _createScriptService; public CreateScriptServiceTest() @@ -31,6 +36,7 @@ public CreateScriptServiceTest() _createFile, _guidProvider, _createScript, + _projectNotificationsService, new ScriptGeneratorStrategyFactory() ); } @@ -194,6 +200,13 @@ await _createScript Arg.Is