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
+
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