Compare commits

..

2 Commits

Author SHA1 Message Date
undergroundwires
58f902216b Fix disabling of Microsoft Defender $170
- Change naming from Windows Defender to Microsoft Defender to match
  latest branding.
- Add more extensive documentation.
- Add more scripts extending ways to disable Defender.
- Disable "Windows Security Center Service"
- Add missing `SetMpPreference` commands
- New disabling:
  - Disabling of Windows features related to Defender.
  - Disable Antimalware Scan Interface (AMSI)

TODO: Soft delete Defender directories, like
`$env:programdata\Microsoft\Windows Defender`

TODO: Add from here: https://learn.microsoft.com/en-us/mem/intune/protect/antivirus-security-experience-windows-settings

New scripts:

- Disable "Windows Security Center" service
- Kill SmartScreen process
- Disable "Microsoft Security Core Boot" service

Improved scripts:

- Disable Intrusion Prevention System (IPS): Add CLI command to disable
  it.

TODO: These to separate commit

TODO:

- Improve disabling of `RenameSystemFile` AsTrustedInstaller and get
  back all commented out code.
2024-07-18 09:48:06 +02:00
undergroundwires
48d6dbd700 Refactor to use string IDs for executables #262
This commit unifies the concepts of executables having same ID
structure. It paves the way for more complex ID structure and using IDs
in collection files as part of new ID solution (#262). Using string IDs
also leads to more expressive test code.

This commit also refactors the rest of the code to adopt to the changes.

This commit:

- Separate concerns from entities for data access (in repositories) and
  executables. Executables use `Identifiable` meanwhile repositories use
  `RepositoryEntity`.
- Refactor unnecessary generic parameters for enttities and ids,
  enforcing string gtype everwyhere.
- Changes numeric IDs to string IDs for categories to unify the
  retrieval and construction for executables, using pseudo-ids (their
  names) just like scripts.
- Remove `BaseEntity` for simplicity.
- Simplify usage and construction of executable objects.
  Move factories responsible for creation of category/scripts to domain
  layer. Do not longer export `CollectionCategorY` and
  `CollectionScript`.
- Use named typed for string IDs for better differentation of different
  ID contexts in code.
2024-07-08 23:23:05 +02:00
181 changed files with 6261 additions and 16509 deletions

View File

@@ -30,7 +30,7 @@ Related documentation:
### Executables
They represent independently executable actions with documentation and reversibility.
They represent independently executable tweaks with documentation and reversibility.
An Executable is a logical entity that can

5413
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,54 +34,54 @@
"postuninstall": "electron-builder install-app-deps"
},
"dependencies": {
"@floating-ui/vue": "^1.1.1",
"@floating-ui/vue": "^1.0.6",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.35.3",
"electron-log": "^5.1.6",
"ace-builds": "^1.33.0",
"electron-log": "^5.1.2",
"electron-progressbar": "^2.2.1",
"electron-updater": "^6.2.1",
"electron-updater": "^6.1.9",
"file-saver": "^2.0.5",
"markdown-it": "^14.1.0",
"vue": "^3.4.32"
"vue": "^3.4.27"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@rushstack/eslint-patch": "^1.10.3",
"@rushstack/eslint-patch": "^1.10.2",
"@types/ace": "^0.0.52",
"@types/file-saver": "^2.0.7",
"@types/markdown-it": "^14.1.1",
"@types/markdown-it": "^14.0.1",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-legacy": "^5.4.1",
"@vitejs/plugin-vue": "^5.0.5",
"@vitejs/plugin-legacy": "^5.3.2",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "^2.4.6",
"@vue/test-utils": "^2.4.5",
"autoprefixer": "^10.4.19",
"cypress": "^13.13.1",
"electron": "^31.2.1",
"cypress": "^13.7.3",
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.3.0",
"electron-vite": "^2.1.0",
"eslint": "8.57.0",
"eslint-plugin-cypress": "^3.3.0",
"eslint-plugin-vue": "^9.27.0",
"eslint-plugin-vuejs-accessibility": "^2.4.0",
"jsdom": "^24.1.0",
"markdownlint-cli": "^0.41.0",
"postcss": "^8.4.39",
"remark-cli": "^12.0.1",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.25.0",
"eslint-plugin-vuejs-accessibility": "^2.2.1",
"jsdom": "^24.0.0",
"markdownlint-cli": "^0.39.0",
"postcss": "^8.4.38",
"remark-cli": "^12.0.0",
"remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^6.0.0",
"remark-validate-links": "^13.0.1",
"sass": "^1.77.8",
"start-server-and-test": "^2.0.4",
"terser": "^5.31.3",
"tslib": "^2.6.3",
"sass": "^1.75.0",
"start-server-and-test": "^2.0.3",
"terser": "^5.30.3",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
"vite": "^5.3.4",
"vitest": "^2.0.3",
"vue-tsc": "^2.0.26",
"vite": "^5.2.8",
"vitest": "^1.5.0",
"vue-tsc": "^2.0.13",
"yaml-lint": "^1.7.0"
},
"//devDependencies": {

View File

@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
if (!match) {
die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`,
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
);
}
}

View File

@@ -1,25 +0,0 @@
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}.`);
}
}

View File

@@ -1,29 +0,0 @@
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,
};

View File

@@ -1,11 +0,0 @@
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/);
}

View File

@@ -1,8 +1,6 @@
import type { Script } from '@/domain/Executables/Script/Script';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
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';
export class CodeChangedEvent implements ICodeChangedEvent {
@@ -41,7 +39,7 @@ export class CodeChangedEvent implements ICodeChangedEvent {
return this.getPositionById(script.executableId);
}
private getPositionById(scriptId: ExecutableId): ICodePosition {
private getPositionById(scriptId: string): ICodePosition {
const position = [...this.scripts.entries()]
.filter(([s]) => s.executableId === scriptId)
.map(([, pos]) => pos)
@@ -54,12 +52,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
}
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = splitTextIntoLines(script).length;
const totalLines = script.split(/\r\n|\r|\n/).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (missingPositions.length > 0) {
throw new Error(
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
+ ` (total code lines: ${totalLines}).`,
+ `(total code lines: ${totalLines}).`,
);
}
}

View File

@@ -1,4 +1,3 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeBuilder } from './ICodeBuilder';
const TotalFunctionSeparatorChars = 58;
@@ -16,7 +15,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
this.lines.push('');
return this;
}
const lines = splitTextIntoLines(code);
const lines = code.match(/[^\r\n]+/g);
if (lines) {
this.lines.push(...lines);
}

View File

@@ -4,7 +4,6 @@ import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
@@ -39,8 +38,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
);
}
public isSelected(scriptExecutableId: ExecutableId): boolean {
return this.scripts.exists(scriptExecutableId);
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
public get selectedScripts(): readonly SelectedScript[] {
@@ -122,7 +121,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return this.removeScript(script.executableId);
}
private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number {
private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) {
@@ -137,7 +136,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return 1;
}
private removeScript(scriptId: ExecutableId): number {
private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) {
return 0;
}

View File

@@ -1,13 +1,12 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { SelectedScript } from './SelectedScript';
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptExecutableId: ExecutableId): boolean;
isSelected(scriptId: string): boolean;
}
export interface ScriptSelection extends ReadonlyScriptSelection {

View File

@@ -1,5 +1,3 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export type ScriptSelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
@@ -9,7 +7,7 @@ export type ScriptSelectionStatus = {
};
export interface ScriptSelectionChange {
readonly scriptId: ExecutableId;
readonly scriptId: string;
readonly newStatus: ScriptSelectionStatus;
}

View File

@@ -31,7 +31,7 @@ function validateCollectionsData(
) {
validator.assertNonEmptyCollection({
value: collections,
valueName: 'Collections',
valueName: 'collections',
});
}

View File

@@ -45,14 +45,14 @@ function validateCollection(
): void {
validator.assertObject({
value: content,
valueName: 'Collection',
valueName: 'collection',
allowedProperties: [
'os', 'scripting', 'actions', 'functions',
],
});
validator.assertNonEmptyCollection({
value: content.actions,
valueName: '\'actions\' in collection',
valueName: '"actions" in collection',
});
}

View File

@@ -1,116 +1,42 @@
import { CustomError } from '@/application/Common/CustomError';
import { indentText } from '@/application/Common/Text/IndentText';
export interface ErrorWithContextWrapper {
(
innerError: Error,
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
innerError,
additionalContext,
error: Error,
additionalContext: string,
) => {
if (!additionalContext) {
throw new Error('Missing additional context');
}
return new ContextualError({
innerError,
additionalContext,
});
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/**
* 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).
*/
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
class ContextualError extends CustomError {
constructor(public readonly context: ErrorContext) {
super(
generateDetailedErrorMessageWithContext(context),
{
cause: context.innerError,
},
);
private readonly additionalContext = new Array<string>();
constructor(
public readonly innerError: Error,
) {
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');
}

View File

@@ -108,7 +108,7 @@ function assertArray(
valueName: string,
): asserts value is Array<unknown> {
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,
): asserts value is string {
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}'.`);
}
}

View File

@@ -57,7 +57,7 @@ function parseCategoryRecursively(
}
try {
return context.categoryUtilities.createCategory({
executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support
executableId: context.categoryData.category, // arbitrary ID
name: context.categoryData.category,
docs: context.categoryUtilities.parseDocs(context.categoryData),
subcategories: children.subcategories,
@@ -82,7 +82,7 @@ function ensureValidCategory(
});
validator.assertType((v) => v.assertObject({
value: category,
valueName: `Category '${category.category}'` ?? 'Category',
valueName: category.category ?? 'category',
allowedProperties: [
'docs', 'children', 'category',
],

View File

@@ -1,4 +1,4 @@
export interface Pipe {
export interface IPipe {
readonly name: string;
apply(input: string): string;
}

View File

@@ -1,11 +1,11 @@
import type { Pipe } from '../Pipe';
import type { IPipe } from '../IPipe';
export class EscapeDoubleQuotes implements Pipe {
export class EscapeDoubleQuotes implements IPipe {
public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string {
if (!raw) {
return '';
return raw;
}
return raw.replaceAll('"', '"^""');
/* eslint-disable vue/max-len */

View File

@@ -1,7 +1,6 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { Pipe } from '../Pipe';
import type { IPipe } from '../IPipe';
export class InlinePowerShell implements Pipe {
export class InlinePowerShell implements IPipe {
public readonly name: string = 'inlinePowerShell';
public apply(code: string): string {
@@ -9,11 +8,9 @@ export class InlinePowerShell implements Pipe {
return code;
}
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
// Order is important
inlineComments,
mergeHereStrings,
mergeLinesWithBacktick,
mergeLinesWithBracketCodeBlocks,
mergeHereStrings,
mergeNewLines,
]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code);
@@ -92,6 +89,10 @@ 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)
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
@@ -101,18 +102,18 @@ function mergeHereStrings(code: string) {
return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = splitTextIntoLines(escaped);
const lines = getLines(escaped);
const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted;
});
}
interface InlinedHereString {
interface IInlinedHereString {
readonly quotesAround: string;
readonly escapedQuotes: string;
readonly separator: string;
}
function getHereStringHandler(quotes: string): InlinedHereString {
function getHereStringHandler(quotes: string): IInlinedHereString {
/*
We handle @' and @" differently.
Single quotes are interpreted literally and doubles are expandable.
@@ -157,33 +158,9 @@ function mergeLinesWithBacktick(code: string) {
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
}
/**
* Inlines code blocks in PowerShell scripts while preserving correct syntax.
* It removes unnecessary newlines and spaces around brackets,
* inlining the code where possible.
* 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)
return getLines(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(' ');
.filter((line) => line.length > 0)
.join('; ');
}

View File

@@ -1,6 +1,6 @@
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
import type { Pipe } from './Pipe';
import type { IPipe } from './IPipe';
const RegisteredPipes = [
new EscapeDoubleQuotes(),
@@ -8,19 +8,19 @@ const RegisteredPipes = [
];
export interface IPipeFactory {
get(pipeName: string): Pipe;
get(pipeName: string): IPipe;
}
export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, Pipe>();
private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly Pipe[] = RegisteredPipes) {
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
for (const pipe of pipes) {
this.registerPipe(pipe);
}
}
public get(pipeName: string): Pipe {
public get(pipeName: string): IPipe {
validatePipeName(pipeName);
const pipe = this.pipes.get(pipeName);
if (!pipe) {
@@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory {
return pipe;
}
private registerPipe(pipe: Pipe): void {
private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`);

View File

@@ -22,7 +22,7 @@ export const createFunctionCallArgument: FunctionCallArgumentFactory = (
utilities.validateParameterName(parameterName);
utilities.typeValidator.assertNonEmptyString({
value: argumentValue,
valueName: `Function parameter '${parameterName}'`,
valueName: `Missing argument value for the parameter "${parameterName}".`,
});
return {
parameterName,

View File

@@ -1,4 +1,3 @@
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { CompiledCode } from '../CompiledCode';
import type { CodeSegmentMerger } from './CodeSegmentMerger';
@@ -9,9 +8,11 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
}
return {
code: joinCodeParts(codeSegments.map((f) => f.code)),
revertCode: joinCodeParts(filterEmptyStrings(
codeSegments.map((f) => f.revertCode),
)),
revertCode: joinCodeParts(
codeSegments
.map((f) => f.revertCode)
.filter((code): code is string => Boolean(code)),
),
};
}
}

View File

@@ -3,7 +3,6 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
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 { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { indentText } from '@/application/Common/Text/IndentText';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
@@ -23,12 +22,10 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
if (calledFunction.body.type !== FunctionBodyType.Code) {
throw new Error([
'Unexpected function body type.',
indentText([
`Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`Actual: "${FunctionBodyType[calledFunction.body.type]}"`,
].join('\n')),
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
'Function:',
indentText(JSON.stringify(callToFunction)),
`\t${JSON.stringify(callToFunction)}`,
].join('\n'));
}
const { code } = calledFunction.body;

View File

@@ -42,7 +42,7 @@ function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): Fu
if (isArray(calls)) {
validator.assertNonEmptyCollection({
value: calls,
valueName: 'Function call sequence',
valueName: 'function call sequence',
});
return calls as FunctionCallData[];
}
@@ -56,7 +56,7 @@ function parseFunctionCall(
): FunctionCall {
utilities.typeValidator.assertObject({
value: call,
valueName: 'Function call',
valueName: 'function call',
allowedProperties: ['function', 'parameters'],
});
const callArgs = parseArgs(call.parameters, utilities.createCallArgument);

View File

@@ -13,7 +13,7 @@ export const validateParameterName = (
) => {
typeValidator.assertNonEmptyString({
value: parameterName,
valueName: 'Parameter name',
valueName: 'parameter name',
rule: {
expectedMatch: /^[0-9a-zA-Z]+$/,
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,

View File

@@ -9,7 +9,6 @@ import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Valida
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -83,7 +82,8 @@ function validateCode(
syntax: ILanguageSyntax,
validator: ICodeValidator,
): void {
filterEmptyStrings([data.code, data.revertCode])
[data.code, data.revertCode]
.filter((code): code is string => Boolean(code))
.forEach(
(code) => validator.throwIfInvalid(
code,
@@ -204,9 +204,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
}
const duplicateRevertCodes = getDuplicates(filterEmptyStrings(
callFunctions.map((func) => func.revertCode),
));
const duplicateRevertCodes = getDuplicates(callFunctions
.map((func) => func.revertCode)
.filter((code): code is string => Boolean(code)));
if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
}

View File

@@ -6,7 +6,6 @@ import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
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 { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
@@ -72,7 +71,9 @@ export class ScriptCompiler implements IScriptCompiler {
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
[compiledCode.code, compiledCode.revertCode]
.filter((code): code is string => Boolean(code))
.map((code) => code as string)
.forEach(
(code) => validator.throwIfInvalid(
code,

View File

@@ -9,7 +9,6 @@ import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptC
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script';
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 { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType';
@@ -38,7 +37,7 @@ export const parseScript: ScriptParser = (
validateScript(data, validator);
try {
const script = scriptUtilities.createScript({
executableId: data.name, // Pseudo-ID for uniqueness until real ID support
executableId: data.name, // arbitrary ID
name: data.name,
code: parseCode(
data,
@@ -88,7 +87,8 @@ function validateHardcodedCodeWithoutCalls(
validator: ICodeValidator,
syntax: ILanguageSyntax,
) {
filterEmptyStrings([scriptCode.execute, scriptCode.revert])
[scriptCode.execute, scriptCode.revert]
.filter((code): code is string => Boolean(code))
.forEach(
(code) => validator.throwIfInvalid(
code,
@@ -103,7 +103,7 @@ function validateScript(
): asserts script is NonNullable<ScriptData> {
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script,
valueName: `Script '${script.name}'` ?? 'Script',
valueName: script.name ?? 'script',
allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
],

View File

@@ -1,4 +1,3 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeLine } from './ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
import type { ICodeValidator } from './ICodeValidator';
@@ -25,11 +24,12 @@ export class CodeValidator implements ICodeValidator {
}
function extractLines(code: string): ICodeLine[] {
const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): ICodeLine => ({
index: lineIndex + 1,
text: lineText,
}));
return code
.split(/\r\n|\r|\n/)
.map((lineText, lineIndex): ICodeLine => ({
index: lineIndex + 1,
text: lineText,
}));
}
function printLines(

View File

@@ -37,7 +37,7 @@ function validateData(
): void {
validator.assertObject({
value: data,
valueName: 'Scripting definition',
valueName: 'scripting definition',
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
});
}

View File

@@ -69,12 +69,6 @@ definitions:
- $ref: '#/definitions/CodeScript'
- $ref: '#/definitions/CallScript'
RecommendationLevel:
oneOf:
- type: string
enum: [standard, strict]
- type: 'null'
ScriptDefinition:
type: object
allOf:
@@ -84,7 +78,8 @@ definitions:
name:
type: string
recommend:
$ref: '#/definitions/RecommendationLevel'
type: string
enum: [standard, strict]
CodeScript:
type: object

View File

@@ -1800,7 +1800,7 @@ actions:
# References for spctl --master-disable
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
# References for /var/db/SystemPolicy-prefs.plist
- https://web.archive.org/web/20240810103202/https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
- 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
code: |-
os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}')
@@ -1842,10 +1842,10 @@ actions:
fi
-
name: Disable library validation entitlement (library signature validation)
docs: |-
- [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)
- [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/)
- [macEnhance | macEnhance.com](https://web.archive.org/web/20220622212008/https://www.macenhance.com/docs/general/sip-library-validation.html)
docs:
- https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation
- https://www.macenhance.com/docs/general/sip-library-validation.html
- https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/
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
-

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import { getEnumValues, assertInRange } from '@/application/Common/Enum';
import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
import { OperatingSystem } from '../OperatingSystem';
import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator';
import type { ExecutableId } from '../Executables/Identifiable';
import type { ExecutableId, Identifiable } from '../Executables/Identifiable';
import type { Category } from '../Executables/Category/Category';
import type { Script } from '../Executables/Script/Script';
import type { IScriptingDefinition } from '../IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection';
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
export class CategoryCollection implements ICategoryCollection {
public readonly os: OperatingSystem;
@@ -24,18 +22,16 @@ export class CategoryCollection implements ICategoryCollection {
constructor(
parameters: CategoryCollectionInitParameters,
validate: CategoryCollectionValidator = validateCategoryCollection,
) {
this.os = parameters.os;
this.actions = parameters.actions;
this.scripting = parameters.scripting;
this.queryable = makeQueryable(this.actions);
validate({
allScripts: this.queryable.allScripts,
allCategories: this.queryable.allCategories,
operatingSystem: this.os,
});
assertInRange(this.os, OperatingSystem);
ensureValid(this.queryable);
ensureNoDuplicateIds(this.queryable.allCategories);
ensureNoDuplicateIds(this.queryable.allScripts);
}
public getCategory(executableId: ExecutableId): Category {
@@ -52,10 +48,10 @@ export class CategoryCollection implements ICategoryCollection {
return scripts ?? [];
}
public getScript(executableId: ExecutableId): Script {
public getScript(executableId: string): Script {
const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
if (!script) {
throw new Error(`Missing script: ${executableId}`);
throw new Error(`missing script: ${executableId}`);
}
return script;
}
@@ -69,6 +65,18 @@ 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 {
readonly os: OperatingSystem;
readonly actions: ReadonlyArray<Category>;
@@ -81,12 +89,35 @@ interface QueryableCollection {
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
}
function flattenCategoryHierarchy(
function ensureValid(application: QueryableCollection) {
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>,
): [Category[], Script[]] {
const [subCategories, subScripts] = (categories || [])
// Parse children
.map((category) => flattenCategoryHierarchy(category.subcategories))
.map((category) => flattenApplication(category.subcategories))
// Flatten results
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
return [
@@ -109,7 +140,7 @@ function flattenCategoryHierarchy(
function makeQueryable(
actions: ReadonlyArray<Category>,
): QueryableCollection {
const flattened = flattenCategoryHierarchy(actions);
const flattened = flattenApplication(actions);
return {
allCategories: flattened[0],
allScripts: flattened[1],

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,33 +0,0 @@
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,
];

View File

@@ -1,9 +0,0 @@
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);
};

View File

@@ -1,35 +0,0 @@
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];
}

View File

@@ -1,9 +0,0 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
context,
) => {
if (!context.allCategories.length) {
throw new Error('Collection must have at least one category');
}
};

View File

@@ -1,9 +0,0 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
context,
) => {
if (!context.allScripts.length) {
throw new Error('Collection must have at least one script');
}
};

View File

@@ -1,43 +0,0 @@
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;
}

View File

@@ -1,10 +1,9 @@
import { RecommendationLevel } from './RecommendationLevel';
import type { ScriptCode } from './Code/ScriptCode';
import type { Script } from './Script';
import type { ExecutableId } from '../Identifiable';
export interface ScriptInitParameters {
readonly executableId: ExecutableId;
readonly executableId: string;
readonly name: string;
readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>;
@@ -20,7 +19,7 @@ export const createScript: ScriptFactory = (parameters) => {
};
class CollectionScript implements Script {
public readonly executableId: ExecutableId;
public readonly executableId: string;
public readonly name: string;

View File

@@ -1,5 +1,5 @@
import type { Repository } from '../../application/Repository/Repository';
import type { RepositoryEntity, RepositoryEntityId } from '../../application/Repository/RepositoryEntity';
import type { RepositoryEntity } from '../../application/Repository/RepositoryEntity';
export class InMemoryRepository<TEntity extends RepositoryEntity>
implements Repository<TEntity> {
@@ -20,7 +20,7 @@ implements Repository<TEntity> {
return predicate ? this.items.filter(predicate) : this.items;
}
public getById(id: RepositoryEntityId): TEntity {
public getById(id: string): TEntity {
const items = this.getItems((entity) => entity.id === id);
if (!items.length) {
throw new Error(`missing item: ${id}`);
@@ -42,7 +42,7 @@ implements Repository<TEntity> {
this.items.push(item);
}
public removeItem(id: RepositoryEntityId): void {
public removeItem(id: string): void {
const index = this.items.findIndex((item) => item.id === id);
if (index === -1) {
throw new Error(`Cannot remove (id: ${id}) as it does not exist`);
@@ -50,7 +50,7 @@ implements Repository<TEntity> {
this.items.splice(index, 1);
}
public exists(id: RepositoryEntityId): boolean {
public exists(id: string): boolean {
const index = this.items.findIndex((item) => item.id === id);
return index !== -1;
}

View File

@@ -29,9 +29,9 @@
for interactive elements during hover or touch interactions.
*/
@mixin clickable($cursor: 'pointer') {
cursor: #{$cursor};
user-select: none;
-webkit-tap-highlight-color: transparent; // Removes blue tap highlight
cursor: #{$cursor};
}
@mixin fade-transition($name) {
@@ -120,13 +120,13 @@
}
@mixin set-property-ch-value-with-fallback($property, $value-in-ch) {
// For browsers that do not support `ch` unit (e.g., Opera Mini):
@supports (width: 1ch) {
#{$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
$calculated-width-in-em: calc(#{$estimated-width-per-character-in-em} * #{$value-in-ch});
#{$property}: $calculated-width-in-em;
@supports (width: 1ch) {
#{$property}: #{$value-in-ch}ch; // Override `em` value if `ch` is supported.
}
}
@mixin base-font-style {

View File

@@ -78,17 +78,15 @@ function getOptionalDevToolkitComponent(): Component | undefined {
margin-right: auto;
margin-left: auto;
max-width: 1600px;
.app__wrapper {
display:flex;
flex-direction: column;
background-color: $color-surface;
color: $color-on-surface;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
@include responsive-spacing;
display:flex;
flex-direction: column;
.app__row {
margin-bottom: $spacing-absolute-large;
}

View File

@@ -79,12 +79,12 @@ export default defineComponent({
box-shadow: 0 3px 9px $color-primary-darkest;
border-radius: 4px;
@include clickable;
.button__icon {
font-size: $font-size-absolute-x-large;
}
@include clickable;
@include hover-or-touch {
background: $color-surface;
box-shadow: 0px 2px 10px 5px $color-secondary;

View File

@@ -110,9 +110,8 @@ export default defineComponent({
@include apply-icon-color($color-danger);
}
.recommendation {
align-items: center;
@include horizontal-stack;
@include apply-icon-color($color-caution);
align-items: center;
}
</style>

View File

@@ -142,3 +142,4 @@ export default defineComponent({
},
});
</script>
@/domain/Collection/ICategoryCollection

View File

@@ -68,11 +68,9 @@ export default defineComponent({
@include horizontal-stack;
@include apply-icon-color($color-caution);
}
.description {
align-items: center;
@include horizontal-stack;
@include apply-icon-color($color-success);
align-items: center;
}
</style>

View File

@@ -58,19 +58,8 @@ $color-hover : $color-primary;
$cursor : v-bind(cursorCssValue);
.handle {
cursor: $cursor;
@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 hover-or-touch {
.line {
background: $color-hover;
@@ -79,7 +68,11 @@ $cursor : v-bind(cursorCssValue);
color: $color-hover;
}
}
cursor: $cursor;
display: flex;
flex-direction: column;
align-items: center;
.line {
flex: 1;
background: $color;
@@ -88,5 +81,7 @@ $cursor : v-bind(cursorCssValue);
.icon {
color: $color;
}
margin-right: $spacing-absolute-small;
margin-left: $spacing-absolute-small;
}
</style>

View File

@@ -3,7 +3,6 @@ import {
} from 'vue';
import { throttle } from '@/application/Common/Timing/Throttle';
import type { Ref } from 'vue';
import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook';
const ThrottleInMs = 15;
@@ -11,7 +10,6 @@ export function useDragHandler(
draggableElementRef: Readonly<Ref<HTMLElement | undefined>>,
dragDomModifier: DragDomModifier = new GlobalDocumentDragDomModifier(),
throttler = throttle,
onTeardown: LifecycleHook = onUnmounted,
) {
const displacementX = ref(0);
const isDragging = ref(false);
@@ -54,7 +52,7 @@ export function useDragHandler(
element.addEventListener('pointerdown', startDrag);
}
onTeardown(() => {
onUnmounted(() => {
stopDrag();
});

View File

@@ -1,11 +1,9 @@
import { watch, type Ref, onUnmounted } from 'vue';
import type { LifecycleHook } from '../../Shared/Hooks/Common/LifecycleHook';
export function useGlobalCursor(
isActive: Readonly<Ref<boolean>>,
cursorCssValue: string,
documentAccessor: CursorStyleDomModifier = new GlobalDocumentCursorStyleDomModifier(),
onTeardown: LifecycleHook = onUnmounted,
) {
const cursorStyle = createCursorStyle(cursorCssValue, documentAccessor);
@@ -17,7 +15,7 @@ export function useGlobalCursor(
}
});
onTeardown(() => {
onUnmounted(() => {
documentAccessor.removeElement(cursorStyle);
});
}

View File

@@ -56,7 +56,7 @@
<script lang="ts">
import {
defineComponent, computed, shallowRef,
type PropType,
type PropType,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';

View File

@@ -61,3 +61,4 @@ export default defineComponent({
font-size: $font-size-absolute-normal;
}
</style>
@/domain/Collection/ICategoryCollection

View File

@@ -78,20 +78,17 @@ export default defineComponent({
}
}
.docs {
color: $color-on-primary;
background: $color-primary-darkest;
margin-left: $spacing-absolute-small;
margin-top: $spacing-relative-x-small;
padding: $spacing-absolute-medium;
color: $color-on-primary;
text-transform: none;
cursor: auto;
user-select: text;
padding: $spacing-absolute-medium;
&-collapsed {
display: none;
}
cursor: auto;
user-select: text;
}
}
</style>

View File

@@ -8,7 +8,6 @@
<script lang="ts">
import { defineComponent, type PropType, computed } from 'vue';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import MarkdownText from '../Markdown/MarkdownText.vue';
export default defineComponent({
@@ -44,7 +43,7 @@ function formatAsMarkdownListItem(content: string): string {
if (content.length === 0) {
throw new Error('missing content');
}
const lines = splitTextIntoLines(content);
const lines = content.split(/\r\n|\r|\n/);
return `- ${lines[0]}${lines.slice(1)
.map((line) => `\n ${line}`)
.join()}`;
@@ -62,4 +61,3 @@ function formatAsMarkdownListItem(content: string): string {
font-size: $font-size-absolute-normal;
}
</style>
@/application/Text/SplitTextIntoLines

View File

@@ -6,7 +6,7 @@ export enum NodeType {
}
export interface NodeMetadata {
readonly executableId: ExecutableId;
readonly id: ExecutableId;
readonly text: string;
readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>;

View File

@@ -64,3 +64,4 @@ export default defineComponent({
},
});
</script>
@/domain/Collection/ICategoryCollection

View File

@@ -5,14 +5,13 @@ import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { ScriptReverter } from './ScriptReverter';
import type { Reverter } from './Reverter';
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
export class CategoryReverter implements Reverter {
private readonly categoryId: ExecutableId;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: TreeNodeId, collection: ICategoryCollection) {
constructor(nodeId: string, collection: ICategoryCollection) {
this.categoryId = createExecutableIdFromNodeId(nodeId);
this.scriptReverters = createScriptReverters(this.categoryId, collection);
}

View File

@@ -4,15 +4,12 @@ import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
import type { Reverter } from './Reverter';
export function getReverter(
node: NodeMetadata,
collection: ICategoryCollection,
): Reverter {
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): Reverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.executableId, collection);
return new CategoryReverter(node.id, collection);
case NodeType.Script:
return new ScriptReverter(node.executableId);
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}

View File

@@ -1,14 +1,12 @@
import type { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { createExecutableIdFromNodeId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import type { Reverter } from './Reverter';
import type { TreeNodeId } from '../../TreeView/Node/TreeNode';
export class ScriptReverter implements Reverter {
private readonly scriptId: ExecutableId;
private readonly scriptId: string;
constructor(nodeId: TreeNodeId) {
constructor(nodeId: string) {
this.scriptId = createExecutableIdFromNodeId(nodeId);
}

View File

@@ -133,14 +133,14 @@ export default defineComponent({
.expansible-node {
display: flex;
flex-direction: row;
align-items: center;
.leaf-node {
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)
}
flex-direction: row;
align-items: center;
.expand-collapse-caret {
$caret-size: 24px;
$padding-right: $spacing-absolute-small;

View File

@@ -1,15 +1,15 @@
import type { ReadOnlyTreeNode, TreeNode, TreeNodeId } from '../../../Node/TreeNode';
import type { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode';
export interface ReadOnlyQueryableNodes {
readonly rootNodes: readonly ReadOnlyTreeNode[];
readonly flattenedNodes: readonly ReadOnlyTreeNode[];
getNodeById(nodeId: TreeNodeId): ReadOnlyTreeNode;
getNodeById(id: string): ReadOnlyTreeNode;
}
export interface QueryableNodes extends ReadOnlyQueryableNodes {
readonly rootNodes: readonly TreeNode[];
readonly flattenedNodes: readonly TreeNode[];
getNodeById(nodeId: TreeNodeId): TreeNode;
getNodeById(id: string): TreeNode;
}

View File

@@ -1,5 +1,5 @@
import type { QueryableNodes } from './QueryableNodes';
import type { TreeNode, TreeNodeId } from '../../../Node/TreeNode';
import type { TreeNode } from '../../../Node/TreeNode';
export class TreeNodeNavigator implements QueryableNodes {
public readonly flattenedNodes: readonly TreeNode[];
@@ -8,10 +8,10 @@ export class TreeNodeNavigator implements QueryableNodes {
this.flattenedNodes = flattenNodes(rootNodes);
}
public getNodeById(nodeId: TreeNodeId): TreeNode {
const foundNode = this.flattenedNodes.find((node) => node.id === nodeId);
public getNodeById(id: string): TreeNode {
const foundNode = this.flattenedNodes.find((node) => node.id === id);
if (!foundNode) {
throw new Error(`Node could not be found: ${nodeId}`);
throw new Error(`Node could not be found: ${id}`);
}
return foundNode;
}

View File

@@ -52,7 +52,7 @@ function convertCategoryToNode(
children: readonly NodeMetadata[],
): NodeMetadata {
return {
executableId: createNodeIdForExecutable(category),
id: createNodeIdForExecutable(category),
type: NodeType.Category,
text: category.name,
children,
@@ -63,7 +63,7 @@ function convertCategoryToNode(
function convertScriptToNode(script: Script): NodeMetadata {
return {
executableId: createNodeIdForExecutable(script),
id: createNodeIdForExecutable(script),
type: NodeType.Script,
text: script.name,
children: [],

View File

@@ -14,7 +14,7 @@ export function getNodeMetadata(
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
return {
id: metadata.executableId,
id: metadata.id,
children: convertChildren(metadata.children, convertToNodeInput),
data: metadata,
};

View File

@@ -0,0 +1,3 @@
export function UseExecutableFromTreeNodeId(treeNodeId: string) {
}

View File

@@ -1,8 +0,0 @@
/*
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;

View File

@@ -5,15 +5,14 @@ import {
import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle';
import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill';
import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter';
import type { LifecycleHook } from '../Common/LifecycleHook';
export function useResizeObserver(
config: ResizeObserverConfig,
usePolyfill = useResizeObserverPolyfill,
useFrameLimiter = useAnimationFrameLimiter,
throttler: ThrottleFunction = throttle,
onSetup: LifecycleHook = onBeforeMount,
onTeardown: LifecycleHook = onBeforeUnmount,
onSetup: LifecycleHookRegistration = onBeforeMount,
onTeardown: LifecycleHookRegistration = onBeforeUnmount,
) {
const { resetNextFrame, cancelNextFrame } = useFrameLimiter();
// This prevents the 'ResizeObserver loop completed with undelivered notifications' error when
@@ -64,3 +63,5 @@ export interface ResizeObserverConfig {
}
export type ObservedElementReference = Readonly<Ref<HTMLElement | undefined>>;
export type LifecycleHookRegistration = (callback: () => void) => void;

View File

@@ -4,17 +4,12 @@ import {
watch,
type Ref,
} from 'vue';
import type { LifecycleHook } from './Common/LifecycleHook';
export interface UseEventListener {
(
onTeardown?: LifecycleHook,
): TargetEventListener;
(): TargetEventListener;
}
export const useAutoUnsubscribedEventListener: UseEventListener = (
onTeardown = onBeforeUnmount,
) => ({
export const useAutoUnsubscribedEventListener: UseEventListener = () => ({
startListening: (eventTargetSource, eventType, eventHandler) => {
const eventTargetRef = isEventTarget(eventTargetSource)
? shallowRef(eventTargetSource)
@@ -23,7 +18,6 @@ export const useAutoUnsubscribedEventListener: UseEventListener = (
eventTargetRef,
eventType,
eventHandler,
onTeardown,
);
},
});
@@ -48,7 +42,6 @@ function startListeningRef<TEvent extends keyof HTMLElementEventMap>(
eventTargetRef: Readonly<Ref<EventTarget | undefined>>,
eventType: TEvent,
eventHandler: (event: HTMLElementEventMap[TEvent]) => void,
onTeardown: LifecycleHook,
): void {
const eventListenerManager = new EventListenerManager();
watch(() => eventTargetRef.value, (element) => {
@@ -59,7 +52,7 @@ function startListeningRef<TEvent extends keyof HTMLElementEventMap>(
eventListenerManager.addListener(element, eventType, eventHandler);
}, { immediate: true });
onTeardown(() => {
onBeforeUnmount(() => {
eventListenerManager.removeListenerIfExists();
});
}

View File

@@ -1,17 +1,15 @@
import { onUnmounted } from 'vue';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
import type { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import type { LifecycleHook } from './Common/LifecycleHook';
export function useAutoUnsubscribedEvents(
events: IEventSubscriptionCollection = new EventSubscriptionCollection(),
onTeardown: LifecycleHook = onUnmounted,
) {
if (events.subscriptionCount > 0) {
throw new Error('there are existing subscriptions, this may lead to side-effects');
}
onTeardown(() => {
onUnmounted(() => {
events.unsubscribeAll();
});

View File

@@ -220,6 +220,9 @@ $color-tooltip-background: $color-primary-darkest;
}
.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
above other elements during transitions. This approach prevents the tooltip from falling behind other
@@ -232,9 +235,6 @@ $color-tooltip-background: $color-primary-darkest;
This prevents unintentional layout issues or overflow.
*/
white-space: normal;
@include set-visibility(false);
@include fixed-fullscreen;
}
.tooltip__trigger {

View File

@@ -116,8 +116,6 @@ export default defineComponent({
}
&__section {
display: flex;
flex-wrap: wrap;
@media screen and (max-width: $media-screen-big-width) {
justify-content: space-around;
width: 100%;
@@ -126,7 +124,7 @@ export default defineComponent({
margin-top: $spacing-relative-small;
}
}
flex-wrap: wrap;
&__item:not(:first-child) {
&::before {
content: "|";

16
test.ps1 Normal file
View File

@@ -0,0 +1,16 @@
# (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

View File

@@ -1,5 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import { indentText, splitTextIntoLines } from '@tests/shared/Text';
import { log, die } from '../utils/log';
import { readAppLogFile } from './app-logs';
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
@@ -173,5 +172,5 @@ function describeError(
function getNonEmptyLines(text: string) {
return splitTextIntoLines(text)
.filter((line) => line.trim().length > 0);
.filter((line) => line?.trim().length > 0);
}

View File

@@ -1,5 +1,4 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { filterEmpty } from '@tests/shared/Text';
import { runCommand } from '../../utils/run-command';
import { log, LogLevel } from '../../utils/log';
import { SupportedPlatform, CURRENT_PLATFORM } from '../../utils/platform';
@@ -57,7 +56,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
return [];
}
const windowIds = splitTextIntoLines(windowIdsOutput.trim());
const windowIds = windowIdsOutput.trim().split('\n');
const titles = await Promise.all(windowIds.map(async (windowId) => {
const { stdout: titleOutput, error: titleError } = await runCommand(
@@ -69,7 +68,7 @@ async function captureTitlesOnLinux(processId: number): Promise<string[]> {
return titleOutput.trim();
}));
return filterEmptyStrings(titles);
return filterEmpty(titles);
}
let hasAssistiveAccessOnMac = true;
@@ -79,7 +78,7 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
if (!hasAssistiveAccessOnMac) {
return [];
}
const command = constructAppleScriptCommand(`
const script = `
tell application "System Events"
try
set targetProcess to first process whose unix id is ${processId}
@@ -94,8 +93,13 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
return allWindowNames
end tell
end tell
`);
const { stdout: titleOutput, error } = await runCommand(command);
`;
const argument = script.trim()
.split(/[\r\n]+/)
.map((line) => `-e '${line.trim()}'`)
.join(' ');
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
if (error) {
let errorMessage = '';
if (error.includes('-25211')) {
@@ -112,13 +116,3 @@ async function captureTitlesOnMac(processId: number): Promise<string[]> {
}
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}`;
}

View File

@@ -1,4 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
import { logCurrentArgs, CommandLineFlag, hasCommandLineFlag } from './cli-args';
import { log, die } from './utils/log';
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm';

View File

@@ -1,5 +1,5 @@
import { exec } from 'child_process';
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
import type { ExecOptions, ExecException } from 'child_process';
const TIMEOUT_IN_SECONDS = 180;

View File

@@ -1,5 +1,5 @@
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
import { type UrlStatus, formatUrlStatus } from './UrlStatus';
const DefaultBaseRetryIntervalInMs = 5 /* sec */ * 1000;

View File

@@ -1,4 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
import { fetchWithTimeout } from './FetchWithTimeout';
import { getDomainFromUrl } from './UrlDomainProcessing';

View File

@@ -1,4 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
import { retryWithExponentialBackOff } from './ExponentialBackOffRetryHandler';
import { fetchFollow, type FollowOptions } from './FetchFollow';
import { getRandomUserAgent } from './UserAgents';

View File

@@ -19,7 +19,7 @@
import { constants } from 'crypto';
import tls from 'tls';
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
export function randomizeTlsFingerprint() {
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');

View File

@@ -1,4 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
export interface UrlStatus {
readonly url: string;

View File

@@ -1,4 +1,4 @@
import { indentText } from '@/application/Common/Text/IndentText';
import { indentText } from '@tests/shared/Text';
export class TestExecutionDetailsLogger {
public logTestSectionStartDelimiter(): void {

View File

@@ -1,8 +1,8 @@
import { test, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
import { indentText } from '@tests/shared/Text';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { shuffle } from '@/application/Common/Shuffle';
import { indentText } from '@/application/Common/Text/IndentText';
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';

View File

@@ -83,7 +83,7 @@ function findElementFast(
win: Cypress.AUTWindow,
query: string,
handler: (element: Element) => void,
timeoutInMs = 10000,
timeoutInMs = 5000,
): void {
const endTime = Date.now() + timeoutInMs;
const finder = new ContinuousRunner();

View File

@@ -1,10 +1,10 @@
import { it, describe, expect } from 'vitest';
import { inject } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { defineComponent, inject } from 'vue';
import { type InjectionKeySelector, InjectionKeys, injectKey } from '@/presentation/injectionSymbols';
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import type { IApplicationContext } from '@/application/Context/IApplicationContext';
import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext';
describe('DependencyResolution', () => {
describe('all dependencies can be injected', async () => {
@@ -16,7 +16,7 @@ describe('DependencyResolution', () => {
// act
const resolvedDependency = resolve(() => key, dependencies);
// assert
expect(resolvedDependency).toBeDefined();
expect(resolvedDependency).to.toBeDefined();
});
});
});
@@ -40,14 +40,13 @@ function resolve<T>(
providedKeys: ProvidedKeys,
): T | undefined {
let injectedDependency: T | undefined;
executeInComponentSetupContext({
setupCallback: () => {
shallowMount(defineComponent({
setup() {
injectedDependency = injectKey(selector);
},
mountOptions: {
global: {
provide: providedKeys,
},
}), {
global: {
provide: providedKeys,
},
});
return injectedDependency;

View File

@@ -3,7 +3,7 @@ import {
} from 'vitest';
import { IconNames } from '@/presentation/components/Shared/Icon/IconName';
import { useSvgLoader } from '@/presentation/components/Shared/Icon/UseSvgLoader';
import { waitForValueChange } from '@tests/shared/Vue/WaitForValueChange';
import { waitForValueChange } from '@tests/shared/WaitForValueChange';
describe('useSvgLoader', () => {
describe('can load all SVGs', () => {

View File

@@ -1,7 +1,8 @@
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 { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { executeInComponentSetupContext } from '@tests/shared/Vue/ExecuteInComponentSetupContext';
describe('useKeyboardInteractionState', () => {
describe('isKeyboardBeingUsed', () => {
@@ -48,12 +49,12 @@ function triggerKeyPress() {
function mountWrapperComponent() {
let returnObject: ReturnType<typeof useKeyboardInteractionState> | undefined;
const wrapper = executeInComponentSetupContext({
setupCallback: () => {
const wrapper = shallowMount(defineComponent({
setup() {
returnObject = useKeyboardInteractionState();
},
disableAutoUnmount: true,
});
template: '<div></div>',
}));
expectExists(returnObject);
return {
returnObject,

View File

@@ -1,5 +1,5 @@
import { indentText } from '@/application/Common/Text/IndentText';
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.

29
tests/shared/Text.ts Normal file
View File

@@ -0,0 +1,29 @@
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}`);
}
}

View File

@@ -1,27 +0,0 @@
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;
}

View File

@@ -1,23 +0,0 @@
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();
});
});
}

View File

@@ -1,8 +1,6 @@
import { afterEach } from 'vitest';
import { enableAutoUnmount } from '@vue/test-utils';
import { polyfillBlob } from './BlobPolyfill';
import { failTestOnConsoleError } from './FailTestOnConsoleError';
enableAutoUnmount(afterEach);
polyfillBlob();
failTestOnConsoleError();

View File

@@ -1,99 +0,0 @@
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,
);
}
}

View File

@@ -1,130 +0,0 @@
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,
},
);
}
}

View File

@@ -1,96 +0,0 @@
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,
);
}
}

View File

@@ -5,7 +5,6 @@ import { CodePosition } from '@/application/Context/State/Code/Position/CodePosi
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('CodeChangedEvent', () => {
describe('ctor', () => {
@@ -20,34 +19,16 @@ describe('CodeChangedEvent', () => {
[new SelectedScriptStub(new ScriptStub('2')), new CodePosition(0, nonExistingLine2)],
]);
// act
const actualErrorMessage = collectExceptionMessage(() => {
let errorText = '';
try {
new CodeChangedEventBuilder()
.withCode(code)
.withNewScripts(newScripts)
.build();
});
} catch (error) { errorText = error.message; }
// assert
expect(actualErrorMessage).to.include(nonExistingLine1);
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();
expect(errorText).to.include(nonExistingLine1);
expect(errorText).to.include(nonExistingLine2);
});
describe('does not throw with valid code position', () => {
// arrange

Some files were not shown because too many files have changed in this diff Show More