Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ba00b0af | ||
|
|
29e1069bf2 | ||
|
|
c7e57b8913 | ||
|
|
4cea6b26ec | ||
|
|
c2f4b68786 | ||
|
|
e8add5ec08 | ||
|
|
55c23e9d4c | ||
|
|
d77c3cbbe2 | ||
|
|
f89c2322b0 | ||
|
|
ded55a66d6 | ||
|
|
6fbc81675f | ||
|
|
48d97afdf6 | ||
|
|
109fc01c9a | ||
|
|
b185255a0a | ||
|
|
c2d3cddc47 | ||
|
|
8526d2510b | ||
|
|
11e566d0e5 | ||
|
|
ae0165f1fe | ||
|
|
a6505587bf | ||
|
|
b16e13678c | ||
|
|
abe03cef3f | ||
|
|
dd7239b8c1 | ||
|
|
851917e049 | ||
|
|
8d7a7eb434 | ||
|
|
0239b52385 |
@@ -30,7 +30,7 @@ Related documentation:
|
|||||||
|
|
||||||
### Executables
|
### Executables
|
||||||
|
|
||||||
They represent independently executable tweaks with documentation and reversibility.
|
They represent independently executable actions with documentation and reversibility.
|
||||||
|
|
||||||
An Executable is a logical entity that can
|
An Executable is a logical entity that can
|
||||||
|
|
||||||
|
|||||||
5417
package-lock.json
generated
5417
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@@ -34,54 +34,54 @@
|
|||||||
"postuninstall": "electron-builder install-app-deps"
|
"postuninstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/vue": "^1.0.6",
|
"@floating-ui/vue": "^1.1.1",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"ace-builds": "^1.33.0",
|
"ace-builds": "^1.35.3",
|
||||||
"electron-log": "^5.1.2",
|
"electron-log": "^5.1.6",
|
||||||
"electron-progressbar": "^2.2.1",
|
"electron-progressbar": "^2.2.1",
|
||||||
"electron-updater": "^6.1.9",
|
"electron-updater": "^6.2.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"vue": "^3.4.27"
|
"vue": "^3.4.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@rushstack/eslint-patch": "^1.10.2",
|
"@rushstack/eslint-patch": "^1.10.3",
|
||||||
"@types/ace": "^0.0.52",
|
"@types/ace": "^0.0.52",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/markdown-it": "^14.0.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||||
"@typescript-eslint/parser": "6.21.0",
|
"@typescript-eslint/parser": "6.21.0",
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
"@vitejs/plugin-legacy": "^5.4.1",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
|
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
|
||||||
"@vue/eslint-config-typescript": "12.0.0",
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
"@vue/test-utils": "^2.4.5",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"cypress": "^13.7.3",
|
"cypress": "^13.13.1",
|
||||||
"electron": "^31.0.2",
|
"electron": "^31.2.1",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-vite": "^2.1.0",
|
"electron-vite": "^2.3.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^3.3.0",
|
||||||
"eslint-plugin-vue": "^9.25.0",
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"eslint-plugin-vuejs-accessibility": "^2.2.1",
|
"eslint-plugin-vuejs-accessibility": "^2.4.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.1.0",
|
||||||
"markdownlint-cli": "^0.39.0",
|
"markdownlint-cli": "^0.41.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.39",
|
||||||
"remark-cli": "^12.0.0",
|
"remark-cli": "^12.0.1",
|
||||||
"remark-lint-no-dead-urls": "^1.1.0",
|
"remark-lint-no-dead-urls": "^1.1.0",
|
||||||
"remark-preset-lint-consistent": "^6.0.0",
|
"remark-preset-lint-consistent": "^6.0.0",
|
||||||
"remark-validate-links": "^13.0.1",
|
"remark-validate-links": "^13.0.1",
|
||||||
"sass": "^1.75.0",
|
"sass": "^1.77.8",
|
||||||
"start-server-and-test": "^2.0.3",
|
"start-server-and-test": "^2.0.4",
|
||||||
"terser": "^5.30.3",
|
"terser": "^5.31.3",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.3.4",
|
||||||
"vitest": "^1.5.0",
|
"vitest": "^2.0.3",
|
||||||
"vue-tsc": "^2.0.13",
|
"vue-tsc": "^2.0.26",
|
||||||
"yaml-lint": "^1.7.0"
|
"yaml-lint": "^1.7.0"
|
||||||
},
|
},
|
||||||
"//devDependencies": {
|
"//devDependencies": {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
die(
|
die(
|
||||||
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
|
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
|
||||||
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
|
`\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/application/Common/Text/FilterEmptyStrings.ts
Normal file
25
src/application/Common/Text/FilterEmptyStrings.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { isArray } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
export type OptionalString = string | undefined | null;
|
||||||
|
|
||||||
|
export function filterEmptyStrings(
|
||||||
|
texts: readonly OptionalString[],
|
||||||
|
isArrayType: typeof isArray = isArray,
|
||||||
|
): string[] {
|
||||||
|
if (!isArrayType(texts)) {
|
||||||
|
throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`);
|
||||||
|
}
|
||||||
|
assertArrayItemsAreStringLike(texts);
|
||||||
|
return texts
|
||||||
|
.filter((title): title is string => Boolean(title));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArrayItemsAreStringLike(
|
||||||
|
texts: readonly unknown[],
|
||||||
|
): asserts texts is readonly OptionalString[] {
|
||||||
|
const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null));
|
||||||
|
if (invalidItems.length > 0) {
|
||||||
|
const invalidTypes = invalidItems.map((item) => typeof item).join(', ');
|
||||||
|
throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/application/Common/Text/IndentText.ts
Normal file
29
src/application/Common/Text/IndentText.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
|
import { splitTextIntoLines } from './SplitTextIntoLines';
|
||||||
|
|
||||||
|
export function indentText(
|
||||||
|
text: string,
|
||||||
|
indentLevel = 1,
|
||||||
|
utilities: TextIndentationUtilities = DefaultUtilities,
|
||||||
|
): string {
|
||||||
|
if (!utilities.isStringType(text)) {
|
||||||
|
throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`);
|
||||||
|
}
|
||||||
|
if (indentLevel <= 0) {
|
||||||
|
throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`);
|
||||||
|
}
|
||||||
|
const indentation = '\t'.repeat(indentLevel);
|
||||||
|
return utilities.splitIntoLines(text)
|
||||||
|
.map((line) => (line ? `${indentation}${line}` : line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextIndentationUtilities {
|
||||||
|
readonly splitIntoLines: typeof splitTextIntoLines;
|
||||||
|
readonly isStringType: typeof isString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultUtilities: TextIndentationUtilities = {
|
||||||
|
splitIntoLines: splitTextIntoLines,
|
||||||
|
isStringType: isString,
|
||||||
|
};
|
||||||
11
src/application/Common/Text/SplitTextIntoLines.ts
Normal file
11
src/application/Common/Text/SplitTextIntoLines.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
export function splitTextIntoLines(
|
||||||
|
text: string,
|
||||||
|
isStringType = isString,
|
||||||
|
): string[] {
|
||||||
|
if (!isStringType(text)) {
|
||||||
|
throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`);
|
||||||
|
}
|
||||||
|
return text.split(/\r\n|\r|\n/);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Script } from '@/domain/Executables/Script/Script';
|
import type { Script } from '@/domain/Executables/Script/Script';
|
||||||
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
|
||||||
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
|
import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
||||||
import type { ICodeChangedEvent } from './ICodeChangedEvent';
|
import type { ICodeChangedEvent } from './ICodeChangedEvent';
|
||||||
|
|
||||||
export class CodeChangedEvent implements ICodeChangedEvent {
|
export class CodeChangedEvent implements ICodeChangedEvent {
|
||||||
@@ -39,7 +41,7 @@ export class CodeChangedEvent implements ICodeChangedEvent {
|
|||||||
return this.getPositionById(script.executableId);
|
return this.getPositionById(script.executableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPositionById(scriptId: string): ICodePosition {
|
private getPositionById(scriptId: ExecutableId): ICodePosition {
|
||||||
const position = [...this.scripts.entries()]
|
const position = [...this.scripts.entries()]
|
||||||
.filter(([s]) => s.executableId === scriptId)
|
.filter(([s]) => s.executableId === scriptId)
|
||||||
.map(([, pos]) => pos)
|
.map(([, pos]) => pos)
|
||||||
@@ -52,12 +54,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
|
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
|
||||||
const totalLines = script.split(/\r\n|\r|\n/).length;
|
const totalLines = splitTextIntoLines(script).length;
|
||||||
const missingPositions = positions.filter((position) => position.endLine > totalLines);
|
const missingPositions = positions.filter((position) => position.endLine > totalLines);
|
||||||
if (missingPositions.length > 0) {
|
if (missingPositions.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
|
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
|
||||||
+ `(total code lines: ${totalLines}).`,
|
+ ` (total code lines: ${totalLines}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
import type { ICodeBuilder } from './ICodeBuilder';
|
import type { ICodeBuilder } from './ICodeBuilder';
|
||||||
|
|
||||||
const TotalFunctionSeparatorChars = 58;
|
const TotalFunctionSeparatorChars = 58;
|
||||||
@@ -15,7 +16,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
|
|||||||
this.lines.push('');
|
this.lines.push('');
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
const lines = code.match(/[^\r\n]+/g);
|
const lines = splitTextIntoLines(code);
|
||||||
if (lines) {
|
if (lines) {
|
||||||
this.lines.push(...lines);
|
this.lines.push(...lines);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { EventSource } from '@/infrastructure/Events/EventSource';
|
|||||||
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
|
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
|
||||||
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
|
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
|
||||||
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
|
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
|
||||||
|
import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
||||||
import { UserSelectedScript } from './UserSelectedScript';
|
import { UserSelectedScript } from './UserSelectedScript';
|
||||||
import type { ScriptSelection } from './ScriptSelection';
|
import type { ScriptSelection } from './ScriptSelection';
|
||||||
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
||||||
@@ -38,8 +39,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isSelected(scriptId: string): boolean {
|
public isSelected(scriptExecutableId: ExecutableId): boolean {
|
||||||
return this.scripts.exists(scriptId);
|
return this.scripts.exists(scriptExecutableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get selectedScripts(): readonly SelectedScript[] {
|
public get selectedScripts(): readonly SelectedScript[] {
|
||||||
@@ -121,7 +122,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
|
|||||||
return this.removeScript(script.executableId);
|
return this.removeScript(script.executableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addOrUpdateScript(scriptId: string, revert: boolean): number {
|
private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number {
|
||||||
const script = this.collection.getScript(scriptId);
|
const script = this.collection.getScript(scriptId);
|
||||||
const selectedScript = new UserSelectedScript(script, revert);
|
const selectedScript = new UserSelectedScript(script, revert);
|
||||||
if (!this.scripts.exists(selectedScript.id)) {
|
if (!this.scripts.exists(selectedScript.id)) {
|
||||||
@@ -136,7 +137,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeScript(scriptId: string): number {
|
private removeScript(scriptId: ExecutableId): number {
|
||||||
if (!this.scripts.exists(scriptId)) {
|
if (!this.scripts.exists(scriptId)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
|
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||||
import type { Script } from '@/domain/Executables/Script/Script';
|
import type { Script } from '@/domain/Executables/Script/Script';
|
||||||
|
import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
||||||
import type { SelectedScript } from './SelectedScript';
|
import type { SelectedScript } from './SelectedScript';
|
||||||
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
|
||||||
|
|
||||||
export interface ReadonlyScriptSelection {
|
export interface ReadonlyScriptSelection {
|
||||||
readonly changed: IEventSource<readonly SelectedScript[]>;
|
readonly changed: IEventSource<readonly SelectedScript[]>;
|
||||||
readonly selectedScripts: readonly SelectedScript[];
|
readonly selectedScripts: readonly SelectedScript[];
|
||||||
isSelected(scriptId: string): boolean;
|
isSelected(scriptExecutableId: ExecutableId): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptSelection extends ReadonlyScriptSelection {
|
export interface ScriptSelection extends ReadonlyScriptSelection {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
||||||
|
|
||||||
export type ScriptSelectionStatus = {
|
export type ScriptSelectionStatus = {
|
||||||
readonly isSelected: true;
|
readonly isSelected: true;
|
||||||
readonly isReverted: boolean;
|
readonly isReverted: boolean;
|
||||||
@@ -7,7 +9,7 @@ export type ScriptSelectionStatus = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ScriptSelectionChange {
|
export interface ScriptSelectionChange {
|
||||||
readonly scriptId: string;
|
readonly scriptId: ExecutableId;
|
||||||
readonly newStatus: ScriptSelectionStatus;
|
readonly newStatus: ScriptSelectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function validateCollectionsData(
|
|||||||
) {
|
) {
|
||||||
validator.assertNonEmptyCollection({
|
validator.assertNonEmptyCollection({
|
||||||
value: collections,
|
value: collections,
|
||||||
valueName: 'collections',
|
valueName: 'Collections',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ function validateCollection(
|
|||||||
): void {
|
): void {
|
||||||
validator.assertObject({
|
validator.assertObject({
|
||||||
value: content,
|
value: content,
|
||||||
valueName: 'collection',
|
valueName: 'Collection',
|
||||||
allowedProperties: [
|
allowedProperties: [
|
||||||
'os', 'scripting', 'actions', 'functions',
|
'os', 'scripting', 'actions', 'functions',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
validator.assertNonEmptyCollection({
|
validator.assertNonEmptyCollection({
|
||||||
value: content.actions,
|
value: content.actions,
|
||||||
valueName: '"actions" in collection',
|
valueName: '\'actions\' in collection',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,116 @@
|
|||||||
import { CustomError } from '@/application/Common/CustomError';
|
import { CustomError } from '@/application/Common/CustomError';
|
||||||
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
|
||||||
export interface ErrorWithContextWrapper {
|
export interface ErrorWithContextWrapper {
|
||||||
(
|
(
|
||||||
error: Error,
|
innerError: Error,
|
||||||
additionalContext: string,
|
additionalContext: string,
|
||||||
): Error;
|
): Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
|
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
|
||||||
error: Error,
|
innerError,
|
||||||
additionalContext: string,
|
additionalContext,
|
||||||
) => {
|
) => {
|
||||||
return (error instanceof ContextualError ? error : new ContextualError(error))
|
if (!additionalContext) {
|
||||||
.withAdditionalContext(additionalContext);
|
throw new Error('Missing additional context');
|
||||||
|
}
|
||||||
|
return new ContextualError({
|
||||||
|
innerError,
|
||||||
|
additionalContext,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
|
/**
|
||||||
|
* Class for building a detailed error trace.
|
||||||
|
*
|
||||||
|
* Alternatives considered:
|
||||||
|
* - `AggregateError`:
|
||||||
|
* Similar but not well-serialized or displayed by browsers such as Chromium (last tested v126).
|
||||||
|
* - `cause` property:
|
||||||
|
* Not displayed by all browsers (last tested v126).
|
||||||
|
* Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
|
||||||
|
*
|
||||||
|
* This is immutable where the constructor sets the values because using getter functions such as
|
||||||
|
* `get cause()`, `get message()` does not work on Chromium (last tested v126), but works fine on
|
||||||
|
* Firefox (last tested v127).
|
||||||
|
*/
|
||||||
class ContextualError extends CustomError {
|
class ContextualError extends CustomError {
|
||||||
private readonly additionalContext = new Array<string>();
|
constructor(public readonly context: ErrorContext) {
|
||||||
|
super(
|
||||||
constructor(
|
generateDetailedErrorMessageWithContext(context),
|
||||||
public readonly innerError: Error,
|
{
|
||||||
) {
|
cause: context.innerError,
|
||||||
super();
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
public withAdditionalContext(additionalContext: string): this {
|
|
||||||
this.additionalContext.push(additionalContext);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get message(): string { // toString() is not used when Chromium logs it on console
|
|
||||||
return [
|
|
||||||
'\n',
|
|
||||||
this.innerError.message,
|
|
||||||
'\n',
|
|
||||||
'Additional context:',
|
|
||||||
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
|
|
||||||
].join('\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ErrorContext {
|
||||||
|
readonly innerError: Error;
|
||||||
|
readonly additionalContext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDetailedErrorMessageWithContext(
|
||||||
|
context: ErrorContext,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
'\n',
|
||||||
|
// Display the current error message first, then the root cause.
|
||||||
|
// This prevents repetitive main messages for errors with a `cause:` chain,
|
||||||
|
// aligning with browser error display conventions.
|
||||||
|
context.additionalContext,
|
||||||
|
'\n',
|
||||||
|
'Error Trace (starting from root cause):',
|
||||||
|
indentText(
|
||||||
|
formatErrorTrace(
|
||||||
|
// Displaying contexts from the top frame (deepest, most recent) aligns with
|
||||||
|
// common debugger/compiler standard.
|
||||||
|
extractErrorTraceAscendingFromDeepest(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'\n',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorTraceAscendingFromDeepest(
|
||||||
|
context: ErrorContext,
|
||||||
|
): string[] {
|
||||||
|
const originalError = findRootError(context.innerError);
|
||||||
|
const contextsDescendingFromMostRecent: string[] = [
|
||||||
|
context.additionalContext,
|
||||||
|
...gatherContextsFromErrorChain(context.innerError),
|
||||||
|
originalError.toString(),
|
||||||
|
];
|
||||||
|
const contextsAscendingFromDeepest = contextsDescendingFromMostRecent.reverse();
|
||||||
|
return contextsAscendingFromDeepest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRootError(error: Error): Error {
|
||||||
|
if (error instanceof ContextualError) {
|
||||||
|
return findRootError(error.context.innerError);
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatherContextsFromErrorChain(
|
||||||
|
error: Error,
|
||||||
|
accumulatedContexts: string[] = [],
|
||||||
|
): string[] {
|
||||||
|
if (error instanceof ContextualError) {
|
||||||
|
accumulatedContexts.push(error.context.additionalContext);
|
||||||
|
return gatherContextsFromErrorChain(error.context.innerError, accumulatedContexts);
|
||||||
|
}
|
||||||
|
return accumulatedContexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorTrace(
|
||||||
|
errorMessages: readonly string[],
|
||||||
|
): string {
|
||||||
|
if (errorMessages.length === 1) {
|
||||||
|
return errorMessages[0];
|
||||||
|
}
|
||||||
|
return errorMessages
|
||||||
|
.map((context, index) => `${index + 1}.${indentText(context)}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function assertArray(
|
|||||||
valueName: string,
|
valueName: string,
|
||||||
): asserts value is Array<unknown> {
|
): asserts value is Array<unknown> {
|
||||||
if (!isArray(value)) {
|
if (!isArray(value)) {
|
||||||
throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`);
|
throw new Error(`${valueName} should be of type 'array', but is of type '${typeof value}'.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ function assertString(
|
|||||||
valueName: string,
|
valueName: string,
|
||||||
): asserts value is string {
|
): asserts value is string {
|
||||||
if (!isString(value)) {
|
if (!isString(value)) {
|
||||||
throw new Error(`'${valueName}' should be of type 'string', but is of type '${typeof value}'.`);
|
throw new Error(`${valueName} should be of type 'string', but is of type '${typeof value}'.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function parseCategoryRecursively(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return context.categoryUtilities.createCategory({
|
return context.categoryUtilities.createCategory({
|
||||||
executableId: context.categoryData.category, // arbitrary ID
|
executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support
|
||||||
name: context.categoryData.category,
|
name: context.categoryData.category,
|
||||||
docs: context.categoryUtilities.parseDocs(context.categoryData),
|
docs: context.categoryUtilities.parseDocs(context.categoryData),
|
||||||
subcategories: children.subcategories,
|
subcategories: children.subcategories,
|
||||||
@@ -82,7 +82,7 @@ function ensureValidCategory(
|
|||||||
});
|
});
|
||||||
validator.assertType((v) => v.assertObject({
|
validator.assertType((v) => v.assertObject({
|
||||||
value: category,
|
value: category,
|
||||||
valueName: category.category ?? 'category',
|
valueName: `Category '${category.category}'` ?? 'Category',
|
||||||
allowedProperties: [
|
allowedProperties: [
|
||||||
'docs', 'children', 'category',
|
'docs', 'children', 'category',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export interface IPipe {
|
export interface Pipe {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
apply(input: string): string;
|
apply(input: string): string;
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { IPipe } from '../IPipe';
|
import type { Pipe } from '../Pipe';
|
||||||
|
|
||||||
export class EscapeDoubleQuotes implements IPipe {
|
export class EscapeDoubleQuotes implements Pipe {
|
||||||
public readonly name: string = 'escapeDoubleQuotes';
|
public readonly name: string = 'escapeDoubleQuotes';
|
||||||
|
|
||||||
public apply(raw: string): string {
|
public apply(raw: string): string {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return raw;
|
return '';
|
||||||
}
|
}
|
||||||
return raw.replaceAll('"', '"^""');
|
return raw.replaceAll('"', '"^""');
|
||||||
/* eslint-disable vue/max-len */
|
/* eslint-disable vue/max-len */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IPipe } from '../IPipe';
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
|
import type { Pipe } from '../Pipe';
|
||||||
|
|
||||||
export class InlinePowerShell implements IPipe {
|
export class InlinePowerShell implements Pipe {
|
||||||
public readonly name: string = 'inlinePowerShell';
|
public readonly name: string = 'inlinePowerShell';
|
||||||
|
|
||||||
public apply(code: string): string {
|
public apply(code: string): string {
|
||||||
@@ -8,9 +9,11 @@ export class InlinePowerShell implements IPipe {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
|
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
|
||||||
|
// Order is important
|
||||||
inlineComments,
|
inlineComments,
|
||||||
mergeLinesWithBacktick,
|
|
||||||
mergeHereStrings,
|
mergeHereStrings,
|
||||||
|
mergeLinesWithBacktick,
|
||||||
|
mergeLinesWithBracketCodeBlocks,
|
||||||
mergeNewLines,
|
mergeNewLines,
|
||||||
]).reduce((a, b) => (data) => b(a(data)));
|
]).reduce((a, b) => (data) => b(a(data)));
|
||||||
const newCode = processor(code);
|
const newCode = processor(code);
|
||||||
@@ -89,10 +92,6 @@ function inlineComments(code: string): string {
|
|||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLines(code: string): string[] {
|
|
||||||
return (code?.split(/\r\n|\r|\n/) || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
|
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
|
||||||
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
|
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
|
||||||
@@ -102,18 +101,18 @@ function mergeHereStrings(code: string) {
|
|||||||
return code.replaceAll(regex, (_$, quotes, scope) => {
|
return code.replaceAll(regex, (_$, quotes, scope) => {
|
||||||
const newString = getHereStringHandler(quotes);
|
const newString = getHereStringHandler(quotes);
|
||||||
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
|
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
|
||||||
const lines = getLines(escaped);
|
const lines = splitTextIntoLines(escaped);
|
||||||
const inlined = lines.join(newString.separator);
|
const inlined = lines.join(newString.separator);
|
||||||
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
|
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
|
||||||
return quoted;
|
return quoted;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
interface IInlinedHereString {
|
interface InlinedHereString {
|
||||||
readonly quotesAround: string;
|
readonly quotesAround: string;
|
||||||
readonly escapedQuotes: string;
|
readonly escapedQuotes: string;
|
||||||
readonly separator: string;
|
readonly separator: string;
|
||||||
}
|
}
|
||||||
function getHereStringHandler(quotes: string): IInlinedHereString {
|
function getHereStringHandler(quotes: string): InlinedHereString {
|
||||||
/*
|
/*
|
||||||
We handle @' and @" differently.
|
We handle @' and @" differently.
|
||||||
Single quotes are interpreted literally and doubles are expandable.
|
Single quotes are interpreted literally and doubles are expandable.
|
||||||
@@ -158,9 +157,33 @@ function mergeLinesWithBacktick(code: string) {
|
|||||||
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
|
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeNewLines(code: string) {
|
/**
|
||||||
return getLines(code)
|
* Inlines code blocks in PowerShell scripts while preserving correct syntax.
|
||||||
.map((line) => line.trim())
|
* It removes unnecessary newlines and spaces around brackets,
|
||||||
.filter((line) => line.length > 0)
|
* inlining the code where possible.
|
||||||
.join('; ');
|
* This prevents syntax errors like "Unexpected token '}'" when inlining brackets.
|
||||||
|
*/
|
||||||
|
function mergeLinesWithBracketCodeBlocks(code: string): string {
|
||||||
|
return code
|
||||||
|
// Opening bracket: [whitespace] Opening bracket (newline)
|
||||||
|
.replace(/(?<=.*)\s*{[\r\n][\s\r\n]*/g, ' { ')
|
||||||
|
// Closing bracket: [whitespace] Closing bracket (newline) (continuation keyword)
|
||||||
|
.replace(/\s*}[\r\n][\s\r\n]*(?=elseif|else|catch|finally|until)/g, ' } ')
|
||||||
|
.replace(/(?<=do\s*{.*)[\r\n\s]*}[\r\n][\r\n\s]*(?=while)/g, ' } '); // Do-While
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeNewLines(code: string) {
|
||||||
|
const nonEmptyLines = splitTextIntoLines(code)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
return nonEmptyLines
|
||||||
|
.map((line, index) => {
|
||||||
|
const isLastLine = index === nonEmptyLines.length - 1;
|
||||||
|
if (isLastLine) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return line.endsWith(';') ? line : `${line};`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
|
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
|
||||||
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
|
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
|
||||||
import type { IPipe } from './IPipe';
|
import type { Pipe } from './Pipe';
|
||||||
|
|
||||||
const RegisteredPipes = [
|
const RegisteredPipes = [
|
||||||
new EscapeDoubleQuotes(),
|
new EscapeDoubleQuotes(),
|
||||||
@@ -8,19 +8,19 @@ const RegisteredPipes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export interface IPipeFactory {
|
export interface IPipeFactory {
|
||||||
get(pipeName: string): IPipe;
|
get(pipeName: string): Pipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PipeFactory implements IPipeFactory {
|
export class PipeFactory implements IPipeFactory {
|
||||||
private readonly pipes = new Map<string, IPipe>();
|
private readonly pipes = new Map<string, Pipe>();
|
||||||
|
|
||||||
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
|
constructor(pipes: readonly Pipe[] = RegisteredPipes) {
|
||||||
for (const pipe of pipes) {
|
for (const pipe of pipes) {
|
||||||
this.registerPipe(pipe);
|
this.registerPipe(pipe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(pipeName: string): IPipe {
|
public get(pipeName: string): Pipe {
|
||||||
validatePipeName(pipeName);
|
validatePipeName(pipeName);
|
||||||
const pipe = this.pipes.get(pipeName);
|
const pipe = this.pipes.get(pipeName);
|
||||||
if (!pipe) {
|
if (!pipe) {
|
||||||
@@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory {
|
|||||||
return pipe;
|
return pipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerPipe(pipe: IPipe): void {
|
private registerPipe(pipe: Pipe): void {
|
||||||
validatePipeName(pipe.name);
|
validatePipeName(pipe.name);
|
||||||
if (this.pipes.has(pipe.name)) {
|
if (this.pipes.has(pipe.name)) {
|
||||||
throw new Error(`Pipe name must be unique: "${pipe.name}"`);
|
throw new Error(`Pipe name must be unique: "${pipe.name}"`);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const createFunctionCallArgument: FunctionCallArgumentFactory = (
|
|||||||
utilities.validateParameterName(parameterName);
|
utilities.validateParameterName(parameterName);
|
||||||
utilities.typeValidator.assertNonEmptyString({
|
utilities.typeValidator.assertNonEmptyString({
|
||||||
value: argumentValue,
|
value: argumentValue,
|
||||||
valueName: `Missing argument value for the parameter "${parameterName}".`,
|
valueName: `Function parameter '${parameterName}'`,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
parameterName,
|
parameterName,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
import type { CompiledCode } from '../CompiledCode';
|
import type { CompiledCode } from '../CompiledCode';
|
||||||
import type { CodeSegmentMerger } from './CodeSegmentMerger';
|
import type { CodeSegmentMerger } from './CodeSegmentMerger';
|
||||||
|
|
||||||
@@ -8,11 +9,9 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
code: joinCodeParts(codeSegments.map((f) => f.code)),
|
code: joinCodeParts(codeSegments.map((f) => f.code)),
|
||||||
revertCode: joinCodeParts(
|
revertCode: joinCodeParts(filterEmptyStrings(
|
||||||
codeSegments
|
codeSegments.map((f) => f.revertCode),
|
||||||
.map((f) => f.revertCode)
|
)),
|
||||||
.filter((code): code is string => Boolean(code)),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
|
|||||||
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||||
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
|
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
|
||||||
|
|
||||||
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
|
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||||
@@ -22,10 +23,12 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
|
|||||||
if (calledFunction.body.type !== FunctionBodyType.Code) {
|
if (calledFunction.body.type !== FunctionBodyType.Code) {
|
||||||
throw new Error([
|
throw new Error([
|
||||||
'Unexpected function body type.',
|
'Unexpected function body type.',
|
||||||
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
|
indentText([
|
||||||
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
|
`Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
|
||||||
|
`Actual: "${FunctionBodyType[calledFunction.body.type]}"`,
|
||||||
|
].join('\n')),
|
||||||
'Function:',
|
'Function:',
|
||||||
`\t${JSON.stringify(callToFunction)}`,
|
indentText(JSON.stringify(callToFunction)),
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
const { code } = calledFunction.body;
|
const { code } = calledFunction.body;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): Fu
|
|||||||
if (isArray(calls)) {
|
if (isArray(calls)) {
|
||||||
validator.assertNonEmptyCollection({
|
validator.assertNonEmptyCollection({
|
||||||
value: calls,
|
value: calls,
|
||||||
valueName: 'function call sequence',
|
valueName: 'Function call sequence',
|
||||||
});
|
});
|
||||||
return calls as FunctionCallData[];
|
return calls as FunctionCallData[];
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ function parseFunctionCall(
|
|||||||
): FunctionCall {
|
): FunctionCall {
|
||||||
utilities.typeValidator.assertObject({
|
utilities.typeValidator.assertObject({
|
||||||
value: call,
|
value: call,
|
||||||
valueName: 'function call',
|
valueName: 'Function call',
|
||||||
allowedProperties: ['function', 'parameters'],
|
allowedProperties: ['function', 'parameters'],
|
||||||
});
|
});
|
||||||
const callArgs = parseArgs(call.parameters, utilities.createCallArgument);
|
const callArgs = parseArgs(call.parameters, utilities.createCallArgument);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const validateParameterName = (
|
|||||||
) => {
|
) => {
|
||||||
typeValidator.assertNonEmptyString({
|
typeValidator.assertNonEmptyString({
|
||||||
value: parameterName,
|
value: parameterName,
|
||||||
valueName: 'parameter name',
|
valueName: 'Parameter name',
|
||||||
rule: {
|
rule: {
|
||||||
expectedMatch: /^[0-9a-zA-Z]+$/,
|
expectedMatch: /^[0-9a-zA-Z]+$/,
|
||||||
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,
|
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Valida
|
|||||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||||
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
||||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
|
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
|
||||||
|
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
||||||
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||||
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
|
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
|
||||||
@@ -82,8 +83,7 @@ function validateCode(
|
|||||||
syntax: ILanguageSyntax,
|
syntax: ILanguageSyntax,
|
||||||
validator: ICodeValidator,
|
validator: ICodeValidator,
|
||||||
): void {
|
): void {
|
||||||
[data.code, data.revertCode]
|
filterEmptyStrings([data.code, data.revertCode])
|
||||||
.filter((code): code is string => Boolean(code))
|
|
||||||
.forEach(
|
.forEach(
|
||||||
(code) => validator.throwIfInvalid(
|
(code) => validator.throwIfInvalid(
|
||||||
code,
|
code,
|
||||||
@@ -204,9 +204,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
|
|||||||
if (duplicateCodes.length > 0) {
|
if (duplicateCodes.length > 0) {
|
||||||
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
|
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
|
||||||
}
|
}
|
||||||
const duplicateRevertCodes = getDuplicates(callFunctions
|
const duplicateRevertCodes = getDuplicates(filterEmptyStrings(
|
||||||
.map((func) => func.revertCode)
|
callFunctions.map((func) => func.revertCode),
|
||||||
.filter((code): code is string => Boolean(code)));
|
));
|
||||||
if (duplicateRevertCodes.length > 0) {
|
if (duplicateRevertCodes.length > 0) {
|
||||||
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
|
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/
|
|||||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
|
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
|
||||||
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
||||||
|
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
|
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
|
||||||
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
|
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
|
||||||
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
|
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
|
||||||
@@ -71,9 +72,7 @@ export class ScriptCompiler implements IScriptCompiler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
|
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
|
||||||
[compiledCode.code, compiledCode.revertCode]
|
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
|
||||||
.filter((code): code is string => Boolean(code))
|
|
||||||
.map((code) => code as string)
|
|
||||||
.forEach(
|
.forEach(
|
||||||
(code) => validator.throwIfInvalid(
|
(code) => validator.throwIfInvalid(
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptC
|
|||||||
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
||||||
import type { Script } from '@/domain/Executables/Script/Script';
|
import type { Script } from '@/domain/Executables/Script/Script';
|
||||||
import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
|
import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
|
||||||
|
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
|
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
|
||||||
import { parseDocs, type DocsParser } from '../DocumentationParser';
|
import { parseDocs, type DocsParser } from '../DocumentationParser';
|
||||||
import { ExecutableType } from '../Validation/ExecutableType';
|
import { ExecutableType } from '../Validation/ExecutableType';
|
||||||
@@ -37,7 +38,7 @@ export const parseScript: ScriptParser = (
|
|||||||
validateScript(data, validator);
|
validateScript(data, validator);
|
||||||
try {
|
try {
|
||||||
const script = scriptUtilities.createScript({
|
const script = scriptUtilities.createScript({
|
||||||
executableId: data.name, // arbitrary ID
|
executableId: data.name, // Pseudo-ID for uniqueness until real ID support
|
||||||
name: data.name,
|
name: data.name,
|
||||||
code: parseCode(
|
code: parseCode(
|
||||||
data,
|
data,
|
||||||
@@ -87,8 +88,7 @@ function validateHardcodedCodeWithoutCalls(
|
|||||||
validator: ICodeValidator,
|
validator: ICodeValidator,
|
||||||
syntax: ILanguageSyntax,
|
syntax: ILanguageSyntax,
|
||||||
) {
|
) {
|
||||||
[scriptCode.execute, scriptCode.revert]
|
filterEmptyStrings([scriptCode.execute, scriptCode.revert])
|
||||||
.filter((code): code is string => Boolean(code))
|
|
||||||
.forEach(
|
.forEach(
|
||||||
(code) => validator.throwIfInvalid(
|
(code) => validator.throwIfInvalid(
|
||||||
code,
|
code,
|
||||||
@@ -103,7 +103,7 @@ function validateScript(
|
|||||||
): asserts script is NonNullable<ScriptData> {
|
): asserts script is NonNullable<ScriptData> {
|
||||||
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
|
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
|
||||||
value: script,
|
value: script,
|
||||||
valueName: script.name ?? 'script',
|
valueName: `Script '${script.name}'` ?? 'Script',
|
||||||
allowedProperties: [
|
allowedProperties: [
|
||||||
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
|
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
import type { ICodeLine } from './ICodeLine';
|
import type { ICodeLine } from './ICodeLine';
|
||||||
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
|
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
|
||||||
import type { ICodeValidator } from './ICodeValidator';
|
import type { ICodeValidator } from './ICodeValidator';
|
||||||
@@ -24,12 +25,11 @@ export class CodeValidator implements ICodeValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractLines(code: string): ICodeLine[] {
|
function extractLines(code: string): ICodeLine[] {
|
||||||
return code
|
const lines = splitTextIntoLines(code);
|
||||||
.split(/\r\n|\r|\n/)
|
return lines.map((lineText, lineIndex): ICodeLine => ({
|
||||||
.map((lineText, lineIndex): ICodeLine => ({
|
index: lineIndex + 1,
|
||||||
index: lineIndex + 1,
|
text: lineText,
|
||||||
text: lineText,
|
}));
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printLines(
|
function printLines(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function validateData(
|
|||||||
): void {
|
): void {
|
||||||
validator.assertObject({
|
validator.assertObject({
|
||||||
value: data,
|
value: data,
|
||||||
valueName: 'scripting definition',
|
valueName: 'Scripting definition',
|
||||||
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
|
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ definitions:
|
|||||||
- $ref: '#/definitions/CodeScript'
|
- $ref: '#/definitions/CodeScript'
|
||||||
- $ref: '#/definitions/CallScript'
|
- $ref: '#/definitions/CallScript'
|
||||||
|
|
||||||
|
RecommendationLevel:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
enum: [standard, strict]
|
||||||
|
- type: 'null'
|
||||||
|
|
||||||
ScriptDefinition:
|
ScriptDefinition:
|
||||||
type: object
|
type: object
|
||||||
allOf:
|
allOf:
|
||||||
@@ -78,8 +84,7 @@ definitions:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
recommend:
|
recommend:
|
||||||
type: string
|
$ref: '#/definitions/RecommendationLevel'
|
||||||
enum: [standard, strict]
|
|
||||||
|
|
||||||
CodeScript:
|
CodeScript:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -1800,7 +1800,7 @@ actions:
|
|||||||
# References for spctl --master-disable
|
# References for spctl --master-disable
|
||||||
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
|
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
|
||||||
# References for /var/db/SystemPolicy-prefs.plist
|
# References for /var/db/SystemPolicy-prefs.plist
|
||||||
- https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
|
- https://web.archive.org/web/20240810103202/https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
|
||||||
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228
|
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228
|
||||||
code: |-
|
code: |-
|
||||||
os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}')
|
os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}')
|
||||||
@@ -1842,10 +1842,10 @@ actions:
|
|||||||
fi
|
fi
|
||||||
-
|
-
|
||||||
name: Disable library validation entitlement (library signature validation)
|
name: Disable library validation entitlement (library signature validation)
|
||||||
docs:
|
docs: |-
|
||||||
- https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation
|
- [Disable Library Validation Entitlement | Apple Developer Documentation | developer.apple.com](https://archive.ph/2024.07.19-101811/https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation)
|
||||||
- https://www.macenhance.com/docs/general/sip-library-validation.html
|
- [Forbidden Commands to Speed Up macOS | www.naut.ca](https://web.archive.org/web/20240625020749/https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/)
|
||||||
- https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/
|
- [macEnhance | macEnhance.com](https://web.archive.org/web/20220622212008/https://www.macenhance.com/docs/general/sip-library-validation.html)
|
||||||
code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true
|
code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true
|
||||||
revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false
|
revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false
|
||||||
-
|
-
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
import { getEnumValues, assertInRange } from '@/application/Common/Enum';
|
import { getEnumValues, assertInRange } from '@/application/Common/Enum';
|
||||||
import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
|
import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
|
||||||
import { OperatingSystem } from '../OperatingSystem';
|
import { OperatingSystem } from '../OperatingSystem';
|
||||||
import type { ExecutableId, Identifiable } from '../Executables/Identifiable';
|
import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator';
|
||||||
|
import type { ExecutableId } from '../Executables/Identifiable';
|
||||||
import type { Category } from '../Executables/Category/Category';
|
import type { Category } from '../Executables/Category/Category';
|
||||||
import type { Script } from '../Executables/Script/Script';
|
import type { Script } from '../Executables/Script/Script';
|
||||||
import type { IScriptingDefinition } from '../IScriptingDefinition';
|
import type { IScriptingDefinition } from '../IScriptingDefinition';
|
||||||
import type { ICategoryCollection } from './ICategoryCollection';
|
import type { ICategoryCollection } from './ICategoryCollection';
|
||||||
|
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
|
||||||
|
|
||||||
export class CategoryCollection implements ICategoryCollection {
|
export class CategoryCollection implements ICategoryCollection {
|
||||||
public readonly os: OperatingSystem;
|
public readonly os: OperatingSystem;
|
||||||
@@ -22,16 +24,18 @@ export class CategoryCollection implements ICategoryCollection {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
parameters: CategoryCollectionInitParameters,
|
parameters: CategoryCollectionInitParameters,
|
||||||
|
validate: CategoryCollectionValidator = validateCategoryCollection,
|
||||||
) {
|
) {
|
||||||
this.os = parameters.os;
|
this.os = parameters.os;
|
||||||
this.actions = parameters.actions;
|
this.actions = parameters.actions;
|
||||||
this.scripting = parameters.scripting;
|
this.scripting = parameters.scripting;
|
||||||
|
|
||||||
this.queryable = makeQueryable(this.actions);
|
this.queryable = makeQueryable(this.actions);
|
||||||
assertInRange(this.os, OperatingSystem);
|
validate({
|
||||||
ensureValid(this.queryable);
|
allScripts: this.queryable.allScripts,
|
||||||
ensureNoDuplicateIds(this.queryable.allCategories);
|
allCategories: this.queryable.allCategories,
|
||||||
ensureNoDuplicateIds(this.queryable.allScripts);
|
operatingSystem: this.os,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCategory(executableId: ExecutableId): Category {
|
public getCategory(executableId: ExecutableId): Category {
|
||||||
@@ -48,10 +52,10 @@ export class CategoryCollection implements ICategoryCollection {
|
|||||||
return scripts ?? [];
|
return scripts ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getScript(executableId: string): Script {
|
public getScript(executableId: ExecutableId): Script {
|
||||||
const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
|
const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
|
||||||
if (!script) {
|
if (!script) {
|
||||||
throw new Error(`missing script: ${executableId}`);
|
throw new Error(`Missing script: ${executableId}`);
|
||||||
}
|
}
|
||||||
return script;
|
return script;
|
||||||
}
|
}
|
||||||
@@ -65,18 +69,6 @@ export class CategoryCollection implements ICategoryCollection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureNoDuplicateIds(executables: ReadonlyArray<Identifiable>) { // TODO: Unit test this
|
|
||||||
const duplicatedIds = executables
|
|
||||||
.map((e) => e.executableId)
|
|
||||||
.filter((id, index, array) => array.findIndex((otherId) => otherId === id) !== index);
|
|
||||||
if (duplicatedIds.length > 0) {
|
|
||||||
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
|
|
||||||
throw new Error(
|
|
||||||
`Duplicate executables are detected with following id(s): ${duplicatedIdsText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryCollectionInitParameters {
|
export interface CategoryCollectionInitParameters {
|
||||||
readonly os: OperatingSystem;
|
readonly os: OperatingSystem;
|
||||||
readonly actions: ReadonlyArray<Category>;
|
readonly actions: ReadonlyArray<Category>;
|
||||||
@@ -89,35 +81,12 @@ interface QueryableCollection {
|
|||||||
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
|
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureValid(application: QueryableCollection) {
|
function flattenCategoryHierarchy(
|
||||||
ensureValidCategories(application.allCategories);
|
|
||||||
ensureValidScripts(application.allScripts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureValidCategories(allCategories: readonly Category[]) {
|
|
||||||
if (!allCategories.length) {
|
|
||||||
throw new Error('must consist of at least one category');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureValidScripts(allScripts: readonly Script[]) {
|
|
||||||
if (!allScripts.length) {
|
|
||||||
throw new Error('must consist of at least one script');
|
|
||||||
}
|
|
||||||
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
|
|
||||||
.filter((level) => allScripts.every((script) => script.level !== level));
|
|
||||||
if (missingRecommendationLevels.length > 0) {
|
|
||||||
throw new Error('none of the scripts are recommended as'
|
|
||||||
+ ` "${missingRecommendationLevels.map((level) => RecommendationLevel[level]).join(', "')}".`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenApplication(
|
|
||||||
categories: ReadonlyArray<Category>,
|
categories: ReadonlyArray<Category>,
|
||||||
): [Category[], Script[]] {
|
): [Category[], Script[]] {
|
||||||
const [subCategories, subScripts] = (categories || [])
|
const [subCategories, subScripts] = (categories || [])
|
||||||
// Parse children
|
// Parse children
|
||||||
.map((category) => flattenApplication(category.subcategories))
|
.map((category) => flattenCategoryHierarchy(category.subcategories))
|
||||||
// Flatten results
|
// Flatten results
|
||||||
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
|
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
|
||||||
return [
|
return [
|
||||||
@@ -140,7 +109,7 @@ function flattenApplication(
|
|||||||
function makeQueryable(
|
function makeQueryable(
|
||||||
actions: ReadonlyArray<Category>,
|
actions: ReadonlyArray<Category>,
|
||||||
): QueryableCollection {
|
): QueryableCollection {
|
||||||
const flattened = flattenApplication(actions);
|
const flattened = flattenCategoryHierarchy(actions);
|
||||||
return {
|
return {
|
||||||
allCategories: flattened[0],
|
allCategories: flattened[0],
|
||||||
allScripts: flattened[1],
|
allScripts: flattened[1],
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Category } from '@/domain/Executables/Category/Category';
|
||||||
|
import type { Script } from '@/domain/Executables/Script/Script';
|
||||||
|
import type { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
|
||||||
|
export interface CategoryCollectionValidationContext {
|
||||||
|
readonly allScripts: readonly Script[];
|
||||||
|
readonly allCategories: readonly Category[];
|
||||||
|
readonly operatingSystem: OperatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryCollectionValidator {
|
||||||
|
(
|
||||||
|
context: CategoryCollectionValidationContext,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ensurePresenceOfAtLeastOneScript } from './Rules/EnsurePresenceOfAtLeastOneScript';
|
||||||
|
import { ensurePresenceOfAtLeastOneCategory } from './Rules/EnsurePresenceOfAtLeastOneCategory';
|
||||||
|
import { ensureUniqueIdsAcrossExecutables } from './Rules/EnsureUniqueIdsAcrossExecutables';
|
||||||
|
import { ensureKnownOperatingSystem } from './Rules/EnsureKnownOperatingSystem';
|
||||||
|
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from './CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & {
|
||||||
|
(
|
||||||
|
...args: [
|
||||||
|
...Parameters<CategoryCollectionValidator>,
|
||||||
|
(readonly CategoryCollectionValidator[])?,
|
||||||
|
]
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
|
||||||
|
context: CategoryCollectionValidationContext,
|
||||||
|
validators: readonly CategoryCollectionValidator[] = DefaultValidators,
|
||||||
|
) => {
|
||||||
|
if (!validators.length) {
|
||||||
|
throw new Error('No validators provided.');
|
||||||
|
}
|
||||||
|
for (const validate of validators) {
|
||||||
|
validate(context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultValidators: readonly CategoryCollectionValidator[] = [
|
||||||
|
ensureKnownOperatingSystem,
|
||||||
|
ensurePresenceOfAtLeastOneScript,
|
||||||
|
ensurePresenceOfAtLeastOneCategory,
|
||||||
|
ensureUniqueIdsAcrossExecutables,
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { assertInRange } from '@/application/Common/Enum';
|
||||||
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export const ensureKnownOperatingSystem: CategoryCollectionValidator = (
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
assertInRange(context.operatingSystem, OperatingSystem);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getEnumValues } from '@/application/Common/Enum';
|
||||||
|
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
|
||||||
|
import type { Script } from '@/domain/Executables/Script/Script';
|
||||||
|
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export const ensurePresenceOfAllRecommendationLevels: CategoryCollectionValidator = (
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
const unrepresentedRecommendationLevels = getUnrepresentedRecommendationLevels(
|
||||||
|
context.allScripts,
|
||||||
|
);
|
||||||
|
if (unrepresentedRecommendationLevels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formattedRecommendationLevels = unrepresentedRecommendationLevels
|
||||||
|
.map((level) => getDisplayName(level))
|
||||||
|
.join(', ');
|
||||||
|
throw new Error(`Missing recommendation levels: ${formattedRecommendationLevels}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUnrepresentedRecommendationLevels(
|
||||||
|
scripts: readonly Script[],
|
||||||
|
): (RecommendationLevel | undefined)[] {
|
||||||
|
const expectedLevels = [
|
||||||
|
undefined,
|
||||||
|
...getEnumValues(RecommendationLevel),
|
||||||
|
];
|
||||||
|
return expectedLevels.filter(
|
||||||
|
(level) => scripts.every((script) => script.level !== level),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(level: RecommendationLevel | undefined): string {
|
||||||
|
return level === undefined ? 'None' : RecommendationLevel[level];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
if (!context.allCategories.length) {
|
||||||
|
throw new Error('Collection must have at least one category');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
if (!context.allScripts.length) {
|
||||||
|
throw new Error('Collection must have at least one script');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Identifiable } from '@/domain/Executables/Identifiable';
|
||||||
|
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
|
||||||
|
|
||||||
|
export const ensureUniqueIdsAcrossExecutables: CategoryCollectionValidator = (
|
||||||
|
context,
|
||||||
|
) => {
|
||||||
|
const allExecutables: readonly Identifiable[] = [
|
||||||
|
...context.allCategories,
|
||||||
|
...context.allScripts,
|
||||||
|
];
|
||||||
|
ensureNoDuplicateIds(allExecutables);
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureNoDuplicateIds(
|
||||||
|
executables: readonly Identifiable[],
|
||||||
|
) {
|
||||||
|
const duplicateExecutables = getExecutablesWithDuplicateIds(executables);
|
||||||
|
if (duplicateExecutables.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formattedDuplicateIds = duplicateExecutables.map(
|
||||||
|
(executable) => `"${executable.executableId}"`,
|
||||||
|
).join(', ');
|
||||||
|
throw new Error(`Duplicate executable IDs found: ${formattedDuplicateIds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutablesWithDuplicateIds(
|
||||||
|
executables: readonly Identifiable[],
|
||||||
|
): Identifiable[] {
|
||||||
|
return executables
|
||||||
|
.filter(
|
||||||
|
(executable, index, array) => {
|
||||||
|
const otherIndex = array.findIndex(
|
||||||
|
(otherExecutable) => haveIdenticalIds(executable, otherExecutable),
|
||||||
|
);
|
||||||
|
return otherIndex !== index;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function haveIdenticalIds(first: Identifiable, second: Identifiable): boolean {
|
||||||
|
return first.executableId === second.executableId;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { RecommendationLevel } from './RecommendationLevel';
|
import { RecommendationLevel } from './RecommendationLevel';
|
||||||
import type { ScriptCode } from './Code/ScriptCode';
|
import type { ScriptCode } from './Code/ScriptCode';
|
||||||
import type { Script } from './Script';
|
import type { Script } from './Script';
|
||||||
|
import type { ExecutableId } from '../Identifiable';
|
||||||
|
|
||||||
export interface ScriptInitParameters {
|
export interface ScriptInitParameters {
|
||||||
readonly executableId: string;
|
readonly executableId: ExecutableId;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly code: ScriptCode;
|
readonly code: ScriptCode;
|
||||||
readonly docs: ReadonlyArray<string>;
|
readonly docs: ReadonlyArray<string>;
|
||||||
@@ -19,7 +20,7 @@ export const createScript: ScriptFactory = (parameters) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class CollectionScript implements Script {
|
class CollectionScript implements Script {
|
||||||
public readonly executableId: string;
|
public readonly executableId: ExecutableId;
|
||||||
|
|
||||||
public readonly name: string;
|
public readonly name: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Repository } from '../../application/Repository/Repository';
|
import type { Repository } from '../../application/Repository/Repository';
|
||||||
import type { RepositoryEntity } from '../../application/Repository/RepositoryEntity';
|
import type { RepositoryEntity, RepositoryEntityId } from '../../application/Repository/RepositoryEntity';
|
||||||
|
|
||||||
export class InMemoryRepository<TEntity extends RepositoryEntity>
|
export class InMemoryRepository<TEntity extends RepositoryEntity>
|
||||||
implements Repository<TEntity> {
|
implements Repository<TEntity> {
|
||||||
@@ -20,7 +20,7 @@ implements Repository<TEntity> {
|
|||||||
return predicate ? this.items.filter(predicate) : this.items;
|
return predicate ? this.items.filter(predicate) : this.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getById(id: string): TEntity {
|
public getById(id: RepositoryEntityId): TEntity {
|
||||||
const items = this.getItems((entity) => entity.id === id);
|
const items = this.getItems((entity) => entity.id === id);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
throw new Error(`missing item: ${id}`);
|
throw new Error(`missing item: ${id}`);
|
||||||
@@ -42,7 +42,7 @@ implements Repository<TEntity> {
|
|||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeItem(id: string): void {
|
public removeItem(id: RepositoryEntityId): void {
|
||||||
const index = this.items.findIndex((item) => item.id === id);
|
const index = this.items.findIndex((item) => item.id === id);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
throw new Error(`Cannot remove (id: ${id}) as it does not exist`);
|
throw new Error(`Cannot remove (id: ${id}) as it does not exist`);
|
||||||
@@ -50,7 +50,7 @@ implements Repository<TEntity> {
|
|||||||
this.items.splice(index, 1);
|
this.items.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public exists(id: string): boolean {
|
public exists(id: RepositoryEntityId): boolean {
|
||||||
const index = this.items.findIndex((item) => item.id === id);
|
const index = this.items.findIndex((item) => item.id === id);
|
||||||
return index !== -1;
|
return index !== -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
for interactive elements during hover or touch interactions.
|
for interactive elements during hover or touch interactions.
|
||||||
*/
|
*/
|
||||||
@mixin clickable($cursor: 'pointer') {
|
@mixin clickable($cursor: 'pointer') {
|
||||||
cursor: #{$cursor};
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent; // Removes blue tap highlight
|
-webkit-tap-highlight-color: transparent; // Removes blue tap highlight
|
||||||
|
cursor: #{$cursor};
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin fade-transition($name) {
|
@mixin fade-transition($name) {
|
||||||
@@ -120,13 +120,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin set-property-ch-value-with-fallback($property, $value-in-ch) {
|
@mixin set-property-ch-value-with-fallback($property, $value-in-ch) {
|
||||||
@supports (width: 1ch) {
|
// For browsers that do not support `ch` unit (e.g., Opera Mini):
|
||||||
#{$property}: #{$value-in-ch}ch;
|
|
||||||
}
|
|
||||||
// For browsers that does not support `ch` unit (e.g., Opera Mini):
|
|
||||||
$estimated-width-per-character-in-em: calc(1em / 2); // 1 character is approximately half the font size
|
$estimated-width-per-character-in-em: calc(1em / 2); // 1 character is approximately half the font size
|
||||||
$calculated-width-in-em: calc(#{$estimated-width-per-character-in-em} * #{$value-in-ch});
|
$calculated-width-in-em: calc(#{$estimated-width-per-character-in-em} * #{$value-in-ch});
|
||||||
#{$property}: $calculated-width-in-em;
|
#{$property}: $calculated-width-in-em;
|
||||||
|
@supports (width: 1ch) {
|
||||||
|
#{$property}: #{$value-in-ch}ch; // Override `em` value if `ch` is supported.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin base-font-style {
|
@mixin base-font-style {
|
||||||
|
|||||||
@@ -78,15 +78,17 @@ function getOptionalDevToolkitComponent(): Component | undefined {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
|
||||||
.app__wrapper {
|
.app__wrapper {
|
||||||
|
display:flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
background-color: $color-surface;
|
background-color: $color-surface;
|
||||||
color: $color-on-surface;
|
color: $color-on-surface;
|
||||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
@include responsive-spacing;
|
@include responsive-spacing;
|
||||||
|
|
||||||
display:flex;
|
|
||||||
flex-direction: column;
|
|
||||||
.app__row {
|
.app__row {
|
||||||
margin-bottom: $spacing-absolute-large;
|
margin-bottom: $spacing-absolute-large;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,12 +79,12 @@ export default defineComponent({
|
|||||||
box-shadow: 0 3px 9px $color-primary-darkest;
|
box-shadow: 0 3px 9px $color-primary-darkest;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
@include clickable;
|
||||||
|
|
||||||
.button__icon {
|
.button__icon {
|
||||||
font-size: $font-size-absolute-x-large;
|
font-size: $font-size-absolute-x-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include clickable;
|
|
||||||
|
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
background: $color-surface;
|
background: $color-surface;
|
||||||
box-shadow: 0px 2px 10px 5px $color-secondary;
|
box-shadow: 0px 2px 10px 5px $color-secondary;
|
||||||
|
|||||||
@@ -110,8 +110,9 @@ export default defineComponent({
|
|||||||
@include apply-icon-color($color-danger);
|
@include apply-icon-color($color-danger);
|
||||||
}
|
}
|
||||||
.recommendation {
|
.recommendation {
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
@include horizontal-stack;
|
@include horizontal-stack;
|
||||||
@include apply-icon-color($color-caution);
|
@include apply-icon-color($color-caution);
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -142,4 +142,3 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@/domain/Collection/ICategoryCollection
|
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ export default defineComponent({
|
|||||||
@include horizontal-stack;
|
@include horizontal-stack;
|
||||||
@include apply-icon-color($color-caution);
|
@include apply-icon-color($color-caution);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
@include horizontal-stack;
|
@include horizontal-stack;
|
||||||
@include apply-icon-color($color-success);
|
@include apply-icon-color($color-success);
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,8 +58,19 @@ $color-hover : $color-primary;
|
|||||||
$cursor : v-bind(cursorCssValue);
|
$cursor : v-bind(cursorCssValue);
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
|
cursor: $cursor;
|
||||||
|
|
||||||
@include reset-button;
|
@include reset-button;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
margin-right: $spacing-absolute-small;
|
||||||
|
margin-left: $spacing-absolute-small;
|
||||||
|
|
||||||
@include clickable($cursor: $cursor);
|
@include clickable($cursor: $cursor);
|
||||||
|
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
.line {
|
.line {
|
||||||
background: $color-hover;
|
background: $color-hover;
|
||||||
@@ -68,11 +79,7 @@ $cursor : v-bind(cursorCssValue);
|
|||||||
color: $color-hover;
|
color: $color-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor: $cursor;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
.line {
|
.line {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: $color;
|
background: $color;
|
||||||
@@ -81,7 +88,5 @@ $cursor : v-bind(cursorCssValue);
|
|||||||
.icon {
|
.icon {
|
||||||
color: $color;
|
color: $color;
|
||||||
}
|
}
|
||||||
margin-right: $spacing-absolute-small;
|
|
||||||
margin-left: $spacing-absolute-small;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { throttle } from '@/application/Common/Timing/Throttle';
|
import { throttle } from '@/application/Common/Timing/Throttle';
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
|
import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook';
|
||||||
|
|
||||||
const ThrottleInMs = 15;
|
const ThrottleInMs = 15;
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export function useDragHandler(
|
|||||||
draggableElementRef: Readonly<Ref<HTMLElement | undefined>>,
|
draggableElementRef: Readonly<Ref<HTMLElement | undefined>>,
|
||||||
dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(),
|
dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(),
|
||||||
throttler = throttle,
|
throttler = throttle,
|
||||||
|
onTeardown: LifecycleHook = onUnmounted,
|
||||||
) {
|
) {
|
||||||
const displacementX = ref(0);
|
const displacementX = ref(0);
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
@@ -52,7 +54,7 @@ export function useDragHandler(
|
|||||||
element.addEventListener('pointerdown', startDrag);
|
element.addEventListener('pointerdown', startDrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onTeardown(() => {
|
||||||
stopDrag();
|
stopDrag();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { watch, type Ref, onUnmounted } from 'vue';
|
import { watch, type Ref, onUnmounted } from 'vue';
|
||||||
|
import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook';
|
||||||
|
|
||||||
export function useGlobalCursor(
|
export function useGlobalCursor(
|
||||||
isActive: Readonly<Ref<boolean>>,
|
isActive: Readonly<Ref<boolean>>,
|
||||||
cursorCssValue: string,
|
cursorCssValue: string,
|
||||||
documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(),
|
documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(),
|
||||||
|
onTeardown: LifecycleHook = onUnmounted,
|
||||||
) {
|
) {
|
||||||
const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor);
|
const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor);
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ export function useGlobalCursor(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onTeardown(() => {
|
||||||
documentAccessor.removeElement(cursorStyle);
|
documentAccessor.removeElement(cursorStyle);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
defineComponent, computed, shallowRef,
|
defineComponent, computed, shallowRef,
|
||||||
type PropType,
|
type PropType,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||||
|
|||||||
@@ -61,4 +61,3 @@ export default defineComponent({
|
|||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/domain/Collection/ICategoryCollection
|
|
||||||
|
|||||||
@@ -78,17 +78,20 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.docs {
|
.docs {
|
||||||
|
color: $color-on-primary;
|
||||||
background: $color-primary-darkest;
|
background: $color-primary-darkest;
|
||||||
|
|
||||||
margin-left: $spacing-absolute-small;
|
margin-left: $spacing-absolute-small;
|
||||||
margin-top: $spacing-relative-x-small;
|
margin-top: $spacing-relative-x-small;
|
||||||
color: $color-on-primary;
|
|
||||||
text-transform: none;
|
|
||||||
padding: $spacing-absolute-medium;
|
padding: $spacing-absolute-medium;
|
||||||
|
|
||||||
|
text-transform: none;
|
||||||
|
cursor: auto;
|
||||||
|
user-select: text;
|
||||||
|
|
||||||
&-collapsed {
|
&-collapsed {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
cursor: auto;
|
|
||||||
user-select: text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType, computed } from 'vue';
|
import { defineComponent, type PropType, computed } from 'vue';
|
||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
import MarkdownText from '../Markdown/MarkdownText.vue';
|
import MarkdownText from '../Markdown/MarkdownText.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -43,7 +44,7 @@ function formatAsMarkdownListItem(content: string): string {
|
|||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
throw new Error('missing content');
|
throw new Error('missing content');
|
||||||
}
|
}
|
||||||
const lines = content.split(/\r\n|\r|\n/);
|
const lines = splitTextIntoLines(content);
|
||||||
return `- ${lines[0]}${lines.slice(1)
|
return `- ${lines[0]}${lines.slice(1)
|
||||||
.map((line) => `\n ${line}`)
|
.map((line) => `\n ${line}`)
|
||||||
.join()}`;
|
.join()}`;
|
||||||
@@ -61,3 +62,4 @@ function formatAsMarkdownListItem(content: string): string {
|
|||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@/application/Text/SplitTextIntoLines
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export enum NodeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeMetadata {
|
export interface NodeMetadata {
|
||||||
readonly id: ExecutableId;
|
readonly executableId: ExecutableId;
|
||||||
readonly text: string;
|
readonly text: string;
|
||||||
readonly isReversible: boolean;
|
readonly isReversible: boolean;
|
||||||
readonly docs: ReadonlyArray<string>;
|
readonly docs: ReadonlyArray<string>;
|
||||||
|
|||||||
@@ -64,4 +64,3 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@/domain/Collection/ICategoryCollection
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
|||||||
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
import { ScriptReverter } from './ScriptReverter';
|
import { ScriptReverter } from './ScriptReverter';
|
||||||
import type { Reverter } from './Reverter';
|
import type { Reverter } from './Reverter';
|
||||||
|
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
|
||||||
|
|
||||||
export class CategoryReverter implements Reverter {
|
export class CategoryReverter implements Reverter {
|
||||||
private readonly categoryId: ExecutableId;
|
private readonly categoryId: ExecutableId;
|
||||||
|
|
||||||
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
|
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
|
||||||
|
|
||||||
constructor(nodeId: string, collection: ICategoryCollection) {
|
constructor(nodeId: TreeNodeId, collection: ICategoryCollection) {
|
||||||
this.categoryId = createExecutableIdFromNodeId(nodeId);
|
this.categoryId = createExecutableIdFromNodeId(nodeId);
|
||||||
this.scriptReverters = createScriptReverters(this.categoryId, collection);
|
this.scriptReverters = createScriptReverters(this.categoryId, collection);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { ScriptReverter } from './ScriptReverter';
|
|||||||
import { CategoryReverter } from './CategoryReverter';
|
import { CategoryReverter } from './CategoryReverter';
|
||||||
import type { Reverter } from './Reverter';
|
import type { Reverter } from './Reverter';
|
||||||
|
|
||||||
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): Reverter {
|
export function getReverter(
|
||||||
|
node: NodeMetadata,
|
||||||
|
collection: ICategoryCollection,
|
||||||
|
): Reverter {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case NodeType.Category:
|
case NodeType.Category:
|
||||||
return new CategoryReverter(node.id, collection);
|
return new CategoryReverter(node.executableId, collection);
|
||||||
case NodeType.Script:
|
case NodeType.Script:
|
||||||
return new ScriptReverter(node.id);
|
return new ScriptReverter(node.executableId);
|
||||||
default:
|
default:
|
||||||
throw new Error('Unknown script type');
|
throw new Error('Unknown script type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||||
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||||
|
import type { ExecutableId } from '@/domain/Executables/Identifiable';
|
||||||
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
import type { Reverter } from './Reverter';
|
import type { Reverter } from './Reverter';
|
||||||
|
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
|
||||||
|
|
||||||
export class ScriptReverter implements Reverter {
|
export class ScriptReverter implements Reverter {
|
||||||
private readonly scriptId: string;
|
private readonly scriptId: ExecutableId;
|
||||||
|
|
||||||
constructor(nodeId: string) {
|
constructor(nodeId: TreeNodeId) {
|
||||||
this.scriptId = createExecutableIdFromNodeId(nodeId);
|
this.scriptId = createExecutableIdFromNodeId(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,14 +133,14 @@ export default defineComponent({
|
|||||||
.expansible-node {
|
.expansible-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.leaf-node {
|
.leaf-node {
|
||||||
flex: 1; // Expands the node horizontally, allowing its content to utilize full width for child item alignment, such as icons and text.
|
flex: 1; // Expands the node horizontally, allowing its content to utilize full width for child item alignment, such as icons and text.
|
||||||
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
||||||
}
|
}
|
||||||
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.expand-collapse-caret {
|
.expand-collapse-caret {
|
||||||
$caret-size: 24px;
|
$caret-size: 24px;
|
||||||
$padding-right: $spacing-absolute-small;
|
$padding-right: $spacing-absolute-small;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode';
|
import type { ReadOnlyTreeNode, TreeNode, TreeNodeId } from '../../../Node/TreeNode';
|
||||||
|
|
||||||
export interface ReadOnlyQueryableNodes {
|
export interface ReadOnlyQueryableNodes {
|
||||||
readonly rootNodes: readonly ReadOnlyTreeNode[];
|
readonly rootNodes: readonly ReadOnlyTreeNode[];
|
||||||
readonly flattenedNodes: readonly ReadOnlyTreeNode[];
|
readonly flattenedNodes: readonly ReadOnlyTreeNode[];
|
||||||
|
|
||||||
getNodeById(id: string): ReadOnlyTreeNode;
|
getNodeById(nodeId: TreeNodeId): ReadOnlyTreeNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryableNodes extends ReadOnlyQueryableNodes {
|
export interface QueryableNodes extends ReadOnlyQueryableNodes {
|
||||||
readonly rootNodes: readonly TreeNode[];
|
readonly rootNodes: readonly TreeNode[];
|
||||||
readonly flattenedNodes: readonly TreeNode[];
|
readonly flattenedNodes: readonly TreeNode[];
|
||||||
|
|
||||||
getNodeById(id: string): TreeNode;
|
getNodeById(nodeId: TreeNodeId): TreeNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { QueryableNodes } from './QueryableNodes';
|
import type { QueryableNodes } from './QueryableNodes';
|
||||||
import type { TreeNode } from '../../../Node/TreeNode';
|
import type { TreeNode, TreeNodeId } from '../../../Node/TreeNode';
|
||||||
|
|
||||||
export class TreeNodeNavigator implements QueryableNodes {
|
export class TreeNodeNavigator implements QueryableNodes {
|
||||||
public readonly flattenedNodes: readonly TreeNode[];
|
public readonly flattenedNodes: readonly TreeNode[];
|
||||||
@@ -8,10 +8,10 @@ export class TreeNodeNavigator implements QueryableNodes {
|
|||||||
this.flattenedNodes = flattenNodes(rootNodes);
|
this.flattenedNodes = flattenNodes(rootNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getNodeById(id: string): TreeNode {
|
public getNodeById(nodeId: TreeNodeId): TreeNode {
|
||||||
const foundNode = this.flattenedNodes.find((node) => node.id === id);
|
const foundNode = this.flattenedNodes.find((node) => node.id === nodeId);
|
||||||
if (!foundNode) {
|
if (!foundNode) {
|
||||||
throw new Error(`Node could not be found: ${id}`);
|
throw new Error(`Node could not be found: ${nodeId}`);
|
||||||
}
|
}
|
||||||
return foundNode;
|
return foundNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function convertCategoryToNode(
|
|||||||
children: readonly NodeMetadata[],
|
children: readonly NodeMetadata[],
|
||||||
): NodeMetadata {
|
): NodeMetadata {
|
||||||
return {
|
return {
|
||||||
id: createNodeIdForExecutable(category),
|
executableId: createNodeIdForExecutable(category),
|
||||||
type: NodeType.Category,
|
type: NodeType.Category,
|
||||||
text: category.name,
|
text: category.name,
|
||||||
children,
|
children,
|
||||||
@@ -63,7 +63,7 @@ function convertCategoryToNode(
|
|||||||
|
|
||||||
function convertScriptToNode(script: Script): NodeMetadata {
|
function convertScriptToNode(script: Script): NodeMetadata {
|
||||||
return {
|
return {
|
||||||
id: createNodeIdForExecutable(script),
|
executableId: createNodeIdForExecutable(script),
|
||||||
type: NodeType.Script,
|
type: NodeType.Script,
|
||||||
text: script.name,
|
text: script.name,
|
||||||
children: [],
|
children: [],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function getNodeMetadata(
|
|||||||
|
|
||||||
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
|
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
|
||||||
return {
|
return {
|
||||||
id: metadata.id,
|
id: metadata.executableId,
|
||||||
children: convertChildren(metadata.children, convertToNodeInput),
|
children: convertChildren(metadata.children, convertToNodeInput),
|
||||||
data: metadata,
|
data: metadata,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function UseExecutableFromTreeNodeId(treeNodeId: string) {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
These types are used to abstract Vue Lifecycle injection APIs
|
||||||
|
(e.g., onBeforeMount, onUnmount) for better testability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LifecycleHook = (callback: LifecycleHookCallback) => void;
|
||||||
|
|
||||||
|
export type LifecycleHookCallback = () => void;
|
||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle';
|
import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle';
|
||||||
import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill';
|
import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill';
|
||||||
import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter';
|
import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter';
|
||||||
|
import type { LifecycleHook } from '../Common/LifecycleHook';
|
||||||
|
|
||||||
export function useResizeObserver(
|
export function useResizeObserver(
|
||||||
config: ResizeObserverConfig,
|
config: ResizeObserverConfig,
|
||||||
usePolyfill = useResizeObserverPolyfill,
|
usePolyfill = useResizeObserverPolyfill,
|
||||||
useFrameLimiter = useAnimationFrameLimiter,
|
useFrameLimiter = useAnimationFrameLimiter,
|
||||||
throttler: ThrottleFunction = throttle,
|
throttler: ThrottleFunction = throttle,
|
||||||
onSetup: LifecycleHookRegistration = onBeforeMount,
|
onSetup: LifecycleHook = onBeforeMount,
|
||||||
onTeardown: LifecycleHookRegistration = onBeforeUnmount,
|
onTeardown: LifecycleHook = onBeforeUnmount,
|
||||||
) {
|
) {
|
||||||
const { resetNextFrame, cancelNextFrame } = useFrameLimiter();
|
const { resetNextFrame, cancelNextFrame } = useFrameLimiter();
|
||||||
// This prevents the 'ResizeObserver loop completed with undelivered notifications' error when
|
// This prevents the 'ResizeObserver loop completed with undelivered notifications' error when
|
||||||
@@ -63,5 +64,3 @@ export interface ResizeObserverConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ObservedElementReference = Readonly<Ref<HTMLElement | undefined>>;
|
export type ObservedElementReference = Readonly<Ref<HTMLElement | undefined>>;
|
||||||
|
|
||||||
export type LifecycleHookRegistration = (callback: () => void) => void;
|
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
import type { LifecycleHook } from './Common/LifecycleHook';
|
||||||
|
|
||||||
export interface UseEventListener {
|
export interface UseEventListener {
|
||||||
(): TargetEventListener;
|
(
|
||||||
|
onTeardown?: LifecycleHook,
|
||||||
|
): TargetEventListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAutoUnsubscribedEventListener: UseEventListener = () => ({
|
export const useAutoUnsubscribedEventListener: UseEventListener = (
|
||||||
|
onTeardown = onBeforeUnmount,
|
||||||
|
) => ({
|
||||||
startListening: (eventTargetSource, eventType, eventHandler) => {
|
startListening: (eventTargetSource, eventType, eventHandler) => {
|
||||||
const eventTargetRef = isEventTarget(eventTargetSource)
|
const eventTargetRef = isEventTarget(eventTargetSource)
|
||||||
? shallowRef(eventTargetSource)
|
? shallowRef(eventTargetSource)
|
||||||
@@ -18,6 +23,7 @@ export const useAutoUnsubscribedEventListener: UseEventListener = () => ({
|
|||||||
eventTargetRef,
|
eventTargetRef,
|
||||||
eventType,
|
eventType,
|
||||||
eventHandler,
|
eventHandler,
|
||||||
|
onTeardown,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -42,6 +48,7 @@ function startListeningRef<TEvent extends keyof HTMLElementEventMap>(
|
|||||||
eventTargetRef: Readonly<Ref<EventTarget | undefined>>,
|
eventTargetRef: Readonly<Ref<EventTarget | undefined>>,
|
||||||
eventType: TEvent,
|
eventType: TEvent,
|
||||||
eventHandler: (event: HTMLElementEventMap[TEvent]) => void,
|
eventHandler: (event: HTMLElementEventMap[TEvent]) => void,
|
||||||
|
onTeardown: LifecycleHook,
|
||||||
): void {
|
): void {
|
||||||
const eventListenerManager = new EventListenerManager();
|
const eventListenerManager = new EventListenerManager();
|
||||||
watch(() => eventTargetRef.value, (element) => {
|
watch(() => eventTargetRef.value, (element) => {
|
||||||
@@ -52,7 +59,7 @@ function startListeningRef<TEvent extends keyof HTMLElementEventMap>(
|
|||||||
eventListenerManager.addListener(element, eventType, eventHandler);
|
eventListenerManager.addListener(element, eventType, eventHandler);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onTeardown(() => {
|
||||||
eventListenerManager.removeListenerIfExists();
|
eventListenerManager.removeListenerIfExists();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { onUnmounted } from 'vue';
|
import { onUnmounted } from 'vue';
|
||||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||||
import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||||
|
import type { LifecycleHook } from './Common/LifecycleHook';
|
||||||
|
|
||||||
export function useAutoUnsubscribedEvents(
|
export function useAutoUnsubscribedEvents(
|
||||||
events: IEventSubscriptionCollection = new EventSubscriptionCollection(),
|
events: IEventSubscriptionCollection = new EventSubscriptionCollection(),
|
||||||
|
onTeardown: LifecycleHook = onUnmounted,
|
||||||
) {
|
) {
|
||||||
if (events.subscriptionCount > 0) {
|
if (events.subscriptionCount > 0) {
|
||||||
throw new Error('there are existing subscriptions, this may lead to side-effects');
|
throw new Error('there are existing subscriptions, this may lead to side-effects');
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onTeardown(() => {
|
||||||
events.unsubscribeAll();
|
events.unsubscribeAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -220,9 +220,6 @@ $color-tooltip-background: $color-primary-darkest;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tooltip__overlay {
|
.tooltip__overlay {
|
||||||
@include set-visibility(false);
|
|
||||||
@include fixed-fullscreen;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The z-index is set for both visible and invisible states to ensure it maintains its stacking order
|
The z-index is set for both visible and invisible states to ensure it maintains its stacking order
|
||||||
above other elements during transitions. This approach prevents the tooltip from falling behind other
|
above other elements during transitions. This approach prevents the tooltip from falling behind other
|
||||||
@@ -235,6 +232,9 @@ $color-tooltip-background: $color-primary-darkest;
|
|||||||
This prevents unintentional layout issues or overflow.
|
This prevents unintentional layout issues or overflow.
|
||||||
*/
|
*/
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
||||||
|
@include set-visibility(false);
|
||||||
|
@include fixed-fullscreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip__trigger {
|
.tooltip__trigger {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
&__section {
|
&__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
@media screen and (max-width: $media-screen-big-width) {
|
@media screen and (max-width: $media-screen-big-width) {
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -124,7 +126,7 @@ export default defineComponent({
|
|||||||
margin-top: $spacing-relative-small;
|
margin-top: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flex-wrap: wrap;
|
|
||||||
&__item:not(:first-child) {
|
&__item:not(:first-child) {
|
||||||
&::before {
|
&::before {
|
||||||
content: "|";
|
content: "|";
|
||||||
|
|||||||
16
test.ps1
16
test.ps1
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# (Command only avalable in Windows Server)
|
|
||||||
# name: Uninstall Windows Defender from Windows Server
|
|
||||||
# docs: https://web.archive.org/web/20210926064024/https://docs.microsoft.com/en-us/microsoft-365/security/defender-endpoint/microsoft-defender-antivirus-on-windows-server?view=o365-worldwide
|
|
||||||
|
|
||||||
# Do
|
|
||||||
Uninstall-WindowsFeature -Name Windows-Defender
|
|
||||||
Uninstall-WindowsFeature -Name Windows-Defender-GUI
|
|
||||||
|
|
||||||
# Revert:
|
|
||||||
Install-WindowsFeature -Name Windows-Defender
|
|
||||||
Install-WindowsFeature -Name Windows-Defender-GUI
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { indentText, splitTextIntoLines } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
import { log, die } from '../utils/log';
|
import { log, die } from '../utils/log';
|
||||||
import { readAppLogFile } from './app-logs';
|
import { readAppLogFile } from './app-logs';
|
||||||
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
|
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
|
||||||
@@ -172,5 +173,5 @@ function describeError(
|
|||||||
|
|
||||||
function getNonEmptyLines(text: string) {
|
function getNonEmptyLines(text: string) {
|
||||||
return splitTextIntoLines(text)
|
return splitTextIntoLines(text)
|
||||||
.filter((line) => line?.trim().length > 0);
|
.filter((line) => line.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { filterEmpty } from '@tests/shared/Text';
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
|
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
import { runCommand } from '../../utils/run-command';
|
import { runCommand } from '../../utils/run-command';
|
||||||
import { log, LogLevel } from '../../utils/log';
|
import { log, LogLevel } from '../../utils/log';
|
||||||
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
|
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
|
||||||
@@ -56,7 +57,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowIds = windowIdsOutput.trim().split('\n');
|
const windowIds = splitTextIntoLines(windowIdsOutput.trim());
|
||||||
|
|
||||||
const titles = await Promise.all(windowIds.map(async (windowId) => {
|
const titles = await Promise.all(windowIds.map(async (windowId) => {
|
||||||
const { stdout: titleOutput, error: titleError } = await runCommand(
|
const { stdout: titleOutput, error: titleError } = await runCommand(
|
||||||
@@ -68,7 +69,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
|
|||||||
return titleOutput.trim();
|
return titleOutput.trim();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return filterEmpty(titles);
|
return filterEmptyStrings(titles);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasAssistiveAccessOnMac = true;
|
let hasAssistiveAccessOnMac = true;
|
||||||
@@ -78,7 +79,7 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
|
|||||||
if (!hasAssistiveAccessOnMac) {
|
if (!hasAssistiveAccessOnMac) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const script = `
|
const command = constructAppleScriptCommand(`
|
||||||
tell application "System Events"
|
tell application "System Events"
|
||||||
try
|
try
|
||||||
set targetProcess to first process whose unix id is ${processId}
|
set targetProcess to first process whose unix id is ${processId}
|
||||||
@@ -93,13 +94,8 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
|
|||||||
return allWindowNames
|
return allWindowNames
|
||||||
end tell
|
end tell
|
||||||
end tell
|
end tell
|
||||||
`;
|
`);
|
||||||
const argument = script.trim()
|
const { stdout: titleOutput, error } = await runCommand(command);
|
||||||
.split(/[\r\n]+/)
|
|
||||||
.map((line) => `-e '${line.trim()}'`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
if (error.includes('-25211')) {
|
if (error.includes('-25211')) {
|
||||||
@@ -116,3 +112,13 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
return [title];
|
return [title];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function constructAppleScriptCommand(appleScriptCode: string): string {
|
||||||
|
const scriptLines = splitTextIntoLines(appleScriptCode.trim());
|
||||||
|
const trimmedLines = scriptLines.map((line) => line.trim());
|
||||||
|
const nonEmptyLines = filterEmptyStrings(trimmedLines);
|
||||||
|
const formattedArguments = nonEmptyLines
|
||||||
|
.map((line) => `-e '${line.trim()}'`)
|
||||||
|
.join(' ');
|
||||||
|
return `osascript ${formattedArguments}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { logCurrentArgs, CommandLineFlag, hasCommandLineFlag } from './cli-args';
|
import { logCurrentArgs, CommandLineFlag, hasCommandLineFlag } from './cli-args';
|
||||||
import { log, die } from './utils/log';
|
import { log, die } from './utils/log';
|
||||||
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm';
|
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import type { ExecOptions, ExecException } from 'child_process';
|
import type { ExecOptions, ExecException } from 'child_process';
|
||||||
|
|
||||||
const TIMEOUT_IN_SECONDS = 180;
|
const TIMEOUT_IN_SECONDS = 180;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { type UrlStatus, formatUrlStatus } from './UrlStatus';
|
import { type UrlStatus, formatUrlStatus } from './UrlStatus';
|
||||||
|
|
||||||
const DefaultBaseRetryIntervalInMs = 5 /* sec */ * 1000;
|
const DefaultBaseRetryIntervalInMs = 5 /* sec */ * 1000;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { fetchWithTimeout } from './FetchWithTimeout';
|
import { fetchWithTimeout } from './FetchWithTimeout';
|
||||||
import { getDomainFromUrl } from './UrlDomainProcessing';
|
import { getDomainFromUrl } from './UrlDomainProcessing';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { retryWithExponentialBackOff } from './ExponentialBackOffRetryHandler';
|
import { retryWithExponentialBackOff } from './ExponentialBackOffRetryHandler';
|
||||||
import { fetchFollow, type FollowOptions } from './FetchFollow';
|
import { fetchFollow, type FollowOptions } from './FetchFollow';
|
||||||
import { getRandomUserAgent } from './UserAgents';
|
import { getRandomUserAgent } from './UserAgents';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { constants } from 'crypto';
|
import { constants } from 'crypto';
|
||||||
import tls from 'tls';
|
import tls from 'tls';
|
||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
|
||||||
export function randomizeTlsFingerprint() {
|
export function randomizeTlsFingerprint() {
|
||||||
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');
|
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
|
||||||
export interface UrlStatus {
|
export interface UrlStatus {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
|
||||||
export class TestExecutionDetailsLogger {
|
export class TestExecutionDetailsLogger {
|
||||||
public logTestSectionStartDelimiter(): void {
|
public logTestSectionStartDelimiter(): void {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { test, expect } from 'vitest';
|
import { test, expect } from 'vitest';
|
||||||
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
||||||
import { indentText } from '@tests/shared/Text';
|
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
import { shuffle } from '@/application/Common/Shuffle';
|
import { shuffle } from '@/application/Common/Shuffle';
|
||||||
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
||||||
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
||||||
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function findElementFast(
|
|||||||
win: Cypress.AUTWindow,
|
win: Cypress.AUTWindow,
|
||||||
query: string,
|
query: string,
|
||||||
handler: (element: Element) => void,
|
handler: (element: Element) => void,
|
||||||
timeoutInMs = 5000,
|
timeoutInMs = 10000,
|
||||||
): void {
|
): void {
|
||||||
const endTime = Date.now() + timeoutInMs;
|
const endTime = Date.now() + timeoutInMs;
|
||||||
const finder = new ContinuousRunner();
|
const finder = new ContinuousRunner();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { it, describe, expect } from 'vitest';
|
import { it, describe, expect } from 'vitest';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
import { inject } from 'vue';
|
||||||
import { defineComponent, inject } from 'vue';
|
|
||||||
import { type InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
|
import { type InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
|
||||||
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
|
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
|
||||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||||
import type { IApplicationContext } from '@/application/Context/IApplicationContext';
|
import type { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
|
import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext';
|
||||||
|
|
||||||
describe('DependencyResolution', () => {
|
describe('DependencyResolution', () => {
|
||||||
describe('all dependencies can be injected', async () => {
|
describe('all dependencies can be injected', async () => {
|
||||||
@@ -16,7 +16,7 @@ describe('DependencyResolution', () => {
|
|||||||
// act
|
// act
|
||||||
const resolvedDependency = resolve(() => key, dependencies);
|
const resolvedDependency = resolve(() => key, dependencies);
|
||||||
// assert
|
// assert
|
||||||
expect(resolvedDependency).to.toBeDefined();
|
expect(resolvedDependency).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -40,13 +40,14 @@ function resolve<T>(
|
|||||||
providedKeys: ProvidedKeys,
|
providedKeys: ProvidedKeys,
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
let injectedDependency: T | undefined;
|
let injectedDependency: T | undefined;
|
||||||
shallowMount(defineComponent({
|
executeInComponentSetupContext({
|
||||||
setup() {
|
setupCallback: () => {
|
||||||
injectedDependency = injectKey(selector);
|
injectedDependency = injectKey(selector);
|
||||||
},
|
},
|
||||||
}), {
|
mountOptions: {
|
||||||
global: {
|
global: {
|
||||||
provide: providedKeys,
|
provide: providedKeys,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return injectedDependency;
|
return injectedDependency;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { IconNames } from '@/presentation/components/Shared/Icon/IconName';
|
import { IconNames } from '@/presentation/components/Shared/Icon/IconName';
|
||||||
import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
|
import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
|
||||||
import { waitForValueChange } from '@tests/shared/WaitForValueChange';
|
import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange';
|
||||||
|
|
||||||
describe('useSvgLoader', () => {
|
describe('useSvgLoader', () => {
|
||||||
describe('can load all SVGs', () => {
|
describe('can load all SVGs', () => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { useKeyboardInteractionState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState';
|
import { useKeyboardInteractionState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState';
|
||||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext';
|
||||||
|
|
||||||
describe('useKeyboardInteractionState', () => {
|
describe('useKeyboardInteractionState', () => {
|
||||||
describe('isKeyboardBeingUsed', () => {
|
describe('isKeyboardBeingUsed', () => {
|
||||||
@@ -49,12 +48,12 @@ function triggerKeyPress() {
|
|||||||
|
|
||||||
function mountWrapperComponent() {
|
function mountWrapperComponent() {
|
||||||
let returnObject: ReturnType<typeof useKeyboardInteractionState> | undefined;
|
let returnObject: ReturnType<typeof useKeyboardInteractionState> | undefined;
|
||||||
const wrapper = shallowMount(defineComponent({
|
const wrapper = executeInComponentSetupContext({
|
||||||
setup() {
|
setupCallback: () => {
|
||||||
returnObject = useKeyboardInteractionState();
|
returnObject = useKeyboardInteractionState();
|
||||||
},
|
},
|
||||||
template: '<div></div>',
|
disableAutoUnmount: true,
|
||||||
}));
|
});
|
||||||
expectExists(returnObject);
|
expectExists(returnObject);
|
||||||
return {
|
return {
|
||||||
returnObject,
|
returnObject,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
import { indentText } from '@tests/shared/Text';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that an array deeply includes a specified item by comparing JSON-serialized versions.
|
* Asserts that an array deeply includes a specified item by comparing JSON-serialized versions.
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { isString } from '@/TypeHelpers';
|
|
||||||
|
|
||||||
export function indentText(
|
|
||||||
text: string,
|
|
||||||
indentLevel = 1,
|
|
||||||
): string {
|
|
||||||
validateText(text);
|
|
||||||
const indentation = '\t'.repeat(indentLevel);
|
|
||||||
return splitTextIntoLines(text)
|
|
||||||
.map((line) => (line ? `${indentation}${line}` : line))
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitTextIntoLines(text: string): string[] {
|
|
||||||
validateText(text);
|
|
||||||
return text
|
|
||||||
.split(/[\r\n]+/);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterEmpty(texts: readonly (string | undefined | null)[]): string[] {
|
|
||||||
return texts
|
|
||||||
.filter((title): title is string => Boolean(title));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateText(text: string): void {
|
|
||||||
if (!isString(text)) {
|
|
||||||
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
tests/shared/Vue/ExecuteInComponentSetupContext.ts
Normal file
27
tests/shared/Vue/ExecuteInComponentSetupContext.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { shallowMount, type ComponentMountingOptions } from '@vue/test-utils';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
type MountOptions = ComponentMountingOptions<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A test helper utility that provides a component `setup()` context.
|
||||||
|
* This function allows running code that depends on Vue lifecycle hooks,
|
||||||
|
* such as `onMounted`, within a component's `setup` function.
|
||||||
|
*/
|
||||||
|
export function executeInComponentSetupContext(options: {
|
||||||
|
readonly setupCallback: () => void;
|
||||||
|
readonly disableAutoUnmount?: boolean;
|
||||||
|
readonly mountOptions?: MountOptions,
|
||||||
|
}): ReturnType<typeof shallowMount> {
|
||||||
|
const componentWrapper = shallowMount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
options.setupCallback();
|
||||||
|
},
|
||||||
|
// Component requires a template or render function
|
||||||
|
template: '<div>Test Component: setup context</div>',
|
||||||
|
}), options.mountOptions);
|
||||||
|
if (!options.disableAutoUnmount) {
|
||||||
|
componentWrapper.unmount(); // Ensure cleanup of callback tasks
|
||||||
|
}
|
||||||
|
return componentWrapper;
|
||||||
|
}
|
||||||
23
tests/shared/bootstrap/FailTestOnConsoleError.ts
Normal file
23
tests/shared/bootstrap/FailTestOnConsoleError.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
beforeEach, afterEach, vi, expect,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { FunctionKeys } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
export function failTestOnConsoleError() {
|
||||||
|
const consoleMethodsToCheck: readonly FunctionKeys<Console>[] = [
|
||||||
|
'warn',
|
||||||
|
'error',
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleMethodsToCheck.forEach((methodName) => {
|
||||||
|
vi.spyOn(console, methodName).mockClear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleMethodsToCheck.forEach((methodName) => {
|
||||||
|
expect(console[methodName]).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
import { enableAutoUnmount } from '@vue/test-utils';
|
import { enableAutoUnmount } from '@vue/test-utils';
|
||||||
import { polyfillBlob } from './BlobPolyfill';
|
import { polyfillBlob } from './BlobPolyfill';
|
||||||
|
import { failTestOnConsoleError } from './FailTestOnConsoleError';
|
||||||
|
|
||||||
enableAutoUnmount(afterEach);
|
enableAutoUnmount(afterEach);
|
||||||
polyfillBlob();
|
polyfillBlob();
|
||||||
|
failTestOnConsoleError();
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { filterEmptyStrings, type OptionalString } from '@/application/Common/Text/FilterEmptyStrings';
|
||||||
|
import { IsArrayStub } from '@tests/unit/shared/Stubs/IsArrayStub';
|
||||||
|
import type { isArray } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
describe('filterEmptyStrings', () => {
|
||||||
|
describe('filtering behavior', () => {
|
||||||
|
// arrange
|
||||||
|
const testScenarios: readonly {
|
||||||
|
readonly description: string;
|
||||||
|
readonly texts: readonly OptionalString[];
|
||||||
|
readonly expected: readonly string[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
description: 'filters out non-string entries',
|
||||||
|
texts: ['Hello', '', 'World', null, 'Test', undefined],
|
||||||
|
expected: ['Hello', 'World', 'Test'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'returns empty array for no valid strings',
|
||||||
|
texts: [null, undefined, ''],
|
||||||
|
expected: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'preserves all valid strings',
|
||||||
|
texts: ['Hello', 'World', 'Test'],
|
||||||
|
expected: ['Hello', 'World', 'Test'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, texts, expected,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
const context = new TestContext()
|
||||||
|
.withTexts(texts);
|
||||||
|
// act
|
||||||
|
const result = context.filterEmptyStrings();
|
||||||
|
// assert
|
||||||
|
expect(result).to.deep.equal(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws for non-array input', () => {
|
||||||
|
// arrange
|
||||||
|
const nonArrayInput = 'Hello';
|
||||||
|
const isArray = new IsArrayStub()
|
||||||
|
.withPredeterminedResult(false)
|
||||||
|
.get();
|
||||||
|
const expectedErrorMessage = `Invalid input: Expected an array, but received type ${typeof nonArrayInput}.`;
|
||||||
|
const context = new TestContext()
|
||||||
|
.withTexts(nonArrayInput as unknown as OptionalString[])
|
||||||
|
.withIsArrayType(isArray);
|
||||||
|
// act
|
||||||
|
const act = () => context.filterEmptyStrings();
|
||||||
|
// assert
|
||||||
|
expect(act).toThrow(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid item types in array', () => {
|
||||||
|
// arrange
|
||||||
|
const invalidInput: unknown[] = ['Hello', 42, 'World']; // Number is invalid
|
||||||
|
const expectedErrorMessage = 'Invalid array items: Expected items as string, undefined, or null. Received invalid types: number.';
|
||||||
|
const context = new TestContext()
|
||||||
|
.withTexts(invalidInput as OptionalString[]);
|
||||||
|
// act
|
||||||
|
const act = () => context.filterEmptyStrings();
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestContext {
|
||||||
|
private texts: readonly OptionalString[] = [
|
||||||
|
`[${TestContext.name}] text to stay after filtering`,
|
||||||
|
];
|
||||||
|
|
||||||
|
private isArrayType: typeof isArray = new IsArrayStub()
|
||||||
|
.get();
|
||||||
|
|
||||||
|
public withTexts(texts: readonly OptionalString[]): this {
|
||||||
|
this.texts = texts;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withIsArrayType(isArrayType: typeof isArray): this {
|
||||||
|
this.isArrayType = isArrayType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public filterEmptyStrings(): ReturnType<typeof filterEmptyStrings> {
|
||||||
|
return filterEmptyStrings(
|
||||||
|
this.texts,
|
||||||
|
this.isArrayType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
tests/unit/application/Common/Text/IndentText.spec.ts
Normal file
130
tests/unit/application/Common/Text/IndentText.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { indentText } from '@/application/Common/Text/IndentText';
|
||||||
|
import { IsStringStub } from '@tests/unit/shared/Stubs/IsStringStub';
|
||||||
|
import type { isString } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
type IndentLevel = Parameters<typeof indentText>['1'];
|
||||||
|
|
||||||
|
const TestLineSeparator = '[TEST-LINE-SEPARATOR]';
|
||||||
|
|
||||||
|
describe('indentText', () => {
|
||||||
|
describe('text indentation', () => {
|
||||||
|
const testScenarios: readonly {
|
||||||
|
readonly description: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly indentLevel: IndentLevel;
|
||||||
|
readonly expected: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
description: 'indents multiple lines with single tab',
|
||||||
|
text: createMultilineTestInput('Hello', 'World', 'Test'),
|
||||||
|
indentLevel: 1,
|
||||||
|
expected: '\tHello\n\tWorld\n\tTest',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'indents multiple lines with two tabs',
|
||||||
|
text: createMultilineTestInput('Hello', 'World', 'Test'),
|
||||||
|
indentLevel: 2,
|
||||||
|
expected: '\t\tHello\n\t\tWorld\n\t\tTest',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'indents single line with one tab',
|
||||||
|
text: 'Hello World',
|
||||||
|
indentLevel: 1,
|
||||||
|
expected: '\tHello World',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'preserves empty string without indentation',
|
||||||
|
text: '',
|
||||||
|
indentLevel: 1,
|
||||||
|
expected: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'defaults to one tab when indent level is unspecified',
|
||||||
|
text: createMultilineTestInput('Hello', 'World'),
|
||||||
|
indentLevel: undefined,
|
||||||
|
expected: '\tHello\n\tWorld',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, text, indentLevel, expected,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
const context = new TextContext()
|
||||||
|
.withText(text)
|
||||||
|
.withIndentLevel(indentLevel);
|
||||||
|
// act
|
||||||
|
const actualText = context.indentText();
|
||||||
|
// assert
|
||||||
|
expect(actualText).to.equal(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws for non-string input', () => {
|
||||||
|
// arrange
|
||||||
|
const invalidInput = 42;
|
||||||
|
const expectedErrorMessage = `Indentation error: The input must be a string. Received type: ${typeof invalidInput}.`;
|
||||||
|
const isString = new IsStringStub()
|
||||||
|
.withPredeterminedResult(false)
|
||||||
|
.get();
|
||||||
|
const context = new TextContext()
|
||||||
|
.withText(invalidInput as unknown as string /* bypass compiler checks */)
|
||||||
|
.withIsStringType(isString);
|
||||||
|
// act
|
||||||
|
const act = () => context.indentText();
|
||||||
|
// assert
|
||||||
|
expect(act).toThrow(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
it('throws for indentation level below one', () => {
|
||||||
|
// arrange
|
||||||
|
const indentLevel = 0;
|
||||||
|
const expectedErrorMessage = `Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`;
|
||||||
|
const context = new TextContext()
|
||||||
|
.withIndentLevel(indentLevel);
|
||||||
|
// act
|
||||||
|
const act = () => context.indentText();
|
||||||
|
// assert
|
||||||
|
expect(act).toThrow(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMultilineTestInput(...lines: readonly string[]): string {
|
||||||
|
return lines.join(TestLineSeparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextContext {
|
||||||
|
private text = `[${TextContext.name}] text to indent`;
|
||||||
|
|
||||||
|
private indentLevel: IndentLevel = undefined;
|
||||||
|
|
||||||
|
private isStringType: typeof isString = new IsStringStub().get();
|
||||||
|
|
||||||
|
public withText(text: string): this {
|
||||||
|
this.text = text;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withIndentLevel(indentLevel: IndentLevel): this {
|
||||||
|
this.indentLevel = indentLevel;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withIsStringType(isStringType: typeof isString): this {
|
||||||
|
this.isStringType = isStringType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public indentText(): ReturnType<typeof indentText> {
|
||||||
|
return indentText(
|
||||||
|
this.text,
|
||||||
|
this.indentLevel,
|
||||||
|
{
|
||||||
|
splitIntoLines: (text) => text.split(TestLineSeparator),
|
||||||
|
isStringType: this.isStringType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||||
|
import type { isString } from '@/TypeHelpers';
|
||||||
|
import { IsStringStub } from '@tests/unit/shared/Stubs/IsStringStub';
|
||||||
|
|
||||||
|
describe('splitTextIntoLines', () => {
|
||||||
|
describe('splits correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const testScenarios: readonly {
|
||||||
|
readonly description: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly expectedLines: readonly string[];
|
||||||
|
} [] = [
|
||||||
|
{
|
||||||
|
description: 'handles Unix-like line separator',
|
||||||
|
text: 'Hello\nWorld\nTest',
|
||||||
|
expectedLines: ['Hello', 'World', 'Test'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'handles Windows line separator',
|
||||||
|
text: 'Hello\r\nWorld\r\nTest',
|
||||||
|
expectedLines: ['Hello', 'World', 'Test'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'handles mixed indentation (both Unix-like and Windows)',
|
||||||
|
text: 'Hello\r\nWorld\nTest',
|
||||||
|
expectedLines: ['Hello', 'World', 'Test'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'returns an array with one element when no new lines',
|
||||||
|
text: 'Hello World',
|
||||||
|
expectedLines: ['Hello World'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'preserves empty lines between text lines',
|
||||||
|
text: 'Hello\n\nWorld\n\n\nTest\n',
|
||||||
|
expectedLines: ['Hello', '', 'World', '', '', 'Test', ''],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'handles empty strings',
|
||||||
|
text: '',
|
||||||
|
expectedLines: [''],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, text, expectedLines,
|
||||||
|
}) => {
|
||||||
|
it(description, () => {
|
||||||
|
const testContext = new TestContext()
|
||||||
|
.withText(text);
|
||||||
|
// act
|
||||||
|
const result = testContext.splitText();
|
||||||
|
// assert
|
||||||
|
expect(result).to.deep.equal(expectedLines);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('checks for string type', () => {
|
||||||
|
// arrange
|
||||||
|
const invalidInput = 42;
|
||||||
|
const errorMessage = `Line splitting error: Expected a string but received type '${typeof invalidInput}'.`;
|
||||||
|
const isString = new IsStringStub()
|
||||||
|
.withPredeterminedResult(false)
|
||||||
|
.get();
|
||||||
|
// act
|
||||||
|
const act = () => new TestContext()
|
||||||
|
.withText(invalidInput as unknown as string)
|
||||||
|
.withIsStringType(isString)
|
||||||
|
.splitText();
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(errorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestContext {
|
||||||
|
private isStringType: typeof isString = new IsStringStub().get();
|
||||||
|
|
||||||
|
private text: string = `[${TestContext.name}] text value`;
|
||||||
|
|
||||||
|
public withText(text: string): this {
|
||||||
|
this.text = text;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withIsStringType(isStringType: typeof isString): this {
|
||||||
|
this.isStringType = isStringType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public splitText(): ReturnType<typeof splitTextIntoLines> {
|
||||||
|
return splitTextIntoLines(
|
||||||
|
this.text,
|
||||||
|
this.isStringType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { CodePosition } from '@/application/Context/State/Code/Position/CodePosi
|
|||||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||||
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
|
||||||
|
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||||
|
|
||||||
describe('CodeChangedEvent', () => {
|
describe('CodeChangedEvent', () => {
|
||||||
describe('ctor', () => {
|
describe('ctor', () => {
|
||||||
@@ -19,16 +20,34 @@ describe('CodeChangedEvent', () => {
|
|||||||
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
|
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
|
||||||
]);
|
]);
|
||||||
// act
|
// act
|
||||||
let errorText = '';
|
const actualErrorMessage = collectExceptionMessage(() => {
|
||||||
try {
|
|
||||||
new CodeChangedEventBuilder()
|
new CodeChangedEventBuilder()
|
||||||
.withCode(code)
|
.withCode(code)
|
||||||
.withNewScripts(newScripts)
|
.withNewScripts(newScripts)
|
||||||
.build();
|
.build();
|
||||||
} catch (error) { errorText = error.message; }
|
});
|
||||||
// assert
|
// assert
|
||||||
expect(errorText).to.include(nonExistingLine1);
|
expect(actualErrorMessage).to.include(nonExistingLine1);
|
||||||
expect(errorText).to.include(nonExistingLine2);
|
expect(actualErrorMessage).to.include(nonExistingLine2);
|
||||||
|
});
|
||||||
|
it('invalid line position validation counts empty lines', () => {
|
||||||
|
// arrange
|
||||||
|
const totalEmptyLines = 5;
|
||||||
|
const code = '\n'.repeat(totalEmptyLines);
|
||||||
|
// If empty lines would not be counted, this would result in error
|
||||||
|
const existingLineEnd = totalEmptyLines;
|
||||||
|
const newScripts = new Map<SelectedScript, ICodePosition>([
|
||||||
|
[new SelectedScriptStub(new ScriptStub('1')), new CodePosition(0, existingLineEnd)],
|
||||||
|
]);
|
||||||
|
// act
|
||||||
|
const act = () => {
|
||||||
|
new CodeChangedEventBuilder()
|
||||||
|
.withCode(code)
|
||||||
|
.withNewScripts(newScripts)
|
||||||
|
.build();
|
||||||
|
};
|
||||||
|
// assert
|
||||||
|
expect(act).to.not.throw();
|
||||||
});
|
});
|
||||||
describe('does not throw with valid code position', () => {
|
describe('does not throw with valid code position', () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user