Improve security by isolating code execution more
This commit enhances application security against potential attacks by isolating dependencies that access the host system (like file operations) from the renderer process. It narrows the exposed functionality to script execution only, adding an extra security layer. The changes allow secure and scalable API exposure, preparing for future functionalities such as desktop notifications for script errors (#264), improved script execution handling (#296), and creating restore points (#50) in a secure and repeatable way. Changes include: - Inject `CodeRunner` into Vue components via dependency injection. - Move `CodeRunner` to the application layer as an abstraction for better domain-driven design alignment. - Refactor `SystemOperations` and related interfaces, removing the `I` prefix. - Update architecture documentation for clarity. - Update return types in `NodeSystemOperations` to match the Node APIs. - Improve `WindowVariablesProvider` integration tests for better error context. - Centralize type checks with common functions like `isArray` and `isNumber`. - Change `CodeRunner` to use `os` parameter, ensuring correct window variable injection. - Streamline API exposure to the renderer process: - Automatically bind function contexts to prevent loss of original context. - Implement a way to create facades (wrapper/proxy objects) for increased security.
This commit is contained in:
@@ -31,9 +31,9 @@ privacy.sexy adopts a defense in depth strategy to protect users on multiple lay
|
|||||||
- **Content Security Policies (CSP):**
|
- **Content Security Policies (CSP):**
|
||||||
privacy.sexy actively follows security guidelines from the Open Web Application Security Project (OWASP) at strictest level.
|
privacy.sexy actively follows security guidelines from the Open Web Application Security Project (OWASP) at strictest level.
|
||||||
This approach protects against attacks like Cross Site Scripting (XSS) and data injection.
|
This approach protects against attacks like Cross Site Scripting (XSS) and data injection.
|
||||||
- **Context Isolation:**
|
- **Host System Access Control:**
|
||||||
The desktop application isolates different code sections based on their access level.
|
The desktop application segregates code sections based on their access levels.
|
||||||
This separation prevents attackers from introducing harmful code into the app, known as injection attacks.
|
This provides a critical defense mechanism, prevents attackers from introducing harmful code into the app, known as injection attacks.
|
||||||
|
|
||||||
### Update Security and Integrity
|
### Update Security and Integrity
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,14 @@ Application uses highly decoupled models & services in different DDD layers:
|
|||||||
**Domain layer**:
|
**Domain layer**:
|
||||||
|
|
||||||
- Serves as the system's core and central truth.
|
- Serves as the system's core and central truth.
|
||||||
- Facilitates communication between the application and presentation layers through the domain model.
|
- It should be independent of other layers and encapsulate the core business concepts.
|
||||||
|
|
||||||
**Infrastructure layer**:
|
**Infrastructure layer**:
|
||||||
|
|
||||||
- Manages technical implementations without dependencies on other layers or domain knowledge.
|
- Provides technical implementations.
|
||||||
|
- Depends on the application and domain layers in terms of interfaces and contracts but should not include business logic.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Application state
|
### Application state
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ Shared components include:
|
|||||||
|
|
||||||
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
|
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
|
||||||
|
|
||||||
|
Host system access is strictly controlled. The [`preloader`](./../src/presentation/electron/preload/) isolates logic that interacts with the host system. These functionalities are then securely exposed to the renderer process (Vue application) using context-bridging. [`ApiContextBridge.ts`](./../src/presentation/electron/preload/ContextBridging/ApiContextBridge.ts) handles the configuration of the exposed APIs, ensuring a secure bridge between the Electron and Vue layers.
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
|
|
||||||
### Style location
|
### Style location
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<mxfile host="Electron" modified="2021-01-31T12:32:01.751Z" agent="5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.1.8 Chrome/87.0.4280.88 Electron/11.1.1 Safari/537.36" etag="OTbSPW1ZOLwiPL6mt-j9" version="14.1.8" type="device"><diagram id="rhL8jzEM8kVVyiS98U7u" name="Page-1">3VtZd6JKF/01/dhZjA6PREjCXRbGFpM2L70QCYIoLsUI/PpvnwKcMD3cazrJl6wEqfHU3vvsKoz5Infm6e3KWU5ZPPGiL5IwSb/I+hdJUsUmflNBVhQoDaEo8FfBpCgS9wWDIPfKwqrZJph466OGSRxHSbA8LnTjxcJzk6MyZ7WKt8fNnuPoeNal43u1goHrRPXSx2CSTIvSlirsy++8wJ9WM4tCWTN3qsZlwXrqTOLtQZFsfJE7qzhOilfztONFhF2FS9Hv5pXaXWArb5H8Tofr+9ZNO9J+6A+Ljvr89N2aP5tflSq4JKtW7E0AQHkbr5Jp7McLJzL2pdereLOYeDSsgLt9m24cL1EoojD0kiQr2XQ2SYyiaTKPylovDZLv1P1KLe9GBzV6Wo7Mb7LqZpGssoNOdDs6rNt343dVv3WyimdeJ47iFV+f3OZfqKkDWGK6jjcr1/sJapUQnZXvJT9pJxXtCNGDCUp6br147iFSNFh5kZMEL8eSc0rl+rt2e3LxouT3T7iW3oXrHW9HrO1J/IC8yZfmjXfVVisnO2iwjINFsj4Y+Z4K0KD0TrnMzdI51ZP0/nlrSVRPBFNMv5fPbh3/QVHyu7rHgXcIV5L6m/bxeWSofCj7KMd9caJNOdP9yltj8Zg7XtSEcEzzdhok3mDpcFy2OCkcU/ocRNEBxhPVa02Uc+i3pLHcaFCPeJFUOjuzFVbBeqvES38KXlnbaB6nT5lN2/2eL1c7+/Rgv69OMxdHW268Z2qJR6n1m5klHmeW9B6pJb1Xav07h28fm7YoH5n2L9tLzebbm7zc+ixHxPcU1MWPDP/JPaSaV2vLZRS4l7DqI+OtWfezSt/nyGjwr3KEg/Li62ck/b6Lq8cuvnsYO7Rx6YyNN9/MxtXPkjwf6flK/pQHJLmWdHo8d4LLHo3G4mTyfBZ3UWjKbe8Njkbtk6Rq15NKUs4klfpWSaXUgDYXzysHiGzcZLPyLgr4c8v1XPcc4OOWqqjCGwDePDmLNs4A3vqbLtaoAe69eOXJ5hWgxT8H+tlrnAd60myPBeEyO0TrBNtmHdvmGWilt4K2WYN25S3jdZDE5eCfGl75zAb8V+Ft1a3CWCRB8hmxPfWFdwe3XQM3ma48ZxIs/P8DeN/dGsT6KX5Of165rO2OBU/2GufAFbyW0GpdBlwcTo7BFZQ6uOqVeAZe6a3grZ/X8DKY/OoZ6aNCLKniVfvDgazWQF4nTvKzI9qf42vo9H0ZFE9MQKze/XhHCP/K6euSGJ6+mXoORPHcu6lvh2H9mOUcviEiRLEfuJ8HULG+Nf1dQHdr2AOazamXG0eR5xKqH1efHw/N+lZUA289dZb0MpjzTywcQkUrh5QjLQr8BcoSeptoV9p1xl50T48UJHVZH8dJEs/RIKKKa8ed+ZyYowMXfaEJn0xbL4tPVtBBy6lunoOUqLwu49GnSUIfydAICenGnSyUq8CNF88BKF9duZhRusHe6uBC5VDHDZIuXn91FpOv4xV+U5FKh54budH88bDxwvUPaoKDUOtqWZ0pL/pk3aa/UpxyryhXap38fenl6a9vkp+ffvmP6f/qTOKxtxOB1Gr9GDjr9QXpl9TWVVP9PQlI5zUgvZkI6tv85xeB+EsRjBdb/AbFKX7wCtsyFdNbausLsS7LyhHlcuOqVT+QSO0zht9+K7Lr55HPT7bwS7Kf45XvLb6uvZg+TXDTAC83y4C/Z7v+Gi+TYB7k/ER2wZzfvUtSsS/XqK/0cUh9VfZfqH/Y3Hrj5vegr7fv2awhLwLh8cyf5n7J/CWIPS+Z8+Qe8V/RzamUteJWulm/gJzrlM58nfs7S3rKrpXxY7pxcyFw7r4Jrh6/dOWJPMlUmWXqizt3X1iobVmnnU/mbmDeTZZPd9/i+4GZWfYoMG+nkfM4iSe6ELBwKJnBteQ8Psj9eVtBm62paz4v103RCkzMfX/r+k/zaD1Gj/G8vXkamMV9R8wmj2l0P/gnmswfNmPp28wMldZIirKRlEbm7dNyfLttmwHL+uE/N0wwMLtFs6Qs9Om1unt9Z7bN0Mh6HfPlPkwXB33Vb7OH62/hLCjLEnfxsH6yi1i8+UM2zniUd9fTya3vPyFK2zawfkVkuulbubGxwijs2qbUBS7WQBC6oZtbgSBYgSJaobthoZl27SGVSyzQUqujCMweJqhPcfWZrW0se5Z17f6G5SMZ2KDtKLcGGq6mYOpMxb1gzY0MV8nKtOoKPA2fdajdKMOcVC4yiSVWpsiWbSbMZhjbxNgzxDFCHKZIYzEepylYmZAjnhx1PuJRmO6jD9aUz1LiqBsaQq8jpKjLMJZv6RjH9uWubaTd0M+tx3MxaRTTpmcPJawb8/Zlby4kWBvKZqqpu2jLtpbEUo4jxwBz2kOxWLshW4GWsUDJe3bfZ/kM8ZBmGOZkSq+j5bQ+FjJoiWL1wYO76el+jv4y4tiygaaijYR+GBu42po0sk1wxfEtr4hzoG27wIjpmkj4WzraZVgvjU9zow/TXQXrABY+8Qeeh4SJivi2TGcJHz830MYgvMCvoIBrzkcP2Fq8DeFPPDDU9WX0zxnVvcqpkHfDmQAN4DpUvYArdzv6/i02b/ttcyakPYo3NGl9W8xHvFPcWyg+QY5izQblGcadIXcFgc+n+xwP8CsWmhymLBBIu+grEB8CcEW8Ptqwck0zWgv0CDz1IepGGLt/Hs8O8BwoWyuc+Wifs9wKGdeARtio4C4j3Vvh0IfuVHALfg1gPEOsBtbaV0x9RPFluE8pZ4ABrn3oxkQe+FuKE30zWgt+gP1MfY1bU+8Tf9Ay8kDvS6RDi17nlAsjhWvtkdY7pHFIY4h1poInmluxOhrmdIUe8gXYiozj7UNrTCy4nAms0KqAssQKSQt96Jj0MiIfkICpwmwXGoWOwdPrsRJOrgz8ZCt/CLkubZYjZ0n3AmLJMJZq6W5i0Rj5CJgMEYNJmMiESY+0w/EeIUcpTkMs8sYAf8CNuAk06Arj5CbnGXy/ih/4Jx3JRR4b4hkdKtxrMi3t6S7laT7KMa+tCUUu+6RB4lXqwc8QP7h3eU739D7mIe0apPuUUUz6kDREeKo9e5T0qL3OuEYseFGXvIzy5/bVvCFNy/C3jPN2W3ohjYs1kkd0aT2hKUKLaW+gKD3ySfJpvQ//MEhjyG3kAvyD8XgE0qxI8XA/DYeUx7S2DJiCY7STXo0HOsB6uIZd1ZKXCTiBRkzu9z19BLwoj42kyEsT+qdYfLHUYIo4Ea+m0N6CeAXue1inRdjkM65dC+OAb8xtSow8PSM9ukmP+1c/ezVXqd4eCZQnaC+McoPvNSzTuIfhWuwPpF/eZkQ5grWT12jEPfj0aayUv87dpPBt4piwYrTPUR5R/iiv40T+oEHPtA/McuSpCK1k2DtoPInZ/4RYM+FAuSgSLvBZeBe45X7F84G4zfg+m1VtuYbLMu20n1/1IzzIO7G/kW/tvL+o2/Xf9ava0rqrGE777eYF5zx/4Iamfj0lbjAW4uvnZZ7Bi4wyV3gcx/WYAzGU9dcN2+7THqLynON7Vz8d5aSZ4UE8/dN4hAMcgAnXu0px4QwjHuC2679fB28LXWrJHhPe73DMPaaFXgn7Au+BdgbvPTZFjgAdySjXDB/RWbVu0jr5vQLfOq2vtFPUL+Kgmyst9/ZGcDrXM5wcLcxH5wHSqkjnLkv/Fv4Rd3WshDpW7BQr9V9j1flbWGEs7rczn2so9zOce6CxmVLstxo8XeBeWJwrNRWemvL9Jx8mHMP8aQo/ynCu86trNU9RT35J50BXIo/r2eRptNe78B/yf9pvyZ/orGls6ZxSxK3RtYxBK2OqYtCKmIr85vtOEdNTwwxadFLvtBf34fblCUroyniiyRV6hrrMnz8l+Uo9fuNJlurPoY0zz6GNP34Oxe3+v8qKTwHv/zVPNv4H</diagram></mxfile>
|
|
||||||
BIN
img/architecture/app-ddd.drawio.png
Normal file
BIN
img/architecture/app-ddd.drawio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
@@ -14,3 +14,35 @@ export type ConstructorArguments<T> =
|
|||||||
export type FunctionKeys<T> = {
|
export type FunctionKeys<T> = {
|
||||||
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never;
|
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never;
|
||||||
}[keyof T];
|
}[keyof T];
|
||||||
|
|
||||||
|
export function isString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumber(value: unknown): value is number {
|
||||||
|
return typeof value === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBoolean(value: unknown): value is boolean {
|
||||||
|
return typeof value === 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunction(value: unknown): value is (...args: unknown[]) => unknown {
|
||||||
|
return typeof value === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArray(value: unknown): value is Array<unknown> {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlainObject(
|
||||||
|
variable: unknown,
|
||||||
|
): variable is object & Record<string, unknown> {
|
||||||
|
return Boolean(variable) // the data type of null is an object
|
||||||
|
&& typeof variable === 'object'
|
||||||
|
&& !Array.isArray(variable);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNullOrUndefined(value: unknown): value is (null | undefined) {
|
||||||
|
return typeof value === 'undefined' || value === null;
|
||||||
|
}
|
||||||
|
|||||||
7
src/application/CodeRunner.ts
Normal file
7
src/application/CodeRunner.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
|
||||||
|
export interface CodeRunner {
|
||||||
|
runCode(
|
||||||
|
code: string, folderName: string, fileExtension: string, os: OperatingSystem,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isFunction } from '@/TypeHelpers';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Provides a unified and resilient way to extend errors across platforms.
|
Provides a unified and resilient way to extend errors across platforms.
|
||||||
|
|
||||||
@@ -50,8 +52,3 @@ function ensureStackTrace(target: Error) {
|
|||||||
}
|
}
|
||||||
captureStackTrace(target, target.constructor);
|
captureStackTrace(target, target.constructor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
function isFunction(func: unknown): func is Function {
|
|
||||||
return typeof func === 'function';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
|
|
||||||
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
|
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
|
||||||
export type EnumType = number | string;
|
export type EnumType = number | string;
|
||||||
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
|
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
|
||||||
@@ -23,7 +25,7 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`missing ${enumName}`);
|
throw new Error(`missing ${enumName}`);
|
||||||
}
|
}
|
||||||
if (typeof value !== 'string') {
|
if (!isString(value)) {
|
||||||
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
|
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
|
||||||
}
|
}
|
||||||
const casedValue = getEnumNames(enumVariable)
|
const casedValue = getEnumNames(enumVariable)
|
||||||
@@ -40,7 +42,7 @@ export function getEnumNames
|
|||||||
): string[] {
|
): string[] {
|
||||||
return Object
|
return Object
|
||||||
.values(enumVariable)
|
.values(enumVariable)
|
||||||
.filter((enumMember) => typeof enumMember === 'string') as string[];
|
.filter((enumMember): enumMember is string => isString(enumMember));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
|
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DocumentableData, DocumentationData } from '@/application/collections/';
|
import type { DocumentableData, DocumentationData } from '@/application/collections/';
|
||||||
|
import { isString, isArray } from '@/TypeHelpers';
|
||||||
|
|
||||||
export function parseDocs(documentable: DocumentableData): readonly string[] {
|
export function parseDocs(documentable: DocumentableData): readonly string[] {
|
||||||
const { docs } = documentable;
|
const { docs } = documentable;
|
||||||
@@ -14,11 +15,9 @@ function addDocs(
|
|||||||
docs: DocumentationData,
|
docs: DocumentationData,
|
||||||
container: DocumentationContainer,
|
container: DocumentationContainer,
|
||||||
): DocumentationContainer {
|
): DocumentationContainer {
|
||||||
if (docs instanceof Array) {
|
if (isArray(docs)) {
|
||||||
if (docs.length > 0) {
|
docs.forEach((doc) => container.addPart(doc));
|
||||||
container.addParts(docs);
|
} else if (isString(docs)) {
|
||||||
}
|
|
||||||
} else if (typeof docs === 'string') {
|
|
||||||
container.addPart(docs);
|
container.addPart(docs);
|
||||||
} else {
|
} else {
|
||||||
throwInvalidType();
|
throwInvalidType();
|
||||||
@@ -29,27 +28,21 @@ function addDocs(
|
|||||||
class DocumentationContainer {
|
class DocumentationContainer {
|
||||||
private readonly parts = new Array<string>();
|
private readonly parts = new Array<string>();
|
||||||
|
|
||||||
public addPart(documentation: string) {
|
public addPart(documentation: unknown): void {
|
||||||
if (!documentation) {
|
if (!documentation) {
|
||||||
throw Error('missing documentation');
|
throw Error('missing documentation');
|
||||||
}
|
}
|
||||||
if (typeof documentation !== 'string') {
|
if (!isString(documentation)) {
|
||||||
throwInvalidType();
|
throwInvalidType();
|
||||||
}
|
}
|
||||||
this.parts.push(documentation);
|
this.parts.push(documentation);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addParts(parts: readonly string[]) {
|
|
||||||
for (const part of parts) {
|
|
||||||
this.addPart(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAll(): ReadonlyArray<string> {
|
public getAll(): ReadonlyArray<string> {
|
||||||
return this.parts;
|
return this.parts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function throwInvalidType() {
|
function throwInvalidType(): never {
|
||||||
throw new Error('docs field (documentation) must be an array of strings');
|
throw new Error('docs field (documentation) must be an array of strings');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
import { INodeDataErrorContext, NodeDataError } from './NodeDataError';
|
import { INodeDataErrorContext, NodeDataError } from './NodeDataError';
|
||||||
import { NodeData } from './NodeData';
|
import { NodeData } from './NodeData';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export class NodeValidator {
|
|||||||
'missing name',
|
'missing name',
|
||||||
)
|
)
|
||||||
.assert(
|
.assert(
|
||||||
() => typeof nameValue === 'string',
|
() => isString(nameValue),
|
||||||
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
|
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from '@/application/collections/';
|
import type { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from '@/application/collections/';
|
||||||
|
import { isArray, isPlainObject } from '@/TypeHelpers';
|
||||||
import { FunctionCall } from './FunctionCall';
|
import { FunctionCall } from './FunctionCall';
|
||||||
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
|
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
|
||||||
import { FunctionCallArgument } from './Argument/FunctionCallArgument';
|
import { FunctionCallArgument } from './Argument/FunctionCallArgument';
|
||||||
@@ -10,13 +11,13 @@ export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
|
function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
|
||||||
if (typeof calls !== 'object') {
|
if (!isPlainObject(calls) && !isArray(calls)) {
|
||||||
throw new Error('called function(s) must be an object');
|
throw new Error('called function(s) must be an object or array');
|
||||||
}
|
}
|
||||||
if (calls instanceof Array) {
|
if (isArray(calls)) {
|
||||||
return calls as FunctionCallData[];
|
return calls as FunctionCallData[];
|
||||||
}
|
}
|
||||||
const singleCall = calls;
|
const singleCall = calls as FunctionCallData;
|
||||||
return [singleCall];
|
return [singleCall];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValida
|
|||||||
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
|
||||||
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
|
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
|
||||||
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
|
||||||
|
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
||||||
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
||||||
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||||
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||||
@@ -121,8 +122,11 @@ function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
||||||
|
const hasValidParameters = (
|
||||||
|
func: FunctionData,
|
||||||
|
) => isNullOrUndefined(func.parameters) || isArrayOfObjects(func.parameters);
|
||||||
const unexpectedFunctions = functions
|
const unexpectedFunctions = functions
|
||||||
.filter((func) => func.parameters && !isArrayOfObjects(func.parameters));
|
.filter((func) => !hasValidParameters(func));
|
||||||
if (unexpectedFunctions.length) {
|
if (unexpectedFunctions.length) {
|
||||||
const errorMessage = `parameters must be an array of objects in function(s) ${printNames(unexpectedFunctions)}`;
|
const errorMessage = `parameters must be an array of objects in function(s) ${printNames(unexpectedFunctions)}`;
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -130,8 +134,7 @@ function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isArrayOfObjects(value: unknown): boolean {
|
function isArrayOfObjects(value: unknown): boolean {
|
||||||
return Array.isArray(value)
|
return isArray(value) && value.every((item) => isPlainObject(item));
|
||||||
&& value.every((item) => typeof item === 'object');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printNames(holders: readonly FunctionData[]) {
|
function printNames(holders: readonly FunctionData[]) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'path';
|
import { join } from 'node:path';
|
||||||
import { chmod, mkdir, writeFile } from 'fs/promises';
|
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { ISystemOperations } from './ISystemOperations';
|
import { SystemOperations } from './SystemOperations';
|
||||||
|
|
||||||
export function createNodeSystemOperations(): ISystemOperations {
|
export function createNodeSystemOperations(): SystemOperations {
|
||||||
return {
|
return {
|
||||||
operatingSystem: {
|
operatingSystem: {
|
||||||
getTempDirectory: () => tmpdir(),
|
getTempDirectory: () => tmpdir(),
|
||||||
@@ -33,7 +33,14 @@ export function createNodeSystemOperations(): ISystemOperations {
|
|||||||
) => writeFile(filePath, data),
|
) => writeFile(filePath, data),
|
||||||
},
|
},
|
||||||
command: {
|
command: {
|
||||||
execute: (command) => exec(command),
|
execute: (command) => new Promise((resolve, reject) => {
|
||||||
|
exec(command, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface SystemOperations {
|
||||||
|
readonly operatingSystem: OperatingSystemOps;
|
||||||
|
readonly location: LocationOps;
|
||||||
|
readonly fileSystem: FileSystemOps;
|
||||||
|
readonly command: CommandOps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperatingSystemOps {
|
||||||
|
getTempDirectory(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationOps {
|
||||||
|
combinePaths(...pathSegments: string[]): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandOps {
|
||||||
|
execute(command: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemOps {
|
||||||
|
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
|
||||||
|
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
|
||||||
|
writeToFile(filePath: string, data: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { getWindowInjectedSystemOperations } from './SystemOperations/WindowInjectedSystemOperations';
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
|
import { SystemOperations } from './SystemOperations/SystemOperations';
|
||||||
|
import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations';
|
||||||
|
|
||||||
export class CodeRunner {
|
export class TemporaryFileCodeRunner implements CodeRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly system = getWindowInjectedSystemOperations(),
|
private readonly system: SystemOperations = createNodeSystemOperations(),
|
||||||
private readonly environment = RuntimeEnvironment.CurrentEnvironment,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
public async runCode(code: string, folderName: string, fileExtension: string): Promise<void> {
|
public async runCode(
|
||||||
const { os } = this.environment;
|
code: string,
|
||||||
if (os === undefined) {
|
folderName: string,
|
||||||
throw new Error('Unidentified operating system');
|
fileExtension: string,
|
||||||
}
|
os: OperatingSystem,
|
||||||
|
): Promise<void> {
|
||||||
const dir = this.system.location.combinePaths(
|
const dir = this.system.location.combinePaths(
|
||||||
this.system.operatingSystem.getTempDirectory(),
|
this.system.operatingSystem.getTempDirectory(),
|
||||||
folderName,
|
folderName,
|
||||||
@@ -22,7 +23,7 @@ export class CodeRunner {
|
|||||||
await this.system.fileSystem.writeToFile(filePath, code);
|
await this.system.fileSystem.writeToFile(filePath, code);
|
||||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||||
const command = getExecuteCommand(filePath, os);
|
const command = getExecuteCommand(filePath, os);
|
||||||
this.system.command.execute(command);
|
await this.system.command.execute(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { isNumber } from '@/TypeHelpers';
|
||||||
import { IEntity } from './IEntity';
|
import { IEntity } from './IEntity';
|
||||||
|
|
||||||
export abstract class BaseEntity<TId> implements IEntity<TId> {
|
export abstract class BaseEntity<TId> implements IEntity<TId> {
|
||||||
protected constructor(public id: TId) {
|
protected constructor(public id: TId) {
|
||||||
if (typeof id !== 'number' && !id) {
|
if (!isNumber(id) && !id) {
|
||||||
throw new Error('Id cannot be null or empty');
|
throw new Error('Id cannot be null or empty');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isBoolean, isFunction } from '@/TypeHelpers';
|
||||||
import { IEnvironmentVariables } from './IEnvironmentVariables';
|
import { IEnvironmentVariables } from './IEnvironmentVariables';
|
||||||
|
|
||||||
/* Validation is externalized to keep the environment objects simple */
|
/* Validation is externalized to keep the environment objects simple */
|
||||||
@@ -15,7 +16,7 @@ export function validateEnvironmentVariables(environment: IEnvironmentVariables)
|
|||||||
function getKeysMissingValues(keyValuePairs: Record<string, unknown>): string[] {
|
function getKeysMissingValues(keyValuePairs: Record<string, unknown>): string[] {
|
||||||
return Object.entries(keyValuePairs)
|
return Object.entries(keyValuePairs)
|
||||||
.reduce((acc, [key, value]) => {
|
.reduce((acc, [key, value]) => {
|
||||||
if (!value && typeof value !== 'boolean') {
|
if (!value && !isBoolean(value)) {
|
||||||
acc.push(key);
|
acc.push(key);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -38,7 +39,7 @@ function capturePropertyValues(instance: object): Record<string, unknown> {
|
|||||||
|
|
||||||
// Capture getter properties from the instance's prototype
|
// Capture getter properties from the instance's prototype
|
||||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||||
if (typeof descriptor.get === 'function') {
|
if (isFunction(descriptor.get)) {
|
||||||
obj[key] = descriptor.get.call(instance);
|
obj[key] = descriptor.get.call(instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ import log from 'electron-log/main';
|
|||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import type { LogFunctions } from 'electron-log';
|
import type { LogFunctions } from 'electron-log';
|
||||||
|
|
||||||
// Using plain-function rather than class so it can be used in Electron's context-bridging.
|
|
||||||
export function createElectronLogger(logger: LogFunctions = log): Logger {
|
export function createElectronLogger(logger: LogFunctions = log): Logger {
|
||||||
return {
|
return logger;
|
||||||
info: (...params) => logger.info(...params),
|
|
||||||
debug: (...params) => logger.debug(...params),
|
|
||||||
warn: (...params) => logger.warn(...params),
|
|
||||||
error: (...params) => logger.error(...params),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElectronLogger = createElectronLogger();
|
export const ElectronLogger = createElectronLogger();
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export interface ISystemOperations {
|
|
||||||
readonly operatingSystem: IOperatingSystemOps;
|
|
||||||
readonly location: ILocationOps;
|
|
||||||
readonly fileSystem: IFileSystemOps;
|
|
||||||
readonly command: ICommandOps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IOperatingSystemOps {
|
|
||||||
getTempDirectory(): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILocationOps {
|
|
||||||
combinePaths(...pathSegments: string[]): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ICommandOps {
|
|
||||||
execute(command: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IFileSystemOps {
|
|
||||||
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
|
|
||||||
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
|
|
||||||
writeToFile(filePath: string, data: string): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { WindowVariables } from '../WindowVariables/WindowVariables';
|
|
||||||
import { ISystemOperations } from './ISystemOperations';
|
|
||||||
|
|
||||||
export function getWindowInjectedSystemOperations(
|
|
||||||
windowVariables: Partial<WindowVariables> = window,
|
|
||||||
): ISystemOperations {
|
|
||||||
if (!windowVariables) {
|
|
||||||
throw new Error('missing window');
|
|
||||||
}
|
|
||||||
if (!windowVariables.system) {
|
|
||||||
throw new Error('missing system');
|
|
||||||
}
|
|
||||||
return windowVariables.system;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
|
|
||||||
/* Primary entry point for platform-specific injections */
|
/* Primary entry point for platform-specific injections */
|
||||||
export interface WindowVariables {
|
export interface WindowVariables {
|
||||||
readonly isDesktop: boolean;
|
readonly isDesktop: boolean;
|
||||||
readonly system?: ISystemOperations;
|
readonly codeRunner?: CodeRunner;
|
||||||
readonly os?: OperatingSystem;
|
readonly os?: OperatingSystem;
|
||||||
readonly log: Logger;
|
readonly log: Logger;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { PropertyKeys } from '@/TypeHelpers';
|
import {
|
||||||
|
PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject,
|
||||||
|
} from '@/TypeHelpers';
|
||||||
import { WindowVariables } from './WindowVariables';
|
import { WindowVariables } from './WindowVariables';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks for consistency in runtime environment properties injected by Electron preloader.
|
* Checks for consistency in runtime environment properties injected by Electron preloader.
|
||||||
*/
|
*/
|
||||||
export function validateWindowVariables(variables: Partial<WindowVariables>) {
|
export function validateWindowVariables(variables: Partial<WindowVariables>) {
|
||||||
if (!isObject(variables)) {
|
if (!isPlainObject(variables)) {
|
||||||
throw new Error('window is not an object');
|
throw new Error('window is not an object');
|
||||||
}
|
}
|
||||||
const errors = [...testEveryProperty(variables)];
|
const errors = [...testEveryProperty(variables)];
|
||||||
@@ -21,7 +23,7 @@ function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<strin
|
|||||||
} = {
|
} = {
|
||||||
os: testOperatingSystem(variables.os),
|
os: testOperatingSystem(variables.os),
|
||||||
isDesktop: testIsDesktop(variables.isDesktop),
|
isDesktop: testIsDesktop(variables.isDesktop),
|
||||||
system: testSystem(variables),
|
codeRunner: testCodeRunner(variables),
|
||||||
log: testLogger(variables),
|
log: testLogger(variables),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,14 +51,15 @@ function testLogger(variables: Partial<WindowVariables>): boolean {
|
|||||||
if (!variables.isDesktop) {
|
if (!variables.isDesktop) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isObject(variables.log);
|
return isPlainObject(variables.log);
|
||||||
}
|
}
|
||||||
|
|
||||||
function testSystem(variables: Partial<WindowVariables>): boolean {
|
function testCodeRunner(variables: Partial<WindowVariables>): boolean {
|
||||||
if (!variables.isDesktop) {
|
if (!variables.isDesktop) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isObject(variables.system);
|
return isPlainObject(variables.codeRunner)
|
||||||
|
&& isFunction(variables.codeRunner.runCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function testIsDesktop(isDesktop: unknown): boolean {
|
function testIsDesktop(isDesktop: unknown): boolean {
|
||||||
@@ -65,17 +68,3 @@ function testIsDesktop(isDesktop: unknown): boolean {
|
|||||||
}
|
}
|
||||||
return isBoolean(isDesktop);
|
return isBoolean(isDesktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNumber(variable: unknown): variable is number {
|
|
||||||
return typeof variable === 'number';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBoolean(variable: unknown): variable is boolean {
|
|
||||||
return typeof variable === 'boolean';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(variable: unknown): variable is object {
|
|
||||||
return Boolean(variable) // the data type of null is an object
|
|
||||||
&& typeof variable === 'object'
|
|
||||||
&& !Array.isArray(variable);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { PropertyKeys } from '@/TypeHelpers';
|
import { PropertyKeys } from '@/TypeHelpers';
|
||||||
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||||
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
||||||
|
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
|
||||||
|
|
||||||
export function provideDependencies(
|
export function provideDependencies(
|
||||||
context: IApplicationContext,
|
context: IApplicationContext,
|
||||||
@@ -62,6 +63,10 @@ export function provideDependencies(
|
|||||||
InjectionKeys.useLogger,
|
InjectionKeys.useLogger,
|
||||||
useLogger,
|
useLogger,
|
||||||
),
|
),
|
||||||
|
useCodeRunner: (di) => di.provide(
|
||||||
|
InjectionKeys.useCodeRunner,
|
||||||
|
useCodeRunner,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
registerAll(Object.values(resolvers), api);
|
registerAll(Object.values(resolvers), api);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
import { defineComponent, computed } from 'vue';
|
import { defineComponent, computed } from 'vue';
|
||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
|
||||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
|
||||||
import IconButton from './IconButton.vue';
|
import IconButton from './IconButton.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -22,11 +20,19 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
||||||
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
|
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
|
||||||
|
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
|
||||||
|
|
||||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||||
|
|
||||||
async function executeCode() {
|
async function executeCode() {
|
||||||
await runCode(currentContext);
|
if (!codeRunner) { throw new Error('missing code runner'); }
|
||||||
|
if (os === undefined) { throw new Error('unidentified host operating system'); }
|
||||||
|
await codeRunner.runCode(
|
||||||
|
currentContext.state.code.current,
|
||||||
|
currentContext.app.info.name,
|
||||||
|
currentState.value.collection.scripting.fileExtension,
|
||||||
|
os,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -45,13 +51,4 @@ function getCanRunState(
|
|||||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||||
return isDesktopVersion && isRunningOnSelectedOs;
|
return isDesktopVersion && isRunningOnSelectedOs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCode(context: IReadOnlyApplicationContext) {
|
|
||||||
const runner = new CodeRunner();
|
|
||||||
await runner.runCode(
|
|
||||||
/* code: */ context.state.code.current,
|
|
||||||
/* appName: */ context.app.info.name,
|
|
||||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isArray } from '@/TypeHelpers';
|
||||||
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
||||||
import { TreeNode } from '../../Node/TreeNode';
|
import { TreeNode } from '../../Node/TreeNode';
|
||||||
import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
||||||
@@ -5,7 +6,7 @@ import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
|||||||
export function parseTreeInput(
|
export function parseTreeInput(
|
||||||
input: readonly TreeInputNodeData[],
|
input: readonly TreeInputNodeData[],
|
||||||
): TreeNode[] {
|
): TreeNode[] {
|
||||||
if (!Array.isArray(input)) {
|
if (!isArray(input)) {
|
||||||
throw new Error('input data must be an array');
|
throw new Error('input data must be an array');
|
||||||
}
|
}
|
||||||
const nodes = input.map((nodeData) => createNode(nodeData));
|
const nodes = input.map((nodeData) => createNode(nodeData));
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||||
|
|
||||||
|
export function useCodeRunner(
|
||||||
|
window: WindowVariables = globalThis.window,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
codeRunner: window.codeRunner,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { contextBridge } from 'electron';
|
||||||
|
import { bindObjectMethods } from './MethodContextBinder';
|
||||||
|
import { provideWindowVariables } from './RendererApiProvider';
|
||||||
|
|
||||||
|
export function connectApisWithContextBridge(
|
||||||
|
bridgeConnector: BridgeConnector = contextBridge.exposeInMainWorld,
|
||||||
|
apiObject: object = provideWindowVariables(),
|
||||||
|
methodContextBinder: MethodContextBinder = bindObjectMethods,
|
||||||
|
) {
|
||||||
|
Object.entries(apiObject).forEach(([key, value]) => {
|
||||||
|
bridgeConnector(key, methodContextBinder(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BridgeConnector = typeof contextBridge.exposeInMainWorld;
|
||||||
|
|
||||||
|
export type MethodContextBinder = typeof bindObjectMethods;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
isArray, isFunction, isNullOrUndefined, isPlainObject,
|
||||||
|
} from '@/TypeHelpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds method contexts to their original object instances and recursively processes
|
||||||
|
* nested objects and arrays. This is particularly useful when exposing objects across
|
||||||
|
* different contexts in Electron, such as from the main process to the renderer process
|
||||||
|
* via the `contextBridge`.
|
||||||
|
*
|
||||||
|
* In Electron's context isolation environment, methods of objects passed through the
|
||||||
|
* `contextBridge` lose their original context (`this` binding). This function ensures that
|
||||||
|
* each method retains its binding to its original object, allowing it to work as intended
|
||||||
|
* when invoked from the renderer process.
|
||||||
|
*
|
||||||
|
* This approach decouples context isolation concerns from class implementations, enabling
|
||||||
|
* classes to operate normally without needing explicit binding or arrow functions to maintain
|
||||||
|
* the context.
|
||||||
|
*/
|
||||||
|
export function bindObjectMethods<T>(obj: T): T {
|
||||||
|
if (isNullOrUndefined(obj)) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (isPlainObject(obj)) {
|
||||||
|
bindMethodsOfObject(obj);
|
||||||
|
Object.values(obj).forEach((value) => {
|
||||||
|
if (!isNullOrUndefined(value) && !isFunction(value)) {
|
||||||
|
bindObjectMethods(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (isArray(obj)) {
|
||||||
|
obj.forEach((item) => bindObjectMethods(item));
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindMethodsOfObject<T>(obj: T): T {
|
||||||
|
const prototype = Object.getPrototypeOf(obj);
|
||||||
|
if (!prototype) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
Object.getOwnPropertyNames(prototype).forEach((property) => {
|
||||||
|
if (!prototype.hasOwnProperty.call(obj, property)) {
|
||||||
|
return; // Skip properties not directly on the prototype
|
||||||
|
}
|
||||||
|
const value = obj[property];
|
||||||
|
if (isFunction(value)) {
|
||||||
|
(obj as object)[property] = value.bind(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||||
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||||
|
import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner';
|
||||||
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
|
import { convertPlatformToOs } from './NodeOsMapper';
|
||||||
|
import { createSecureFacade } from './SecureFacadeCreator';
|
||||||
|
|
||||||
|
export function provideWindowVariables(
|
||||||
|
createCodeRunner: CodeRunnerFactory = () => new TemporaryFileCodeRunner(),
|
||||||
|
createLogger: LoggerFactory = () => createElectronLogger(),
|
||||||
|
convertToOs = convertPlatformToOs,
|
||||||
|
createApiFacade: ApiFacadeFactory = createSecureFacade,
|
||||||
|
): WindowVariables {
|
||||||
|
return {
|
||||||
|
isDesktop: true,
|
||||||
|
log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']),
|
||||||
|
os: convertToOs(process.platform),
|
||||||
|
codeRunner: createApiFacade(createCodeRunner(), ['runCode']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoggerFactory = () => Logger;
|
||||||
|
|
||||||
|
export type CodeRunnerFactory = () => CodeRunner;
|
||||||
|
|
||||||
|
export type ApiFacadeFactory = typeof createSecureFacade;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { isFunction } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a secure proxy for the specified object, exposing only the public properties
|
||||||
|
* of its interface.
|
||||||
|
*
|
||||||
|
* This approach prevents the full exposure of the object, thereby reducing the risk
|
||||||
|
* of unintended access or misuse. For instance, creating a facade for a class rather
|
||||||
|
* than exposing the class itself ensures that private members and dependencies
|
||||||
|
* (such as file access or internal state) remain encapsulated and inaccessible.
|
||||||
|
*/
|
||||||
|
export function createSecureFacade<T>(
|
||||||
|
originalObject: T,
|
||||||
|
accessibleMembers: KeyTypeCombinations<T>,
|
||||||
|
): T {
|
||||||
|
const facade: Partial<T> = {};
|
||||||
|
|
||||||
|
accessibleMembers.forEach((key: keyof T) => {
|
||||||
|
const member = originalObject[key];
|
||||||
|
if (isFunction(member)) {
|
||||||
|
facade[key] = ((...args: unknown[]) => {
|
||||||
|
return member.apply(originalObject, args);
|
||||||
|
}) as T[keyof T];
|
||||||
|
} else {
|
||||||
|
facade[key] = member;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return facade as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrependTuple<H, T extends readonly unknown[]> = H extends unknown ? T extends unknown ?
|
||||||
|
((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never : never : never;
|
||||||
|
type RecursionDepthControl = [
|
||||||
|
never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||||
|
];
|
||||||
|
type AllKeyCombinations<T, U = T, N extends number = 15> = T extends unknown ?
|
||||||
|
PrependTuple<T, Exclude<U, T> extends infer X ? {
|
||||||
|
0: [], 1: AllKeyCombinations<X, X, RecursionDepthControl[N]>
|
||||||
|
}[[X] extends [never] ? 0 : 1] : never> :
|
||||||
|
never;
|
||||||
|
type KeyTypeCombinations<T> = AllKeyCombinations<keyof T>;
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations';
|
|
||||||
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
|
||||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
|
||||||
import { convertPlatformToOs } from './NodeOsMapper';
|
|
||||||
|
|
||||||
export function provideWindowVariables(
|
|
||||||
createSystem = createNodeSystemOperations,
|
|
||||||
createLogger: () => Logger = () => createElectronLogger(),
|
|
||||||
convertToOs = convertPlatformToOs,
|
|
||||||
): WindowVariables {
|
|
||||||
return {
|
|
||||||
system: createSystem(),
|
|
||||||
isDesktop: true,
|
|
||||||
log: createLogger(),
|
|
||||||
os: convertToOs(process.platform),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// This file is used to securely expose Electron APIs to the application.
|
// This file is used to securely expose Electron APIs to the application.
|
||||||
|
|
||||||
import { contextBridge } from 'electron';
|
|
||||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||||
import { provideWindowVariables } from './WindowVariablesProvider';
|
import { connectApisWithContextBridge } from './ContextBridging/ApiContextBridge';
|
||||||
|
|
||||||
validateRuntimeSanity({
|
validateRuntimeSanity({
|
||||||
// Validate metadata as a preventive measure for fail-fast,
|
// Validate metadata as a preventive measure for fail-fast,
|
||||||
@@ -14,10 +13,7 @@ validateRuntimeSanity({
|
|||||||
validateWindowVariables: false,
|
validateWindowVariables: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const windowVariables = provideWindowVariables();
|
connectApisWithContextBridge();
|
||||||
Object.entries(windowVariables).forEach(([key, value]) => {
|
|
||||||
contextBridge.exposeInMainWorld(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
|
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
|
||||||
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');
|
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseC
|
|||||||
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||||
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||||
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
|
||||||
|
import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner';
|
||||||
|
|
||||||
export const InjectionKeys = {
|
export const InjectionKeys = {
|
||||||
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
|
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
|
||||||
@@ -17,6 +18,7 @@ export const InjectionKeys = {
|
|||||||
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
||||||
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
|
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
|
||||||
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
|
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
|
||||||
|
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface InjectionKeyWithLifetime<T> {
|
export interface InjectionKeyWithLifetime<T> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
|
|
||||||
export function indentText(
|
export function indentText(
|
||||||
text: string,
|
text: string,
|
||||||
indentLevel = 1,
|
indentLevel = 1,
|
||||||
@@ -21,7 +23,7 @@ export function filterEmpty(texts: readonly (string | undefined | null)[]): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateText(text: string): void {
|
function validateText(text: string): void {
|
||||||
if (typeof text !== 'string') {
|
if (!isString(text)) {
|
||||||
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
|
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
|
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
|
||||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||||
|
import { isBoolean } from '@/TypeHelpers';
|
||||||
|
|
||||||
describe('SanityChecks', () => {
|
describe('SanityChecks', () => {
|
||||||
describe('validateRuntimeSanity', () => {
|
describe('validateRuntimeSanity', () => {
|
||||||
@@ -42,7 +43,7 @@ function generateBooleanPermutations<T>(object: T | undefined): T[] {
|
|||||||
const currentKey = keys[0];
|
const currentKey = keys[0];
|
||||||
const currentValue = object[currentKey];
|
const currentValue = object[currentKey];
|
||||||
|
|
||||||
if (typeof currentValue !== 'boolean') {
|
if (!isBoolean(currentValue)) {
|
||||||
return generateBooleanPermutations({
|
return generateBooleanPermutations({
|
||||||
...object,
|
...object,
|
||||||
[currentKey]: currentValue,
|
[currentKey]: currentValue,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { it, describe, expect } from 'vitest';
|
||||||
|
import { connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge';
|
||||||
|
|
||||||
|
describe('ApiContextBridge', () => {
|
||||||
|
describe('connectApisWithContextBridge', () => {
|
||||||
|
it('can provide keys and values', () => {
|
||||||
|
// arrange
|
||||||
|
const bridgeConnector = () => {};
|
||||||
|
// act
|
||||||
|
const act = () => connectApisWithContextBridge(bridgeConnector);
|
||||||
|
// assert
|
||||||
|
expect(act).to.not.throw();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { it, describe, expect } from 'vitest';
|
||||||
|
import { provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider';
|
||||||
|
import {
|
||||||
|
isArray, isBoolean, isFunction, isNullOrUndefined, isNumber, isPlainObject, isString,
|
||||||
|
} from '@/TypeHelpers';
|
||||||
|
|
||||||
|
describe('RendererApiProvider', () => {
|
||||||
|
describe('provideWindowVariables', () => {
|
||||||
|
describe('conforms to Electron\'s context bridging requirements', () => {
|
||||||
|
// https://www.electronjs.org/docs/latest/api/context-bridge
|
||||||
|
const variables = provideWindowVariables();
|
||||||
|
Object.entries(variables).forEach(([key, value]) => {
|
||||||
|
it(`\`${key}\` conforms to allowed types for context bridging`, () => {
|
||||||
|
// act
|
||||||
|
const act = () => checkAllowedType(value);
|
||||||
|
// assert
|
||||||
|
expect(act).to.not.throw();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkAllowedType(value: unknown): void {
|
||||||
|
if (isBasicType(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isArray(value)) {
|
||||||
|
checkArrayElements(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
throw new Error(`Type error: Expected a valid object, array, or primitive type, but received type '${typeof value}'.`);
|
||||||
|
}
|
||||||
|
if (isNullOrUndefined(value)) {
|
||||||
|
throw new Error('Type error: Value is null or undefined, which is not allowed.');
|
||||||
|
}
|
||||||
|
checkObjectProperties(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBasicType(value: unknown): boolean {
|
||||||
|
return isString(value) || isNumber(value) || isBoolean(value) || isFunction(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkArrayElements(array: unknown[]): void {
|
||||||
|
array.forEach((item, index) => {
|
||||||
|
try {
|
||||||
|
checkAllowedType(item);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid array element at index ${index}: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkObjectProperties(obj: NonNullable<object>): void {
|
||||||
|
if (Object.keys(obj).some((key) => !isString(key))) {
|
||||||
|
throw new Error('Type error: At least one object key is not a string, which violates the allowed types.');
|
||||||
|
}
|
||||||
|
Object.entries(obj).forEach(([key, memberValue]) => {
|
||||||
|
try {
|
||||||
|
checkAllowedType(memberValue);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid object property '${key}': ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { it, describe, expect } from 'vitest';
|
|
||||||
import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider';
|
|
||||||
|
|
||||||
describe('WindowVariablesProvider', () => {
|
|
||||||
describe('provideWindowVariables', () => {
|
|
||||||
describe('conforms to Electron\'s context bridging requirements', () => {
|
|
||||||
// https://www.electronjs.org/docs/latest/api/context-bridge
|
|
||||||
const variables = provideWindowVariables();
|
|
||||||
Object.entries(variables).forEach(([key, value]) => {
|
|
||||||
it(`\`${key}\` conforms to allowed types for context bridging`, () => {
|
|
||||||
expect(checkAllowedType(value)).to.equal(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function checkAllowedType(value: unknown) {
|
|
||||||
const type = typeof value;
|
|
||||||
if (['string', 'number', 'boolean', 'function'].includes(type)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.every(checkAllowedType);
|
|
||||||
}
|
|
||||||
if (type === 'object' && value !== null && value !== undefined) {
|
|
||||||
return (
|
|
||||||
// Every key should be a string
|
|
||||||
Object.keys(value).every((key) => typeof key === 'string')
|
|
||||||
// Every value should be of allowed type
|
|
||||||
&& Object.values(value).every(checkAllowedType)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -91,8 +91,8 @@ describe('CodeBuilder', () => {
|
|||||||
it('appendFunction', () => {
|
it('appendFunction', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const sut = new CodeBuilderConcrete();
|
const sut = new CodeBuilderConcrete();
|
||||||
const functionName = 'function';
|
const functionName = 'expected-function-name';
|
||||||
const code = 'code';
|
const code = 'expected-code';
|
||||||
// act
|
// act
|
||||||
sut.appendFunction(functionName, code);
|
sut.appendFunction(functionName, code);
|
||||||
// assert
|
// assert
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
|
import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner';
|
||||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||||
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
|
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
|
||||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||||
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
||||||
import { IFileSystemOps, ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
|
||||||
import { FunctionKeys } from '@/TypeHelpers';
|
import { FunctionKeys } from '@/TypeHelpers';
|
||||||
|
|
||||||
describe('CodeRunner', () => {
|
describe('TemporaryFileCodeRunner', () => {
|
||||||
describe('runCode', () => {
|
describe('runCode', () => {
|
||||||
it('creates temporary directory recursively', async () => {
|
it('creates temporary directory recursively', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
@@ -121,11 +120,11 @@ describe('CodeRunner', () => {
|
|||||||
describe('executes as expected', () => {
|
describe('executes as expected', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const filePath = 'expected-file-path';
|
const filePath = 'expected-file-path';
|
||||||
interface IExecutionTestCase {
|
interface ExecutionTestCase {
|
||||||
readonly givenOs: OperatingSystem;
|
readonly givenOs: OperatingSystem;
|
||||||
readonly expectedCommand: string;
|
readonly expectedCommand: string;
|
||||||
}
|
}
|
||||||
const testData: readonly IExecutionTestCase[] = [
|
const testData: readonly ExecutionTestCase[] = [
|
||||||
{
|
{
|
||||||
givenOs: OperatingSystem.Windows,
|
givenOs: OperatingSystem.Windows,
|
||||||
expectedCommand: filePath,
|
expectedCommand: filePath,
|
||||||
@@ -164,7 +163,7 @@ describe('CodeRunner', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
it('runs in expected order', async () => { // verifies correct `async`, `await` usage.
|
it('runs in expected order', async () => { // verifies correct `async`, `await` usage.
|
||||||
const expectedOrder: readonly FunctionKeys<IFileSystemOps>[] = [
|
const expectedOrder: readonly FunctionKeys<FileSystemOps>[] = [
|
||||||
'createDirectory',
|
'createDirectory',
|
||||||
'writeToFile',
|
'writeToFile',
|
||||||
'setFilePermissions',
|
'setFilePermissions',
|
||||||
@@ -186,7 +185,7 @@ describe('CodeRunner', () => {
|
|||||||
describe('throws with invalid OS', () => {
|
describe('throws with invalid OS', () => {
|
||||||
const testScenarios: ReadonlyArray<{
|
const testScenarios: ReadonlyArray<{
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly invalidOs: OperatingSystem | undefined;
|
readonly invalidOs: OperatingSystem;
|
||||||
readonly expectedError: string;
|
readonly expectedError: string;
|
||||||
}> = [
|
}> = [
|
||||||
(() => {
|
(() => {
|
||||||
@@ -197,11 +196,6 @@ describe('CodeRunner', () => {
|
|||||||
expectedError: `unsupported os: ${OperatingSystem[unsupportedOs]}`,
|
expectedError: `unsupported os: ${OperatingSystem[unsupportedOs]}`,
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
{
|
|
||||||
description: 'unknown OS',
|
|
||||||
invalidOs: undefined,
|
|
||||||
expectedError: 'Unidentified operating system',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
testScenarios.forEach(({ description, invalidOs, expectedError }) => {
|
testScenarios.forEach(({ description, invalidOs, expectedError }) => {
|
||||||
it(description, async () => {
|
it(description, async () => {
|
||||||
@@ -225,19 +219,17 @@ class TestContext {
|
|||||||
|
|
||||||
private fileExtension = 'fileExtension';
|
private fileExtension = 'fileExtension';
|
||||||
|
|
||||||
private os: OperatingSystem | undefined = OperatingSystem.Windows;
|
private os: OperatingSystem = OperatingSystem.Windows;
|
||||||
|
|
||||||
private systemOperations: ISystemOperations = new SystemOperationsStub();
|
private systemOperations: SystemOperations = new SystemOperationsStub();
|
||||||
|
|
||||||
public async runCode(): Promise<void> {
|
public async runCode(): Promise<void> {
|
||||||
const environment = new RuntimeEnvironmentStub()
|
const runner = new TemporaryFileCodeRunner(this.systemOperations);
|
||||||
.withOs(this.os);
|
await runner.runCode(this.code, this.folderName, this.fileExtension, this.os);
|
||||||
const runner = new CodeRunner(this.systemOperations, environment);
|
|
||||||
await runner.runCode(this.code, this.folderName, this.fileExtension);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public withSystemOperations(
|
public withSystemOperations(
|
||||||
systemOperations: ISystemOperations,
|
systemOperations: SystemOperations,
|
||||||
): this {
|
): this {
|
||||||
this.systemOperations = systemOperations;
|
this.systemOperations = systemOperations;
|
||||||
return this;
|
return this;
|
||||||
@@ -250,22 +242,22 @@ class TestContext {
|
|||||||
return this.withSystemOperations(stub);
|
return this.withSystemOperations(stub);
|
||||||
}
|
}
|
||||||
|
|
||||||
public withOs(os: OperatingSystem | undefined) {
|
public withOs(os: OperatingSystem): this {
|
||||||
this.os = os;
|
this.os = os;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withFolderName(folderName: string) {
|
public withFolderName(folderName: string): this {
|
||||||
this.folderName = folderName;
|
this.folderName = folderName;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withCode(code: string) {
|
public withCode(code: string): this {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withExtension(fileExtension: string) {
|
public withExtension(fileExtension: string): this {
|
||||||
this.fileExtension = fileExtension;
|
this.fileExtension = fileExtension;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator';
|
import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator';
|
||||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
import { getAbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
|
|
||||||
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
|
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
|
||||||
|
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
|
||||||
|
|
||||||
describe('WindowVariablesValidator', () => {
|
describe('WindowVariablesValidator', () => {
|
||||||
describe('validateWindowVariables', () => {
|
describe('validateWindowVariables', () => {
|
||||||
@@ -92,51 +92,35 @@ describe('WindowVariablesValidator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('`isDesktop` property', () => {
|
describe('`isDesktop` property', () => {
|
||||||
it('throws an error when only isDesktop is provided and it is true without a system object', () => {
|
it('does not throw when true with valid services', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const systemObject = undefined;
|
const validCodeRunner = new CodeRunnerStub();
|
||||||
const expectedError = getExpectedError(
|
|
||||||
{
|
|
||||||
name: 'system',
|
|
||||||
object: systemObject,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const input = new WindowVariablesStub()
|
const input = new WindowVariablesStub()
|
||||||
.withIsDesktop(true)
|
.withIsDesktop(true)
|
||||||
.withSystem(systemObject);
|
.withCodeRunner(validCodeRunner);
|
||||||
// act
|
|
||||||
const act = () => validateWindowVariables(input);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when isDesktop is true with a valid system object', () => {
|
|
||||||
// arrange
|
|
||||||
const validSystem = new SystemOperationsStub();
|
|
||||||
const input = new WindowVariablesStub()
|
|
||||||
.withIsDesktop(true)
|
|
||||||
.withSystem(validSystem);
|
|
||||||
// act
|
// act
|
||||||
const act = () => validateWindowVariables(input);
|
const act = () => validateWindowVariables(input);
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.not.throw();
|
expect(act).to.not.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when isDesktop is false without a system object', () => {
|
describe('does not throw when false without services', () => {
|
||||||
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const absentSystem = undefined;
|
const absentCodeRunner = absentValue;
|
||||||
const input = new WindowVariablesStub()
|
const input = new WindowVariablesStub()
|
||||||
.withIsDesktop(false)
|
.withIsDesktop(false)
|
||||||
.withSystem(absentSystem);
|
.withCodeRunner(absentCodeRunner);
|
||||||
// act
|
// act
|
||||||
const act = () => validateWindowVariables(input);
|
const act = () => validateWindowVariables(input);
|
||||||
// assert
|
// assert
|
||||||
expect(act).to.not.throw();
|
expect(act).to.not.throw();
|
||||||
|
}, { excludeNull: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('`system` property', () => {
|
describe('`codeRunner` property', () => {
|
||||||
expectObjectOnDesktop('system');
|
expectObjectOnDesktop('codeRunner');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('`log` property', () => {
|
describe('`log` property', () => {
|
||||||
@@ -158,6 +142,7 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
|
|||||||
describe('validates object type on desktop', () => {
|
describe('validates object type on desktop', () => {
|
||||||
itEachInvalidObjectValue((invalidObjectValue) => {
|
itEachInvalidObjectValue((invalidObjectValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
|
const isOnDesktop = true;
|
||||||
const invalidObject = invalidObjectValue as T;
|
const invalidObject = invalidObjectValue as T;
|
||||||
const expectedError = getExpectedError({
|
const expectedError = getExpectedError({
|
||||||
name: key,
|
name: key,
|
||||||
@@ -165,7 +150,7 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
|
|||||||
});
|
});
|
||||||
const input: WindowVariables = {
|
const input: WindowVariables = {
|
||||||
...new WindowVariablesStub(),
|
...new WindowVariablesStub(),
|
||||||
isDesktop: true,
|
isDesktop: isOnDesktop,
|
||||||
[key]: invalidObject,
|
[key]: invalidObject,
|
||||||
};
|
};
|
||||||
// act
|
// act
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { getWindowInjectedSystemOperations } from '@/infrastructure/SystemOperations/WindowInjectedSystemOperations';
|
|
||||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
|
||||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
|
||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
|
||||||
|
|
||||||
describe('WindowInjectedSystemOperations', () => {
|
|
||||||
describe('getWindowInjectedSystemOperations', () => {
|
|
||||||
describe('throws if window is absent', () => {
|
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'missing window';
|
|
||||||
const window: WindowVariables = absentValue as never;
|
|
||||||
// act
|
|
||||||
const act = () => getWindowInjectedSystemOperations(window);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
}, { excludeUndefined: true });
|
|
||||||
});
|
|
||||||
describe('throw if system is absent', () => {
|
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
|
||||||
// arrange
|
|
||||||
const expectedError = 'missing system';
|
|
||||||
const absentSystem = absentValue;
|
|
||||||
const window: Partial<WindowVariables> = {
|
|
||||||
system: absentSystem as never,
|
|
||||||
};
|
|
||||||
// act
|
|
||||||
const act = () => getWindowInjectedSystemOperations(window);
|
|
||||||
// assert
|
|
||||||
expect(act).to.throw(expectedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('returns from window', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedValue = new SystemOperationsStub();
|
|
||||||
const window: Partial<WindowVariables> = {
|
|
||||||
system: expectedValue,
|
|
||||||
};
|
|
||||||
// act
|
|
||||||
const actualValue = getWindowInjectedSystemOperations(window);
|
|
||||||
// assert
|
|
||||||
expect(actualValue).to.equal(expectedValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,7 @@ describe('DependencyProvider', () => {
|
|||||||
useCurrentCode: createTransientTests(),
|
useCurrentCode: createTransientTests(),
|
||||||
useUserSelectionState: createTransientTests(),
|
useUserSelectionState: createTransientTests(),
|
||||||
useLogger: createTransientTests(),
|
useLogger: createTransientTests(),
|
||||||
|
useCodeRunner: createTransientTests(),
|
||||||
};
|
};
|
||||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||||
const registeredKey = InjectionKeys[key].key;
|
const registeredKey = InjectionKeys[key].key;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
|
||||||
|
|
||||||
|
describe('UseCodeRunner', () => {
|
||||||
|
it('returns from the provided window object', () => {
|
||||||
|
// arrange
|
||||||
|
const mockCodeRunner = { run: () => {} };
|
||||||
|
const mockWindow = { codeRunner: mockCodeRunner } as unknown as Window;
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { codeRunner } = useCodeRunner(mockWindow);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(codeRunner).to.equal(mockCodeRunner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when not defined in the window object', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockWindow = {} as unknown as Window;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { codeRunner } = useCodeRunner(mockWindow);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(codeRunner).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { it, describe, expect } from 'vitest';
|
||||||
|
import { BridgeConnector, MethodContextBinder, connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge';
|
||||||
|
|
||||||
|
describe('ApiContextBridge', () => {
|
||||||
|
describe('connectApisWithContextBridge', () => {
|
||||||
|
it('connects properties as keys', () => {
|
||||||
|
// arrange
|
||||||
|
const context = new BridgeConnectorTestContext();
|
||||||
|
const { exposedItems, bridgeConnector } = mockBridgeConnector();
|
||||||
|
const expectedKeys = ['a', 'b'];
|
||||||
|
const api = {
|
||||||
|
[`${expectedKeys[0]}`]: () => {},
|
||||||
|
[`${expectedKeys[1]}`]: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
context
|
||||||
|
.withApiObject(api)
|
||||||
|
.withBridgeConnector(bridgeConnector)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(exposedItems).to.have.lengthOf(expectedKeys.length);
|
||||||
|
expect(exposedItems.map(([key]) => key)).to.have.members(expectedKeys);
|
||||||
|
});
|
||||||
|
it('connects values after binding their context', () => {
|
||||||
|
// arrange
|
||||||
|
const context = new BridgeConnectorTestContext();
|
||||||
|
const { exposedItems, bridgeConnector } = mockBridgeConnector();
|
||||||
|
const rawValues = ['a', 'b'];
|
||||||
|
const api = {
|
||||||
|
first: rawValues[0],
|
||||||
|
second: rawValues[1],
|
||||||
|
};
|
||||||
|
const boundValues = {
|
||||||
|
[`${rawValues[0]}`]: 'bound-a',
|
||||||
|
[`${rawValues[1]}`]: 'bound-b',
|
||||||
|
};
|
||||||
|
const expectedValues = Object.values(boundValues);
|
||||||
|
const contextBinderMock: MethodContextBinder = (property) => {
|
||||||
|
return boundValues[property as string] as never;
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
context
|
||||||
|
.withApiObject(api)
|
||||||
|
.withContextBinder(contextBinderMock)
|
||||||
|
.withBridgeConnector(bridgeConnector)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(exposedItems).to.have.lengthOf(rawValues.length);
|
||||||
|
expect(exposedItems.map(([,value]) => value)).to.have.members(expectedValues);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockBridgeConnector() {
|
||||||
|
const exposedItems = new Array<[string, unknown]>();
|
||||||
|
const bridgeConnector: BridgeConnector = (key, api) => exposedItems.push([key, api]);
|
||||||
|
return {
|
||||||
|
exposedItems,
|
||||||
|
bridgeConnector,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BridgeConnectorTestContext {
|
||||||
|
private bridgeConnector: BridgeConnector = () => {};
|
||||||
|
|
||||||
|
private apiObject: object = {};
|
||||||
|
|
||||||
|
private contextBinder: MethodContextBinder = (obj) => obj;
|
||||||
|
|
||||||
|
public withBridgeConnector(bridgeConnector: BridgeConnector): this {
|
||||||
|
this.bridgeConnector = bridgeConnector;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withApiObject(apiObject: object): this {
|
||||||
|
this.apiObject = apiObject;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withContextBinder(contextBinder: MethodContextBinder): this {
|
||||||
|
this.contextBinder = contextBinder;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public run() {
|
||||||
|
return connectApisWithContextBridge(
|
||||||
|
this.bridgeConnector,
|
||||||
|
this.apiObject,
|
||||||
|
this.contextBinder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { bindObjectMethods } from '@/presentation/electron/preload/ContextBridging/MethodContextBinder';
|
||||||
|
|
||||||
|
describe('MethodContextBinder', () => {
|
||||||
|
describe('bindObjectMethods', () => {
|
||||||
|
it('binds methods of an object to itself', () => {
|
||||||
|
// arrange
|
||||||
|
class TestClass {
|
||||||
|
constructor(public value: number) {}
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const instance = new TestClass(0);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const boundInstance = bindObjectMethods(instance);
|
||||||
|
boundInstance.increment();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(boundInstance.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles objects without prototype methods gracefully', () => {
|
||||||
|
// arrange
|
||||||
|
const object = Object.create(null); // object without prototype
|
||||||
|
object.value = 0;
|
||||||
|
// eslint-disable-next-line func-names
|
||||||
|
object.increment = function () {
|
||||||
|
this.value += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
const boundObject = bindObjectMethods(object);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(() => boundObject.increment()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively binds methods in nested objects', () => {
|
||||||
|
// arrange
|
||||||
|
const nestedObject = {
|
||||||
|
child: {
|
||||||
|
value: 0,
|
||||||
|
increment() {
|
||||||
|
this.value += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// act
|
||||||
|
const boundObject = bindObjectMethods(nestedObject);
|
||||||
|
boundObject.child.increment();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(boundObject.child.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recursively binds methods in arrays', () => {
|
||||||
|
// arrange
|
||||||
|
const array = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
increment() {
|
||||||
|
this.value += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// act
|
||||||
|
const boundArray = bindObjectMethods(array);
|
||||||
|
boundArray[0].increment();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(boundArray[0].value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('returns the same object if it is neither an object nor an array', () => {
|
||||||
|
const testScenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly value: unknown;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'given primitive',
|
||||||
|
value: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'null',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'undefined',
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({ description, value }) => {
|
||||||
|
it(description, () => {
|
||||||
|
// act
|
||||||
|
const boundValue = bindObjectMethods(value);
|
||||||
|
// assert
|
||||||
|
expect(boundValue).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips binding inherited properties', () => {
|
||||||
|
// arrange
|
||||||
|
class ParentClass {
|
||||||
|
inheritedMethod() {}
|
||||||
|
}
|
||||||
|
class TestClass extends ParentClass {
|
||||||
|
constructor(public value: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.value += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const instance = new TestClass(0);
|
||||||
|
const originalInheritedMethod = instance.inheritedMethod;
|
||||||
|
|
||||||
|
// act
|
||||||
|
const boundInstance = bindObjectMethods(instance);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(boundInstance.inheritedMethod).toBe(originalInheritedMethod);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper';
|
import { convertPlatformToOs } from '@/presentation/electron/preload/ContextBridging/NodeOsMapper';
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
|
||||||
describe('NodeOsMapper', () => {
|
describe('NodeOsMapper', () => {
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ApiFacadeFactory, provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider';
|
||||||
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||||
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
|
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
|
||||||
|
import { PropertyKeys } from '@/TypeHelpers';
|
||||||
|
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||||
|
|
||||||
|
describe('RendererApiProvider', () => {
|
||||||
|
describe('provideWindowVariables', () => {
|
||||||
|
interface WindowVariableTestCase {
|
||||||
|
readonly description: string;
|
||||||
|
setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext;
|
||||||
|
readonly expectedValue: unknown;
|
||||||
|
}
|
||||||
|
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, WindowVariableTestCase> = {
|
||||||
|
isDesktop: {
|
||||||
|
description: 'returns true',
|
||||||
|
setupContext: (context) => context,
|
||||||
|
expectedValue: true,
|
||||||
|
},
|
||||||
|
codeRunner: (() => {
|
||||||
|
const codeRunner = new CodeRunnerStub();
|
||||||
|
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
|
||||||
|
return {
|
||||||
|
description: 'encapsulates correctly',
|
||||||
|
setupContext: (context) => context
|
||||||
|
.withCodeRunner(codeRunner)
|
||||||
|
.withApiFacadeCreator(createFacadeMock),
|
||||||
|
expectedValue: codeRunner,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
os: (() => {
|
||||||
|
const operatingSystem = OperatingSystem.WindowsPhone;
|
||||||
|
return {
|
||||||
|
description: 'returns expected',
|
||||||
|
setupContext: (context) => context.withOs(operatingSystem),
|
||||||
|
expectedValue: operatingSystem,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
log: (() => {
|
||||||
|
const logger = new LoggerStub();
|
||||||
|
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
|
||||||
|
return {
|
||||||
|
description: 'encapsulates correctly',
|
||||||
|
setupContext: (context) => context
|
||||||
|
.withLogger(logger)
|
||||||
|
.withApiFacadeCreator(createFacadeMock),
|
||||||
|
expectedValue: logger,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
Object.entries(testScenarios).forEach((
|
||||||
|
[property, { description, setupContext, expectedValue }],
|
||||||
|
) => {
|
||||||
|
it(`${property}: ${description}`, () => {
|
||||||
|
// arrange
|
||||||
|
const testContext = setupContext(new RendererApiProviderTestContext());
|
||||||
|
// act
|
||||||
|
const variables = testContext.provideWindowVariables();
|
||||||
|
// assert
|
||||||
|
const actualValue = variables[property];
|
||||||
|
expect(actualValue).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class RendererApiProviderTestContext {
|
||||||
|
private codeRunner: CodeRunner = new CodeRunnerStub();
|
||||||
|
|
||||||
|
private os: OperatingSystem = OperatingSystem.Android;
|
||||||
|
|
||||||
|
private log: Logger = new LoggerStub();
|
||||||
|
|
||||||
|
private apiFacadeCreator: ApiFacadeFactory = (obj) => obj;
|
||||||
|
|
||||||
|
public withCodeRunner(codeRunner: CodeRunner): this {
|
||||||
|
this.codeRunner = codeRunner;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withOs(os: OperatingSystem): this {
|
||||||
|
this.os = os;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withLogger(log: Logger): this {
|
||||||
|
this.log = log;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withApiFacadeCreator(apiFacadeCreator: ApiFacadeFactory): this {
|
||||||
|
this.apiFacadeCreator = apiFacadeCreator;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public provideWindowVariables() {
|
||||||
|
return provideWindowVariables(
|
||||||
|
() => this.codeRunner,
|
||||||
|
() => this.log,
|
||||||
|
() => this.os,
|
||||||
|
this.apiFacadeCreator,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createSecureFacade } from '@/presentation/electron/preload/ContextBridging/SecureFacadeCreator';
|
||||||
|
|
||||||
|
describe('SecureFacadeCreator', () => {
|
||||||
|
describe('createSecureFacade', () => {
|
||||||
|
describe('methods', () => {
|
||||||
|
it('allows access to external methods', () => {
|
||||||
|
// arrange
|
||||||
|
let value = 0;
|
||||||
|
const testObject = {
|
||||||
|
increment: () => value++,
|
||||||
|
};
|
||||||
|
const facade = createSecureFacade(testObject, ['increment']);
|
||||||
|
|
||||||
|
// act
|
||||||
|
facade.increment();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(value).toBe(1);
|
||||||
|
});
|
||||||
|
it('proxies external methods', () => {
|
||||||
|
// arrange
|
||||||
|
const testObject = {
|
||||||
|
method: () => {},
|
||||||
|
};
|
||||||
|
const facade = createSecureFacade(testObject, ['method']);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const actualMethod = facade.method;
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(testObject.method).not.equal(actualMethod);
|
||||||
|
expect(testObject.method).not.equal(actualMethod);
|
||||||
|
});
|
||||||
|
it('does not expose internal methods', () => {
|
||||||
|
// arrange
|
||||||
|
interface External {
|
||||||
|
publicMethod(): void;
|
||||||
|
}
|
||||||
|
interface Internal {
|
||||||
|
privateMethod(): void;
|
||||||
|
}
|
||||||
|
const testObject: External & Internal = {
|
||||||
|
publicMethod: () => {},
|
||||||
|
privateMethod: () => {},
|
||||||
|
};
|
||||||
|
const facade = createSecureFacade<External>(testObject, ['publicMethod']);
|
||||||
|
|
||||||
|
// act
|
||||||
|
facade.publicMethod();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect((facade as unknown as Internal).privateMethod).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('maintains original function context', () => {
|
||||||
|
// arrange
|
||||||
|
const testObject = {
|
||||||
|
value: 0,
|
||||||
|
increment() { this.value++; },
|
||||||
|
};
|
||||||
|
// act
|
||||||
|
const facade = createSecureFacade(testObject, ['increment', 'value']);
|
||||||
|
// assert
|
||||||
|
facade.increment();
|
||||||
|
expect(testObject.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('properties', () => {
|
||||||
|
it('allows access to external properties', () => {
|
||||||
|
// arrange
|
||||||
|
const testObject = { a: 1 };
|
||||||
|
// act
|
||||||
|
const facade = createSecureFacade(testObject, ['a']);
|
||||||
|
// assert
|
||||||
|
expect(facade.a).toBe(1);
|
||||||
|
});
|
||||||
|
it('does not expose internal properties', () => {
|
||||||
|
// arrange
|
||||||
|
interface External {
|
||||||
|
readonly public: string;
|
||||||
|
}
|
||||||
|
interface Internal {
|
||||||
|
readonly private: string;
|
||||||
|
}
|
||||||
|
const testObject: External & Internal = {
|
||||||
|
public: '',
|
||||||
|
private: '',
|
||||||
|
};
|
||||||
|
const facade = createSecureFacade<External>(testObject, ['public']);
|
||||||
|
|
||||||
|
// act
|
||||||
|
(() => facade.public)();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect((facade as unknown as Internal).private).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider';
|
|
||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
|
||||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
|
||||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
|
||||||
|
|
||||||
describe('WindowVariablesProvider', () => {
|
|
||||||
describe('provideWindowVariables', () => {
|
|
||||||
it('returns expected `system`', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedValue = new SystemOperationsStub();
|
|
||||||
// act
|
|
||||||
const variables = new TestContext()
|
|
||||||
.withSystem(expectedValue)
|
|
||||||
.provideWindowVariables();
|
|
||||||
// assert
|
|
||||||
expect(variables.system).to.equal(expectedValue);
|
|
||||||
});
|
|
||||||
it('returns expected `os`', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedValue = OperatingSystem.WindowsPhone;
|
|
||||||
// act
|
|
||||||
const variables = new TestContext()
|
|
||||||
.withOs(expectedValue)
|
|
||||||
.provideWindowVariables();
|
|
||||||
// assert
|
|
||||||
expect(variables.os).to.equal(expectedValue);
|
|
||||||
});
|
|
||||||
it('returns expected `log`', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedValue = new LoggerStub();
|
|
||||||
// act
|
|
||||||
const variables = new TestContext()
|
|
||||||
.withLogger(expectedValue)
|
|
||||||
.provideWindowVariables();
|
|
||||||
// assert
|
|
||||||
expect(variables.log).to.equal(expectedValue);
|
|
||||||
});
|
|
||||||
it('`isDesktop` is true', () => {
|
|
||||||
// arrange
|
|
||||||
const expectedValue = true;
|
|
||||||
// act
|
|
||||||
const variables = new TestContext()
|
|
||||||
.provideWindowVariables();
|
|
||||||
// assert
|
|
||||||
expect(variables.isDesktop).to.equal(expectedValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
class TestContext {
|
|
||||||
private system: ISystemOperations = new SystemOperationsStub();
|
|
||||||
|
|
||||||
private os: OperatingSystem = OperatingSystem.Android;
|
|
||||||
|
|
||||||
private log: Logger = new LoggerStub();
|
|
||||||
|
|
||||||
public withSystem(system: ISystemOperations): this {
|
|
||||||
this.system = system;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public withOs(os: OperatingSystem): this {
|
|
||||||
this.os = os;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public withLogger(log: Logger): this {
|
|
||||||
this.log = log;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public provideWindowVariables() {
|
|
||||||
return provideWindowVariables(
|
|
||||||
() => this.system,
|
|
||||||
() => this.log,
|
|
||||||
() => this.os,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7
tests/unit/shared/Stubs/CodeRunnerStub.ts
Normal file
7
tests/unit/shared/Stubs/CodeRunnerStub.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
|
|
||||||
|
export class CodeRunnerStub implements CodeRunner {
|
||||||
|
public runCode(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ICommandOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
import { CommandOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class CommandOpsStub
|
export class CommandOpsStub
|
||||||
extends StubWithObservableMethodCalls<ICommandOps>
|
extends StubWithObservableMethodCalls<CommandOps>
|
||||||
implements ICommandOps {
|
implements CommandOps {
|
||||||
public execute(command: string): void {
|
public execute(command: string): Promise<void> {
|
||||||
this.registerMethodCall({
|
this.registerMethodCall({
|
||||||
methodName: 'execute',
|
methodName: 'execute',
|
||||||
args: [command],
|
args: [command],
|
||||||
});
|
});
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IFileSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
import { FileSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class FileSystemOpsStub
|
export class FileSystemOpsStub
|
||||||
extends StubWithObservableMethodCalls<IFileSystemOps>
|
extends StubWithObservableMethodCalls<FileSystemOps>
|
||||||
implements IFileSystemOps {
|
implements FileSystemOps {
|
||||||
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
|
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
|
||||||
this.registerMethodCall({
|
this.registerMethodCall({
|
||||||
methodName: 'setFilePermissions',
|
methodName: 'setFilePermissions',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ILocationOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
import { LocationOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class LocationOpsStub
|
export class LocationOpsStub
|
||||||
extends StubWithObservableMethodCalls<ILocationOps>
|
extends StubWithObservableMethodCalls<LocationOps>
|
||||||
implements ILocationOps {
|
implements LocationOps {
|
||||||
private sequence = new Array<string>();
|
private sequence = new Array<string>();
|
||||||
|
|
||||||
private scenarios = new Map<string, string>();
|
private scenarios = new Map<string, string>();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IOperatingSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations';
|
import { OperatingSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class OperatingSystemOpsStub
|
export class OperatingSystemOpsStub
|
||||||
extends StubWithObservableMethodCalls<IOperatingSystemOps>
|
extends StubWithObservableMethodCalls<OperatingSystemOps>
|
||||||
implements IOperatingSystemOps {
|
implements OperatingSystemOps {
|
||||||
private temporaryDirectory = '/stub-temp-dir/';
|
private temporaryDirectory = '/stub-temp-dir/';
|
||||||
|
|
||||||
public withTemporaryDirectoryResult(directory: string): this {
|
public withTemporaryDirectoryResult(directory: string): this {
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
import {
|
import type {
|
||||||
ICommandOps,
|
CommandOps,
|
||||||
IFileSystemOps,
|
FileSystemOps,
|
||||||
IOperatingSystemOps,
|
OperatingSystemOps,
|
||||||
ILocationOps,
|
LocationOps,
|
||||||
ISystemOperations,
|
SystemOperations,
|
||||||
} from '@/infrastructure/SystemOperations/ISystemOperations';
|
} from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations';
|
||||||
import { CommandOpsStub } from './CommandOpsStub';
|
import { CommandOpsStub } from './CommandOpsStub';
|
||||||
import { FileSystemOpsStub } from './FileSystemOpsStub';
|
import { FileSystemOpsStub } from './FileSystemOpsStub';
|
||||||
import { LocationOpsStub } from './LocationOpsStub';
|
import { LocationOpsStub } from './LocationOpsStub';
|
||||||
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
|
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
|
||||||
|
|
||||||
export class SystemOperationsStub implements ISystemOperations {
|
export class SystemOperationsStub implements SystemOperations {
|
||||||
public operatingSystem: IOperatingSystemOps = new OperatingSystemOpsStub();
|
public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub();
|
||||||
|
|
||||||
public location: ILocationOps = new LocationOpsStub();
|
public location: LocationOps = new LocationOpsStub();
|
||||||
|
|
||||||
public fileSystem: IFileSystemOps = new FileSystemOpsStub();
|
public fileSystem: FileSystemOps = new FileSystemOpsStub();
|
||||||
|
|
||||||
public command: ICommandOps = new CommandOpsStub();
|
public command: CommandOps = new CommandOpsStub();
|
||||||
|
|
||||||
public withOperatingSystem(operatingSystemOps: IOperatingSystemOps): this {
|
public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this {
|
||||||
this.operatingSystem = operatingSystemOps;
|
this.operatingSystem = operatingSystemOps;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withLocation(location: ILocationOps): this {
|
public withLocation(location: LocationOps): this {
|
||||||
this.location = location;
|
this.location = location;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withFileSystem(fileSystem: IFileSystemOps): this {
|
public withFileSystem(fileSystem: FileSystemOps): this {
|
||||||
this.fileSystem = fileSystem;
|
this.fileSystem = fileSystem;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withCommand(command: ICommandOps): this {
|
public withCommand(command: CommandOps): this {
|
||||||
this.command = command;
|
this.command = command;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
|
|
||||||
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||||
import { SystemOperationsStub } from './SystemOperationsStub';
|
import { CodeRunner } from '@/application/CodeRunner';
|
||||||
import { LoggerStub } from './LoggerStub';
|
import { LoggerStub } from './LoggerStub';
|
||||||
|
import { CodeRunnerStub } from './CodeRunnerStub';
|
||||||
|
|
||||||
export class WindowVariablesStub implements WindowVariables {
|
export class WindowVariablesStub implements WindowVariables {
|
||||||
public system?: ISystemOperations = new SystemOperationsStub();
|
public codeRunner?: CodeRunner = new CodeRunnerStub();
|
||||||
|
|
||||||
public isDesktop = false;
|
public isDesktop = false;
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ export class WindowVariablesStub implements WindowVariables {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withSystem(value?: ISystemOperations): this {
|
public withCodeRunner(value?: CodeRunner): this {
|
||||||
this.system = value;
|
this.codeRunner = value;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user