Compare commits

..

25 Commits

Author SHA1 Message Date
undergroundwires
50ba00b0af win: fix, constrain and document WNS #227 #314
This change addresses issues #227 and #314 by preventing unintended side
effects on newer Windows versions while still offering WNS control on
supported systems.

Changes:

- Constrain `WpnUserService` disabling to Windows 10 v1909 and earlier.
- Update documentation for WNS and related services.
- Remove redundant warnings (in generated code and script title).
- Improve DisablePerUserService function:
  - Add documentation and generated comments
  - Implement Windows version constraint capability
2024-08-13 11:19:46 +02:00
undergroundwires
29e1069bf2 win, mac: fix minor typos, formatting, dead URLs
- Update dead URLs to archived versions
- Correct Windows version references (22H3 to 23H2)
- Correct reference order
- Fix incorrect usage of double quotes
2024-08-12 09:28:55 +02:00
undergroundwires
c7e57b8913 win: improve disabling NCSI #189, #216, #279
- Group NCSI disabling under single category for better organization.
- Remove NCSI from 'Strict' recommendations due to side effects
  (addressing #189, #216).
- Improve documentation with cautions about breaking internet status and
  captive portals (addressing #189, #216).
- Add removal of new `NcsiUwpApp` system app #279.
- Add more ways to disable the feature.
- Add ability to constrain Windows version in `DisableService`.
2024-08-10 12:16:33 +02:00
undergroundwires
4cea6b26ec win: unify registry data setting, fix #380
This commit unifies and centralizes registry data operations. This
improves reliability and robustness by avoiding bugs caused by incorrect
syntax when making modifications, as operations are now centralized.
This change resolves issue #380.

It also enhances reversibility by adding all missing revert codes and
correcting existing revert codes, tested against default values on fresh
OS and software installations.

Key changes:

- Add ability to revert to OS default data in `SetRegistryValue`
- Refactor manual registry operations to use shared functions
- Improve documentation for some affected scripts
- Fix incorrect revert codes (adding value instead of deleting) for some scripts
- Add missing revert logic for affected scripts

Other supporting changes:

- Fix revert code generation in `SetRegistryValueAsTrustedInstaller` to
  avoid generating empty revert code when no actions are needed.
- Add ability to delete keys on revert in `CreateRegistryKey`.
- Change `HKCR` key modifications to `HKLM|HKCU\Software\Classes` keys
  to always generate correct revert logic.
2024-08-09 17:13:00 +02:00
undergroundwires
c2f4b68786 win: improve Microsoft Edge associations removal
This commit refactors the removal of Edge associations to use shared
registry functions, streamlining all registry operations. It also
enhances the logic for removing associations with several improvements.

Key changes:

- Create separate shared functions for each association modification.
- Prefer modifying real keys over `HKCR` keys for reliability
- Add more documentation for affected scripts and new shared functions
- Replace loops with explicit calls for clarity and maintainability
- Extend handling of registry keys to both HKCU/HKLM hives
- Add missing association removals
- Add OS checks to apply only on correct Windows versions
- Split scripts for more granularity and maintainability
- Handle permission errors for user choice keys on recent Windows

Other supporting changes:

- Add `REG_DWORD` revert support for `DeleteRegistryKey`
- Add `grantPermissions` support for `DeleteRegistryValue`
- Add delete data only if undesired for `DeleteRegistryValue`
- Shorten generated code in `DeleteRegistryValue` to prevent reaching
  `cmd.exe` limits
- Add more Windows versions for script constraints
2024-08-08 17:57:19 +02:00
undergroundwires
e8add5ec08 win: improve folder hiding in "This PC" #16
This commit improves how folders are hidden under "This PC" on Windows.
It introduces shared functions to improve maintainability. This increases
the robustness and simplifies future updates and maintenance.

Key changes:

- Fix revert codes to match the default operating system state.
- Implement shared functions for higher maintainability.
- Add more documentation.
- Introduce more methods to hide folders, adding the suggestion from
  #16.

Other supporting changes:

- Add ability to revert `DeleteRegistryKey`.
- Add ability to contrain Windows version in `DeleteRegistryKey`.
- Add generated comment by default in `DeleteRegistryKey`.
2024-08-07 11:51:28 +02:00
undergroundwires
55c23e9d4c win: improve registry value deletion #381
This commit enhances the deletion of registry values with improved
robustness and better error handling. One-line `reg.exe` calls where
errors were suppressed are replaced with PowerShell commands that
provide proper error handling. This fixes #381 where wrong `reg delete`
syntax was used.

Key changes:

- Introduce `DeleteRegistryValue` and change registry value deletion
  logic to use it.
- Fix Windows version comparison to ignore patch numbers.
  This ensures versions like `10.0.19045.0` are treated the same as
  `10.0.19045`, resolving issues where scripts were incorrectly skipped
  due to patch number differences in Windows versions.

Other supporting changes:

- Add missing revert codes.
- Include more comments in the generated code.
- Use `-LiteralPath` in all registry deletion commands to prevent
  unintended wildcard expansion when '*' is used in registry paths.
- Remove unused `revertCodeComment` parameter from `DeleteRegistryKey`.

Changed scripts:

- 'Remove "Scan with Microsoft Defender" from context menu':
  - Use `DeleteRegistryKey` in script.
  - Remove problematic `HKCR\*\shellex\ContextMenuHandlers` key deletion.
    This caused errors on both Windows 10 (22H2) and Windows 11 (23H2).
    The wildcard usage made this operation potentially risky, so it's
    replaced with more specific registry cleanup.
  - Remove modifications to `HKCR` values. `HKCR` is a virtual hive,
    and changes to `HKLM` are automatically reflected in `HKCR`.
- Update 'Disable automatic OneDrive installation' to target only
  Windows 1909, improve documentation, and recommend in 'Standard'.
- Simplify 'Disable Diagnostics Hub log collection' by removing VS
  version check, enhance documentation, recommend in 'Standard'.
2024-08-06 07:15:26 +02:00
undergroundwires
d77c3cbbe2 Fix PowerShell code block inlining in compiler
This commit enhances the compiler's ability to inline PowerShell code
blocks. Previously, the compiler attempted to inline all lines ending
with brackets (`}` and `{`) using semicolons, which leads to syntax
errors. This improvement allows for more flexible PowerShell code
writing with reliable outcomes.

Key Changes:

- Update InlinePowerShell pipe to handle code blocks specifically
- Extend unit tests for the InlinePowerShell pipe

Other supporting changes:

- Refactor InlinePowerShell tests for improved scalability
- Enhance pipe unit test running with regex support
- Expand test coverage for various PowerShell syntax used in
  privacy.sexy
- Update related interfaces to align with new code conventions, dropping
  `I` prefix
- Optimize line merging to skip lines already ending with semicolons
- Increase timeout in E2E tests to accommodate for slower application
  load caused by more processing introduced in this commit.
2024-08-05 19:44:30 +02:00
undergroundwires
f89c2322b0 win: fix, improve and unify Windows version logic
This commit centralizes Windows version constraints through a new
function for improved clarity, maintainability and reusability.

Changes:

- Add `RunPowerShellWithWindowsVersionConstraints` function
- Support specifying minimum and maximum Windows versions
- Introduce user-friendly tags like `Windows11-FirstRelease`
- Fix version logic by correcting incorrect block syntax in various
  functions.
2024-08-04 15:29:29 +02:00
undergroundwires
ded55a66d6 Refactor executable IDs to use strings #262
This commit unifies executable ID structure across categories and
scripts, paving the way for more complex ID solutions for #262.
It also refactors related code to adapt to the changes.

Key changes:

- Change numeric IDs to string IDs for categories
- Use named types for string IDs to improve code clarity
- Add unit tests to verify ID uniqueness

Other supporting changes:

- Separate concerns in entities for data access and executables by using
  separate abstractions (`Identifiable` and `RepositoryEntity`)
- Simplify usage and construction of entities.
- Remove `BaseEntity` for simplicity.
- Move creation of categories/scripts to domain layer
- Refactor CategoryCollection for better validation logic isolation
- Rename some categories to keep the names (used as pseudo-IDs) unique
  on Windows.
2024-08-03 16:54:14 +02:00
undergroundwires
6fbc81675f Relax linting to allow null recommendation
This commit updates the YAML schema to permit explicitly setting
`recommend: null` or `recommend: ~` in scripts within collection files.

Previously, the schema only allowed string values for the recommendation
field, restricting it to 'standard' or 'strict'. By introducing the
`RecommendationLevel` definition, the schema now supports both string
and null values, providing more flexibility in specifying
recommendations in collection YAML files.
2024-08-02 16:44:15 +02:00
undergroundwires
48d97afdf6 win: improve registry/recent cleaning
This commit introduces a new shared function to centralize all usages of
`reg delete .. /va`. The new function generates comments in code and can
recurse through subkeys. This enhances maintainability and reliability
by avoiding potential misuse or syntax errors.

Key changes:

- Add `ClearRegistryValues` function
- Update scripts to use the new function
- Add ability to recurse subkeys for registry value deletion, addressing
  issues where desired data was not deleted.

Other supporting changes:

- Improve documentation of the changed scripts.
- Add missing registry paths in scripts.
- Change value removal to value/subkey removal for correct behavior.
- Remove removal of undocumented keys.
- Rename related scripts for clarity.
- Adjust script recommendations.
2024-08-01 23:02:01 +02:00
undergroundwires
109fc01c9a win: fix and document VStudio license removal
Before this commit, license deletion used `reg delete .. /va /f`, which
deletes all valued under a key. However, the license data did not exist
under the specified subkeys, making the logic ineffective. This commit
changes it to delete the license registry key completely to correctly
remove the license data.

Changes:

- Change `reg delete /va` to delete the correct registry data.
- Create shared function for deleting license data for better
  maintainability.
- Add comment in generated script code for license removal.
- Add documentation for the scripts.
- Include a missing script to clear Visual Studio 2013 telemetry.
- Remove redundant lines of code in `CreateRegistryKey` and
  `DeleteRegistryKey` that initialize an unused variable.
2024-07-31 13:35:39 +02:00
undergroundwires
b185255a0a win: centralize, improve Defender data collection
This commit reorganizes scripts related to disabling Defender's data
collection and telemetry into a dedicated category. This improves
usability for users focused on enhancing privacy without needing to
understand technical details of each option.

Changes:

- Create "Disable Defender data collection" category
- Move related scripts under new category
- Improve script documentation and naming
- Add alternate configurations to some scripts
- Fix extended cloud check feature being enabled instead of disabled
- Update script recommendations to 'Strict'
2024-07-28 23:50:38 +02:00
undergroundwires
c2d3cddc47 win: improve, fix, restructure CEIP disabling
- Restructure and expand rename CEIP-related scripts for clarity and
  granularity.
- Add missing tasks and registry keys for comprehensive CEIP disabling.
- Improve documentation with detailed explanations and references.
- Rename scripts for better user understanding and consistency
- Fix incorrect revert behavior in some scripts
2024-07-26 15:45:33 +02:00
undergroundwires
8526d2510b win: unify registry setting as TrustedInstaller
- Introduce SetRegistryValueAsTrustedInstaller function to unify setting
  registry values as TrustedInstaller.
- Introduce RunPowerShellWithMinimumWindowsVersion function to unify
  Windows version specific registry modifications.
- Add more documentation for scripts using TrustedInstaller.
- Correct revert code for affected scripts to match default OS behavior
  (setting registry value back) instead of just deleting keys.
2024-07-25 14:23:31 +02:00
undergroundwires
11e566d0e5 win: improve disabling SmartScreen #385
- Add comprehensive documentation with security cautions
- Expand SmartScreen disabling for Internet Explorer
- Fix registry data for Internet Explorer SmartScreen disabling
- Add disabling of `smartscreen.exe` process, resolving #385
- Implement additional SmartScreen disabling methods
- Correct registry key for Store apps
- Simplify script names for clarity
2024-07-24 16:23:28 +02:00
undergroundwires
ae0165f1fe Ensure tests do not log warning or errors
This commit increases strictnes of tests by failing on tests (even
though they pass) if `console.warn` or `console.error` is used. This is
used to fix warning outputs from Vue, cleaning up test output and
preventing potential issues with tests.

This commit fixes all of the failing tests, including refactoring in
code to make them more testable through injecting Vue lifecycle
hook function stubs. This removes `shallowMount`ing done on places,
improving the speed of executing unit tests. It also reduces complexity
and increases maintainability by removing `@vue/test-utils` dependency
for these tests.

Changes:

- Register global hook for all tests to fail if console.error or
  console.warn is being used.
- Fix all issues with failing tests.
- Create test helper function for running code in a wrapper component to
  run code in reliable/unified way to surpress Vue warnings about code
  not running inside `setup`.
2024-07-23 16:08:04 +02:00
undergroundwires
a6505587bf Fix intermittent ModalDialog unit test failures
Refactor `ModalDialog` unit tests to use `shallowMount` consistently.
Previously, tests sometimes failed due to the `UseSvgLoader` hook
attempting icon loads during component teardown, which occasionally led
led to errors when the `window` object became unavailable. By
switching to `shallowMount`, tests no longer deeply render child
components, mitigating the risk of such errors and aligning with
unit testing best practices.

Additionally, this commit sets a default value for the `modelValue`
prop in test setups to address Vue warnings about missing required
props, further stabilizing the test environment.
2024-07-22 15:10:12 +02:00
undergroundwires
b16e13678c Improve compiler error display for latest Chromium
This commit addresses the issue of Chromium v126 and later not displaying
error messages correctly when the error object's `message` property uses
a getter. It refactors the code to utilize an immutable Error object with
recursive context, improves error message formatting and leverages the
`cause` property.

Changes:

- Refactor error wrapping internals to use an immutable error object,
  eliminating `message` getters.
- Utilize the `cause` property in contextual errors for enhanced error
  display in the console.
- Enhance message formatting with better indentation and listing.
- Improve clarity by renaming values thrown during validations.
2024-07-21 10:18:27 +02:00
undergroundwires
abe03cef3f Refactor styles to match new CSS nesting behavior
This commit refactors SCSS to resolve deprecation warnings related to
mixed declaration after nested rules.

Sass is changing how it processes declarations that appear after nested
rules to align with CSS standards. Previously, Sass would hoist
declarations to avoid duplicating selectors, However, this behavior will
soon change to make declarations apply in the order they appear, as per
CSS standards.
2024-07-20 11:56:31 +02:00
undergroundwires
dd7239b8c1 Bump dependencies to latest 2024-07-19 10:29:38 +02:00
undergroundwires
851917e049 Refactor text utilities and expand their usage
This commit refactors existing text utility functions into the
application layer for broad reuse and integrates them across
the codebase. Initially, these utilities were confined to test
code, which limited their application.

Changes:

- Move text utilities to the application layer.
- Centralize text utilities into dedicated files for better
  maintainability.
- Improve robustness of utility functions with added type checks.
- Replace duplicated logic with centralized utility functions
  throughout the codebase.
- Expand unit tests to cover refactored code parts.
2024-07-18 20:49:21 +02:00
undergroundwires
8d7a7eb434 win: support Microsoft Store Firefox installations
This commit updates the Windows scripts to handle Firefox installations
acquired through the Microsoft Store. It adds support by modifying
script functions to clear and delete profile directories specific to
this version of Firefox.
2024-07-10 08:37:59 +02:00
undergroundwires
0239b52385 win: refactor version-specific actions
Optimize PowerShell script invocation to differentiate actions based on
Windows version. This revision introduces a more efficient way to handle
version-specific scripting within Windows collection by abstraction
complexity into dedicated shared functions.
2024-07-09 19:09:06 +02:00
181 changed files with 16516 additions and 6268 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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": {

View File

@@ -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')}`,
); );
} }
} }

View 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}.`);
}
}

View 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,
};

View 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/);
}

View File

@@ -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}).`,
); );
} }
} }

View File

@@ -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);
} }

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -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;
} }

View File

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

View File

@@ -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',
}); });
} }

View File

@@ -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');
}

View File

@@ -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}'.`);
} }
} }

View File

@@ -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',
], ],

View File

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

View File

@@ -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 */

View File

@@ -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(' ');
} }

View File

@@ -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}"`);

View File

@@ -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,

View File

@@ -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)),
),
}; };
} }
} }

View File

@@ -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;

View File

@@ -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);

View File

@@ -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}".`,

View File

@@ -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)}`);
} }

View File

@@ -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,

View File

@@ -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',
], ],

View File

@@ -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(

View File

@@ -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'],
}); });
} }

View File

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

View File

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

View File

@@ -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],

View File

@@ -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;
}

View File

@@ -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,
];

View File

@@ -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);
};

View File

@@ -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];
}

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}); });

View File

@@ -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);
}); });
} }

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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>;

View File

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

View File

@@ -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);
} }

View File

@@ -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');
} }

View File

@@ -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);
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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: [],

View File

@@ -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,
}; };

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}); });
} }

View File

@@ -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();
}); });

View File

@@ -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 {

View File

@@ -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: "|";

View File

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

View File

@@ -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);
} }

View File

@@ -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}`;
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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(':');

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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();

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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.

View File

@@ -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}`);
}
}

View 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;
}

View 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();
});
});
}

View File

@@ -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();

View File

@@ -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,
);
}
}

View 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,
},
);
}
}

View File

@@ -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,
);
}
}

View File

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