Fix script deletion during execution on desktop

This commit fixes an issue seen on certain Windows environments (Windows
10 22H2 and 11 23H2 Pro Azure VMs) where scripts were being deleted
during execution due to temporary directory usage. To resolve this,
scripts are now stored in a persistent directory, enhancing reliability
for long-running scripts and improving auditability along with
troubleshooting.

Key changes:

- Move script execution logic to the `main` process from `preloader` to
  utilize Electron's `app.getPath`.
- Improve runtime environment detection for non-browser environments to
  allow its usage in Electron main process.
- Introduce a secure module to expose IPC channels from the main process
  to the renderer via the preloader process.

Supporting refactorings include:

- Simplify `CodeRunner` interface by removing the `tempScriptFolderName`
  parameter.
- Rename `NodeSystemOperations` to `NodeElectronSystemOperations` as it
  now wraps electron APIs too, and convert it to class for simplicity.
- Rename `TemporaryFileCodeRunner` to `ScriptFileCodeRunner` to reflect
  its new functinoality.
- Rename `SystemOperations` folder to `System` for simplicity.
- Rename `HostRuntimeEnvironment` to `BrowserRuntimeEnvironment` for
  clarity.
- Refactor main Electron process configuration to align with latest
  Electron documentation/recommendations.
- Refactor unit tests `BrowserRuntimeEnvironment` to simplify singleton
  workaround.
- Use alias imports like `electron/main` and `electron/common` for
  better clarity.
This commit is contained in:
undergroundwires
2024-01-06 18:47:58 +01:00
parent bf7fb0732c
commit c84a1bb74c
75 changed files with 1809 additions and 574 deletions

View File

@@ -0,0 +1,260 @@
import { describe, it, expect } from 'vitest';
import { BrowserCondition, TouchSupportExpectation } from '@/infrastructure/RuntimeEnvironment/Browser/BrowserOs/BrowserCondition';
import { ConditionBasedOsDetector } from '@/infrastructure/RuntimeEnvironment/Browser/BrowserOs/ConditionBasedOsDetector';
import { getAbsentStringTestCases, itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserEnvironmentStub } from '@tests/unit/shared/Stubs/BrowserEnvironmentStub';
import { BrowserConditionStub } from '@tests/unit/shared/Stubs/BrowserConditionStub';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
describe('ConditionBasedOsDetector', () => {
describe('constructor', () => {
describe('throws when given no conditions', () => {
itEachAbsentCollectionValue<BrowserCondition>((absentCollection) => {
// arrange
const expectedError = 'empty conditions';
const conditions = absentCollection;
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions(conditions)
.build();
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true, excludeNull: true });
});
it('throws if user agent part is missing', () => {
// arrange
const expectedError = 'Each condition must include at least one identifiable part of the user agent string.';
const invalidCondition = new BrowserConditionStub().withExistingPartsInSameUserAgent([]);
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).to.throw(expectedError);
});
describe('validates touch support expectation range', () => {
// arrange
const validValue = TouchSupportExpectation.MustExist;
// act
const act = (touchSupport: TouchSupportExpectation) => new ConditionBasedOsDetectorBuilder()
.withConditions([new BrowserConditionStub().withTouchSupport(touchSupport)])
.build();
// assert
new EnumRangeTestRunner(act)
.testOutOfRangeThrows()
.testValidValueDoesNotThrow(validValue);
});
it('throws if duplicate parts exist in user agent', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Windows. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Windows,
existingPartsInSameUserAgent: ['Windows', 'Windows'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
it('throws if duplicate non-existing parts exist in user agent', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Linux. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Linux,
existingPartsInSameUserAgent: ['Linux'],
notExistingPartsInUserAgent: ['Linux'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
it('throws if duplicates found in any user agent parts', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Android. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Android,
existingPartsInSameUserAgent: ['Android'],
notExistingPartsInUserAgent: ['iOS', 'Android'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
});
describe('detect', () => {
it('detects the correct OS when multiple conditions match', () => {
// arrange
const expectedOperatingSystem = OperatingSystem.Linux;
const testUserAgent = 'test-user-agent';
const expectedCondition = new BrowserConditionStub()
.withOperatingSystem(expectedOperatingSystem)
.withExistingPartsInSameUserAgent([testUserAgent]);
const conditions = [
expectedCondition,
new BrowserConditionStub()
.withExistingPartsInSameUserAgent(['unrelated user agent'])
.withOperatingSystem(OperatingSystem.Android),
new BrowserConditionStub()
.withNotExistingPartsInUserAgent([testUserAgent])
.withOperatingSystem(OperatingSystem.macOS),
];
const environment = new BrowserEnvironmentStub()
.withUserAgent(testUserAgent);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions(conditions)
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem).to.equal(expectedOperatingSystem);
});
describe('user agent checks', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly buildEnvironment: (environment: BrowserEnvironmentStub) => BrowserEnvironmentStub;
readonly buildCondition: (condition: BrowserConditionStub) => BrowserConditionStub;
readonly detects: boolean;
}> = [
...getAbsentStringTestCases({ excludeUndefined: true, excludeNull: true })
.map((testCase) => ({
description: `does not detect when user agent is empty (${testCase.valueName})`,
buildEnvironment: (environment) => environment.withUserAgent(testCase.absentValue),
buildCondition: (condition) => condition,
detects: false,
})),
{
description: 'detects when user agent matches completely',
buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test-user-agent']),
detects: true,
},
{
description: 'detects when substring of user agent exists',
buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test']),
detects: true,
},
{
description: 'does not detect when no part of user agent exists',
buildEnvironment: (environment) => environment.withUserAgent('unrelated-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['lorem-ipsum']),
detects: false,
},
{
description: 'detects when non-existing parts do not match',
buildEnvironment: (environment) => environment.withUserAgent('1-3'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']),
detects: true,
},
{
description: 'does not detect when non-existing and existing parts match',
buildEnvironment: (environment) => environment.withUserAgent('1-2'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']),
detects: false,
},
];
testScenarios.forEach(({
description, buildEnvironment, buildCondition, detects,
}) => {
it(description, () => {
// arrange
const environment = buildEnvironment(new BrowserEnvironmentStub());
const condition = buildCondition(
new BrowserConditionStub().withOperatingSystem(OperatingSystem.Linux),
);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions([condition])
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem !== undefined).to.equal(detects);
});
});
});
describe('touch support checks', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectation: TouchSupportExpectation;
readonly isTouchSupportInEnvironment: boolean;
readonly detects: boolean;
}> = [
{
description: 'detects when touch support exists and is expected',
expectation: TouchSupportExpectation.MustExist,
isTouchSupportInEnvironment: true,
detects: true,
},
{
description: 'does not detect when touch support does not exists but is expected',
expectation: TouchSupportExpectation.MustExist,
isTouchSupportInEnvironment: false,
detects: false,
},
{
description: 'detects when touch support does not exist and is not expected',
expectation: TouchSupportExpectation.MustNotExist,
isTouchSupportInEnvironment: false,
detects: true,
},
{
description: 'does not detect when touch support exists but is not expected',
expectation: TouchSupportExpectation.MustNotExist,
isTouchSupportInEnvironment: true,
detects: false,
},
];
testScenarios.forEach(({
description, expectation, isTouchSupportInEnvironment, detects,
}) => {
it(description, () => {
// arrange
const userAgent = 'iPhone';
const environment = new BrowserEnvironmentStub()
.withUserAgent(userAgent)
.withIsTouchSupported(isTouchSupportInEnvironment);
const conditionWithTouchSupport = new BrowserConditionStub()
.withExistingPartsInSameUserAgent([userAgent])
.withTouchSupport(expectation);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions([conditionWithTouchSupport])
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem !== undefined)
.to.equal(detects);
});
});
});
});
});
class ConditionBasedOsDetectorBuilder {
private conditions: readonly BrowserCondition[] = [{
operatingSystem: OperatingSystem.iOS,
existingPartsInSameUserAgent: ['iPhone'],
}];
public withConditions(conditions: readonly BrowserCondition[]): this {
this.conditions = conditions;
return this;
}
public build(): ConditionBasedOsDetector {
return new ConditionBasedOsDetector(
this.conditions,
);
}
}

View File

@@ -0,0 +1,213 @@
// eslint-disable-next-line max-classes-per-file
import { describe, it, expect } from 'vitest';
import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/Browser/BrowserOs/BrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/Browser/BrowserRuntimeEnvironment';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('BrowserRuntimeEnvironment', () => {
describe('ctor', () => {
describe('throws if window is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing window';
const absentWindow = absentValue;
// act
const act = () => new BrowserRuntimeEnvironmentBuilder()
.withWindow(absentWindow as never)
.build();
// assert
expect(act).to.throw(expectedError);
});
});
it('uses browser OS detector with current touch support', () => {
// arrange
const expectedTouchSupport = true;
const osDetector = new BrowserOsDetectorStub();
const window = { os: undefined, navigator: { userAgent: 'Forcing touch detection' } } as Partial<Window>;
// act
new BrowserRuntimeEnvironmentBuilder()
.withWindow(window)
.withBrowserOsDetector(osDetector)
.withTouchSupported(expectedTouchSupport)
.build();
// assert
const actualCall = osDetector.callHistory.find((c) => c.methodName === 'detect');
expectExists(actualCall);
const [{ isTouchSupported: actualTouchSupport }] = actualCall.args;
expect(actualTouchSupport).to.equal(expectedTouchSupport);
});
});
describe('isDesktop', () => {
it('returns true when window property isDesktop is true', () => {
// arrange
const desktopWindow = {
isDesktop: true,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(desktopWindow)
.build();
// assert
expect(sut.isDesktop).to.equal(true);
});
it('returns false when window property isDesktop is false', () => {
// arrange
const expectedValue = false;
const browserWindow = {
isDesktop: false,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(browserWindow)
.build();
// assert
expect(sut.isDesktop).to.equal(expectedValue);
});
});
describe('os', () => {
it('returns undefined if user agent is missing', () => {
// arrange
const expected = undefined;
const browserDetectorMock: BrowserOsDetector = {
detect: () => {
throw new Error('should not reach here');
},
};
const sut = new BrowserRuntimeEnvironmentBuilder()
.withBrowserOsDetector(browserDetectorMock)
.build();
// act
const actual = sut.os;
// assert
expect(actual).to.equal(expected);
});
it('gets browser os from BrowserOsDetector', () => {
// arrange
const givenUserAgent = 'testUserAgent';
const expected = OperatingSystem.macOS;
const windowWithUserAgent = {
navigator: {
userAgent: givenUserAgent,
},
};
const browserDetectorMock: BrowserOsDetector = {
detect: (environment) => {
if (environment.userAgent !== givenUserAgent) {
throw new Error('Unexpected user agent');
}
return expected;
},
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(windowWithUserAgent as Partial<Window>)
.withBrowserOsDetector(browserDetectorMock)
.build();
const actual = sut.os;
// assert
expect(actual).to.equal(expected);
});
describe('desktop os', () => {
describe('returns from window property `os`', () => {
const testValues = [
OperatingSystem.macOS,
OperatingSystem.Windows,
OperatingSystem.Linux,
];
testValues.forEach((testValue) => {
it(`given ${OperatingSystem[testValue]}`, () => {
// arrange
const expectedOs = testValue;
const desktopWindowWithOs = {
isDesktop: true,
os: expectedOs,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(desktopWindowWithOs)
.build();
// assert
const actualOs = sut.os;
expect(actualOs).to.equal(expectedOs);
});
});
});
describe('returns undefined when window property `os` is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedValue = undefined;
const windowWithAbsentOs = {
os: absentValue as never,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(windowWithAbsentOs)
.build();
// assert
expect(sut.os).to.equal(expectedValue);
});
});
});
});
describe('isNonProduction', () => {
[true, false].forEach((value) => {
it(`sets ${value} from environment variables`, () => {
// arrange
const expectedValue = value;
const environment = new EnvironmentVariablesStub()
.withIsNonProduction(expectedValue);
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withEnvironmentVariables(environment)
.build();
// assert
const actualValue = sut.isNonProduction;
expect(actualValue).to.equal(expectedValue);
});
});
});
});
class BrowserRuntimeEnvironmentBuilder {
private window: Partial<Window> = {};
private browserOsDetector: BrowserOsDetector = new BrowserOsDetectorStub();
private environmentVariables: IEnvironmentVariables = new EnvironmentVariablesStub();
private isTouchSupported = false;
public withEnvironmentVariables(environmentVariables: IEnvironmentVariables): this {
this.environmentVariables = environmentVariables;
return this;
}
public withWindow(window: Partial<Window>): this {
this.window = window;
return this;
}
public withBrowserOsDetector(browserOsDetector: BrowserOsDetector): this {
this.browserOsDetector = browserOsDetector;
return this;
}
public withTouchSupported(isTouchSupported: boolean): this {
this.isTouchSupported = isTouchSupported;
return this;
}
public build() {
return new BrowserRuntimeEnvironment(
this.window,
this.environmentVariables,
this.browserOsDetector,
() => this.isTouchSupported,
);
}
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { BrowserTouchSupportAccessor, isTouchEnabledDevice } from '@/infrastructure/RuntimeEnvironment/Browser/TouchSupportDetection';
describe('TouchSupportDetection', () => {
describe('isTouchEnabledDevice', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly accessor: BrowserTouchSupportAccessor;
readonly expectedTouch: boolean;
}> = [
{
description: 'detects no touch capabilities',
accessor: createMockAccessor(),
expectedTouch: false,
},
{
description: 'detects touch capability with defined document.ontouchend',
accessor: createMockAccessor({ documentOntouchend: () => 'not-undefined' }),
expectedTouch: true,
},
{
description: 'detects touch capability with navigator.maxTouchPoints > 0',
accessor: createMockAccessor({ navigatorMaxTouchPoints: () => 1 }),
expectedTouch: true,
},
{
description: 'detects touch capability when matchMedia for pointer coarse is true',
accessor: createMockAccessor({
windowMatchMediaMatches: (query: string) => {
return query === '(any-pointer: coarse)';
},
}),
expectedTouch: true,
},
];
testScenarios.forEach(({ description, accessor, expectedTouch }) => {
it(`${description} - returns ${expectedTouch}`, () => {
// act
const isTouchDetected = isTouchEnabledDevice(accessor);
// assert
expect(isTouchDetected).to.equal(expectedTouch);
});
});
});
});
function createMockAccessor(
touchSupportFeatures: Partial<BrowserTouchSupportAccessor> = {},
): BrowserTouchSupportAccessor {
const defaultTouchSupport: BrowserTouchSupportAccessor = {
navigatorMaxTouchPoints: () => undefined,
windowMatchMediaMatches: () => false,
documentOntouchend: () => undefined,
};
return {
...defaultTouchSupport,
...touchSupportFeatures,
};
}